Home › SMS API › SMS Webhooks
OTP webhook notifications

SMS Webhooks with Signature Verification

When an SMS code arrives, Turonay sends a signed HTTPS POST to your server. You stop polling for minutes at a time, and you verify that each request really came from us with an HMAC-SHA256 signature. This guide covers the events, the request body, the signature algorithm, the retry rules, and verification code for PHP, Python and Node.js.

5 sreply time limit
8delivery attempts
HMAC-SHA256signature algorithm

Why a webhook instead of polling?

Asking getStatus every few seconds for an activation that is waiting for a code works, but it is expensive: with hundreds of concurrent activations your request count climbs fast, you approach the rate limit, and you see each code as late as your polling interval. A webhook runs the other way: once the code is recorded the notification is queued and is typically delivered to you within 1–2 minutes, without a request from you.

It has three concrete benefits: the notification comes to you on its own (typically within 1–2 minutes), your API call volume (and rate-limit pressure) drops, and your server stops waiting for nothing. In return you take on two duties: verify the signature before trusting an inbound request, and expect that the same event can arrive more than once. The sections below cover exactly that.

Events

Every request names its event in the event field; the same value is also in the X-Turonay-Event header.

EventWhen it is sent
code_receivedWhen the SMS code for an activation has been recorded. The fee is finalised at this moment; the code and text fields are filled in.
cancelledWhen an activation is cancelled before a code arrives: the provider cancelled the number, or you sent setStatus with status=8. The held amount is released and nothing is charged. The code, text and receivedAt fields are null.
testWhen you press “Send test” in your profile. It carries sample values and is not tied to a real activation.
cancelled is not sent for a silent expiry. If an activation simply runs out of time without anyone cancelling it, no notification is sent; track those yourself with getStatus or getActiveActivations (the activationEndTime in the getNumberV2 response gives the end time).

If a new code later arrives for the same activation (an additional SMS), it is delivered as a separate code_received event with a different id.

Request body (payload)

The body is UTF-8 JSON sent with Content-Type: application/json. When a code arrives you receive a request like this:

http
POST /turonay-webhook HTTP/1.1
Host: api.example.com
Content-Type: application/json
User-Agent: Turonay-Webhook/1.0
X-Turonay-Event: code_received
X-Turonay-Timestamp: 1791630193
X-Turonay-Signature: sha256=3f1c…9ab2
X-Turonay-Delivery: dlv_9f2c4a7d1b3e5f60a8c2d4e6
json
{
  "event": "code_received",
  "activationId": "184213",
  "phone": "905301234567",
  "service": "wa",
  "country": "62",
  "code": "123456",
  "text": "Your WhatsApp code: 123-456",
  "receivedAt": "2026-10-10T11:03:12Z",
  "sentAt": "2026-10-10T11:03:13Z",
  "id": "dlv_9f2c4a7d1b3e5f60a8c2d4e6"
}

A cancellation carries the same fields; only code, text and receivedAt are null:

json
{
  "event": "cancelled",
  "activationId": "184214",
  "phone": "6281234567890",
  "service": "tg",
  "country": "6",
  "code": null,
  "text": null,
  "receivedAt": null,
  "sentAt": "2026-10-10T11:08:40Z",
  "id": "dlv_3b7e1c9a5d2f4860b1c3e5a7"
}
FieldTypeDescription
eventstringEvent name: code_received, cancelled or test.
activationIdstringActivation id; the same id returned by getNumber.
phonestringThe phone number of the activation (country code included).
servicestringService code (for example wa).
countrystringCountry id (for example 62).
codestring | nullThe SMS code; null for cancelled.
textstring | nullThe SMS text from the operator; may be empty, and is null for cancelled.
receivedAtstring | nullWhen the code was recorded, in UTC as ISO 8601 (e.g. 2026-10-10T11:03:12Z). null for cancelled.
sentAtstringWhen this delivery was sent, UTC, ISO 8601. Use this for exact timing.
idstringDelivery id (dlv_…); it does not change across retries.

Request headers

Every request carries the headers below. Signature verification only needs the timestamp and signature headers; the delivery id is for de-duplication.

HeaderDescription
Content-TypeAlways application/json.
User-AgentClient name: Turonay-Webhook/1.0.
X-Turonay-EventEvent name (code_received, cancelled, test).
X-Turonay-TimestampSend time, Unix seconds. Part of the signature.
X-Turonay-Signaturesha256= + hex HMAC-SHA256 signature.
X-Turonay-DeliveryDelivery id; equal to id in the body and unchanged across retries.

How to verify the signature

The signature is an HMAC-SHA256 computed with your secret (it starts with whsec_). Verification takes six steps:

  1. Read the body as raw bytes. Do not parse and re-serialise the JSON; if whitespace or key order changes, the signature will not match.
  2. Take the X-Turonay-Timestamp and X-Turonay-Signature headers.
  3. Reject the request if the timestamp is more than 5 minutes away from now (replay protection).
  4. Build this string: timestamp + . + raw body.
  5. Compute the hex HMAC-SHA256 of that string with your secret and prefix it with sha256=.
  6. Compare the result with the header signature using a constant-time (timing-safe) comparison. If they differ, return 401.
