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. Fourteen 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 |
payin.refunding | An onramp refund is in progress |
payin.refunded | An onramp refund completed (terminal) |
payout.created | Offramp intent created — awaiting BRS burn |
payout.processing | Burn approved / burning / scheduled / paying out |
payout.completed | PIX paid out to destination (terminal) |
payout.failed | Offramp failed; inspect data.failureCode |
payout.expired | Offramp expired before BRS receipt |
payout.refunding | An offramp refund is in progress |
payout.refunded | An offramp refund completed (terminal) |
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
thirteen standard fields inside data. A scheduled payout also has
scheduledFor. 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,
"failureCode": null,
"providerFailureCode": null,
"originalFailureCode": null,
"originalProviderFailureCode": 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 14 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 (13 fields below). |
data fields
chainId is "solana", "ethereum", or "base". Base is live in both environments (sandbox = Base Sepolia, production = Base mainnet). chainId can also be "polygon", live in both environments (sandbox = Polygon Amoy, production = Polygon mainnet).
| 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 five. |
scheduledFor | string | omitted | ISO 8601. Present only when data.status is scheduled. It is the earliest time payout processing can start, not a completion guarantee. |
statusReason | string | null | Human-readable status context. |
failureCode | enum | null | Machine-readable current failure class. See payout recovery below. |
providerFailureCode | string | null | Raw provider rejection code, when available. |
originalFailureCode | enum | null | Initial failure class when a later failed event changes failureCode. |
originalProviderFailureCode | string | null | Raw provider code for the initial failure. |
clientReference | string | null | Your foreign key from the original create call. |
amountCents | integer | BRL cents. |
chainId | "solana" | "ethereum" | "base" | "polygon" | The chain on which the intent executes. |
createdAt / updatedAt | ISO 8601 UTC | Intent timestamps. |
Payout failure recovery
Use data.failureCode to handle the current failure. If a later failed
event changes it, use data.originalFailureCode to identify the initial failure.
Do not retry the same intent.
| Failure code | Action |
|---|---|
destination_invalid, destination_ownership_mismatch, recipient_account_invalid | Correct the PIX destination. If Nora starts a refund, wait for it to complete. Create a replacement intent with a new clientReference. |
provider_rejected, burn_failed, operator_aborted, unknown | Wait for Nora recovery or contact support. |
refund_failed | The refund attempt returned the intent to failed. Wait for Nora recovery or contact support. |
data.providerFailureCode and data.originalProviderFailureCode can help
with diagnosis. Do not use either as your primary business rule because
providers can add or change codes.
An intent in failed can transition to refunding, but a refund does not
always start. Wait for refund events only after Nora starts a refund.
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
Every non-2xx response — 4xx included — and every timeout triggers a
retry on 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
stays disabled until Nora re-enables it — ask your Nora contact, with
the endpoint's URL, to turn it back on.
There is no "do not retry" status. A 4xx is retried like a 5xx,
and an endpoint that keeps failing is auto-disabled. If your handler
decides an event is not for it (unknown clientReference, already
processed), answer 2xx and drop it; log it on your side.
Set up an endpoint
Endpoints are created on the dashboard, per environment: Webhooks →
Add endpoint, paste your HTTPS URL, and copy the signing secret
shown once (it can be rotated there later). The same page has the
delivery log with every attempt, its response code and a Redeliver
button. There is no dashboard control to re-enable a disabled
endpoint: if yours gets auto-disabled, ask your Nora contact, with the
endpoint's URL, to re-enable it. The endpoint's id (ENDPOINT_ID
below) is not yet displayed on the page; ask your Nora contact for it
until it is.
Test your integration
With the endpoint registered, 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,
"failureCode": null,
"providerFailureCode": null,
"originalFailureCode": null,
"originalProviderFailureCode": 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,
"failureCode": null,
"providerFailureCode": null,
"originalFailureCode": null,
"originalProviderFailureCode": null,
"clientReference": "settle_99",
"amountCents": 30000,
"chainId": "solana",
"createdAt": "2026-05-13T11:55:00.000Z",
"updatedAt": "2026-05-13T12:00:00.000Z"
}
}
Example: payout.failed
This example shows an EFI recipient-account rejection. The Nora code is stable. The provider code is diagnostic information.
{
"id": "00000000-0000-4000-8000-000000000003",
"apiVersion": "v2",
"type": "payout.failed",
"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": "failed",
"statusReason": "EFI rejected the recipient account.",
"failureCode": "recipient_account_invalid",
"providerFailureCode": "AC03",
"originalFailureCode": "recipient_account_invalid",
"originalProviderFailureCode": "AC03",
"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.
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