The callback we send you

POSThttps://<your-endpoint>

When an order reaches a final state — delivered, failed or refunded — we POST it to the URL you registered, as a whole order object with a signature you can verify came from us · Your endpoint has to do three things, in this order: (1) verify the signature against the raw body before parsing it → (2) answer 2xx as fast as you can → (3) then do the work, keeping it idempotent on order_id · ⚠️ A callback is a convenience notification, not the source of truth: your system has to stay correct even if not one of them ever arrives (see the warnings at the end of this page).

Verify the signature against the raw body, before parsing

The signed message is t, a single dot, then the entire request body exactly as it arrived on the wirenever parse and re-serialise it. JSON.parse() followed by JSON.stringify() gives you a different string (key order, whitespace, escaping), and the signature will then fail on every single delivery with nothing wrong on our side · Most frameworks need to be told to keep the raw body (a raw-body option or middleware) — do that first · Always compare the HMAC in constant time; never compare with an equality operator, because the time the comparison takes leaks information about the key · Trust nothing in the body until the signature verifies: anyone can POST to your URL.

Callbacks are not the source of truth — reading the order is

Your system has to stay correct even if it never receives a single callback · Delivery is not guaranteed: an endpoint we cannot reach is retried a limited number of times and then abandoned, nothing brings that event back afterwards, and there is no endpoint for you to request a resend · You can receive the same event more than once, so make your receiver idempotent on order_id — a duplicate must not deliver goods to your own customer twice · Order is not guaranteed, and this is not a full history: the queue holds one pending notification per order, so statuses that change in quick succession (an order that closes and is refunded within the same minute) produce only the latest event · Treat a callback as a nudge to go and read the order, and always keep your own sweep of unfinished orders as a backstop.

An order that finished inside its own request still produces a callback

An order that was created and closed within a single ordering request (a 201) still produces one callback afterwards. That is intended behaviour, not an accidental duplicate — do not write code that panics over an order whose outcome you already know, and do not read it as a sign that something changed since the ordering response told you the result · A receiver that is idempotent on order_id already handles this case on its own.

What we send you

Request body

NameTypeHow to use it
eventRequiredstring
What happened to make us send this call
Details and caveats
  • Three values exist today: order.completed closed successfully
  • order.failed closed as a failure
  • order.refunded refunded afterwards
  • We only notify on final states; intermediate ones produce no event
  • New names may be added later
  • have your endpoint answer 2xx and skip events it does not recognise, rather than answering with an error.
dataRequiredobject
The whole order, the same object in exactly the same shape the single-order endpoint returns (see the Orders group in the sidebar)
Details and caveats
  • The fields you branch on are in the table below; the rest are described on the single-order page, deliberately not copied into two places.

Header

NameTypeHow to use it
Content-TypeRequiredstring
Always application/json — the body is one JSON object, never a form and never a file upload.
User-AgentRequiredstring
naxset-webhook/1
Details and caveats
  • useful for telling our calls apart from other traffic in your logs
  • Never use it to decide that a call came from us: anyone can send any header.
  • The signature is the only thing that identifies the sender.
X-Naxset-SignatureRequiredstring
The signature over this request body, shaped t=<seconds>,v1=<64 hex characters>
Details and caveats
  • It must verify before you read the body, every time
  • The procedure, a worked example and a test vector are in "Verifying the signature" further down this page.

Fields in the order you must read

NameTypeHow to use it
data.order_idstring
Our id for the order · Always use it as the deduplication key in your receiver — the same event for the same order can reach you more than once.
data.statusstring
The final state of the order
Details and caveats
  • It always pairs with event (order.completed with completed
  • order.failed with failed
  • order.refunded with refunded), so branch on whichever you prefer
  • just pick one and stay with it.
data.typestring
The product type of this order · It is the only thing that decides which shape delivery has. Never guess from the contents.
data.deliveryobject or null
What was handed over
Details and caveats
  • Filled in only while status is completed; null otherwise
  • It comes in two shapes with no field in common: key products give you a shape holding keys, while a pre-owned account gives you login/password/email/email_password/extra.
