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.
Source of truth: A webhook tells you an event happened. The invoice, withdrawal, or routing record remains the canonical state for fulfillment and reconciliation.
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 operators when payout routing fails or a withdrawal is blocked.
Signature verification
Verify every webhook using the raw request body.
X-Webhook-Signature: ...
X-Webhook-Timestamp: ...
X-Webhook-ID: ...
Raw body note: Do not parse JSON and then re-stringify it before verification. Signature verification must use the exact raw bytes PayChain sent.
Express raw-body example
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 and process idempotently.
// Fetch the canonical invoice, withdrawal, or routing record before fulfillment.
return res.sendStatus(200);
});
Next.js route handler example
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 and process idempotently.
// Fetch the canonical invoice, withdrawal, or routing record before fulfillment.
return NextResponse.json({ received: true });
}
Recommended handler flow
- Receive the webhook request.
- Read the raw body.
- Verify the signature and timestamp.
- Check idempotency using the webhook event ID.
- Fetch the invoice, withdrawal, or routing record from PayChain.
- Apply your business action once.
- 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 an event ID, event type, environment, business ID, timestamp, and a data object for the resource that changed. The exact schema may vary by event, so always use the event type and then fetch the canonical resource before fulfilling an order or marking a payout complete.
Invoice paid
{
"id": "evt_01HX...",
"type": "invoice.paid",
"environment": "live",
"businessId": "biz_123",
"createdAt": "2026-05-20T12:30:00.000Z",
"data": {
"invoiceId": "inv_123",
"status": "paid",
"customerId": "cus_123",
"amount": "100.00",
"paidAmount": "100.00",
"token": "USDC",
"chain": "evm",
"networkId": "base-mainnet",
"transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000000",
"metadata": {
"orderId": "order_123"
}
}
}
Withdrawal completed
{
"id": "evt_01HY...",
"type": "withdrawal.completed",
"environment": "live",
"businessId": "biz_123",
"createdAt": "2026-05-20T12:36:00.000Z",
"data": {
"withdrawalId": "wd_123",
"status": "completed",
"amount": "50.00",
"token": "USDC",
"chain": "evm",
"networkId": "base-mainnet",
"destinationAddress": "0x0000000000000000000000000000000000000000",
"clientReference": "payout_123",
"transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000000"
}
}
Payout routing failed
{
"id": "evt_01HZ...",
"type": "invoice.payout_routing.failed",
"environment": "live",
"businessId": "biz_123",
"createdAt": "2026-05-20T12:40:00.000Z",
"data": {
"invoiceId": "inv_123",
"routingId": "route_run_123",
"status": "failed",
"failureReason": "Payout quota exceeded before all recipient legs could be created.",
"payoutRouteId": "route_123"
}
}
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.
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.
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.
Manual recovery: Failed webhook events can be retried or replayed from the webhook event APIs where supported. A replay creates a fresh delivery event.
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 for the exact endpoint schemas.
Common mistakes
- Treating webhook delivery as fulfillment without fetching the canonical resource.
- Not storing processed webhook IDs.
- Verifying against parsed JSON instead of raw body.
- Taking too long before responding.
- Using the same webhook URL and secret for sandbox and live.