nora

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:

EventWhen you receive it
payin.createdOnramp intent created — show the PIX QR code to your customer
payin.processingPIX confirmed or BRS minting in progress
payin.completedBRS minted to destination wallet (terminal)
payin.failedOnramp failed; inspect data.statusReason
payin.expiredOnramp expired before payment
payin.refundingAn onramp refund is in progress
payin.refundedAn onramp refund completed (terminal)
payout.createdOfframp intent created — awaiting BRS burn
payout.processingBurn approved / burning / scheduled / paying out
payout.completedPIX paid out to destination (terminal)
payout.failedOfframp failed; inspect data.failureCode
payout.expiredOfframp expired before BRS receipt
payout.refundingAn offramp refund is in progress
payout.refundedAn 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

FieldTypeNotes
idstring (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.
typeenumOne of the 14 event types above.
environment"sandbox" | "production"Matches the API key prefix that created the intent.
createdAtISO 8601 UTCWhen Nora produced the event, not when delivered.
idempotencyKeystring | nullEchoes the Idempotency-Key header from your original create call. null for intents created before this feature shipped.
dataobjectThe 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).

FieldTypeNotes
idstring (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.
statusenumCurrent intent state. payin.processing covers both fiat_received and minting; payout.processing covers five.
scheduledForstring | omittedISO 8601. Present only when data.status is scheduled. It is the earliest time payout processing can start, not a completion guarantee.
statusReasonstring | nullHuman-readable status context.
failureCodeenum | nullMachine-readable current failure class. See payout recovery below.
providerFailureCodestring | nullRaw provider rejection code, when available.
originalFailureCodeenum | nullInitial failure class when a later failed event changes failureCode.
originalProviderFailureCodestring | nullRaw provider code for the initial failure.
clientReferencestring | nullYour foreign key from the original create call.
amountCentsintegerBRL cents.
chainId"solana" | "ethereum" | "base" | "polygon"The chain on which the intent executes.
createdAt / updatedAtISO 8601 UTCIntent 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 codeAction
destination_invalid, destination_ownership_mismatch, recipient_account_invalidCorrect 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, unknownWait for Nora recovery or contact support.
refund_failedThe 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 event

Retry policy

Every non-2xx response — 4xx included — and every timeout triggers a retry on this fixed schedule. Eight attempts total, ~27h35m to dead-letter:

AttemptDelay beforeCumulative
1immediate0
25 seconds5s
35 minutes5m 5s
430 minutes35m 5s
52 hours2h 35m 5s
65 hours7h 35m 5s
710 hours17h 35m 5s
810 hours27h 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

On this page