Webhooks
Manage your endpoints, and the exact format of every call we send them.
/api/v1/webhooksYour endpoints/api/v1/webhooksAdd an endpoint/api/v1/webhooks/{id}Remove an endpoint/api/v1/webhooks/{id}/rotateNew signing secret/api/v1/webhooks/{id}/enableTurn a disabled endpoint back on/api/v1/webhooks/{id}/testSend a test event/api/v1/webhooks/{id}/deliveriesRecent deliveriesRegister an HTTPS endpoint and we POST a small signed JSON event to it when one of your orders changes. Events never contain logins: fetch those with /order and the order id in the event. The Webhooks guide walks through setting one up.
Managing endpoints
All of these answer JSON, errors included. Listing and the delivery log work with any key; every change needs a full key (E1701). An id that does not exist, or belongs to another account, gets E1803. At most 3 endpoints per account (E1802).
| Call | Answer |
|---|---|
GET /webhooks | {"ok": true, "webhooks": [endpoint, ...]}. Secrets are never shown here. |
POST /webhooks | Body {"url": "https://...", "events": [...]}. Answers {"ok": true, "webhook": endpoint, "secret": "whsec_..."}. The secret is shown once: store it straight away. Leave out events for every type except webhook.test; an empty list or an unknown type is E1805. |
DELETE /webhooks/{id} | {"ok": true}. Anything still queued for it is dropped. |
POST /webhooks/{id}/rotate | {"ok": true, "secret": "whsec_..."}, shown once. The old secret stops working at once: every delivery from now on, retries of earlier events included, is signed with the new one. |
POST /webhooks/{id}/enable | {"ok": true, "webhook": endpoint}, with status back to active and the failure count reset. Deliveries held while it was disabled go out on the next run. |
POST /webhooks/{id}/test | {"ok": true, "queued": true, "eventId": "evt_..."}. Queues one webhook.test event for that endpoint, whatever it is subscribed to, signed and retried like any other. |
GET /webhooks/{id}/deliveries | {"ok": true, "deliveries": [...]}, newest first. limit is 1 to 50, default 20; values outside are clamped. Each delivery has eventId, type, status, attempts, lastStatus (your endpoint's last HTTP status, null if it never answered), createdAt and deliveredAt. Only status codes are kept, never a body. Finished deliveries are kept for 30 days. |
curl -X POST "https://www.nfa.pub/api/v1/webhooks" \ -H "X-Api-Key: rsk_..." \ -H "Content-Type: application/json" \ -d '{"url":"https://hooks.example.com/nfa","events":["order.delivered","order.partial","order.refunded"]}'The endpoint object
{ "id": "wh_Q2xZa1b2C3d4E5f6", "url": "https://hooks.example.com/nfa", "events": ["order.delivered", "order.partial", "order.refunded"], "status": "active", "createdAt": "2026-10-26T12:00:00.000Z", "lastSuccessAt": "2026-10-26T12:05:01.000Z", "lastFailureAt": null, "lastStatus": 200, "disabledReason": null}statusisactive, ordisabledafter 50 failed attempts in a row;disabledReasonthen says so.lastSuccessAtandlastFailureAtare the last attempt each way, andlastStatusthe HTTP status of the most recent one.- Every timestamp in these objects is ISO 8601, UTC.
| Delivery status | Meaning |
|---|---|
pending | Waiting for its first attempt or its next retry, and held while the endpoint is disabled. |
delivered | Your endpoint answered 2xx. |
failed | All 8 attempts failed. |
expired | Still not delivered 3 days after it was queued, for example because the endpoint was disabled. |
URLs we accept
https://on the standard port (443), at most 500 characters, with no username or password and no spaces, control characters or backslashes. A#fragmentis dropped.- A public hostname: not an IP address in any notation, not
localhostor a private or reserved name (.local,.internal,.lan,.home.arpa,.test,.example,.invalid,.onionand the like), and not one of our own domains. - When you register it, the name must resolve, and only to public addresses.
- Anything else is refused with
E1801, and theerrorsentence says which rule it broke.
Which orders
- Only the account that bought the order hears about it, by the same rule as
/order: the order's buyer email is your account's email. - Only while the account has at least one active API key. Revoke every key and deliveries stop too.
- Balance top-ups are never announced.
- An endpoint gets events for changes from the time it exists. Nothing earlier is replayed.
- Leaving out
eventssubscribes an endpoint to every type that exists when you create it. A type added later (such aspreorder.created) is not added to an existing endpoint: create a new one to receive it.
Events
| Event | Sent when |
|---|---|
order.delivered | Every account on the order was delivered. |
order.partial | Some accounts were delivered and the rest refunded to your balance, a part-filled pre-order included. |
order.failed | A delivery attempt failed. It may still be retried or held for review. Money going back is its own order.refunded. A retry that fails again with nothing changed is not announced twice. |
order.refunded | Money for the order went back, in full or in part, a cancelled pre-order included. One event each time the total returned changes. |
order.updated | More accounts were added to an order after it was first delivered. |
replacement.completed | A replacement was delivered or recorded, through /replace, the website, our Discord bot or by staff. |
preorder.created | A pre-order was placed and paid through the API (preorder=1 on a buy). |
preorder.delivered | A pre-order's stock arrived and it was delivered (all of it, or the next part). The same for API and website pre-orders. |
webhook.test | Sent by the test endpoint only. |
Payload
{ "id": "evt_Zk3q9V0c1mR8xT2aLp7wQe", "type": "order.partial", "created": 1793023861, "data": { "order": "339620d0e1843b5b-cc957e7efa86a0db", "product": "rust-500-999-hours", "quantity": 2, "delivered": 1, "state": "partial", "refundedCents": 145, "currency": "EUR" }}idis unique per event and the same on every retry. De-duplicate on it.createdis unix seconds when the change happened, not when this attempt was sent.orderis the id/orderaccepts, andstateuses the same words as/order.refundedCentsis the total returned for this order so far, by any route, ornullwhen nothing has been returned.webhook.testcarries the same fields, allnullexceptcurrency.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | nfa.pub-webhooks/1.0 |
Nfa-Event-Id | The event id, the same as id in the body. |
Nfa-Event-Type | The event type, the same as type in the body. |
Nfa-Timestamp | Unix seconds when this attempt was signed, the same value as t in the signature. |
Nfa-Signature | t=<unix seconds>,v1=<hex>. v1 is the hex HMAC-SHA256 of t + "." + rawBody, keyed with your endpoint's secret. |
Verify the signature
- Use the raw request body, byte for byte. Parsing and re-serialising the JSON changes it and the signature will not match. Every retry sends the same body, signed again with a fresh
t. - The HMAC key is the whole secret exactly as you received it,
whsec_prefix included, as UTF-8 bytes. - Compare in constant time (
timingSafeEqual,hmac.compare_digest), never with==. - Reject a
tmore than 5 minutes from your clock, so a captured request cannot be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";import express from "express";// rawBody: the request body exactly as received (a Buffer or string), never// JSON.stringify(req.body) -- re-serialising changes the bytes.// secret: the whole "whsec_..." string you were given, prefix included.export function verifyNfaWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) { let t = null; const signatures = []; for (const piece of String(signatureHeader || "").split(",")) { const at = piece.indexOf("="); if (at < 1) continue; const name = piece.slice(0, at).trim(); const value = piece.slice(at + 1).trim(); if (name === "t" && /^\d{1,12}$/.test(value)) t = Number(value); if (name === "v1" && /^[0-9a-f]{64}$/.test(value)) signatures.push(value); } if (t === null || !signatures.length) return false; if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false; // stale (or clock skew) const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest(); return signatures.some((hex) => { const given = Buffer.from(hex, "hex"); return given.length === expected.length && timingSafeEqual(given, expected); });}// Express: take the raw body for this route only.const app = express();app.post("/nfa-webhook", express.raw({ type: "application/json" }), (req, res) => { if (!verifyNfaWebhook(req.body, req.get("Nfa-Signature"), process.env.NFA_WEBHOOK_SECRET)) { return res.status(400).end(); } const event = JSON.parse(req.body.toString("utf8")); if (alreadySeen(event.id)) return res.status(200).end(); // retries reuse the id queueForProcessing(event); // answer fast, work later res.status(200).end();});import hashlib, hmac, os, re, timefrom flask import Flask, request# raw_body: the request body exactly as received. secret: the whole "whsec_..."# string you were given, prefix included.def verify_nfa_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool: t, signatures = None, [] for piece in (signature_header or "").split(","): name, sep, value = piece.partition("=") name, value = name.strip(), value.strip() if not sep: continue if name == "t" and re.fullmatch(r"[0-9]{1,12}", value): t = int(value) elif name == "v1" and re.fullmatch(r"[0-9a-f]{64}", value): signatures.append(value) if t is None or not signatures: return False if abs(int(time.time()) - t) > tolerance: # stale (or clock skew) return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, sig) for sig in signatures)# Flask: request.get_data() is the raw body.app = Flask(__name__)@app.post("/nfa-webhook")def nfa_webhook(): if not verify_nfa_webhook(request.get_data(), request.headers.get("Nfa-Signature", ""), os.environ["NFA_WEBHOOK_SECRET"]): return "", 400 event = request.get_json() if already_seen(event["id"]): # retries reuse the id return "", 200 queue_for_processing(event) return "", 200No fixed IPs
Webhooks are sent from Cloudflare's network, not from a fixed IP address. The address changes and is shared with other Cloudflare traffic, so do not allowlist IPs for webhook requests: an allowlist drops real deliveries and proves nothing. Check the signature instead. It is the only proof a request came from us.
Delivery and retries
- We check for new events and due retries about once a minute.
- Answer with any 2xx within 10 seconds. Anything else, no answer, or a redirect is a failure. Redirects are never followed, so point the URL at the final address.
- Up to 8 attempts: the first, then retries 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours after each failure. After the 8th the delivery is
failed; one still pending 3 days after it was queued isexpired. - After 50 failed attempts in a row the endpoint is disabled and holds its deliveries. Fix it, then call
POST /webhooks/{id}/enable. - Delivery is at least once: the same event can arrive more than once, and events can arrive out of order. De-duplicate on the event id, and read the current state from
/orderwhen order matters. - Answer first and do the work afterwards, so a slow job never turns into a timeout and a retry.
Errors
| Code | HTTP | Meaning |
|---|---|---|
E1001 | 401 | The key is missing, malformed, revoked or unknown. |
E1002 | 429 | Too many requests. Wait for the minute to roll over and retry. See Rate limits. |
E1101 | 400 | No JSON body, or one that cannot be read. |
E1701 | 403 | This key is read-only. It can look things up, but not buy, replace, change or cancel a pre-order, export logins with include=accounts or change webhooks. |
E1702 | 403 | The request came from an IP that is not on your allowlist. |
E1801 | 400 | That webhook URL cannot be used. It must be https:// on port 443, a public hostname (not an IP address, a private or reserved name, or one of our domains), at most 500 characters, and resolve only to public addresses. The error sentence says which rule it broke. |
E1802 | 409 | The account already has 3 webhook endpoints. Delete one first. |
E1803 | 404 | No webhook with that id on this account. An id that belongs to another account gets the same answer. |
E1804 | 503 | Webhooks are not available right now, so nothing was created or rotated. Try again later. |
E1805 | 400 | The events list is not valid. Send a non-empty list of known event types; the error sentence lists them. |
Rate limits: 120 requests a minute per IP address across all of these, 20 changes a minute per account, and 6 test events a minute per account.