data.errorobject or null
Why the order failed, holding code and message
Details and caveats
  • Non-null on the order.failed event only
  • Branch on code and never match against the message text
  • the wording changes while the code stays put.
data.chargedobject or null
What was actually taken from your wallet, holding amount and currency
Details and caveats
  • This is the only answer to the question of whether this order was paid for. Never infer it from the event name or from the code in error: a failure can carry a charged value, and a refunded order keeps reporting the amount that had been taken, because the field answers what was taken, not what you are currently out of pocket.

What your endpoint must answer

StatusCodeMeaning
200
2xx — we record the delivery as successful and stop
Any status in the 200-299 range counts as success, and we do not read a single byte of your response body
Details and caveats
  • Answer as fast as you can and do the heavy work afterwards: the timeout is 10 seconds per attempt, and work that takes longer is recorded as a failure even though you already received the event, so we send it again.
302
3xx — counted as a failure, then retried
We do not follow redirects, wherever they point
Details and caveats
  • so the endpoint has not actually received that event
  • Register the real URL in the first place rather than an address that bounces onward.
408
408 or 429 — a temporary failure, then retried
These two are the only exception to the 4xx rule in the next row, because they are answers the endpoint deliberately gives to ask us to come back, not answers saying we are misconfigured.
404
Any other 4xx — given up immediately, never retried
400
Details and caveats
  • 401
  • 403
  • 404
  • 410 and the rest
  • An endpoint answering like this is misconfigured; repeating the call cannot fix that, and if the URL is a typo we are only calling a stranger's server for nothing
  • That event is gone, and nothing brings it back
  • reading the order yourself is the only route left.
500
5xx — a failure, then retried
Treated as a temporary problem on your side · It is retried the same way as every other failure — see "When a delivery fails" at the end of this page.

The other three payload shapes

The example panel above is the first one: order.completed for a key product (type is cdkey or account1). These are the other three.

order.completed for a pre-owned account

type is account2, so delivery is the account shape instead. It has no field in common with the shape above — read type to know which one you are holding, and never guess from the contents.

{
  "event": "order.completed",
  "data": {
    "order_id": "ord_89abcdef0123456789abcdef",
    "status": "completed",
    "type": "account2",
    "product_id": "exampleproduct02",
    "price": 1290,
    "charged": { "amount": 1290, "currency": "THB" },
    "delivery": {
      "login": "example_account",
      "password": "<the account password>",
      "email": "[email protected]",
      "email_password": "<the mailbox password>",
      "extra": {
        "item_id": "<supplier listing id>",
        "steam_level": 8,
        "steam_game_count": 24,
        "steam_country": "TH",
        "steam_mfa": false,
        "canChangePassword": true
      }
    },
    "error": null,
    "created_at": "2026-09-07T04:20:41.000Z",
    "updated_at": "2026-09-07T04:22:03.000Z"
  }
}

The four login fields are always present on this shape, though any of them can be null when we do not hold that value. extra describes what is inside the account — level, games, country, bans and so on — never how to log in to it. The extra above is abbreviated to keep the example readable: a real one carries the whole set of fields defined for that platform, which for Steam is around two dozen keys. Which keys those are depends on the platform of the product, and a platform we have not defined a field set for yet yields an extra holding item_id alone. Treat it as free-form: read the keys you know and ignore the rest.

order.failed

delivery is null, because nothing was delivered, and error is filled in.

{
  "event": "order.failed",
  "data": {
    "order_id": "ord_456789abcdef0123456789ab",
    "status": "failed",
    "type": "account2",
    "product_id": "exampleproduct02",
    "price": 1290,
    "charged": null,
    "delivery": null,
    "error": {
      "code": "supplier_failed",
      "message": "The order could not be placed with our supplier. Check the charged field on this order to see whether any payment was taken and refunded."
    },
    "created_at": "2026-09-07T05:01:12.000Z",
    "updated_at": "2026-09-07T05:04:47.000Z"
  }
}

⚠️ charged is null in this particular example, but that is a property of this example, not of the event. A failure can carry a charged value too. Read the field; never infer it from the event name or the code.

order.refunded

The order had been delivered, and was refunded afterwards.

