/Webhooks & events

API docs

Webhooks & events

Receive signed events the moment a verification is sent, delivered, approved, or closed - so your backend reacts without polling. Webhooks are set per request, with nothing to register.

A verification moves through several states after you send it: it is dispatched to a carrier, delivered to the handset, and eventually approved, expired, or closed. Rather than polling GET /v1/verifications/{id}, pass a webhook_url when you send and OTPRelay will POST a JSON event to it as each milestone occurs.

Receiving events

There is no endpoint to register. Include an HTTPS webhook_url in the send requestand OTPRelay delivers that verification's events to it.

POST /v1/verifications
curl https://api.otprelay.io/v1/verifications \  -H "Authorization: Bearer sk_live_••••" \  -H "Content-Type: application/json" \  -d '{    "to": "+966512345678",    "webhook_url": "https://api.yourapp.com/otp/webhook"  }'

Each event mirrors the verification object and its attempts[], so you always see which carrier and SMS route handled delivery.

Delivery & retries

Your endpoint must acknowledge each event quickly. Respond 200 as soon as you have stored it, then do any slower processing asynchronously.

BehaviourValue
Expected response200 OK
Timeout5 seconds
On non-2xx or timeoutRetried up to 3 times with backoff, then dropped.
DuplicatesPossible on retry - make your handler idempotent (dedupe on the event id, evt_…).

Event types

Each event corresponds to a transition in the verification lifecycle. The verification.delivered and verification.failed events are SMS delivery signals; the rest reflect the overall status of the verification.

EventWhen it fires
verification.sentOTPRelay accepted the verification and dispatched the SMS to a carrier.
verification.deliveredThe carrier confirmed the SMS reached the handset. With route failover, fires for whichever attempt delivered.
verification.failedAn attempt could not be delivered (carrier rejection, no route, or undeliverable). Automatic SMS route failover may still recover the send.
verification.approvedThe user submitted the correct code and the verification is now approved.
verification.expiredThe code's TTL elapsed before approval. The verification is closed.
verification.max_attempts_reachedThe check limit (5) was exhausted with no correct code. The verification is closed.

Event payload

Every delivery uses the same envelope: a top-level id (evt_…), a type, a created_at timestamp, and a data.object containing the affected resource - the verification, including the attempts[] that produced the event and any metadata you set when sending.

verification.delivered
{  "id": "evt_01HZX9Q2K3M7B8N4P5R6S7T8EV",  "type": "verification.delivered",  "created_at": "2026-06-16T10:00:04Z",  "data": {    "object": {      "id": "ver_01HZX9Q2K3M7B8N4P5R6S7T8U9",      "object": "verification",      "status": "pending",      "to": "+966512345678",      "country": "SA",      "sender": "OTPRelay",      "code_length": 6,      "verified": false,      "attempts": [        {          "id": "att_01HZX9Q2K3M7B8N4P5R6S7T8UA",          "status": "delivered",          "carrier": "stc",          "at": "2026-06-16T10:00:04Z"        }      ],      "metadata": { "user_id": "u_8842" },      "created_at": "2026-06-16T10:00:00Z",      "expires_at": "2026-06-16T10:10:00Z"    }  }}

Verifying signatures

Every request carries an OTPRelay-Signature header so you can confirm it came from OTPRelay and was not tampered with. The signature is an HMAC-SHA256 computed over the timestamp and the raw, unparsed request body, keyed by your account's webhook signing secret (whsec_…). Compute the same HMAC and compare it against the header before trusting the event - re-serializing the parsed JSON will change the signature, so use the raw bytes OTPRelay sent.

Verifying OTPRelay-Signature
Header sent with every delivery:  OTPRelay-Signature: t=1718532004, v1=<hex-hmac> Verify it (pseudocode - works in any language):  1. Read the RAW, unparsed request body as bytes.  2. signed_payload = "<t>." + raw_body  3. expected = HMAC_SHA256(signing_secret, signed_payload)   // hex digest  4. Compare "expected" to "v1" using a CONSTANT-TIME comparison.  5. Reject the request if they differ - or if "t" is too old (replay guard).

Signature scheme is being finalized

The exact webhook signature scheme (header format and signed payload) is still being finalized - the 200 / 5-second / 3-retry delivery contract above is settled. Build against the shapes shown here and expect signature details to be confirmed before general availability.

Webhooks pair naturally with delivery & failover for delivery visibility and rate limits & fraud for safeguards. Event error bodies follow the standard error format.