Webhooks and signature verification
Get codes pushed to your server the moment they arrive, and verify that every delivery really came from us.
- Last updated
- Updated
- Reading time
- 2 min read
Instead of polling, give us an HTTPS URL and we'll POST an event to it whenever something happens to your orders or rentals.
Setting up
- In Settings, enter your endpoint URL. In production it must use
https://and be reachable on the public internet. - Copy your signing secret — it starts with
whsec_. It's created the first time you save a URL and stays the same if you change the URL later. - Use Send test event to receive a
webhook.testevent and check your endpoint end to end.
Events
| Event | Sent when |
|---|---|
order.code_received |
An SMS arrives on an order (once per message). |
order.expired |
An order timed out without an SMS and was refunded. |
order.cancelled |
An order was cancelled and refunded. |
rental.message_received |
An SMS arrives on a rented number. |
webhook.test |
You sent a test from Settings. |
Payload
Every event uses the same envelope:
{
"id": "evt_…",
"type": "order.code_received",
"created_at": "2026-10-06T14:03:12.000Z",
"data": {
"order": {
"id": "ord_…",
"status": "received",
"phone_number": "+12025550147",
"code": "482913"
}
}
}order.* events carry { "order": … }, the same order object the API returns (abridged above). rental.message_received carries { "rental": …, "message": … }.
Each delivery has these headers:
| Header | Value |
|---|---|
X-Passcode-Signature |
t=<unix seconds>,v1=<hex signature> |
X-Passcode-Event |
The event type |
X-Passcode-Delivery |
The event id (evt_…), the same on a retry |
Verifying the signature
The signature is an HMAC-SHA256, keyed with your signing secret, of the timestamp, a full stop, and the raw request body: <t>.<body>. To verify a delivery:
- Read the raw body before parsing it. Re-serialized JSON won't match byte for byte.
- Split the header on commas and take
tand everyv1value. - Compute the HMAC-SHA256 of
t + "." + bodywith your secret, as hex. - Compare it with each
v1in constant time. Accept if any of them matches. - Reject the delivery if
tis more than 5 minutes away from your clock. That stops replays.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyPasscodeSignature(rawBody, header, secret, toleranceSeconds = 300) {
if (!header) return false;
let timestamp = NaN;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.trim().split("=");
if (key === "t") timestamp = Number(value);
if (key === "v1" && value) signatures.push(value);
}
if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}import hashlib
import hmac
import time
def verify_passcode_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
if not header:
return False
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)Warning
Always verify, including in development. Anyone who learns your endpoint's URL can send it requests; only we know your signing secret.
Delivery and retries
- Answer with any
2xxstatus within 5 seconds, and do slow work after you've responded. - If your endpoint times out, can't be reached, or answers
429or5xx, we retry once, about a second later. Other responses, such as400or404, aren't retried. - Redirects aren't followed, so point us at the final URL.
- Deliveries can occasionally arrive twice or out of order. Use the event
idto ignore duplicates, and treat the order'sstatusas the source of truth. - A webhook is a fast path, not the only one. If a delivery fails,
GET /api/v1/orders/{id}still has the code.
Rotating the secret
Rotate the signing secret in Settings whenever you need to. New deliveries are signed with the new secret straight away, so update your server promptly — and during the switch, accept a signature that matches either secret.