{
  "event": "order.refunded",
  "data": {
    "order_id": "ord_cdef0123456789abcdef0123",
    "status": "refunded",
    "type": "cdkey",
    "product_id": "exampleproduct01",
    "price": 349,
    "charged": { "amount": 349, "currency": "THB" },
    "delivery": null,
    "error": null,
    "created_at": "2026-09-07T04:15:02.000Z",
    "updated_at": "2026-09-08T09:12:30.000Z"
  }
}

⚠️ delivery goes back to null on a refunded order, even though goods really were handed over earlier. If you need what was delivered, keep it from the order.completed event you already received — this event will not repeat it, and neither will reading the order.

Verifying the signature

Every request carries a header named X-Naxset-Signature that looks like this:

X-Naxset-Signature: t=1788753600,v1=c8874fd9e2607a0b4925f96b5d8cbdc8d3a152551d16ebe80fc70e98349e8f4e
  • t is the time we signed at, as a Unix timestamp in seconds
  • v1 is hmac_sha256("<t>.<body>", secret), hex-encoded in lowercase, 64 characters
  • secret is the key you were handed when you registered the URL, on Configure webhooks

Because t is inside what we signed, you can reject stale or time-shifted deliveries yourself without having to trust an unsigned value.

The steps

  1. Read the raw body and the header value.
  2. Split t and v1 out of the header.
  3. Reject the request if t differs from your own clock by more than 5 minutes — check both directions, older and newer, because replaying an old delivery and forging a future-dated one are the same attack from opposite sides.
  4. Compute the HMAC yourself and compare it to v1 in constant time (crypto.timingSafeEqual, hmac.compare_digest). Never compare with ===: the time the comparison takes leaks information about the key.
  5. Only parse the JSON once it verifies. If it does not, drop the request.

Example (Node.js)

import { createHmac, timingSafeEqual } from 'node:crypto'

const MAX_SKEW_MS = 5 * 60 * 1000

// rawBody = the bytes as received (string or Buffer), never the result of JSON.stringify()
export function verifyNaxsetSignature(rawBody, header, secret, nowMs = Date.now()) {
    const fields = new Map()
    for (const part of String(header ?? '').split(',')) {
        const eq = part.indexOf('=')
        if (eq !== -1) fields.set(part.slice(0, eq).trim(), part.slice(eq + 1).trim())
    }

    const t = Number(fields.get('t'))
    const v1 = fields.get('v1')
    if (!Number.isFinite(t) || !v1) return false
    if (Math.abs(nowMs - t * 1000) > MAX_SKEW_MS) return false

    const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest()
    const provided = Buffer.from(v1, 'hex')

    // Lengths must match first — timingSafeEqual throws when they differ
    return expected.length === provided.length && timingSafeEqual(expected, provided)
}

Test vector

These values were produced with the same function that signs real deliveries, so you can use them to prove your verifier works before going live. (The body here is shortened so you can retype it; a real one is a full order object.)

PartValue
secretexample_secret
body{"event":"order.completed","data":{"order_id":"ord_0123456789abcdef01234567"}}
t1788753600
v1c8874fd9e2607a0b4925f96b5d8cbdc8d3a152551d16ebe80fc70e98349e8f4e

Compare against the t of the vector rather than the current time, or the freshness check will reject it before it gets as far as the HMAC.

When a delivery fails

Besides the answers listed in the status table, one more group counts as a failure: we got no answer at all — connection refused, broken TLS, or no response within the 10 second timeout.

We retry up to 5 times per event when the endpoint does not answer or answers with a status that counts as a failure. The gap between attempts grows each time, but ⚠️ we do not guarantee an exact schedule — the minutes or hours between any two attempts can change without notice. Do not write code that predicts an event's arrival time from how many attempts have already happened.

  • Once all 5 attempts are used up, nothing brings that event back, and there is no endpoint for you to request a resend. Reading the order directly is the only route left.
  • A later event on the same order starts the count again from scratch.

⚠️ Webhooks are not the source of truth — an event that never gets through after all its attempts is simply not sent again. Poll GET /api/v1/orders/{order_id} as your primary source of truth (see the warning above).

Last updated 2026-09-08