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
tis the time we signed at, as a Unix timestamp in secondsv1ishmac_sha256("<t>.<body>", secret), hex-encoded in lowercase, 64 characterssecretis 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
- Read the raw body and the header value.
- Split
tandv1out of the header. - Reject the request if
tdiffers 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. - Compute the HMAC yourself and compare it to
v1in constant time (crypto.timingSafeEqual,hmac.compare_digest). Never compare with===: the time the comparison takes leaks information about the key. - 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.)
| Part | Value |
|---|---|
secret | example_secret |
body | {"event":"order.completed","data":{"order_id":"ord_0123456789abcdef01234567"}} |
t | 1788753600 |
v1 | c8874fd9e2607a0b4925f96b5d8cbdc8d3a152551d16ebe80fc70e98349e8f4e |
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).