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

# Webhooks

# Webhooks

Webhooks notify your backend when payment, withdrawal, and payout-routing state changes. Use webhooks for real-time updates, then fetch the canonical PayChain resource before releasing value.

<Info>
  **Source of truth:** A webhook tells you an event happened. The invoice, withdrawal, or routing record remains the canonical state for fulfillment and reconciliation.
</Info>

## Delivery contract

PayChain webhook delivery is at least once. Events can be retried, manually replayed, duplicated, delayed, or delivered after the underlying invoice, withdrawal, or payout-routing resource has already moved to a later state. Do not assume strict ordering across event families, such as `invoice.*` before `withdrawal.*`, or across retries for the same resource.

Use these identifiers intentionally:

* Top-level `id` and `X-Webhook-ID` identify this webhook delivery event. Store this value to ignore duplicate deliveries of the same event.
* `data.resourceId` identifies the changed PayChain resource. It matches `data.invoiceId`, `data.withdrawalId`, or `data.payoutRoutingId` depending on `data.resourceType`.
* `data.txHash` identifies the on-chain transaction when one exists.
* `data.clientReference` is your own reference when supplied on the original API request.

Treat lifecycle events as state synchronization, not commands. For example, `withdrawal.completed` means a withdrawal resource reached `completed`; it must not create or submit another withdrawal in your system.

## What webhooks are for

Use webhooks to:

* Mark an order as paid.
* Release a digital good or service.
* Update a customer deposit ledger.
* Track withdrawal progress.
* Track automated payout routing progress.
* Alert your team when payout routing fails or a withdrawal is blocked.

## Signature verification

Verify every webhook using the raw request body.

```http theme={null}
X-Webhook-Signature: ...
X-Webhook-Timestamp: ...
X-Webhook-ID: ...
```

<Warning>
  **Raw body note:** Do not parse JSON and then re-stringify it before verification. Signature verification must use the exact raw bytes PayChain sent.
</Warning>

### Express raw-body example

```ts theme={null}
import express from 'express';
import { verifyWebhookSignature } from '@paychainhq/sdk';

const app = express();

app.post('/paychain/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const valid = verifyWebhookSignature({
    rawBody: req.body,
    signature: req.header('X-Webhook-Signature'),
    timestamp: req.header('X-Webhook-Timestamp'),
    secret: process.env.PAYCHAIN_WEBHOOK_SECRET!,
    toleranceSeconds: 300
  });

  if (!valid) {
    return res.status(400).send('invalid signature');
  }

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

  // Store event.id or X-Webhook-ID to deduplicate this delivery.
  // Use event.data.resourceId to fetch the canonical PayChain resource.
  // Apply resource state changes idempotently; do not treat lifecycle events as commands.

  return res.sendStatus(200);
});
```

### Next.js route handler example

```ts theme={null}
import { NextResponse } from 'next/server';
import { verifyWebhookSignature } from '@paychainhq/sdk';

export async function POST(request: Request) {
  const rawBody = await request.text();

  const valid = verifyWebhookSignature({
    rawBody,
    signature: request.headers.get('X-Webhook-Signature'),
    timestamp: request.headers.get('X-Webhook-Timestamp'),
    secret: process.env.PAYCHAIN_WEBHOOK_SECRET!,
    toleranceSeconds: 300
  });

  if (!valid) {
    return new NextResponse('invalid signature', { status: 400 });
  }

  const event = JSON.parse(rawBody);

  // Store event.id or X-Webhook-ID to deduplicate this delivery.
  // Use event.data.resourceId to fetch the canonical PayChain resource.
  // Apply resource state changes idempotently; do not treat lifecycle events as commands.

  return NextResponse.json({ received: true });
}
```

## Recommended handler flow

1. Receive the webhook request.
2. Read the raw body.
3. Verify the signature and timestamp.
4. Store the top-level webhook `id` or `X-Webhook-ID` and return `2xx` for duplicates.
5. Use `data.resourceId` and `data.resourceType` to fetch the canonical invoice, withdrawal, or routing record from PayChain.
6. Apply resource state transitions idempotently. Do not move a completed local payout back to processing because an older event arrived late.
7. Treat `withdrawal.*` events as status updates only. Never create a new withdrawal from a `withdrawal.completed` webhook.
8. Return a `2xx` response quickly.

## Common event families

| Event family               | Meaning                                     |
| -------------------------- | ------------------------------------------- |
| `invoice.*`                | Invoice lifecycle changed.                  |
| `withdrawal.*`             | Withdrawal lifecycle changed.               |
| `invoice.payout_routing.*` | Automated payout routing lifecycle changed. |

Common examples:

