Guides

Webhooks

Webhooks are how your systems learn that something changed without polling. Every event is persisted before any delivery is attempted, every delivery is signed, and every attempt is recorded and replayable.

Event types

An endpoint subscribes to a list of event types, or to * for all of them. Unknown types are rejected when the endpoint is saved, so a typo fails loudly rather than silently dropping traffic.

Event typeSent when
payment.createdA payment object was created and accepted for orchestration.
payment.processingThe payment is in flight at a provider, or awaiting a customer action.
payment.authorizedFunds were authorised and are held for capture (capture_method = manual).
payment.successfulThe payment completed. For automatic capture this is the terminal success event.
payment.failedNo eligible provider completed the payment. The failure object carries the code and category.
payment.cancelledThe payment was cancelled before completion, by the merchant or by expiry of an authorisation.
payment.refundedThe payment reached refunded or partially_refunded after a refund settled.
refund.successfulA refund was accepted by the provider.
refund.failedA refund was rejected by the provider. The payment amounts are unchanged.
payout.createdA payout object was created and queued for a provider.
payout.successfulThe provider confirmed the payout.
payout.failedThe payout was rejected. The failure object carries the code and category.
settlement.createdA provider settlement record was ingested for the merchant.
Subscribe narrowly
Most integrations need only the terminal events: payment.successful, payment.failed, payment.cancelled, payment.refunded and the refund and payout results. Subscribing to everything multiplies deliveries without adding information.

Payload envelope

Every delivery has the same top-level shape. The object that changed is always nested under data.object and is the same serialisation you get from the corresponding API endpoint, so one code path can handle both.

Request body
{
  "id": "evt_8sQ1mB4nZpL2xR7wT0dK",
  "type": "payment.successful",
  "mode": "test",
  "created_at": "2026-09-22T02:10:34.159Z",
  "data": {
    "object": {
      "id": "pay_lfsWb45Pf5wJ1ZGgROzH",
      "object": "payment",
      "mode": "test",
      "status": "successful",
      "amount": 10000,
      "currency": "USD",
      "captured_amount": 10000,
      "refunded_amount": 0,
      "reference": "ORD-1001",
      "route": {
        "provider": { "code": "demo_acquirer_b", "name": "NATIO Demo Acquirer B" },
        "attempts": 2,
        "rule": "Cards → Acquirer A, fallback Acquirer B"
      },
      "failure": null,
      "created_at": "2026-09-22T02:10:33.845Z"
    }
  }
}
FieldMeaning
idEvent id, prefixed evt_. Stable across every delivery attempt and every endpoint. Deduplicate on this.
typeThe event type from the table above.
modetest or live. Endpoints are per mode, so this should always match the endpoint you registered.
created_atWhen the event was emitted, ISO 8601 UTC. Not when this delivery attempt was made.
data.objectThe payment, refund, payout or settlement object as it stood when the event was emitted.

Alongside the body, each request carries identifying headers:

Delivery headers
POST /webhooks/natio HTTP/1.1
content-type: application/json
user-agent: NATIO-Webhooks/1.0
natio-signature: t=1758507034,v1=9f2c1b...c47a
natio-event-id: evt_8sQ1mB4nZpL2xR7wT0dK
natio-event-type: payment.successful
natio-delivery-id: whd_3pQ8xV2kR9mL1nW6tY4z
natio-delivery-attempt: 1
HeaderMeaning
natio-signatureThe signature to verify: t=<unix>,v1=<hex>.
natio-event-idThe event id, matching id in the body.
natio-event-typeThe event type, for cheap routing before parsing.
natio-delivery-idThis delivery to this endpoint. Quote it in support requests about a missing webhook.
natio-delivery-attempt1-based attempt number. Anything above 1 means an earlier attempt did not get a 2xx.

Signature verification

Each endpoint has its own signing secret (whsec_…), shown once when the endpoint is created. The header is:

Natio-Signature
Natio-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">

To verify:

  1. Split the header on commas into t and v1.
  2. Reject the request if t is more than 5 minutes away from your current time, in either direction. The timestamp is inside the signed payload, so an attacker cannot move it.
  3. Compute HMAC-SHA256(secret, "<t>.<raw body>") and hex-encode it.
  4. Compare it to v1 with a constant-time comparison.
Use the raw body, always
The signature covers the exact bytes NATIO sent. If a body parser has already turned the request into an object, re-serialising it changes whitespace, key order and number formatting, and the signature will never match. Capture the raw buffer before any JSON middleware runs, and keep it as bytes or as a UTF-8 string — never as a parsed object.

Node

Node — node:crypto
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300; // 5 minutes

/**
 * Verify a Natio-Signature header against the RAW request body.
 * @param {string} rawBody  the exact bytes NATIO sent, as a string
 * @param {string} header   the value of the Natio-Signature header
 * @param {string} secret   your endpoint signing secret (whsec_...)
 */
export function verifyNatioSignature(rawBody, header, secret) {
  if (!header) return false;

  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
    }),
  );

  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!Number.isFinite(t) || !v1) return false;

  // Reject replays: the timestamp is signed, so it cannot be tampered with.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

