# Verifying one of our webhooks: signatures, retries and every error, with our own API (https://app.resellerhq.com/blog/verifying-one-of-our-webhooks)

By ResellerHQ. Published 2026-10-15.

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](/blog/idempotency-keys-and-webhooks-for-reseller-apis) compares the Standard Webhooks and idempotency-key patterns across providers in general. [Can AI agents order through a reseller API?](/blog/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](/tools/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? [#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`:

1. Join `webhook-id`, `webhook-timestamp` and 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.
2. Strip the `whsec_` prefix off your secret and base64-decode what is left, to get the raw HMAC key.
3. Compute HMAC-SHA256 of the joined string, using that key.
4. Base64-encode the digest and prefix it `v1,`. Compare it to the `v1,<value>` in `webhook-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):

```bash
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," yourself
```

**Python**, with the standard library's `hmac` and `hashlib`:

```python
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:

```js
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](/tools/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](/tools/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? [#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](/tools/signing-secret-generator) makes a `whsec_` secret in your browser: the literal prefix followed by 24 random bytes, base64-encoded, the same shape `generateWebhookSecret` in `packages/events/src/signing.ts:8-10` produces. `openssl rand -base64 24` on the command line, with `whsec_` added by hand, makes an identical shape without even opening a browser.
* The [idempotency key generator](/tools/idempotency-key-generator) makes a UUID v4 for the `Idempotency-Key` header, 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? [#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](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md), read 2026-10-15); 300 seconds is the JavaScript reference library's own default, not a figure ResellerHQ sets. The [webhook replay window checker](/tools/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? [#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](/tools/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.

```bash
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](/tools/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? [#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](/tools/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? [#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](/tools/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? [#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](https://github.com/ulid/spec) (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](/tools/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.


## Frequently asked questions

### How do I verify a ResellerHQ webhook signature by hand?
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.

### What exact HTTP headers does a ResellerHQ webhook delivery carry?
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`.

### How long does ResellerHQ keep retrying a webhook delivery?
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`).

### What does ResellerHQ's API send back in an error response?
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).

### How long is an idempotency key remembered, and what happens on a reused one?
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`).

### What is encoded inside one of ResellerHQ's ids, like ord_01JGFJJZ007Q9K2XWM5A3N8PZ2?
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](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md)
- [Standard Webhooks: project site](https://www.standardwebhooks.com/)
- [ULID specification](https://github.com/ulid/spec)