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

# Webhooks

> Receive every payout and refund transition, signed and verifiable.

Webhooks are how you find out what happened to a payout. You should not have to
poll the API for state at all.

You register one HTTPS URL for your account, and every event is signed with a
secret only you and we hold. See [Webhook events](/reference/events) for the full
catalogue.

## Register your endpoint

```bash theme={null}
curl -X PUT "$SPENDIN_BASE_URL/v1/webhooks/config" \
  -H "X-API-Key: $SPENDIN_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "webhook_url": "https://merchant.example.com/webhooks/spendin" }'
```

The response contains your signing secret, prefixed `whsec_`.

<Warning>
  The secret is returned **once**, when you set the URL or rotate it. It is never
  retrievable afterwards — `GET /v1/webhooks/config` only tells you whether one
  exists. Store it in your secrets manager before you discard the response.
</Warning>

The URL must be HTTPS. Plain HTTP is rejected except for `localhost` and
`127.0.0.1`, allowed for local development. An invalid URL returns
`400 WEBHOOK_URL_INVALID`.

Requires the `webhooks:manage` scope.

### Per-payout override

Set `notification_url` on an individual payout and that payout's events go there
instead of your default URL. Your tenant secret signs both, so verification does not
change.

Useful for routing a specific payout's events to a different consumer without
standing up a second account. Everything else still goes to the default.

## Verify every delivery

Each request carries these headers:

| Header                | Value                                       |
| --------------------- | ------------------------------------------- |
| `X-Spendin-Signature` | `t=<unix_timestamp>,v1=<hmac_sha256_hex>`   |
| `X-Spendin-Timestamp` | The same unix timestamp                     |
| `X-Spendin-Event`     | The `event_type`, e.g. `payout.successful`  |
| `X-Spendin-Delivery`  | Delivery job ID, useful in support requests |

The signature is an HMAC-SHA256 over `<timestamp>.<raw_request_body>`, keyed with
your signing secret.

```typescript theme={null}
import { createHmac, timingSafeEqual } from 'crypto';

function verifySignature(
  raw_body: string,          // the exact bytes received — never a re-serialised object
  signature_header: string,
  signing_secret: string,
  tolerance_seconds = 300,
): boolean {
  const parts = Object.fromEntries(
    signature_header.split(',').map((p) => p.split('=')),
  );
  const timestamp = parseInt(parts.t, 10);

  // Reject stale deliveries — blocks replay of a captured request
  if (Math.abs(Date.now() / 1000 - timestamp) > tolerance_seconds) return false;

  const expected = createHmac('sha256', signing_secret)
    .update(`${timestamp}.${raw_body}`, 'utf8')
    .digest('hex');

  const received = Buffer.from(parts.v1);
  const computed = Buffer.from(expected);

  // Constant-time comparison — a plain === leaks the secret via timing
  return received.length === computed.length && timingSafeEqual(received, computed);
}
```

<Warning>
  Verify against the **raw request body**, before any JSON parsing. Parsing and
  re-serialising changes whitespace and key order, and the signature will never
  match. In Express, capture it with
  `express.json({ verify: (req, _res, buf) => { req.raw_body = buf.toString(); } })`.
</Warning>

Reject anything that fails verification. An unverified request is not from us.

<Note>
  If no secret is configured for your account, deliveries are sent **unsigned** —
  the signature headers are simply absent. Treat a missing `X-Spendin-Signature` as
  a failure in production rather than as "nothing to check", and confirm
  `GET /v1/webhooks/config` reports a secret before you go live.
</Note>

## Responding

Return any `2xx`, quickly. Anything else — or no response within **10 seconds** —
counts as a failure.

Do your real work asynchronously: acknowledge first, process after. A handler that
runs a slow database write inline will eventually time out and cause duplicate
deliveries.

## Retries

Failed deliveries are retried up to **5 attempts** total (the first attempt plus
four retries), with exponential backoff starting at 10 seconds.

Every attempt is logged, whether it succeeded or not. After the fifth the delivery
is marked `DEAD` and not retried again.

<Warning>
  There is no replay endpoint. A `DEAD` delivery is not resent, so an endpoint that
  is down for long enough will permanently miss events.

  Recover by reading state back: `GET /v1/payouts` with a `created_at` filter on
  your side, or `GET /v1/webhooks/deliveries` to see exactly what was missed. This
  is why the [status timeline](/guides/payout-lifecycle#the-status-timeline) is the
  authoritative history and webhooks are the notification.
</Warning>

## Idempotency on your side

The same event may be delivered more than once. Use `event_id` as the dedupe key —
it is stable across every retry.

```typescript theme={null}
if (await events.exists(payload.event_id)) return res.sendStatus(200);
await events.record(payload.event_id);
await queue.enqueue(payload);
res.sendStatus(200);
```

Do not deduplicate on `created_at` or on arrival order. Deliveries can arrive out of
order; treat `status` in the payload as the truth and ignore an event that would
move a payout backwards from where your record already is.

## Debugging

```bash theme={null}
curl "$SPENDIN_BASE_URL/v1/webhooks/deliveries?limit=20" \
  -H "X-API-Key: $SPENDIN_API_KEY"
```

```json theme={null}
{
  "data": [
    {
      "id": "a1b2c3d4-...",
      "payout_id": "a3f1c2d4-...",
      "event_id": "d9c8b7a6-...",
      "event_type": "payout.successful",
      "attempt_number": 2,
      "target_url": "https://merchant.example.com/webhooks/spendin",
      "response_status": 200,
      "status": "SUCCESS",
      "failure_reason": null,
      "created_at": "2026-08-16T14:39:12.000Z"
    }
  ],
  "meta": { "next_cursor": null, "has_more": false, "limit": 20 }
}
```

| Delivery status | Meaning                                         |
| --------------- | ----------------------------------------------- |
| `SUCCESS`       | Your endpoint returned `2xx`                    |
| `FAILED`        | This attempt failed; a retry is scheduled       |
| `DEAD`          | All five attempts exhausted — not retried again |

This is the fastest way to tell "we never sent it" from "your endpoint rejected
it". `failure_reason` carries the HTTP status or the transport error — a timeout, a
DNS failure, a TLS problem.

## Rotating the secret

```bash theme={null}
curl -X POST "$SPENDIN_BASE_URL/v1/webhooks/config/rotate-secret" \
  -H "X-API-Key: $SPENDIN_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

The new secret is returned once and takes effect **immediately** — there is no
grace window during which the old one still verifies.

<Warning>
  Deploy the new secret to your handler within the same change, or in-flight
  deliveries signed with the new secret will fail verification against your old
  one. If you need zero-gap rotation, have your handler accept either secret for a
  short period, then drop the old one.
</Warning>
