Webhooks
Real-time push notifications for intent state changes. Verify signatures, dedupe by event id, ack any 2xx.
Webhooks deliver intent state changes to your server in real time. Use
them to reconcile payments without polling. Every event arrives as a
signed HTTPS POST with the same envelope shape — only the type and
the contents of data change.
Explore interactively: Sandbox · Production — both link to the same OpenAPI catalog with a server-switcher.
When you'll receive events
Each intent transition that's partner-facing fires exactly one event. Ten event types total:
| Event | When you receive it |
|---|---|
payin.created | Onramp intent created — show the PIX QR code to your customer |
payin.processing | PIX confirmed or BRS minting in progress |
payin.completed | BRS minted to destination wallet (terminal) |
payin.failed | Onramp failed; inspect data.statusReason |
payin.expired | Onramp expired before payment |
payout.created | Offramp intent created — awaiting BRS burn |
payout.processing | Burn approved / burning / paying out |
payout.completed | PIX paid out to destination (terminal) |
payout.failed | Offramp failed; inspect data.statusReason |
payout.expired | Offramp expired before BRS receipt |
For event types with multiple intermediate states (payin.processing,
payout.created, payout.processing), use data.status for
fine-grained UX. The list of valid data.status values per event type
is documented in the interactive reference.
Payload shape
Every payload is a JSON object with exactly seven envelope fields and
nine inside data. Volatile fields (id, createdAt,
idempotencyKey, data.id) change per delivery; the rest is stable
for a given (type, data.status) pair.
Canonical payin.completed:
{
"id": "00000000-0000-4000-8000-000000000003",
"apiVersion": "v2",
"type": "payin.completed",
"environment": "sandbox",
"createdAt": "2026-05-13T12:00:00.000Z",
"idempotencyKey": "00000000-0000-4000-8000-000000000004",
"data": {
"id": "00000000-0000-4000-8000-000000000002",
"intentType": "onramp",
"status": "completed",
"statusReason": null,
"clientReference": "order_42",
"amountCents": 15000,
"chainId": "solana",
"createdAt": "2026-05-13T11:55:00.000Z",
"updatedAt": "2026-05-13T12:00:00.000Z"
}
}
Envelope fields
| Field | Type | Notes |
|---|---|---|
id | string (uuid) | Event identifier. Use this for idempotency — Nora may deliver the same event multiple times on retry. |
apiVersion | "v2" | Pin your handler to this. Additive changes ship as new optional fields; rename/removal bumps to v3. |
type | enum | One of the 10 event types above. |
environment | "sandbox" | "production" | Matches the API key prefix that created the intent. |
createdAt | ISO 8601 UTC | When Nora produced the event, not when delivered. |
idempotencyKey | string | null | Echoes the Idempotency-Key header from your original create call. null for intents created before this feature shipped. |
data | object | The intent block (9 fields below). |
data fields
| Field | Type | Notes |
|---|---|---|
id | string (uuid) | The intent id. Look this up via GET /v2/intents/{id} for richer detail (PIX info, party, deposit instructions). |
intentType | "onramp" | "offramp" | Coarser than type — payin.* ⇔ onramp, payout.* ⇔ offramp. |
status | enum | Current intent state. payin.processing covers both fiat_received and minting; payout.processing covers four. |
statusReason | string | null | Failure context; populated on *.failed. |
clientReference | string | null | Your foreign key from the original create call. |
amountCents | integer | BRL cents. |
chainId | "solana" | The chain on which the intent executes. |
createdAt / updatedAt | ISO 8601 UTC | Intent timestamps. |
Verify the signature
Every webhook carries three headers:
webhook-id: <uuid — same as the body's id>
webhook-timestamp: <unix seconds>
webhook-signature: v1,<base64-hmac>Nora's signing is Svix-compatible. The signature is HMAC-SHA256 over
{id}.{timestamp}.{rawBody}, base64-encoded, prefixed v1,. The signing
key is your endpoint secret with the whsec_ prefix stripped and the
remainder base64-decoded — not the raw secret string. During the
24-hour rotation window two signatures arrive separated by a space —
accept either.
The simplest correct path is any Svix-standard library:
import { Webhook } from 'svix';
// `secret` is the `whsec_...` value from the dashboard — pass it as-is.
const wh = new Webhook(secret);
const event = wh.verify(rawBody, {
'webhook-id': req.headers['webhook-id'],
'webhook-timestamp': req.headers['webhook-timestamp'],
'webhook-signature': req.headers['webhook-signature'],
});If you'd rather not add a dependency, verify by hand. Two things matter:
the secret must be whsec_-stripped and base64-decoded into the key, and
the webhook-timestamp header must be checked for freshness — otherwise an
attacker who captured a single valid delivery can replay it indefinitely.
import { createHmac, timingSafeEqual } from 'node:crypto';
const FIVE_MINUTES_SECONDS = 5 * 60;
function verifyWebhook(
rawBody: string,
webhookId: string,
webhookTimestamp: string,
signatureHeader: string,
secret: string, // the `whsec_...` value from the dashboard
): boolean {
// Replay guard: reject anything more than 5 minutes off our clock. The
// Svix library does the same — skip this and captured deliveries verify
// forever. Tune the window only if you have clock-sync issues.
const ts = Number(webhookTimestamp);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > FIVE_MINUTES_SECONDS) return false;
// The HMAC key is base64(secret-without-`whsec_`), decoded to bytes.
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const message = `${webhookId}.${webhookTimestamp}.${rawBody}`;
const expected = createHmac('sha256', key).update(message).digest();
// Header: "v1,<sig>" — or space-delimited "v1,<a> v1,<b>" during rotation.
return signatureHeader
.split(' ')
.map((part) => part.replace(/^v1,/, ''))
.some((sig) => {
const got = Buffer.from(sig, 'base64');
return got.length === expected.length && timingSafeEqual(got, expected);
});
}Reject any request whose signature doesn't verify. Don't trust the body otherwise — anyone with your endpoint URL could forge a POST.
Stay idempotent
Use event.id as your dedupe key. Persist the id (and the
(endpointId, eventId) pair if you support multi-endpoint partners)
so that a redelivered event is a no-op.
const seen = await db.webhookEventsSeen.findOne({ id: event.id });
if (seen) return res.status(200).end(); // already processed
await db.webhookEventsSeen.insert({ id: event.id, receivedAt: new Date() });
// ... handle eventRetry policy
Non-2xx responses (or timeouts after 10 seconds) trigger retries with this fixed schedule. Eight attempts total, ~27h35m to dead-letter:
| Attempt | Delay before | Cumulative |
|---|---|---|
| 1 | immediate | 0 |
| 2 | 5 seconds | 5s |
| 3 | 5 minutes | 5m 5s |
| 4 | 30 minutes | 35m 5s |
| 5 | 2 hours | 2h 35m 5s |
| 6 | 5 hours | 7h 35m 5s |
| 7 | 10 hours | 17h 35m 5s |
| 8 | 10 hours | 27h 35m 5s (dead-letter) |
After attempt 8 fails, the delivery row is marked exhausted. After
5 consecutive exhausted days, the endpoint is auto-disabled and
you must re-enable it from the dashboard.
4xx responses do not trigger retries. Use 4xx when your handler
determines the event will never succeed (e.g. unknown
clientReference). Use 5xx (or just let your handler crash) for
transient failures that should be retried.
Test your integration
Register your endpoint in the dashboard, then send sample events without creating real intents:
curl -X POST https://sandbox.api.nora.finance/v2/webhooks/endpoints/$ENDPOINT_ID/test \
-H "X-API-Key: sk_test_..." \
-H "Content-Type: application/json" \
-d '{"eventType":"payin.completed"}'You receive the canonical payload above (with fresh id, createdAt,
idempotencyKey, and data.id), signed and ready for your handler.
Testing non-canonical cases
For event types with multiple internal statuses (payin.processing,
payout.created, payout.processing), pass an explicit caseId:
curl -X POST https://sandbox.api.nora.finance/v2/webhooks/endpoints/$ENDPOINT_ID/test \
-H "X-API-Key: sk_test_..." \
-H "Content-Type: application/json" \
-d '{"eventType":"payin.processing","caseId":"payin.processing.minting"}'The valid caseId values per eventType are enumerated in the
interactive API reference.
Example: payin.created
Sent when an onramp intent is created and ready to receive PIX payment. Show the PIX QR code from the create response (not from this event) to your customer.
{
"id": "00000000-0000-4000-8000-000000000003",
"apiVersion": "v2",
"type": "payin.created",
"environment": "sandbox",
"createdAt": "2026-05-13T12:00:00.000Z",
"idempotencyKey": "00000000-0000-4000-8000-000000000004",
"data": {
"id": "00000000-0000-4000-8000-000000000002",
"intentType": "onramp",
"status": "awaiting_fiat_payment",
"statusReason": null,
"clientReference": "order_42",
"amountCents": 15000,
"chainId": "solana",
"createdAt": "2026-05-13T11:55:00.000Z",
"updatedAt": "2026-05-13T12:00:00.000Z"
}
}
Example: payout.completed
Sent when the PIX payout to the destination has been confirmed. The offramp is terminal.
{
"id": "00000000-0000-4000-8000-000000000003",
"apiVersion": "v2",
"type": "payout.completed",
"environment": "sandbox",
"createdAt": "2026-05-13T12:00:00.000Z",
"idempotencyKey": "00000000-0000-4000-8000-000000000004",
"data": {
"id": "00000000-0000-4000-8000-000000000002",
"intentType": "offramp",
"status": "completed",
"statusReason": null,
"clientReference": "settle_99",
"amountCents": 30000,
"chainId": "solana",
"createdAt": "2026-05-13T11:55:00.000Z",
"updatedAt": "2026-05-13T12:00:00.000Z"
}
}
Versioning
This is apiVersion: "v2". Within v2, we may add optional
fields without breaking your handler — write tolerant parsers that
ignore unknown fields. We will never remove or rename fields in
v2; that bumps to v3 with a minimum 6-month deprecation window.
Watch the CHANGELOG
for version history.
Next
- Onramp flow — when a
payin.*event fires - Offramp flow — when a
payout.*event fires - Error handling — handler retry semantics