> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pay.aptahq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Verify the signature, correlate on metadata, and treat delivery as at-least-once and unordered.

A charge or payout does not settle synchronously. The webhook is where you
learn the real outcome.

AptaPay delivers events to your registered `callback_url` — or
`callback_url_test` for test-mode traffic — as a signed `POST`.

## Event shape

```json theme={null}
{
  "event_id": "evt_AbCdEf1234567890",
  "event_type": "collection.successful",
  "sequence": 3,
  "created_at": "2026-08-24T10:15:00.000Z",
  "data": {
    "event_type": "collection.successful",
    "reference": "APT-LCS-8fH2kQ",
    "status": "successful",
    "previous_status": "pending",
    "observed_amount_minor": 500000,
    "observed_currency": "UGX",
    "is_test_mode": false,
    "sequence": 3,
    "metadata": { "order_id": "ord_123" }
  }
}
```

Headers on every delivery:

```http theme={null}
X-AptaPay-Signature: v1=<hex hmac>
X-AptaPay-Timestamp: <unix seconds>
X-AptaPay-Nonce:     <random>
X-AptaPay-Event-Id:  <same as body.event_id>
```

## Verify before you trust

Verify the signature before doing anything with the body. A request that
fails verification should get a non-2xx response — do not process it and do
not acknowledge it.

Use your **outbound** signing secret here. It is a different value from the
secret you sign requests with.

```typescript verify-webhook.ts theme={null}
import { createHash, createHmac, timingSafeEqual } from "node:crypto";

const APP_ID = process.env.APTAPAY_APP_ID!;
const OUTBOUND_SECRET = process.env.APTAPAY_OUTBOUND_SECRET!;
// The callback URL you registered with AptaPay — you know it without
// inspecting the request, which is the point.
const CALLBACK_URL = "https://api.yourapp.com/webhooks/aptapay";

export function verifyWebhook(
  rawBody: Buffer,
  headers: Record<string, string>,
): boolean {
  const [version, presented] = (
    headers["x-aptapay-signature"] ?? ""
  ).split("=");
  if (version !== "v1" || !presented) return false;

  const url = new URL(CALLBACK_URL);
  const canonical = [
    "v1",
    APP_ID,
    "outbound",
    url.host.toLowerCase(),
    "POST",
    url.pathname,
    "",
    headers["x-aptapay-timestamp"],
    headers["x-aptapay-nonce"],
    createHash("sha256").update(rawBody).digest("hex"),
  ].join("\n");

  const expected = createHmac("sha256", OUTBOUND_SECRET)
    .update(canonical)
    .digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(presented, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

<Warning>
  Verify over the **raw bytes**. If your framework parses JSON before you get
  to the body, the re-serialized object will not hash to the same value.
  In Express, mount `express.raw({ type: "application/json" })` on the webhook
  route specifically.
</Warning>

<Note>
  The outbound signature always signs the literal string `"outbound"` in the
  `key_id` position, not a generation identifier. During a rotation of your
  outbound secret you cannot tell from the signed bytes which generation
  signed a given callback, so be prepared to verify against either the
  previous or the current secret for a short window around a rotation.
</Note>

## Correlate on metadata, never on our reference

`reference` is AptaPay's own identifier, formatted `APT-{prefix}-{nanoid}`. It
is globally unique and unguessable, and it deliberately carries none of your
structure. You cannot recover an order id from it, and parsing it as though
it were yours will fail on every callback.

Send what you need at charge time, and read it straight back:

```typescript correlate.ts theme={null}
// When creating the charge:
await createCharge({ /* ... */, metadata: { order_id: order.id } });

// In the webhook handler:
const orderId = event.data.metadata?.order_id;
```

`metadata` is stored verbatim on the transaction and echoed on every
delivery. It must serialize to at most **4096 bytes** — it rides every
attempt of every event, so an oversized object is rejected with a `422` at
charge time rather than producing undeliverable webhooks. It is `null` when
the charge carried none.

## Delivery is at-least-once and unordered

<Steps>
  <Step title="Deduplicate on event_id">
    AptaPay guarantees a stable `event_id` per logical event. Track processed
    ids — even a short-TTL cache is enough — so a legitimate retry of an
    already-processed event is a safe no-op rather than a double-application.
  </Step>

  <Step title="Detect out-of-order arrivals with sequence">
    `sequence` increases monotonically per transaction. Combined with
    `previous_status`, it lets you ignore a stale delivery rather than
    assuming arrival order.
  </Step>

  <Step title="Check the amount, not just the status">
    Always compare `observed_amount_minor` and `observed_currency` against
    what you expected. A webhook reporting `successful` for the wrong amount
    is the underpayment case. AptaPay already guards this at the gateway — a
    mismatch is frozen for review and never fanned out — but your settlement
    logic should verify independently rather than trust `status` alone.
  </Step>
</Steps>

## Retry schedule

If your endpoint does not return 2xx, AptaPay retries at **0s, 30s, 2m, 10m,
1h, 6h**. After the final attempt the event lands in a dead-letter queue with
an operator replay path.

Return `2xx` as soon as you have durably recorded the event. Do the slow work
afterwards — a handler that does settlement inline and times out will be
retried, and you will process it twice.

## Responding

```typescript handler.ts theme={null}
app.post(
  "/webhooks/aptapay",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    if (!verifyWebhook(req.body, req.headers as Record<string, string>)) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString());

    if (await alreadyProcessed(event.event_id)) {
      return res.status(200).end(); // safe no-op
    }

    await enqueueForProcessing(event); // durable, fast
    res.status(200).end();
  },
);
```