// ---------------------------------------------------------------------------
// Express: express.raw() keeps the body as a Buffer, so nothing re-serialises it.
// ---------------------------------------------------------------------------
import express from "express";

const app = express();
const seen = new Set(); // replace with a durable store keyed on event id

app.post("/webhooks/natio", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");

  if (!verifyNatioSignature(rawBody, req.get("natio-signature"), process.env.NATIO_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(rawBody);

  // 1. Acknowledge immediately — do not process inside the request.
  res.sendStatus(200);

  // 2. Deduplicate on the event id: the same event can arrive more than once.
  if (seen.has(event.id)) return;
  seen.add(event.id);

  // 3. Hand off to your queue.
  void enqueue(event);
});

app.listen(3000);

Python

Python — hmac / hashlib
import hashlib
import hmac
import json
import time

TOLERANCE_SECONDS = 300  # 5 minutes


def verify_natio_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
    """Verify a Natio-Signature header against the RAW request body."""
    if not header:
        return False

    parts = {}
    for kv in header.split(","):
        key, _, value = kv.partition("=")
        parts[key.strip()] = value.strip()

    try:
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False

    # Reject replays.
    if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
        return False

    signed_payload = f"{t}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)


# ---------------------------------------------------------------------------
# Flask: request.get_data() returns the raw bytes, before any JSON parsing.
# ---------------------------------------------------------------------------
import os

from flask import Flask, request

app = Flask(__name__)
seen = set()  # replace with a durable store keyed on event id


@app.post("/webhooks/natio")
def natio_webhook():
    raw_body = request.get_data()

    if not verify_natio_signature(raw_body, request.headers.get("Natio-Signature"), os.environ["NATIO_WEBHOOK_SECRET"]):
        return "", 400

    event = json.loads(raw_body)

    if event["id"] not in seen:
        seen.add(event["id"])
        enqueue(event)  # process asynchronously

    return "", 200

Retry schedule

A delivery succeeds on any 2xx. Anything else — a 4xx, a 5xx, a connection error or a timeout — is a failure and is retried with a fixed backoff. Redirects are not followed.

AttemptSent
1immediately when the event is emitted
230 seconds after attempt 1
32 minutes after attempt 2
410 minutes after attempt 3
530 minutes after attempt 4
62 hours after attempt 5
7 and later2 hours between attempts (the last interval repeats)

The maximum number of attempts is configurable per deployment and defaults to 6. When it is reached, the delivery is marked exhausted and stops. The event itself is never lost: it remains in the delivery history and can be resent manually.

Delivery statusMeaning
pendingQueued, not yet attempted, or waiting for its next scheduled attempt.
deliveringAn attempt is in flight. A delivery is claimed before sending, so it is never sent twice concurrently.
succeededA 2xx was received. No further attempts.
failedThe last attempt did not succeed and another one is scheduled.
exhaustedThe attempt limit was reached. Only a manual resend will try again.
Respond fast
Deliveries time out server-side. If your handler does real work before answering — writing to a database, calling another service — a slow dependency turns into a failed delivery and a retry storm. Answer 2xx first, process afterwards.

Delivery history and manual resend

Every delivery and every individual attempt is stored: the request headers that were sent (with the signature truncated), the response status, the response body up to 2 KB, the error if the request never completed, and the duration. Open Webhooks in the dashboard to inspect them.

  • Filter deliveries by endpoint, event type and status to find what did not land.
  • Open a delivery to see the exact payload that was sent and the response your server returned on each attempt.
  • Resend replays that delivery immediately with the same event id and the same payload. It is safe precisely because you deduplicate on the event id.
  • Resending works even for an exhausted delivery, and even while the endpoint is disabled, so you can fix a receiver and then replay what it missed.

Sending a test event

POST /v1/webhooks/test emits an event of the type you name to the endpoints registered for that key, so you can build and debug a receiver without creating payments.

Test event
curl -X POST https://api.natio.me/v1/webhooks/test \
  -H "Authorization: Bearer natio_sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "event_type": "payment.successful" }'

The test event is signed and delivered exactly like a real one, including retries and delivery history. There is also a test button on each endpoint in the dashboard.

Best practices

RuleWhy
Respond 2xx in milliseconds, process asynchronouslyThe delivery times out server-side. Acknowledge, enqueue, return. Never do business logic inside the request.
Deduplicate on event.idDelivery is at-least-once. Retries, manual resends and network ambiguity all produce repeats of the same event id.
Make the handler idempotentDeduplication is a cache, not a guarantee. Writing the same terminal state twice must be harmless.
Verify before you parse, and use the raw bodyAn unverified payload is untrusted input. Parsing first also tempts you to re-serialise, which breaks the signature.
Ignore event types you do not handleNew event types can appear. Return 2xx for them rather than 400, or you will generate retries for events you do not care about.
Treat the event as a notification, not as the truthEvents can arrive out of order. When ordering matters, re-read the object with GET /v1/payments/{id} and act on that.
Store the delivery id you receivedIt is the fastest way to have one specific delivery investigated.
Keep the endpoint on HTTPS and publicly reachableEndpoint URLs are validated when saved; private and loopback addresses are refused in production.
Rotate the signing secret like an API keyCreate the new endpoint, run both, move traffic, delete the old one.