Webhooks

Signed webhook payloads, signature verification, idempotency and retries.

The gateway POSTs signed events to your registered webhook URL so you learn — without polling — when a payee becomes payable and when a payout changes state. Every delivery is signed with your client's signing secret; verify it before trusting the body.

Events

You subscribe to specific event types when your client is provisioned. An event you're not subscribed to is recorded but never delivered.

payee.updated

Fired when a payee's onboarding state changes — typically when they finish and become payable. Map it back to your record via externalId.

{
  "type": "payee.updated",
  "externalId": "creator_8842",
  "payeeId": "9b2e4c1a-7f3d-4b21-8c6e-2a1f0d9e5b34",
  "payable": true,
  "kycStatus": "verified",
  "kycReason": null,
  "occurredAt": "2026-06-24T18:30:00.000Z"
}

kycReason is non-null only when kycStatus is rejected and the provider supplies a reason — otherwise it is null.

payout.status

Fired on every payout state change. Settle your own ledger row by the ref you supplied on POST /payouts. Terminal states are paid and returned.

{
  "type": "payout.status",
  "transactionId": "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d",
  "ref": "invoice_123",
  "status": "paid",
  "providerRef": "grade_pmt_9f3c21",
  "externalId": "creator_8842",
  "payeeId": "9b2e4c1a-7f3d-4b21-8c6e-2a1f0d9e5b34",
  "amountCents": 5000,
  "currency": "USD",
  "provider": "grade",
  "claimUrl": null,
  "failureKind": null,
  "settledAt": null,
  "occurredAt": "2026-06-24T18:32:10.000Z"
}

A returned event additionally carries liftingFeeCents and restoredCents (the provider return fee and the net cents put back to the payee's balance). Note settledAt stays null on rails where paid means funds released to the provider rather than confirmed bank arrival — paid is release to the rail, not arrival. Key off settledAt when you need genuine settlement.

Owed-row sources: ref is null for portal withdrawals

A payee-initiated (or admin-initiated) withdrawal consolidates a payee's owed disbursements into one payout per currency, created without a ref. A source that pushes disbursements instead of calling POST /payouts itself cannot settle by ref. Match by payee instead: GET /payouts/{id} (the event's transactionId) returns payeeId, amountCents and currency; resolve the payee to your user and reconcile your pushed-but-unsettled rows in that currency against the amount (a withdrawal drains every owed row of that currency, so the sum matches). Remember the transactionId you resolved; later events for the same payout carry it.

Every payout.status carries these fields:

  • transactionId: stable gateway payout id, your join key across events for this payout.
  • ref: your POST /payouts ref. null for portal/consolidated withdrawals (see the callout above).
  • status: on status version 2 (the default for new clients) owed | sent | awaiting_withdraw | paid | failed | returned | held; on the legacy version 1 requested | authorized | submitted | paid | failed | returned | held (see Status versions). Terminal in both: paid, returned.
  • providerRef: the rail's own id for the payment, null before the rail accepts it.
  • externalId: the payee's id in your system, your match key to the creator. payeeId: the gateway's own payee id.
  • amountCents / currency: the money moved.
  • provider: the rail (grade | stripe | sideshift | tipalti | mock), null before routing.
  • claimUrl: the Grade claim link when the creator must act, null on rails without one. On REST payout responses claimUrl is always null; POST /payees/{id}/withdraw returns it as payoutLink. The gateway emails it to the creator for payouts created by POST /payouts and for owed-balance releases and withdrawals. It does not email after POST /payouts/{id}/release, POST /payouts/{id}/retry or a batch row, so keep this copy.
  • failureKind: transient or permanent, set on a failed or held transition, null otherwise.
  • settledAt: bank-arrival time, null until it lands. occurredAt: when this event was emitted.
  • liftingFeeCents / restoredCents: returned events only. The bank-bounce fee and the amount put back in the payee's balance. Render restoredCents; do not re-derive it.

The held or failed reason is not in the webhook

failureKind is only a coarse category. The machine reason (for example not_payable, missing_name, below_min_payout) is statusReason on GET /api/v1/payouts/{id}, and a payee's blockers are on GET /api/v1/payees/{id}/readiness. Log failureKind off the webhook; fetch the reason on demand.