text
signature = "sha256=" + hex( HMAC_SHA256( secret, timestamp + "." + rawBody ) )

The secret is generated automatically when you first save the URL and is shown in full only at that moment; afterwards it is masked. If you suspect a leak, generate a new one: the old one stops working immediately. Keep the secret in an environment variable, not in source code.

Verification code: PHP, Python, Node.js

All three samples apply the same rules: raw body, a 5-minute tolerance, a constant-time comparison and a fast 2xx reply. Do the heavy work (database, a second API call) after replying, from a queue; your response time must stay under 5 seconds.

PHP

php
<?php
// webhook.php — receives Turonay SMS webhooks
$secret = getenv('TURONAY_WEBHOOK_SECRET');            // whsec_...
$raw    = file_get_contents('php://input');             // RAW bytes: never json_decode() before verifying
$ts     = $_SERVER['HTTP_X_TURONAY_TIMESTAMP'] ?? '';
$sig    = $_SERVER['HTTP_X_TURONAY_SIGNATURE'] ?? '';

// 1) replay protection: reject anything more than 5 minutes away from now
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 300) { http_response_code(400); exit; }

// 2) signature = "sha256=" + HMAC_SHA256(secret, timestamp + "." + rawBody)
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret);
if (!hash_equals($expected, $sig)) { http_response_code(401); exit; }   // constant-time compare

// 3) de-duplicate on the delivery id (identical across all retries)
$event = json_decode($raw, true);
$stmt  = $pdo->prepare('INSERT IGNORE INTO webhook_deliveries (id) VALUES (?)');   // $pdo = your PDO connection
$stmt->execute([$event['id']]);
if ($stmt->rowCount() === 0) { http_response_code(200); exit; }          // already processed

if ($event['event'] === 'code_received') {
    // $event['activationId'], $event['code'], $event['text'] ...
}
http_response_code(200);                                // answer within 5 seconds

Python (Flask)

python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["TURONAY_WEBHOOK_SECRET"].encode()   # whsec_...
seen = set()                                              # use Redis / a unique DB column in production


@app.post("/turonay-webhook")
def turonay_webhook():
    raw = request.get_data()                              # RAW bytes
    ts = request.headers.get("X-Turonay-Timestamp", "")
    sig = request.headers.get("X-Turonay-Signature", "")

    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        abort(400)                                        # replay protection (5 min)

    expected = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected.encode(), sig.encode()):   # constant-time compare
        abort(401)

    event = request.get_json(force=True)
    if event["id"] in seen:                               # idempotency: same id on every retry
        return "", 200
    seen.add(event["id"])

    if event["event"] == "code_received":
        print(event["activationId"], event["code"])
    return "", 200                                        # answer within 5 seconds

Node.js (Express)

js
const crypto = require("crypto");
const express = require("express");

const app = express();
const SECRET = process.env.TURONAY_WEBHOOK_SECRET;       // whsec_...
const seen = new Set();                                   // use Redis / a unique DB column in production

// express.raw keeps the body as a Buffer: the signature covers the exact bytes
app.post("/turonay-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.get("X-Turonay-Timestamp") || "";
  const sig = req.get("X-Turonay-Signature") || "";

  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return res.sendStatus(400);                           // replay protection (5 min)
  }

  const expected = "sha256=" + crypto.createHmac("sha256", SECRET)
    .update(ts + ".").update(req.body).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);                           // constant-time compare
  }

  const event = JSON.parse(req.body.toString("utf8"));
  if (seen.has(event.id)) return res.sendStatus(200);     // idempotency: same id on every retry
  seen.add(event.id);

  if (event.event === "code_received") console.log(event.activationId, event.code);
  res.sendStatus(200);                                    // answer within 5 seconds
});

app.listen(3000);

Retry policy

Your server must answer within 5 seconds with a 2xx status. Any other status, a timeout or a connection error counts as a failure; the response body is not read. Redirects (3xx) are not followed and count as a failure.

A failed delivery is attempted up to 8 times. The wait starts at 1 minute and doubles on every attempt up to a ceiling of 1 hour (1, 2, 4, 8, 16, 32, 60 minutes; about two hours in total). If every attempt fails the event is dropped. Permanent errors, such as the URL being rejected, are not retried.

Each attempt is written to the “last 10 deliveries” list in your profile with its HTTP status and an error note; that is the first place to look when something goes wrong.

Duplicate events and idempotency

Delivery is “at least once”: if your reply does not arrive in time, the same event comes again. The id field of each delivery (and the X-Turonay-Delivery header) is identical across all attempts, which makes it the ideal de-duplication key.

The practical rule: store id in a unique column; if the row already exists, skip the work but still return 200. Event order is not guaranteed, and events for different activations can interleave.

If you also read the code with getStatus, de-duplicate on activationId too: the same code can reach you both by polling and by webhook.

