Webhooks

Webhook notifications

Stop polling. We post the SMS code to your server the moment it arrives, signed and retried.

Why webhooks

Polling costs you requests and adds delay. A webhook reaches your server in the same second the code lands.

  • No polling loop: your daily request quota goes to real work instead of status checks.
  • Lower latency: the code reaches your system as soon as we receive it, not on your next poll.
  • Terminal events too: cancellations and expiries arrive with the refund status, so your accounting stays in sync.

Setup

Register one URL per account with your API key. The signing secret is returned once.

curl -X POST https://smsbulk.net/api/users/webhook \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-app.com/smsbulk/webhook"}'

Response

{
  "id": "wep_2f8c1a9d",
  "url": "https://your-app.com/smsbulk/webhook",
  "sourceFilter": "API",
  "secret": "whsec_5f3a9c1e8b2d47a6903c5e1f7d8b4a26"
}

Store the secret now

The plain secret is shown only in this response. Later reads return a masked value. If you lose it, rotate the secret from the dashboard or the rotate endpoint.

URL requirements

  • HTTPS on port 443. Plain HTTP and custom ports are rejected.
  • A public address. Private ranges, loopback and cloud metadata addresses are rejected at registration and again at every delivery.
  • No redirects. A 3xx response is not followed, it counts as a failed attempt.
MethodPathPurpose
POST/api/users/webhookRegister or replace the endpoint, returns the secret once
GET/api/users/webhookRead the current endpoint, secret is masked
PATCH/api/users/webhookChange the URL or the channel filter, or re-enable a disabled endpoint
POST/api/users/webhook/rotateRotate the signing secret with a grace window
POST/api/users/webhook/testSend a test event that carries no activation data
DELETE/api/users/webhookDelete the endpoint and stop all notifications
GET/api/users/webhook/deliveriesLast 20 delivery attempts, metadata only

Events

Three events, all about a single activation.

EventSent when
activation.code_receivedThe SMS code arrives. This is the event most integrations act on.
activation.cancelledThe activation is cancelled. The body carries the reason and whether the balance was refunded.
activation.expiredThe activation runs out of time without a code. The body carries the refund status.

Channel filter

By default only orders placed through the API trigger a notification. Set sourceFilter to null if you also want the orders you place from the dashboard.

Request and payload

Every delivery is a POST with a JSON body. Fields are selected one by one: internal routing and cost details never appear.

Request shape

POST /smsbulk/webhook HTTP/1.1
Content-Type: application/json
User-Agent: SMSBulk-Webhook/1
X-SMSBulk-Signature: t=1785312000,v1=8f2a...c41d
X-SMSBulk-Event-Id: evt_del_9f2c1a
X-SMSBulk-Event-Type: activation.code_received
X-SMSBulk-Delivery-Attempt: 1

activation.code_received

{
  "id": "evt_del_9f2c1a",
  "type": "activation.code_received",
  "created": 1785312000,
  "data": {
    "activation_id": "cms2u6x80d1twmkudp2cfbqo4",
    "status": "RECEIVED",
    "phone_number": "447700900123",
    "service": "wa",
    "country": "GB",
    "code": "483920",
    "price": "0.42",
    "currency": "USD",
    "source": "API",
    "created_at": "2026-08-04T09:12:00.000Z",
    "sms_text": "Your code is 483920",
    "received_at": "2026-08-04T09:12:34.000Z"
  }
}

activation.cancelled and activation.expired

{
  "id": "evt_del_7b31de",
  "type": "activation.cancelled",
  "created": 1785312600,
  "data": {
    "activation_id": "cms2u6x80d1twmkudp2cfbqo4",
    "status": "CANCELLED",
    "phone_number": "447700900123",
    "service": "wa",
    "country": "GB",
    "code": null,
    "price": "0.42",
    "currency": "USD",
    "source": "API",
    "created_at": "2026-08-04T09:12:00.000Z",
    "reason": "user_cancel",
    "refunded": true,
    "refund_amount": "0.42"
  }
}

New fields may be added in future versions. Ignore unknown fields instead of failing on them.

Verifying the signature

Every request carries an HMAC SHA256 signature built from the timestamp and the raw body.

X-SMSBulk-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>
signed_string = "<t>.<raw_request_body>"

Verify the raw bytes

Compute the signature over the exact body you received. If you parse the JSON and stringify it again, key order and spacing change and the signature will not match.

There can be more than one v1

During a secret rotation the header carries two v1 values. Collect them into a list, not a dictionary: a dictionary keeps only the last one and the new secret would silently stay unverified.

Node.js

const crypto = require('crypto');

function verify(rawBody, header, secret) {
  const pairs = header.split(',').map((p) => {
    const i = p.indexOf('=');
    return [p.slice(0, i), p.slice(i + 1)];
  });

  const t = pairs.find(([k]) => k === 't')?.[1];
  // During a rotate window more than one signature arrives: collect them all.
  const signatures = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || signatures.length === 0) return false;

  // Replay window: reject anything older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  return signatures.some(
    (s) =>
      s.length === expected.length &&
      crypto.timingSafeEqual(
        Buffer.from(s, 'hex'),
        Buffer.from(expected, 'hex'),
      ),
  );
}

Python

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    pairs = [p.split("=", 1) for p in header.split(",")]

    t = next((v for k, v in pairs if k == "t"), None)
    # During a rotate window more than one signature arrives: collect them all.
    signatures = [v for k, v in pairs if k == "v1"]
    if t is None or not signatures:
        return False

    # Replay window: reject anything older than 5 minutes.
    if abs(time.time() - int(t)) > 300:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{t}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return any(hmac.compare_digest(s, expected) for s in signatures)

The timestamp is part of the signed string, so you can reject anything older than a few minutes. Compare signatures in constant time.

Responding and retries

Answer fast. We wait 10 seconds for a response.

Do

  • Return 2xx as soon as you have stored the event.
  • Queue the heavy work and answer first.
  • Treat a 4xx from your own side as a bug: it stops the retries.

Do not

  • Do not run long jobs before answering, the request times out at 10 seconds.
  • Do not answer with 3xx, redirects are not followed.
  • Do not build a large response body, it is discarded without being read.
AttemptWait beforeactivation.code_receivedcancelled and expired
1immediate
210s
360s
45m
515m
630m

Retries happen on network errors, timeouts, 408, 429 and 5xx. Any other 4xx stops the ladder at once. code_received stops at 4 attempts because the activation is over long before the later steps would help.

Deduplication

Use X-SMSBulk-Event-Id as the idempotency key. Retries of the same event carry the same id.

At least once, not exactly once

One record is created per activation and event type, so duplicates are rare. Even so, a stalled job recovery can deliver the same record again. Store the event id and ignore an id you have already processed.

Circuit breaker

A dead endpoint is not called forever.

  • After 20 consecutive failed deliveries the endpoint is disabled.
  • You get an email, and the dashboard shows why it was disabled.
  • Events created while the endpoint is disabled are not replayed. Use GET /v1/activations to catch up.

Rotating the secret

Change the signing secret without downtime.

  • The rotate endpoint returns the new secret once.
  • During the grace window both the new and the previous signature are sent.
  • Move to the new secret at your own pace, after the window only the new one is sent.

Set up your endpoint

Register a URL, send a test event, and watch the delivery list in your dashboard.