Webhooks

Manage your endpoints, and the exact format of every call we send them.

GET/api/v1/webhooksYour endpoints
POST/api/v1/webhooksAdd an endpoint
DELETE/api/v1/webhooks/{id}Remove an endpoint
POST/api/v1/webhooks/{id}/rotateNew signing secret
POST/api/v1/webhooks/{id}/enableTurn a disabled endpoint back on
POST/api/v1/webhooks/{id}/testSend a test event
GET/api/v1/webhooks/{id}/deliveriesRecent deliveries

Register 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).

CallAnswer
GET /webhooks{"ok": true, "webhooks": [endpoint, ...]}. Secrets are never shown here.
POST /webhooksBody {"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.
Request
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

Endpoint
{  "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}
  • status is active, or disabled after 50 failed attempts in a row; disabledReason then says so.
  • lastSuccessAt and lastFailureAt are the last attempt each way, and lastStatus the HTTP status of the most recent one.
  • Every timestamp in these objects is ISO 8601, UTC.
Delivery statusMeaning
pendingWaiting for its first attempt or its next retry, and held while the endpoint is disabled.
deliveredYour endpoint answered 2xx.
failedAll 8 attempts failed.
expiredStill 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 #fragment is dropped.
  • A public hostname: not an IP address in any notation, not localhost or a private or reserved name (.local, .internal, .lan, .home.arpa, .test, .example, .invalid, .onion and 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 the error sentence 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 events subscribes an endpoint to every type that exists when you create it. A type added later (such as preorder.created) is not added to an existing endpoint: create a new one to receive it.

Events

EventSent when
order.deliveredEvery account on the order was delivered.
order.partialSome accounts were delivered and the rest refunded to your balance, a part-filled pre-order included.
order.failedA 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.refundedMoney for the order went back, in full or in part, a cancelled pre-order included. One event each time the total returned changes.
order.updatedMore accounts were added to an order after it was first delivered.
replacement.completedA replacement was delivered or recorded, through /replace, the website, our Discord bot or by staff.
preorder.createdA pre-order was placed and paid through the API (preorder=1 on a buy).
preorder.deliveredA 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.testSent by the test endpoint only.

Payload

POST to your endpoint
{  "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"  }}
  • id is unique per event and the same on every retry. De-duplicate on it.
  • created is unix seconds when the change happened, not when this attempt was sent.
  • order is the id /order accepts, and state uses the same words as /order.
  • refundedCents is the total returned for this order so far, by any route, or null when nothing has been returned.
  • webhook.test carries the same fields, all null except currency.

Headers

HeaderValue
Content-Typeapplication/json
User-Agentnfa.pub-webhooks/1.0
Nfa-Event-IdThe event id, the same as id in the body.
Nfa-Event-TypeThe event type, the same as type in the body.
Nfa-TimestampUnix seconds when this attempt was signed, the same value as t in the signature.
Nfa-Signaturet=<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 t more than 5 minutes from your clock, so a captured request cannot be replayed later.
Node.js
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();});
Python
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 "", 200

No 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 is expired.
  • 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 /order when order matters.
  • Answer first and do the work afterwards, so a slow job never turns into a timeout and a retry.

Errors

CodeHTTPMeaning
E1001401The key is missing, malformed, revoked or unknown.
E1002429Too many requests. Wait for the minute to roll over and retry. See Rate limits.
E1101400No JSON body, or one that cannot be read.
E1701403This 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.
E1702403The request came from an IP that is not on your allowlist.
E1801400That 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.
E1802409The account already has 3 webhook endpoints. Delete one first.
E1803404No webhook with that id on this account. An id that belongs to another account gets the same answer.
E1804503Webhooks are not available right now, so nothing was created or rotated. Try again later.
E1805400The 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.

Webhooks · API reference · nfa.pub