What to do on each delivered payout.status:

  • paid — settle the ledger row.
  • returned — reverse it (read restoredCents for the amount put back).
  • failed with failureKind: "transient" — safe to re-drive via POST /payouts/{id}/retry. The gateway retries on its own only a Grade or SideShift payout that failed for lack of rail funding.
  • held — surface for remediation, then re-drive via POST /payouts/{id}/release.
  • A non-null claimUrl — the creator must open it to complete the payout. Show it to them yourself; the gateway emails it only for direct payouts and owed releases (see the field list above).

Status versions

Your client's status version picks the words a payout's status uses. It covers the payout.status webhook and every REST payout response (POST and GET /api/v1/payouts, GET /api/v1/payouts/{id}, POST /api/v1/payouts/{id}/release, POST /api/v1/payouts/{id}/retry, POST /api/v1/payouts/batch). The webhook and REST always use the same version.

  • Version 2 is the default for new clients — the normalized vocab: owed, sent, awaiting_withdraw, paid, failed, returned, held. Build for these words.
  • Version 1 is legacy — existing clients stay on it and its words will not change: requested, authorized, submitted, paid, failed, returned, held. A v1 client migrates to v2 per-source (update your consumer for the v2 words first, then ask 8x ops to flip you).

What the version 2 words mean:

  • owed: earnings exist, no payout transaction yet.
  • sent: the rail accepted the payout and money is on its way.
  • awaiting_withdraw: funds reached a custody-free rail (Grade claim link, SideShift second leg) and the creator must still claim or withdraw.
  • paid: settled. Same as version 1, so key off settledAt for confirmed bank arrival.
  • failed: the rail rejected the payout. Nothing landed.
  • returned: it landed, then bounced back. Kept separate from failed on purpose.
  • held: deliberately held, not yet released.

From version 1: requested and authorized become sent. submitted becomes sent on custodial rails (Stripe, Tipalti, and the mock rail in test mode) and awaiting_withdraw on custody-free rails (Grade, SideShift). A Grade payout reads sent once the creator has claimed it; a SideShift payout stays awaiting_withdraw until it settles as paid. paid, failed, returned and held stay the same.

owed belongs to the version 2 vocabulary but never appears on a payout or a payout.status event today. It lives upstream on disbursements (POST /disbursements), before any payout exists. The status filter on GET /api/v1/payouts takes version 1 words only, whatever your status version.

Signature scheme

Each delivery carries two headers, the same scheme used for outbound API requests:

  • x-8x-timestamp — Unix seconds when the gateway signed the delivery.
  • x-8x-signature — hex HMAC-SHA256(signingSecret, "{timestamp}.{rawBody}") over the exact raw body.

Verify the raw bytes

Verify against the raw request body bytes. If you parse the JSON and re-serialize it, key order/whitespace can change and the signature won't match.

Verify a delivery

The SDK does it for you:

import { PayoutClient } from '@8x-payout/sdk';

const client = new PayoutClient({ baseUrl, apiKey, signingSecret });

export async function POST(req: Request) {
  const rawBody = await req.text(); // EXACT raw body — never JSON.parse then re-stringify
  let event;
  try {
    event = await client.verifyWebhook(
      rawBody,
      req.headers.get('x-8x-timestamp')!,
      req.headers.get('x-8x-signature')!,
    );
  } catch {
    return new Response('bad signature', { status: 401 });
  }

  switch (event.type) {
    case 'payout.status':
      // settle your ledger row by event.ref (idempotent — see below)
      break;
    case 'payee.updated':
      // flip your cached payable flag, keyed by event.externalId
      break;
  }
  return new Response('ok'); // any 2xx acknowledges
}

Or verify by hand in any language — it's a plain HMAC:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody: string, timestamp: string, signature: string, secret: string) {
  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    throw new Error('Invalid webhook signature');
  }
}

Idempotency & retries

Delivery is at-least-once: the gateway attempts delivery inline, then retries on any non-2xx (or timeout) with exponential backoff — 60s, 120s, 240s … capped at 1 hour, up to 8 attempts. Because the same event can arrive more than once, process idempotently:

  • For payout.status, key on (transactionId, status) — applying the same transition twice should be a no-op.
  • For payee.updated, treat it as "latest state wins" keyed by externalId.
  • Return any 2xx to acknowledge. Anything else (or a slow response) is treated as a failure and retried.

A delivery that exhausts its retries is marked failed and can be manually replayed by 8x from the admin console. If you ever miss an event, the pull endpoints are authoritative: GET /payouts/{id} and GET /payees/{id}/readiness.

Next steps

See the full payloads in the API reference, or start from the Quickstart.