0fra

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

EventWhen
order.pending_paymentOrder created, waiting for the buyer to pay on-chain
order.confirmedChain indexer saw OrderPaid with required confirms
order.failedOrder didn't confirm before its deadline_at
refund.createdRefund issued (state=processing for chain refunds)
refund.completedRefund finalized (reserve-funded → instant)
settlement.processedReserve release / payout batch finalized
dispute.openedReserved for future use
dispute.resolvedReserved for future use

Verifying signatures

0fra sends three headers per delivery:

HeaderExample
X-Webhook-Idwhdel_... (delivery row id)
X-Webhook-TimestampUnix seconds
X-Webhook-Signaturev1=<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 retried

A 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.body raw before any framework body-parser mangles it.
  • Keep one secret per environment (test / live). Rotate by deleting + recreating the endpoint.
  • Log X-Webhook-Id everywhere — makes incident triage 10× easier.

On this page