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 type | Sent when |
|---|---|
payment.created | A payment object was created and accepted for orchestration. |
payment.processing | The payment is in flight at a provider, or awaiting a customer action. |
payment.authorized | Funds were authorised and are held for capture (capture_method = manual). |
payment.successful | The payment completed. For automatic capture this is the terminal success event. |
payment.failed | No eligible provider completed the payment. The failure object carries the code and category. |
payment.cancelled | The payment was cancelled before completion, by the merchant or by expiry of an authorisation. |
payment.refunded | The payment reached refunded or partially_refunded after a refund settled. |
refund.successful | A refund was accepted by the provider. |
refund.failed | A refund was rejected by the provider. The payment amounts are unchanged. |
payout.created | A payout object was created and queued for a provider. |
payout.successful | The provider confirmed the payout. |
payout.failed | The payout was rejected. The failure object carries the code and category. |
settlement.created | A provider settlement record was ingested for the merchant. |
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.
{
"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"
}
}
}| Field | Meaning |
|---|---|
id | Event id, prefixed evt_. Stable across every delivery attempt and every endpoint. Deduplicate on this. |
type | The event type from the table above. |
mode | test or live. Endpoints are per mode, so this should always match the endpoint you registered. |
created_at | When the event was emitted, ISO 8601 UTC. Not when this delivery attempt was made. |
data.object | The payment, refund, payout or settlement object as it stood when the event was emitted. |
Alongside the body, each request carries identifying 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| Header | Meaning |
|---|---|
natio-signature | The signature to verify: t=<unix>,v1=<hex>. |
natio-event-id | The event id, matching id in the body. |
natio-event-type | The event type, for cheap routing before parsing. |
natio-delivery-id | This delivery to this endpoint. Quote it in support requests about a missing webhook. |
natio-delivery-attempt | 1-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: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">To verify:
- Split the header on commas into
tandv1. - Reject the request if
tis 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. - Compute
HMAC-SHA256(secret, "<t>.<raw body>")and hex-encode it. - Compare it to
v1with a constant-time comparison.
Node
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
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 "", 200Retry 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.
| Attempt | Sent |
|---|---|
1 | immediately when the event is emitted |
2 | 30 seconds after attempt 1 |
3 | 2 minutes after attempt 2 |
4 | 10 minutes after attempt 3 |
5 | 30 minutes after attempt 4 |
6 | 2 hours after attempt 5 |
7 and later | 2 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 status | Meaning |
|---|---|
pending | Queued, not yet attempted, or waiting for its next scheduled attempt. |
delivering | An attempt is in flight. A delivery is claimed before sending, so it is never sent twice concurrently. |
succeeded | A 2xx was received. No further attempts. |
failed | The last attempt did not succeed and another one is scheduled. |
exhausted | The attempt limit was reached. Only a manual resend will try again. |
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.
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
| Rule | Why |
|---|---|
| Respond 2xx in milliseconds, process asynchronously | The delivery times out server-side. Acknowledge, enqueue, return. Never do business logic inside the request. |
Deduplicate on event.id | Delivery is at-least-once. Retries, manual resends and network ambiguity all produce repeats of the same event id. |
| Make the handler idempotent | Deduplication is a cache, not a guarantee. Writing the same terminal state twice must be harmless. |
| Verify before you parse, and use the raw body | An unverified payload is untrusted input. Parsing first also tempts you to re-serialise, which breaks the signature. |
| Ignore event types you do not handle | New 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 truth | Events 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 received | It is the fastest way to have one specific delivery investigated. |
| Keep the endpoint on HTTPS and publicly reachable | Endpoint URLs are validated when saved; private and loopback addresses are refused in production. |
| Rotate the signing secret like an API key | Create the new endpoint, run both, move traffic, delete the old one. |