A console built for the people who integrate
Manage API keys, wire up signed webhooks, test deliveries, read delivery logs and watch usage — everything you need to run OTP routing in production, in one place.
activation.sms_received200number.sms_received200activation.expired500 · retryactivation.sms_received200Tools for building, testing and operating
The console is the control plane for your integration. The API is the data plane. Both use the same account and wallet.
Multiple API keys
Create a key per environment, service or teammate. Keys use the or_live_ prefix, are shown once at creation, and can be revoked individually.
Webhook endpoints
Register HTTPS endpoints and choose the events each one receives — or subscribe to all of them.
Test-send
Send a signed test event to an endpoint and confirm your handler verifies and acknowledges it.
Delivery log
Every attempt is logged with event type, HTTP status and a response excerpt. Failed deliveries are retried automatically with backoff.
Secret rotation
Rotate an endpoint's signing secret from the console whenever your security policy requires it.
Usage analytics
Per-day API calls and errors and your top endpoints, to spot regressions and plan capacity.
Trust only what we signed
Every delivery includes X-OTPRoute-Signature in the form t=<unix>,v1=<hex>, plus X-OTPRoute-Event and a unique X-OTPRoute-Delivery ID.
- Compute HMAC-SHA256 of
<t>.<raw body>with the endpoint secret - Compare against
v1using a constant-time function - Reject timestamps outside your tolerance (for example 5 minutes)
- Use the delivery ID to make your handler idempotent
import hmac, hashlib, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["OTPROUTE_WEBHOOK_SECRET"]
def verified(header: str, body: bytes) -> bool:
p = dict(x.split("=", 1) for x in header.split(","))
if abs(time.time() - int(p["t"])) > 300:
return False
mac = hmac.new(SECRET.encode(), p["t"].encode() + b"." + body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, p.get("v1", ""))
@app.post("/hooks/otproute")
def hook():
if not verified(request.headers.get("X-OTPRoute-Signature", ""), request.get_data()):
abort(401)
evt = request.get_json()
if evt["type"] == "activation.sms_received":
handle_code(evt["data"]["id"], evt["data"]["code"])
return "", 204
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.OTPROUTE_WEBHOOK_SECRET;
function verified(header = "", raw) {
const p = Object.fromEntries(header.split(",").map((x) => x.split("=")));
if (!p.t || Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
const mac = crypto.createHmac("sha256", SECRET)
.update(`${p.t}.`).update(raw).digest("hex");
return !!p.v1 && mac.length === p.v1.length &&
crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(p.v1));
}
app.post("/hooks/otproute", express.raw({ type: "application/json" }), (req, res) => {
if (!verified(req.get("X-OTPRoute-Signature"), req.body)) return res.sendStatus(401);
const evt = JSON.parse(req.body);
if (evt.type === "activation.sms_received") handleCode(evt.data.id, evt.data.code);
res.sendStatus(204);
});
Events you can subscribe to
Each payload has the shape {"id": "evt_…", "type": "…", "created": <unix>, "data": {…}}. Failed deliveries are retried automatically with backoff.
| Event | Fires when | Data |
|---|---|---|
activation.sms_received | An OTP is captured for an activation (and again if the code is updated) | Activation object with code and sms_text |
activation.expired | The window closed without an SMS; the activation is refunded | Activation object |
activation.cancelled | The activation was cancelled; it is refunded | Activation object |
number.sms_received | A dedicated number receives an inbound SMS | Message with from, to, text |
number.renewed | A dedicated number's term is renewed from the wallet | Number object with new renews_at |
number.released | A dedicated number is released by you or at an unpaid term end | Number object |
Rate limits and quotas
Every account is pay as you go and gets the same limits, applied per account across all keys. Exceeding the request rate returns 429 rate_limited with a Retry-After header.
| Account | API requests / min | API keys | Webhook endpoints | Monthly fee |
|---|---|---|---|---|
| Pay as you go (every account) | 300 | Up to 10 | Up to 5 | None |
Webhook deliveries do not count toward your request rate. See pricing →
Live in five steps
Create an account
Sign up with email or Google, verify your email and turn on two-factor authentication. There is no monthly fee.
Generate an API key
In the console, create a key and store it as a secret — it is only shown once.
Top up your wallet
Add funds by card or USDT/USDC. Or ask support to enable a sandbox account first.
Register a webhook
Add your HTTPS endpoint, copy its secret and use test-send to confirm verification.
Order an activation
Call POST /v1/activations and read the code from the webhook or by polling.
Sandbox accounts
Sandbox accounts receive simulated numbers and codes through the same endpoints and webhook events, with no wallet spend. Use them to build and test your integration end to end, including CI runs.
Email [email protected] to enable sandbox mode on a development account.
Generate your first API key
Pay as you go with no monthly fee. Bring your own test flow and route your first OTP today.