Quick links

Long-term SMS
Balance
My Account
Important articles

How to setup webhooks for SMSPool

A webhook sends each SMS to your server the moment it arrives, so you don't have to keep polling the API to check for a code. This guide covers setting one up, what we send, how to check that a request really came from SMSPool, and what happens when your server doesn't answer.

Where webhooks can go

  • Your own server: a signed JSON POST for every event, described below.
  • Discord (https://discord.com/api/webhooks/...): a chat message for each SMS, showing the code and the full message.
  • Slack (https://hooks.slack.com/...): a chat message for each SMS.

Discord and Slack need no code on your side. The rest of this guide is about your own server.

Set up your webhook

  1. Go to Settings and open the Developer tab.
  2. Under Webhook, enter your URL and your account password, then press Update webhook.
  3. Copy the Signing secret (it starts with whsec_) into your server's configuration. You need it to check requests.
  4. Press Send test event. The result appears under Webhook deliveries on the same page.

Your URL must:

  • start with https:// and be no longer than 255 characters;
  • be reachable from the internet. Private, local and other reserved addresses are refused when you save the URL and again before each request;
  • answer by itself. Redirects are not followed, so a 301 or 302 counts as a failure.

Changing the URL later keeps the same signing secret.

What we send

Every request is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
User-AgentSMSPool-Webhook/2.0
X-SMSPool-EventThe event type, for example order.code
X-SMSPool-Event-IdUnique per event. A retry of the same event uses the same ID
X-SMSPool-Delivery-Attempt1 for the first try, up to 4
X-SMSPool-Signaturet=<unix time>,v1=<signature>, see "Check that a request came from SMSPool" below
X-SMSPool-SecretYour signing secret, only if you turn on the simple check (see below)

order.code: an SMS arrived for an order

{
  "event": "order.code",
  "event_id": "evt_6f1c0e9a2b4d8f7e1a3c5b90",
  "orderid": "AB12CD34",
  "sms": "123456",
  "full_sms": "Your verification code is 123456",
  "type": "short_term",
  "order_code": "AB12CD34",
  "status": "completed",
  "code": "123456",
  "full_code": "Your verification code is 123456",
  "phonenumber": "14155550123",
  "timestamp": "2026-10-18 02:15:09pm",
  "unix_timestamp": 1792332909
}

New integrations should read order_code, code, full_code and unix_timestamp. The fields orderid, sms, full_sms, type and timestamp are still sent unchanged for older integrations. timestamp is in server time with no time zone, so prefer unix_timestamp.

An order that receives more than one SMS, for example after a resend, sends one order.code event per SMS.

order.status: an order ended without a code (optional)

Turn on Also send order.status events in the Developer tab to receive these.

{
  "event": "order.status",
  "event_id": "evt_0b7d2c4e6a8f1e3d5c7b9a01",
  "order_code": "AB12CD34",
  "orderid": "AB12CD34",
  "status": "refunded",
  "phonenumber": "14155550123",
  "timestamp": "2026-10-18 02:35:12pm",
  "unix_timestamp": 1792334112
}

status is expired, refunded or cancelled. An order that expires and is then refunded sends two events. A completed order doesn't send an order.status event, because the order.code event already tells you.

rental.sms: an SMS arrived on a rental

{
  "event": "rental.sms",
  "event_id": "evt_3a5c7e9b1d2f4a6c8e0b2d4f",
  "phonenumber": "14155550123",
  "rental_code": "R1A2B3C4",
  "full_sms": "Your code is 998877",
  "timestamp": "2026-10-18 02:15:09pm",
  "unix_timestamp": 1792332909
}

rental.auto_extend: a rental was auto-extended, or couldn't be

{
  "event": "rental.auto_extend",
  "event_id": "evt_9e8d7c6b5a4f3e2d1c0b9a8f",
  "rental_code": "R1A2B3C4",
  "phonenumber": "14155550123",
  "success": 1,
  "timestamp": "2026-10-18 02:15:09pm",
  "unix_timestamp": 1792332909
}

success is 1 when the rental was extended and 0 when it couldn't be.

webhook.test: sent by the Send test event button

{
  "event": "webhook.test",
  "event_id": "evt_1f2e3d4c5b6a7f8e9d0c1b2a",
  "message": "This is a test event from SMSPool.",
  "timestamp": "2026-10-18 02:15:09pm",
  "unix_timestamp": 1792332909
}

Answer quickly

Answer with any 2xx status within 5 seconds (3 seconds to connect). If your processing takes longer, answer first and process afterwards.

Anything else, such as another status, a timeout or a refused connection, is retried after 15, 45 and 90 seconds. Each event is tried 4 times within about 3 minutes and then dropped, because orders expire a few minutes after purchase and a later code would be no use. If your server can be down for longer than that, keep checking orders through the API as a fallback.

Because of retries, the same event can reach you more than once, and events can arrive out of order. Use X-SMSPool-Event-Id (or event_id in the body) to skip events you've already handled.

Webhook deliveries in the Developer tab lists your last 25 attempts and their results. Entries are kept for 30 days.

Check that a request came from SMSPool

Anyone who learns your URL can send requests to it. Check the signature before you trust a request.

The X-SMSPool-Signature header looks like this:

X-SMSPool-Signature: t=1792843200,v1=268b68d207c255da63fcf785e80376ebc3699018a56f3a10b62a9d9fee6be899
  • t is the Unix time (in seconds) when the request was signed.
  • v1 is an HMAC-SHA256, written as lowercase hexadecimal with no sha256= prefix.
  • The signed text is the timestamp, a full stop, then the raw request body: <t>.<raw body>.
  • The key is your signing secret exactly as shown in Settings, including the whsec_ prefix. Don't decode it.

To check a request:

  1. Split the header on , into t and v1.
  2. Compute HMAC-SHA256 of <t>.<raw body> with your secret as the key, and write it as hexadecimal.
  3. Compare the result with v1 using a constant-time comparison.
  4. Reject the request if t is more than 5 minutes from your server's clock, so an old request can't be replayed.

Use the body exactly as you received it. If you parse the JSON and encode it again, the bytes change and the signature won't match.

Worked example

Use this example to test your code. The secret is made up.

  • Secret: whsec_0123456789abcdef0123456789abcdef0123456789abcdef
  • t: 1792843200
  • Raw body: {"event":"webhook.test","event_id":"evt_0000000000000000000000aa"}
  • Signed text: 1792843200.{"event":"webhook.test","event_id":"evt_0000000000000000000000aa"}
  • Expected v1: 268b68d207c255da63fcf785e80376ebc3699018a56f3a10b62a9d9fee6be899

PHP

<?php
$secret = getenv('SMSPOOL_WEBHOOK_SECRET'); // whsec_..., exactly as shown in Settings
$body = file_get_contents('php://input');   // the raw body, before any JSON parsing
$header = $_SERVER['HTTP_X_SMSPOOL_SIGNATURE'] ?? '';

parse_str(str_replace(',', '&', $header), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);

if (!hash_equals($expected, $parts['v1'] ?? '') || abs(time() - $timestamp) > 300) {
    http_response_code(401);
    exit;
}

http_response_code(200);
$event = json_decode($body, true);
// Skip $event['event_id'] values you have already handled, then process the event.

Node.js (Express)

const crypto = require('crypto');
const express = require('express');
const app = express();

// express.raw keeps the body as bytes, so the signature can be checked.
app.post('/smspool-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-SMSPool-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = crypto
    .createHmac('sha256', process.env.SMSPOOL_WEBHOOK_SECRET)
    .update(`${parts.t}.${req.body}`)
    .digest('hex');

  const valid =
    parts.v1 &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)) &&
    Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
  if (!valid) return res.sendStatus(401);

  res.sendStatus(200);
  const event = JSON.parse(req.body);
  // Skip event.event_id values you have already handled, then process the event.
});

