Webhook-based OTP capture means the gateway pushes each received code to your server the moment it arrives, instead of your code repeatedly asking whether it has arrived yet. With OTP Route, an activation.sms_received event is POSTed to your endpoint and signed with an X-OTPRoute-Signature header. A correct receiver does three things before acting on it: it verifies the HMAC-SHA256 signature over the raw body, rejects timestamps outside a tolerance window, and deduplicates deliveries so the same event is never processed twice.
This guide explains why webhooks beat polling for OTP workloads, then gives complete, copy-ready receivers in Python and Node.js.
Webhooks vs polling
Polling works for a quick script. At any meaningful volume, webhooks are the better default.
| Dimension | Polling GET /v1/activations/{id} |
Webhook activation.sms_received |
|---|---|---|
| Latency to code | Up to one polling interval after arrival | As soon as the event is delivered |
| Request volume | Grows with in-flight activations multiplied by poll frequency | One inbound request per event |
| Rate-limit pressure | High under concurrency, often leads to HTTP 429 | None on your outbound quota |
| Infrastructure | No public endpoint needed | Requires a reachable HTTPS endpoint |
| Failure mode | Missed codes if the poller dies | Missed events if the receiver is down, mitigated by reconciliation |
| Best for | Prototypes, local scripts | Production, CI at volume, ops tooling |
The strongest pattern is hybrid. Use webhooks as the primary path and a low-frequency reconciler that polls only activations stuck in pending past their expected window. You get webhook latency with polling's safety net, without polling's cost. The bulk verification guide describes how this fits into a larger pipeline.
Event types
| Event | When it fires | Typical action |
|---|---|---|
activation.sms_received |
An SMS arrives for a temporary activation | Store the code, wake the waiting job |
activation.expired |
The activation window (typically 20 minutes) ends with no SMS | Mark the job failed; the price is refunded automatically |
activation.cancelled |
The activation was cancelled | Mark the job closed; refunded if no SMS was received |
number.sms_received |
An SMS arrives on a dedicated virtual number | Route to the team, ticket or test that owns the line |
Payload schemas are documented in the API reference. In the code below, payload field names are illustrative.
Anatomy of the signature header
Every delivery includes:
X-OTPRoute-Signature: t=1760536800,v1=5f2b...e91c
tis the Unix timestamp at which OTP Route signed the event.v1is the hex-encoded HMAC-SHA256 of the string"<t>.<raw body>", keyed with your webhook secret from the developer console.
To verify, follow these steps in order:
- Read the raw request body as bytes, before any JSON parsing. Re-serializing parsed JSON changes whitespace and key order and breaks the signature.
- Parse the header into
tand one or morev1values. - Reject the request if
tis too far from the current time. - Compute
HMAC-SHA256(secret, t + "." + raw_body)and hex-encode it. - Compare against each
v1using a constant-time comparison. - Only then parse the JSON and act on it.
Python receiver (Flask)
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["OTPROUTE_WEBHOOK_SECRET"].encode()
TOLERANCE_S = 300 # reject events signed more than 5 minutes ago or ahead
def parse_signature(header):
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
return timestamp, signatures
def verify(raw_body: bytes, header: str) -> bool:
timestamp, signatures = parse_signature(header or "")
if not timestamp or not signatures:
return False
try:
if abs(time.time() - int(timestamp)) > TOLERANCE_S:
return False
except ValueError:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(WEBHOOK_SECRET, signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
@app.post("/webhooks/otproute")
def otproute_webhook():
raw = request.get_data() # raw bytes, before JSON parsing
if not verify(raw, request.headers.get("X-OTPRoute-Signature")):
abort(400)
event = json.loads(raw)
event_type = event.get("type") # illustrative field names
activation_id = event.get("data", {}).get("id")
if not mark_processed(activation_id, event_type):
return "", 200 # duplicate delivery, already handled
enqueue_for_processing(event) # do slow work asynchronously
return "", 200
Node.js receiver (Express)
The key detail in Express is using express.raw() on the webhook route so you receive the exact bytes that were signed.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.OTPROUTE_WEBHOOK_SECRET;
const TOLERANCE_S = 300;
function parseSignature(header = "") {
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.trim().split("=");
if (key === "t") timestamp = value;
if (key === "v1" && value) signatures.push(value);
}
return { timestamp, signatures };
}
function verify(rawBody, header) {
const { timestamp, signatures } = parseSignature(header);
if (!timestamp || signatures.length === 0) return false;
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_S) return false;
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
return signatures.some((sig) => {
const sigBuf = Buffer.from(sig, "hex");
return sigBuf.length === expectedBuf.length && crypto.timingSafeEqual(sigBuf, expectedBuf);
});
}
app.post("/webhooks/otproute", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.body, req.get("X-OTPRoute-Signature"))) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
const dedupeKey = `${event.data?.id}:${event.type}`; // illustrative field names
if (!(await markProcessed(dedupeKey))) return res.sendStatus(200); // duplicate
queue.push(event); // process asynchronously
res.sendStatus(200);
});
app.listen(3000);
Note the length check before timingSafeEqual. That function throws when buffers differ in length, so a malformed signature would otherwise crash the handler.
Replay protection with timestamp tolerance
A valid signed request captured in transit, or leaked from a log, could be resent later. Because the timestamp is part of the signed string, an attacker cannot change t without invalidating the signature. Rejecting events whose t is outside a tolerance window therefore limits any replay to that window.
Choosing the tolerance is a trade-off:
| Tolerance | Effect |
|---|---|
| Very short, such as 30 seconds | Smallest replay window, but legitimate deliveries can fail when clocks drift or queues lag |
| Moderate, such as 5 minutes | Common default; tolerates normal clock skew and delivery delay |
| Very long or none | Effectively no replay protection |
Keep server clocks synchronized with NTP. Within the tolerance window, deduplication closes the remaining gap. A replayed event is a duplicate, and your idempotency check discards it.
Idempotency and duplicate deliveries
Design every webhook receiver on the assumption that the same event can arrive more than once. Duplicates happen in any webhook system, for example when your endpoint processes an event but the response is lost, and the sender retries. Consult the docs for OTP Route's current delivery and retry behavior, but do not let correctness depend on it.
A practical idempotency key for OTP events is the activation ID combined with the event type. Store processed keys in a table with a unique constraint, or in a key-value store with a time to live comfortably longer than the activation window. The first insert wins, and any later insert with the same key means "already handled, return 200".
CREATE TABLE processed_webhook_events (
dedupe_key TEXT PRIMARY KEY,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- INSERT ... ON CONFLICT DO NOTHING; zero rows affected means duplicate
Respond fast, process later
Return a 2xx status as soon as the signature is verified and the event is durably recorded. Heavy work, such as resuming a test, calling other services or writing analytics, belongs in a background queue. Slow handlers cause timeouts, timeouts cause redeliveries, and redeliveries add load at exactly the wrong moment.
Status code guidance for your receiver:
- 200 or 204. The event was accepted, including duplicates you chose to ignore.
- 400. The signature or timestamp is invalid. Do not process the event.
- 5xx. Your system genuinely failed to record the event, signalling that a redelivery would be useful.
Reconciliation as a safety net
Even a well-built receiver can miss events during a deploy or outage. Run a periodic job that finds activations still pending in your store well past their expected time and calls GET /v1/activations/{id} to fetch the true state. This keeps your local view consistent without returning to full-time polling.
Testing your receiver
- Use the test-send feature in the developer console to fire a signed sample event at your endpoint.
- During local development, expose your server through a tunnel so the console can reach it.
- Write unit tests that sign a fixture body with a known secret and assert that tampered bodies, wrong secrets and stale timestamps are all rejected.
- Log verification failures with the reason, but never log the secret.
For the rest of the integration, see the OTP API integration guide, the developer hub and the CI/CD solution.
Responsible use
Webhook capture makes automated verification efficient, so apply it within the boundaries it is meant for. OTP Route is designed for QA and testing, privacy-preserving sign-ups, app verification flows and operational lines. Respect the terms of the platforms you interact with, and do not use the service for fraud, spam or abuse.
Frequently asked questions
Why does my signature verification fail even with the right secret?
Almost always because the body was parsed and re-serialized before hashing. Compute the HMAC over the exact raw bytes received, prefixed with the timestamp and a dot. In Express use express.raw(), and in Flask use request.get_data().
What timestamp tolerance should I use?
A window of around five minutes is a common default. It tolerates normal clock skew and delivery delay while keeping the replay window small. Keep your server clock synchronized with NTP, and rely on deduplication to discard replays inside the window.
How do I prevent processing the same OTP event twice?
Derive an idempotency key from the activation ID and event type, and record it with a unique constraint before acting. If the insert conflicts, the event was already handled, so return 200 and do nothing else.
Should I still poll if I use webhooks?
Only as a safety net. Run a low-frequency reconciler that checks activations stuck in pending past their expected time with GET /v1/activations/{id}. Do not poll every in-flight activation continuously.
Do dedicated virtual numbers use the same signature scheme?
Yes. Inbound messages on dedicated US and Canada lines arrive as number.sms_received events at your configured endpoint with the same X-OTPRoute-Signature header, so one verification function covers both.
Route your first OTP in minutes
Pay as you go · no monthly fee · automatic refunds on unused activations.
Topics: otp webhook · webhook signature verification · hmac sha256 webhook · webhook replay protection · webhook idempotency · receive otp webhook · polling vs webhooks