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.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/users/webhook | Register or replace the endpoint, returns the secret once |
| GET | /api/users/webhook | Read the current endpoint, secret is masked |
| PATCH | /api/users/webhook | Change the URL or the channel filter, or re-enable a disabled endpoint |
| POST | /api/users/webhook/rotate | Rotate the signing secret with a grace window |
| POST | /api/users/webhook/test | Send a test event that carries no activation data |
| DELETE | /api/users/webhook | Delete the endpoint and stop all notifications |
| GET | /api/users/webhook/deliveries | Last 20 delivery attempts, metadata only |
Events
Three events, all about a single activation.
| Event | Sent when |
|---|---|
| activation.code_received | The SMS code arrives. This is the event most integrations act on. |
| activation.cancelled | The activation is cancelled. The body carries the reason and whether the balance was refunded. |
| activation.expired | The 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: 1activation.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.
| Attempt | Wait before | activation.code_received | cancelled and expired |
|---|---|---|---|
| 1 | immediate | ||
| 2 | 10s | ||
| 3 | 60s | ||
| 4 | 5m | ||
| 5 | 15m | ||
| 6 | 30m |
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.
