# Webhooks

Partners can configure one HTTPS webhook endpoint in the dashboard. RoamWorks sends signed events
for completed and processing orders and for redemptions. The delivery log is visible in the
dashboard.

## Events

| Event | When | Data |
| --- | --- | --- |
| `order.completed` | The purchase is complete and every child order is fulfilled. | `purchaseId`, `reference`, `orderIds`, `amountMinor`, `currency`, `status`, `pendingOrderIds` |
| `order.processing` | The purchase is paid but at least one unit awaits upstream fulfilment. | Same shape as `order.completed`. |
| `esim.redeemed` | A redemption batch was created. | `batchId`, `purchaseId`, `orderIds`, `itemIds`, `format` |

No webhook payload contains LPA text, QR data, a secret, card data or customer personal data. Use
the purchase or batch id to fetch authorised data through the API when needed.

Partner webhook management is currently dashboard-only. A management API is **Planned**.

## Envelope

```json
{
  "id": "99999999-9999-4999-8999-999999999999",
  "type": "order.completed",
  "createdAt": "2026-11-04T03:00:00.000Z",
  "data": {
    "purchaseId": "33333333-3333-4333-8333-333333333333",
    "reference": "booking-123",
    "orderIds": ["44444444-4444-4444-8444-444444444444"],
    "amountMinor": 1618,
    "currency": "USD",
    "status": "completed",
    "pendingOrderIds": []
  }
}
```

## Signature

Every delivery carries:

```http
x-roamworks-signature: t=1793251200,v1=5f2c9d...lowercase-hex...
x-roamworks-event: order.completed
content-type: application/json
user-agent: RoamWorks-Webhooks/1.0
```

The signed content is:

```text
<t>.<raw request body>
```

`v1` is the lowercase hex HMAC-SHA256 of that string using your webhook signing secret. The
secret is shown once when you save or rotate the endpoint in the dashboard. Store it in a secret
manager.

## Verify

Verify against the raw bytes before parsing JSON. Reject a timestamp older than five minutes.
Compare digests in constant time.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyRoamWorksWebhook(input: {
  rawBody: string;
  header: string | null;
  secret: string;
  nowSeconds?: number;
}) {
  if (!input.header) return false;
  const parts = Object.fromEntries(
    input.header.split(",").map((part) => {
      const index = part.indexOf("=");
      return [part.slice(0, index).trim(), part.slice(index + 1).trim()];
    }),
  );
  const timestamp = Number(parts.t);
  const given = parts.v1;
  if (!Number.isInteger(timestamp) || !given) return false;
  const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > 300) return false;

  const expected = createHmac("sha256", input.secret)
    .update(timestamp + "." + input.rawBody)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(given, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Reject the delivery with a non-2xx response when verification fails. Do not parse and re-serialize
the body before verification; whitespace matters.

## Delivery and retries

- Request timeout: 10 seconds.
- Success: any 2xx response.
- Retry: connection errors, timeouts, non-2xx responses and redirects (redirects are not followed).
- Attempts: up to 10.
- Delay after attempt 1 to 9: 5 seconds, 15 seconds, 60 seconds, 5 minutes, 15 minutes, 30 minutes,
  1 hour, 2 hours, 4 hours.
- After the tenth failed attempt the event is marked dead in the dashboard.

Deliveries are at least once. Deduplicate on the envelope `id`. A duplicate delivery can arrive
after a timeout even if your handler processed the first one.

## Endpoint requirements

- Public HTTPS URL. HTTP, loopback, link-local, private and local-only addresses are rejected.
- No embedded username or password in the URL.
- A valid public certificate. The API does not disable certificate validation.
- Return a 2xx quickly, then process asynchronously. A slow handler increases retry pressure.
- Use a dedicated endpoint path. Do not expose the signing secret in a query string.

## Local testing

Use a tunnel with a public HTTPS URL. The API resolves the hostname and rejects private addresses,
so a localhost URL will not work. Verify the signature with the same raw body your framework
received.

## Stripe funding webhook

`POST /v1/webhooks/stripe` is a separate server-to-server receiver for Stripe. It is not a
partner-configured endpoint and partners do not call it. It returns `501` when Stripe funding is
not enabled and `400` for an invalid Stripe signature.

## Related

- [Orders](/docs/orders) for purchase status.
- [Wallet](/docs/wallet) for funding credit.
- [Errors](/docs/errors) for retry rules.