Security rules for your webhook URL

Because your URL makes our servers send a request, we enforce strict rules that stop our servers from being pointed at internal networks. The rules are checked when you save the URL and before every delivery. Your URL must meet all of the following:

  • https:// only. Plain http:// is rejected and the TLS certificate must be valid.
  • Port 443 or 8443 only. If you omit the port, 443 is used.
  • At most 500 characters, and no username or password (user:pass@).
  • The host must be a domain name. IP addresses (including decimal, octal and hexadecimal spellings), localhost, single-label names and internal suffixes such as .local, .internal or .test are rejected; the domain must not end with a dot.
  • Every IP address the domain resolves to must be public. Private, loopback, link-local, CGNAT, multicast, reserved and documentation ranges, as well as our own servers’ addresses, are rejected.
  • Turonay’s own domain is rejected, so that we never loop into our own API.
  • We connect pinned to the IP we validated (protection against DNS rebinding) and never follow redirects. If your endpoint redirects, register the final URL directly.
Example URLResult
https://api.example.com/turonay-webhookValid
http://api.example.com/hookRejected: not https
https://203.0.113.10/hookRejected: an IP address, not a domain name
https://api.example.com:8080/hookRejected: port not allowed
https://user:pass@api.example.com/hookRejected: contains credentials
https://localhost/hookRejected: internal domain name

Enabling the webhook in your profile

You configure the webhook in your profile before writing any code:

  1. Sign in, go to Profile → API → Webhook and open the Webhook box.
  2. Enter your URL (for example https://api.yoursite.com/turonay-webhook) and save. On the first save a secret (whsec_…) is generated automatically; copy it right away, because afterwards it is shown masked.
  3. Press Send test. A test event is sent to your URL with a real signature, and the result appears on screen with the HTTP status and timing. Wait a few seconds between tests.
  4. The Last 10 deliveries table shows time, event, activation, HTTP status, attempt number and error note.
  5. To turn the webhook off, save an empty field. To rotate the secret, use the “Generate new secret” button; the old secret stops working immediately.

Webhook + getStatus: the most robust setup

Make the webhook your main path and getStatus your safety net. A network outage, a server that was down during a deploy, or all 8 attempts failing can make you miss an event, so never forget the activations that are still waiting.

  1. When you buy a number, record the activation as “waiting”.
  2. When the webhook arrives, de-duplicate on id first, process the code and close the activation.
  3. Every few minutes call getStatus for activations that are still waiting; if STATUS_OK:code comes back, hand it to the same handler.
  4. When your application restarts, restore open activations with getActiveActivations.
python
import requests

API = "https://turonay.com/stubs/handler_api.php"


def sweep(waiting_ids):          # run every few minutes
    for act_id in waiting_ids:
        text = requests.get(API, params={
            "api_key": "YOUR_API_KEY", "action": "getStatus", "id": act_id,
        }, timeout=15).text
        if text.startswith("STATUS_OK:"):
            handle_code(act_id, text.split(":", 1)[1])   # the same handler the webhook uses

The webhook delivers the notification to you (typically within 1–2 minutes) and polling closes the rare gaps; because you can keep the polling interval long, your API call volume stays low too. For language-specific samples see the Python, PHP and Node.js guides, and the API reference for every endpoint.

Frequently asked questions

What is a webhook in an SMS API, and how is it different from polling?

A webhook is a signed HTTPS POST that Turonay queues for your server as soon as a code is recorded; it is typically delivered within 1–2 minutes. With polling you call getStatus over and over to see the code; with a webhook the event comes to you. That cuts the number of API calls.

Which events are sent?

code_received (the SMS code was recorded) and cancelled (cancelled before a code arrived) are sent, and the test button in your profile sends a test event. No cancelled notification is sent for activations that simply expire without anyone cancelling them; track those with getStatus.

How is the signature verified?

Join the X-Turonay-Timestamp value, a dot and the raw request body; compute the HMAC-SHA256 of that string with your secret as hex and prefix it with sha256=. Compare the result with the X-Turonay-Signature header using a constant-time comparison. Reject the request if the timestamp is older than 5 minutes.

What happens if my reply is late?

If your server does not return 2xx within 5 seconds, the delivery counts as failed and is retried up to 8 attempts in total, with waits that start at 1 minute and double up to 1 hour. The same event arrives with the same id, so de-duplicate on id.

Why is my webhook URL being rejected?

The URL must be https, use port 443 or 8443, contain no credentials and be a domain name that resolves only to public IP addresses. IP addresses, localhost, internal domain names and URLs that redirect are not accepted. For local development you can use a tunnelling service.

Do I still need getStatus when I use webhooks?

It is recommended. The webhook is the main path and getStatus is the backup for missed events and restarts. Check waiting activations with getStatus every so often.

What should I do if the secret leaks?

Generate a new secret in the API section of your profile. The old one stops working immediately; update the value on your server right away, otherwise signature verification will fail.

Try the webhook now

Create a free account, get your API key, save your URL and press “Send test”. You do not pay for activations that never receive a code.