Webhooks
The webhook is the primary and only reliable channel telling you a payment
happened. The customer landing on return_url is not proof: the tab can be closed
while the money still goes through.
On every status change we POST to the webhook URL of the project
that owns the payment. The address is set on the
project page.
Headers
| Header | Value |
|---|---|
X-Signature | HMAC-SHA256 over the raw request body, hex-encoded. |
X-Key-Prefix | First 10 characters of the signing key. Needed during rotation — see below. |
X-Payment-Id | The payment ID. |
X-Webhook-Version | Format version, currently 2. |
Body
{
"id": "uuid",
"external_order_id": "order-123",
"amount": "1000.00",
"amount_net": "820.00",
"fee": "180.00",
"status": "succeeded",
"is_test": false,
"paid_at": "2026-05-19T12:01:32Z",
"timestamp": "2026-05-19T12:01:32.512Z",
"nonce": "8f3a..."
} -
amountis what the customer was charged (gross);amount_netis what you receive (gross minusfee). In "customer pays the fee" mode reconcile againstamount_net— it equals the amount you passed to create. -
is_testistruefor payments made before the account was activated. Accept and verify such a webhook, but never fulfil an order on it. timestampandnonceare the replay defence — see below.
Verifying the signature
The signature is computed as:
X-Signature = HMAC_SHA256(key = hex(SHA256(api_key)), msg = raw_body) → hex The HMAC secret is not the key itself but its SHA-256 hash in hex. That is deliberate: the hash is all we store — we do not know the key and cannot recover it.
🔴 Compute the signature over the raw request body, before parsing JSON. Parsing and re-serialising changes key order and whitespace, and the signature will not match. This is the single most common integration mistake.
Signing uses the project's newest active key. So during rotation, while a
project holds several active keys, use X-Key-Prefix to decide which one to verify
against.
Verify in Node.js
import crypto from "node:crypto";
function verifyWebhook(rawBody, signature, apiKey) {
const signingSecret = crypto.createHash("sha256").update(apiKey).digest("hex");
const expected = crypto.createHmac("sha256", signingSecret)
.update(rawBody)
.digest("hex");
if (expected !== signature) return false;
// Replay defense: reject bodies older than 5 minutes.
const body = JSON.parse(rawBody);
const ts = Date.parse(body.timestamp);
if (Date.now() - ts > 5 * 60 * 1000) return false;
return true;
} Verify in Python
import hmac, hashlib, json
from datetime import datetime, timezone
def verify_webhook(raw_body: bytes, signature: str, api_key: str) -> bool:
signing_secret = hashlib.sha256(api_key.encode()).hexdigest()
expected = hmac.new(signing_secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return False
body = json.loads(raw_body)
ts = datetime.fromisoformat(body["timestamp"])
age = (datetime.now(timezone.utc) - ts).total_seconds()
return age <= 300 # reject if > 5 minutes old Replay defence
The signed body carries timestamp (ISO 8601) and nonce. Reject
anything older than 5 minutes — otherwise a valid request captured once can be
replayed indefinitely, signature and all.
What counts as delivery
Delivery means a 2xx response. Everything else is a reason to retry, redirects
included: 301 and 302 are not success and we do not
follow them.
Check this specifically. An endpoint that answers with a redirect — typically a framework moving/webhookto/webhook/or to https — looks fine in a browser while accepting no notifications at all. It usually surfaces as unfulfilled orders, not as an error.
Six attempts, until a 2xx arrives:
| Attempt | After |
|---|---|
| 1 | immediately |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
After the sixth failure delivery stops. Recover state with GET /api/v1/payments/{id}.
Your handler must be idempotent
The same payment can reach you more than once: a retry after a network timeout that swallowed
your response is normal. Key off the payment id and whether the order is already
processed, not off the fact that a request arrived.