* `invoice.paid`
* `invoice.overpaid`
* `invoice.failed`
* `withdrawal.created`
* `withdrawal.processing`
* `withdrawal.completed`
* `withdrawal.failed`
* `invoice.payout_routing.started`
* `invoice.payout_routing.completed`
* `invoice.payout_routing.failed`

## Example payloads

Webhook payloads include a webhook delivery ID, event type, business ID, timestamp, and a `data` object for the resource that changed. The top-level `id` is the webhook delivery/event ID. The changed resource ID is inside `data.id` and also in the typed field, such as `data.invoiceId` or `data.withdrawalId`. Use `event` as the canonical event type; `type` is included as a compatibility alias.

### Invoice paid

```json theme={null}
{
  "id": "evt_01HX...",
  "event": "invoice.paid",
  "type": "invoice.paid",
  "businessId": "biz_123",
  "timestamp": "2026-05-20T12:30:01.000Z",
  "createdAt": "2026-05-20T12:30:00.000Z",
  "data": {
    "id": "inv_123",
    "invoiceId": "inv_123",
    "resourceId": "inv_123",
    "resourceType": "invoice",
    "businessId": "biz_123",
    "status": "paid",
    "state": "completed",
    "txHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
    "metadata": {
      "orderId": "order_123"
    }
  }
}
```

### Withdrawal completed

```json theme={null}
{
  "id": "evt_01HY...",
  "event": "withdrawal.completed",
  "type": "withdrawal.completed",
  "businessId": "biz_123",
  "timestamp": "2026-05-20T12:36:01.000Z",
  "createdAt": "2026-05-20T12:36:00.000Z",
  "data": {
    "id": "wd_123",
    "withdrawalId": "wd_123",
    "resourceId": "wd_123",
    "resourceType": "withdrawal",
    "businessId": "biz_123",
    "status": "completed",
    "state": "completed",
    "destination": "0x0000000000000000000000000000000000000000",
    "clientReference": "payout_123",
    "txHash": "0x0000000000000000000000000000000000000000000000000000000000000000"
  }
}
```

### Payout routing failed

```json theme={null}
{
  "id": "evt_01HZ...",
  "event": "invoice.payout_routing.failed",
  "type": "invoice.payout_routing.failed",
  "businessId": "biz_123",
  "timestamp": "2026-05-20T12:40:01.000Z",
  "createdAt": "2026-05-20T12:40:00.000Z",
  "data": {
    "id": "route_run_123",
    "invoiceId": "inv_123",
    "payoutRoutingId": "route_run_123",
    "resourceId": "route_run_123",
    "resourceType": "invoice_payout_routing",
    "failureReason": "Payout quota exceeded before all recipient legs could be created.",
    "payoutRouteId": "route_123"
  }
}
```

<Warning>
  **Fulfillment note:** Do not fulfill from the webhook payload alone. Verify the signature, store the event ID, fetch the invoice or withdrawal from PayChain, then apply your business action once.
</Warning>

## Retry behavior

If your endpoint does not return a successful response, PayChain retries delivery according to the configured retry policy. Repeated failures eventually move the delivery to a failed state. Retryable failures, manual retries, and manual replays can cause older events to arrive after newer resource states.

Return a `2xx` response only after your system has accepted the event for processing. If you need more time, store the event and process it asynchronously; do not hold the webhook request open for long-running fulfillment, payout, or customer-notification work.

<Info>
  **Manual recovery:** Failed webhook events can be retried or replayed from the webhook event APIs where supported. A replay creates a fresh delivery event.
</Info>

## API retry guidance

Use an `Idempotency-Key` for every mutating API call, including `createWithdrawal`. If a `createWithdrawal` request times out or returns a `5xx`, retry with the same `Idempotency-Key` or fetch the existing withdrawal by the returned `withdrawalId`, your `clientReference`, or the `txHash` once available. Do not submit a fresh withdrawal request with a new idempotency key until you have confirmed the original request did not create a withdrawal.

## Endpoint management

Use the webhook API to:

* Get webhook configuration.
* Update the webhook URL or enabled state.
* Rotate the webhook signing secret.
* Send a test webhook.
* List webhook events.
* Retry or replay failed events.

See [OpenAPI reference](openapi-reference) for the exact endpoint schemas.

## Common mistakes

* Treating webhook delivery as fulfillment without fetching the canonical resource.
* Not storing processed webhook IDs.
* Treating `withdrawal.completed` as a command to create another payout.
* Assuming webhook event order matches invoice, sweep, or withdrawal execution order.
* Retrying a timed-out `createWithdrawal` request with a new idempotency key before checking the canonical withdrawal.
* Verifying against parsed JSON instead of raw body.
* Taking too long before responding.
* Using the same webhook URL and secret for sandbox and live.