Python (Flask)

import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["SMSPOOL_WEBHOOK_SECRET"].encode()

@app.post("/smspool-webhook")
def smspool_webhook():
    header = request.headers.get("X-SMSPool-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    body = request.get_data()  # raw bytes
    expected = hmac.new(SECRET, parts.get("t", "").encode()   b"."   body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts.get("t", 0))) > 300:
        abort(401)
    event = request.get_json()
    # Skip event["event_id"] values you have already handled, then process the event.
    return "", 200

Simple check

If you'd rather not compute the HMAC, turn on Simple check under the signing secret in the Developer tab. Every request to your https:// URL then also carries your secret, exactly as shown in Settings, in an X-SMSPool-Secret header. Your server only has to compare it with the secret:

if (!hash_equals(getenv('SMSPOOL_WEBHOOK_SECRET'), $_SERVER['HTTP_X_SMSPOOL_SECRET'] ?? '')) {
    http_response_code(401);
    exit;
}

This is weaker than the signature. Anything that logs request headers, such as a proxy or an error tracker, ends up holding your secret, and a captured request can be sent again. X-SMSPool-Signature is still sent, so you can switch to the full check at any time.

When you press Generate new secret, the old secret stops working immediately. Update your server straight away.

Troubleshooting

  • The signature never matches. Make sure you sign the raw body, not parsed and re-encoded JSON. Make sure you include the t. prefix and use the full secret, whsec_ included. Then compare your code against the worked example above.
  • Nothing arrives. Check Webhook deliveries in the Developer tab for the error. Common causes are a firewall blocking our requests, a redirect, or an answer slower than 5 seconds.
  • The webhook was switched off. Each dropped event sends you an on-site notification. After 100 dropped events the webhook is switched off. Fix the problem, then save your URL again to turn it back on.

Still stuck? Contact support and include the time of a failed delivery.

Return to knowledgebase

About the author

Other interesting articles

SMSPool Business Account Explained

Learn how to unlock your free SMSPool Business Account. Get step-by-step videos on creating sub-accounts, funding your team, and managing access

  • By Admin
  • April 16, 2026

How to create a Greggs account

This a step by step guide on how to create a Greggs account, bypass SMS verification and get free treats!

  • By Admin
  • June 26, 2025

How to Get an Australian Phone Number

Need an Australian phone number but not in Australia? Get a +61 number online with SMSPool, no SIM or ID required. Verify Gumtree and more in seconds.

  • By Admin
  • September 18, 2026

How to get a Signal SMS verification

This guide shows you how to get an SMS verification for Signal without a phone number.

  • By Admin
  • May 25, 2023

How to get a Facebook SMS verification

In this guide we will show you a step to step guide on registering on Facebook without a phone number, using a Facebook SMS verification service.

  • By Admin
  • May 24, 2023

Create an anonymous account on SMSPool

This guide showcases how to create an anonymous account on SMSPool.

  • By Admin
  • November 17, 2025

How to get a Zalo SMS verification

This guide shows you how to create a Zalo account with our Zalo SMS verification service.

  • By Admin
  • April 18, 2024
Notifications
New notifications - Mark all as read
Preferences
Color mode
Look
Audio mode
Prompts