Home / Blog / Engineering
Engineering · 7 min read · Published 2026-10-15 · OTP Route Engineering

Webhook-Based OTP Capture and Signature Verification Guide

Capture OTPs with signed webhooks instead of polling. Verify X-OTPRoute-Signature in Python and Node.js, dedupe retries and block replays with timestamps.

TL;DR — Webhook-based OTP capture pushes each received code to your endpoint, and a secure receiver verifies the HMAC-SHA256 signature over the timestamp and raw body, rejects stale timestamps and deduplicates repeated deliveries.

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
  • t is the Unix timestamp at which OTP Route signed the event.
  • v1 is 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:

  1. Read the raw request body as bytes, before any JSON parsing. Re-serializing parsed JSON changes whitespace and key order and breaks the signature.
  2. Parse the header into t and one or more v1 values.
  3. Reject the request if t is too far from the current time.
  4. Compute HMAC-SHA256(secret, t + "." + raw_body) and hex-encode it.
  5. Compare against each v1 using a constant-time comparison.
  6. 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