Webhooks
Receive real-time events from 0fra. HMAC-signed, retried, replay-safe.
Webhooks are how your backend learns about payments without polling. Each event is signed with a per-endpoint secret and delivered with exponential backoff on failure.
Register an endpoint
curl -X POST https://api.0fra.dev/v1/webhooks/endpoints \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://acme.example/0fra/hook",
"subscribed_events": [
"order.confirmed",
"refund.completed",
"settlement.processed"
]
}'{
"id": "...",
"url": "https://acme.example/0fra/hook",
"subscribed_events": [...],
"secret": "whsec_2eb2cae9..." // shown once
}Save the secret — you'll need it to verify incoming requests.
Event catalog
| Event | When |
|---|---|
order.pending_payment | Order created, waiting for the buyer to pay on-chain |
order.confirmed | Chain indexer saw OrderPaid with required confirms |
order.failed | Order didn't confirm before its deadline_at |
refund.created | Refund issued (state=processing for chain refunds) |
refund.completed | Refund finalized (reserve-funded → instant) |
settlement.processed | Reserve release / payout batch finalized |
dispute.opened | Reserved for future use |
dispute.resolved | Reserved for future use |
Verifying signatures
0fra sends three headers per delivery:
| Header | Example |
|---|---|
X-Webhook-Id | whdel_... (delivery row id) |
X-Webhook-Timestamp | Unix seconds |
X-Webhook-Signature | v1=<hex> |
Compute the expected signature:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(req, rawBody, secret) {
const ts = req.headers['x-webhook-timestamp'];
const sig = req.headers['x-webhook-signature']?.replace('v1=', '');
const expected = createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
return sig && timingSafeEqual(
Buffer.from(sig, 'hex'),
Buffer.from(expected, 'hex'),
);
}Reject any request older than 5 minutes (replay window).
Use the raw request body — not the JSON-parsed object. If you re-stringify, formatting differences will break the HMAC.
Payload shape
All events share the same envelope:
{
"id": "evt_...",
"type": "order.confirmed",
"created_at": "2026-04-25T12:00:00Z",
"data": {
"order": { /* full order object */ },
"split": {
"service_fee": "1000000",
"platform_fee": "10000000",
"merchant_net": "89000000",
"reserve_hold": "4450000"
}
}
}Always assert on type first — the data shape varies per event.
Retries
If your endpoint returns anything other than 2xx (or fails to respond within 10 s), 0fra retries with exponential backoff:
attempt 1 → fail
attempt 2 ← 2 s later
attempt 3 ← 4 s later
attempt 4 ← 8 s later
...
attempt 8 ← 256 s later
permanent failure → recorded; not retriedA 4xx response (other than 408 / 429) is treated as a permanent failure — your code rejected the event, no point retrying.
At-least-once delivery
Webhook delivery is at-least-once. Use the id field to deduplicate on your side, especially if your handler is non-idempotent.
Best practices
- Return 200 immediately, then process async. Slow handlers cause retries.
- Stream-read
req.bodyraw before any framework body-parser mangles it. - Keep one secret per environment (test / live). Rotate by deleting + recreating the endpoint.
- Log
X-Webhook-Ideverywhere — makes incident triage 10× easier.