Engineering
Verifying one of our webhooks: signatures, retries and every error, with our own API
Recompute our webhook signature in bash, Python and Node, generate test secrets and keys, read every documented error code, and decode a ULID, against our exact API.
The short answer
To verify one of our webhooks, read the raw request body (never re-parsed), join webhook-id, webhook-timestamp and that body with full stops, compute an HMAC-SHA256 of the result using the part of your whsec_ secret after the prefix (base64-decoded) as the key, base64-encode the digest, and check it against the v1,<value> entries in webhook-signature. The same construction works in bash with openssl, in Python with hmac and hashlib, and in Node with crypto; this guide shows all three against one real, worked example.
You have our API open, you have just created a webhook endpoint or pasted a sample delivery into your own code, and you need to know, right now, whether your signature check is correct, what to send your receiver before a real event ever fires, and what a given error code actually means. This is that page: every section below ends in a header, a secret, a key or a request you can paste somewhere and get a real answer.
Two earlier posts cover why any of this matters in the first place, and this one does not repeat them. Idempotency keys and signed webhooks: the two things a reseller API must get right compares the Standard Webhooks and idempotency-key patterns across providers in general. Can AI agents order through a reseller API? names the four things an API has to give a caller, human or agent, to be usable at all. This guide picks up where both leave off: our exact signing construction, our exact retry schedule, our exact error codes, run against our own API.
The most common reason a signature check fails is not a wrong secret - it is a body that reached your verifier already re-serialised by a framework's own JSON parser, changing every byte of what gets signed without changing what the event means. Our webhook signature verifier recomputes the signature over whatever you paste and shows the computed and received values side by side, so a mismatch like that is something you see, not something a library just tells you happened.
How do I verify a webhook signature, by hand, in three languages?
A delivery from ResellerHQ carries three headers: webhook-id, webhook-timestamp (an integer number of seconds since the Unix epoch) and webhook-signature (apps/web/content/docs/(guides)/webhooks.mdx:89). Verifying it means recomputing the signature yourself and checking it matches, rather than trusting the headers on their word. The construction, straight out of packages/events/src/signing.ts:12-27:
- Join
webhook-id,webhook-timestampand the raw request body with full stops:id.timestamp.body. Not the parsed-and-reserialised JSON - the exact bytes that arrived on the wire, because reformatting changes nothing about the meaning of a JSON body but changes every byte of the string being signed. - Strip the
whsec_prefix off your secret and base64-decode what is left, to get the raw HMAC key. - Compute HMAC-SHA256 of the joined string, using that key.
- Base64-encode the digest and prefix it
v1,. Compare it to thev1,<value>inwebhook-signature.
Here is one worked example, in bash, Python and Node, all three producing the identical signature for the same inputs:
webhook-id: evt_01JABCDEFG1234567890ABCD
webhook-timestamp: 1760000000
body: {"type":"number.message.received","data":{"number":"15550100002"}}
secret: whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
expected signature: v1,ylDLAl/oATQHyJkqEBj/V/NapUVnEzhl+qlVutkSAzI=bash, with openssl (-macopt hexkey: takes the key as hex, so the decoded secret's raw bytes are never mangled passing through a shell variable as text):
id="evt_01JABCDEFG1234567890ABCD"
timestamp="1760000000"
body='{"type":"number.message.received","data":{"number":"15550100002"}}'
secret="whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
keyhex=$(printf '%s' "${secret#whsec_}" | base64 -d | od -An -tx1 | tr -d ' \n')
printf '%s' "${id}.${timestamp}.${body}" \
| openssl dgst -sha256 -mac HMAC -macopt hexkey:"$keyhex" -binary \
| base64
# ylDLAl/oATQHyJkqEBj/V/NapUVnEzhl+qlVutkSAzI= -- prefix with "v1," yourselfPython, with the standard library's hmac and hashlib:
import hmac, hashlib, base64
webhook_id = "evt_01JABCDEFG1234567890ABCD"
timestamp = "1760000000"
body = '{"type":"number.message.received","data":{"number":"15550100002"}}'
secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
key = base64.b64decode(secret.removeprefix("whsec_"))
message = f"{webhook_id}.{timestamp}.{body}".encode()
digest = hmac.new(key, message, hashlib.sha256).digest()
print("v1," + base64.b64encode(digest).decode())
# v1,ylDLAl/oATQHyJkqEBj/V/NapUVnEzhl+qlVutkSAzI=Node, with the built-in crypto module:
const { createHmac, timingSafeEqual } = require('crypto');
const webhookId = 'evt_01JABCDEFG1234567890ABCD';
const timestamp = '1760000000';
const body = '{"type":"number.message.received","data":{"number":"15550100002"}}';
const secret = 'whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw';
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const signature = createHmac('sha256', key)
.update(`${webhookId}.${timestamp}.${body}`)
.digest('base64');
console.log(`v1,${signature}`);
// v1,ylDLAl/oATQHyJkqEBj/V/NapUVnEzhl+qlVutkSAzI=Compare the two signatures in constant time, not with ===, and never compare them before checking the timestamp is still fresh (see the retry section below). Our own webhook signature verifier runs exactly this arithmetic against whatever id, timestamp, body and secret you paste, and shows the two values side by side so a mismatch is something you see, not something a library just asserts happened. To go the other way, generating a correctly signed test request for your own receiver before a real delivery exists, use the Standard Webhooks payload signer: give it an id, a timestamp, a body and a secret, and it hands back the exact three headers.
How do I generate a test secret and an idempotency key without opening the dashboard?
Every real webhook secret is shown exactly once, in the dashboard, at the moment an endpoint is created - there is no API route that reads it back afterwards. For writing and testing a receiver beforehand, you need something shaped the same way without touching a real endpoint:
- The signing secret generator makes a
whsec_secret in your browser: the literal prefix followed by 24 random bytes, base64-encoded, the same shapegenerateWebhookSecretinpackages/events/src/signing.ts:8-10produces.openssl rand -base64 24on the command line, withwhsec_added by hand, makes an identical shape without even opening a browser. - The idempotency key generator makes a UUID v4 for the
Idempotency-Keyheader, with a ready curl line for whichever operation you name.
Neither is a real credential. Label every secret or key made this way as local-testing-only in your own notes, because nothing produced by either tool is ever sent to ResellerHQ or recognised by it.
What is the retry schedule, and why does a stale timestamp stop a replay?
Your endpoint has 10 seconds to answer with a 2xx. Anything else and the delivery is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 14 hours, about 32 hours in total, before it is marked failed and can be resent by hand from the dashboard or the API (apps/web/content/docs/(guides)/webhooks.mdx:116-121). Each retry of the same event keeps the same webhook-id, so a receiver that records the ids it has already handled can skip repeats safely.
The timestamp check is a separate, earlier line of defence, against a different threat: a captured, legitimately signed request being sent again later by someone who was never meant to have it. A signature on its own only proves the id, timestamp and body, taken together, match what the secret produced; it says nothing about when that match was computed. Rejecting a webhook-timestamp that is too old means a request signed last month cannot be replayed today even with the exact original bytes, because the signature is valid but the moment it claims to be from no longer is. The Standard Webhooks specification itself sets no number, only that a receiver should check the timestamp is "within some allowable tolerance" and use webhook-id "as an idempotency key to prevent accidentally processing the same webhook more than once" (Standard Webhooks specification, read 2026-10-15); 300 seconds is the JavaScript reference library's own default, not a figure ResellerHQ sets. The webhook replay window checker decodes a webhook-timestamp into its exact UTC moment, shows the window a verifying library would accept around it, and checks a clock reading you give against that window.
How do I turn a documented operation into a ready request?
Every route in our API accepts a bearer token (Authorization: Bearer $RHQ_API_KEY) against the base URL https://resellerhq.com/api, and any route that can spend money accepts an Idempotency-Key header. Rather than copying those details out of the API reference by hand for every call, the API request builder takes one of our documented operations and gives you a ready curl, Python or Node snippet, with the real base URL, every documented parameter filled from its own example value, and an Idempotency-Key header already added wherever the operation spends money.
curl -X POST "https://resellerhq.com/api/v1/numbers/orders" \
-H "Authorization: Bearer $RHQ_API_KEY" \
-H "Idempotency-Key: 2f1c9a52-7d0e-4a8e-9a53-0c1f6f3f2b11" \
-H "Content-Type: application/json" \
-d '{"country":"US", ...}'To bring the whole API into Postman at once instead of one call at a time, the Postman collection generator builds a Postman Collection v2.1 JSON straight from the same committed OpenAPI document, with bearer auth already wired to an RHQ_API_KEY collection variable you fill in once rather than per request. Use Import → Raw text to bring the copied JSON in.
How do I get a sample webhook event to point my receiver at, before a real one fires?
Writing a receiver usually starts before any real order, renewal or message exists to trigger a delivery. The webhook payload example generator picks one of our documented event types and builds a sample payload for it - the same envelope shape a real delivery carries, with id, type, timestamp and data - then signs that exact sample with whatever whsec_ secret you give it, producing the same three headers a real delivery would carry. Pointing a receiver under test at that payload and those headers exercises the whole path: parsing, signature verification and whatever your code does once it trusts the event, all before a real domain.registered or number.message.received has ever happened.
What does every error from the API look like, and what do I fix?
Every error response shares one shape: error, a stable code meant for your code to branch on; message, written for a person reading logs, never meant to be parsed; and requestId, to quote if you ask us for help. A handful of codes carry extra fields in a details object, named for that specific code (apps/api/src/openapi.ts, errorResponse, from line 305). The table below is a small, representative slice, one or two codes per status, copied verbatim from ERRORS in apps/api/src/openapi.ts:
| Status | error | message | What to change |
|---|---|---|---|
| 400 | validation_error | querystring/country must match pattern "^[A-Z]{2}$" | The request itself is malformed; the message names the exact field and the rule it broke. |
| 401 | unauthorized | Missing or invalid credentials | The Authorization header is missing, carries an unknown key, or the key was revoked; issue a new one in the dashboard. |
| 402 | insufficient_funds | Wallet balance is too low for this purchase | Nothing was charged; top up the wallet before retrying, with the same Idempotency-Key if this was already a retry. |
| 403 | reseller_suspended | This account is suspended | The account itself is suspended; this is not something a request can work around. |
| 404 | not_found | Number not found | The id in the path does not exist on this account; check it was not already deleted or belongs to a different account. |
| 409 | domain_taken | This domain is already registered | Someone, possibly you on an earlier call, already registered this domain; check before registering rather than retrying blindly. |
| 409 | idempotency_in_progress | A request with this key is still being processed | The first request with this exact key has not finished yet; wait and retry the same key and body rather than minting a new key. |
| 422 | idempotency_key_reused | This Idempotency-Key was used with a different request body | This is a client bug, not a timing issue: generate a fresh key per distinct operation and never reuse one across a different body. |
| 429 | rate_limited | Rate limit exceeded, retry in 1 minute | Slow down; this is not a request fault, just too many of them, too close together. |
| 502 | renewal_failed | The provider could not renew this number | Nothing was charged; the upstream carrier failed the renewal, worth a retry later rather than a change to the request. |
Every code in that table matches ERRORS in apps/api/src/openapi.ts verbatim, and a drift test (apps/web/src/lib/webhook-guide-errors.test.ts) fails the build if either side changes without the other. For the full set, not a slice, the API error code explainer takes a documented code, a bare status, or a whole pasted response body, and answers with the exact status, message, meaning and what to change - it already has a hand-written fix for every single code our API documents, generated from the same table this guide's own slice was copied from.
How do I read the timestamp hidden inside one of our ids?
Every id our API hands back is a three- or four-letter prefix (ord for a number order, evt for an event, whd for a webhook delivery, and so on; the full table is ID_PREFIXES in packages/kernel/src/ids.ts:3-50) in front of a ULID - a 26-character identifier whose first 10 characters are a millisecond Unix timestamp written in Crockford's base32, and whose last 16 are random, per the ULID specification (read 2026-10-15). That means an id sorts by creation time as a plain string, with no separate created_at lookup needed, and it also means the creation time is sitting right there in a log line without ever calling the API.
As a worked example: ord_01JGFJJZ007Q9K2XWM5A3N8PZ2 splits into the prefix ord (a number order) and the ULID 01JGFJJZ007Q9K2XWM5A3N8PZ2, whose first 10 characters decode to 1735689600000 milliseconds since the Unix epoch, which is 2025-01-01 00:00:00.000 UTC. The last 16 characters, 007Q9K2XWM5A3N8PZ2, carry no further information; they exist only so two ids minted in the same millisecond still differ. The ULID / request id decoder does this split and decode for any of our own ids or a bare ULID, and says plainly when a prefix is not one of ours rather than guessing.
Questions
- Take the raw, unparsed request body and the webhook-id and webhook-timestamp headers, join them as `id.timestamp.body`, compute HMAC-SHA256 over that string using the base64-decoded bytes after whsec_ in your secret as the key, base64-encode the result, and compare it to the v1,<value> entries in webhook-signature. `packages/events/src/signing.ts:12-27` is the exact code that produces a real delivery's headers this way.
- Three: webhook-id (a stable event id, unchanged across retries), webhook-timestamp (an integer Unix timestamp in seconds) and webhook-signature (one v1,<base64> value). See `apps/web/content/docs/(guides)/webhooks.mdx:89`.
- Your endpoint has 10 seconds to answer with a 2xx. After that, retries follow at 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 14 hours, about 32 hours in all, before the delivery is marked failed and can be resent by hand (`apps/web/content/docs/(guides)/webhooks.mdx:116-121`).
- Every error answers with the same three fields: error, a stable code to branch your code on; message, written for a person and not meant to be parsed; and requestId, to quote when asking for help. A handful of codes also carry a details object with extra fields, named in the API reference for that code (`apps/api/src/openapi.ts`, errorResponse, from line 305).
- The outcome of the first request with a given Idempotency-Key is stored for 24 hours. A retry with the same key and the same body gets that stored outcome back; the same key with a different body answers 422 idempotency_key_reused; a retry that arrives while the first request is still running answers 409 idempotency_in_progress (`apps/web/content/docs/(guides)/idempotency.mdx:19-24,28`).
- A three- or four-letter prefix naming the record kind (ord for a number order, evt for an event, whd for a webhook delivery, and so on; the table is `ID_PREFIXES` in `packages/kernel/src/ids.ts:3-50`), an underscore, then a 26-character ULID. Per the ULID specification, the first 10 characters are a Crockford base32 millisecond timestamp, so ord_01JGFJJZ007Q9K2XWM5A3N8PZ2 was minted at 2025-01-01 00:00:00.000 UTC; the last 16 characters are random (https://github.com/ulid/spec, read 2026-10-15).
Sources
- Standard Webhooks: specification github.com
- Standard Webhooks: project site standardwebhooks.com
- ULID specification github.com