Webhooks
Add an endpoint URL in the console and every event (inbound messages, delivery updates, verification results) is POSTed to it as JSON. That plus an API key is everything you need: send with the API, receive with webhooks.
Events are also pollable via GET /v1/events if you prefer
pull over push.
Payload
Webhook bodies are exactly the event objects from /v1/events:
{
"id": "evt_a1B2c3D4e5F6g7H8",
"object": "event",
"type": "message.received",
"created_at": "2026-08-13T00:41:00.000Z",
"data": {
"message_id": "msg_x9Y8z7W6v5U4t3S2",
"from": "+14155550132",
"to": "+15005550100",
"body": "Hey, got your message!"
}
}Event types
| Type | Fires when |
|---|---|
message.sent | An outbound message was accepted by the carrier |
message.delivered | The carrier confirmed delivery |
message.failed | Delivery failed permanently |
message.received | An inbound SMS arrived on one of your numbers |
number.purchased | A number was added to your account |
number.released | A number was released |
number.sender_updated | A live number's standing with the carriers changed (test_only, pending, active, action_needed). See Numbers |
verification.sent | A verification code was sent |
verification.approved | A code was checked successfully |
verification.failed | A code check failed |
verification.blocked | A verification was blocked by fraud protection |
deliverability.degraded | Your delivery rate dropped sharply for all traffic, a carrier, or a sending number (Deliverability) |
spend.threshold_reached | Estimated spend crossed one of your spend limit alert thresholds |
automation.run.started, automation.run.completed, automation.run.failed | An automation run began, or ended (with reason) |
test.ping | You pressed "Send test" in the console |
Endpoints receive all events by default; pass an events array when creating
one to filter.
message.failed
{
"type": "message.failed",
"data": {
"message_id": "msg_a1B2c3D4e5F6g7H8",
"to": "+15005550009",
"code": "undeliverable",
"reason": "stat:UNDELIV err:001",
"failure": {
"code": "invalid_number",
"title": "Number not in service",
"explanation": "The destination number does not exist or is no longer assigned to a phone.",
"action": "Recommended: stop sending to this number and ask the recipient for an up-to-date one. Retrying will not help.",
"carrier_code": "001"
}
}
}failure is the same object as on the message (see
delivery failures); code and reason are
kept for existing integrations.
Verify signatures
Every request carries a simplesms-signature header:
simplesms-signature: t=1755043260,v1=5257a869e7...
The event id (evt_..., the same across retries) is in
simplesms-event-id.
Both are also sent under the header names from before the product was renamed,
with identical values, and always will be: dsms-signature /
dsms-event-id and resms-signature / resms-event-id. A handler that
reads either keeps verifying; new handlers should read the simplesms- pair.
v1 is HMAC-SHA256(secret, \${t}.${rawBody}`), the same scheme Stripe uses. Your signing secret (whsec_...`) is shown next to the endpoint
in the console.
To replace a secret, choose Rotate secret on the endpoint in the
console (admins only). The old secret stops verifying
immediately: there is no overlap period, and retries of earlier failures are
signed with the new secret too. Update your receiver as soon as you rotate;
anything it rejects in between is retried on the usual schedule and can be
replayed from the delivery log. Rotations appear in the
audit log as webhook.secret_rotated.
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhook(rawBody: string, header: string, secret: string): boolean {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // 5 min tolerance
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return v1.length === expected.length &&
timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}Compute the HMAC over the raw request body; parse the JSON only after the signature checks out.
Both SDKs ship this check, with the 5-minute replay window built in. They
throw (code: "invalid_signature") on a bad signature and return the parsed
event otherwise:
import { verifyWebhook } from 'joinsimplesms';
const event = await verifyWebhook({
payload: rawBody, // string or Buffer, unparsed
signature: req.headers['simplesms-signature'],
secret: process.env.SIMPLESMS_WEBHOOK_SECRET,
});from joinsimplesms import verify_webhook
event = verify_webhook(
request.body, # raw bytes, unparsed
request.headers["simplesms-signature"],
os.environ["SIMPLESMS_WEBHOOK_SECRET"],
)Pass headers: req.headers (Node) or request.headers (Python) in place
of the signature and the SDK reads it from whichever header name arrived.
Events about a message, verification, or number that belongs to one of your
customers carry data.customer_id.
Retries
Respond with any 2xx within 5 seconds. Anything else (including a timeout) is
retried with backoff: 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours.
Deliveries can arrive out of order and, rarely, more than once; use the
id field to deduplicate.
If all six attempts fail, the event goes to Undelivered events on the console's Webhooks page, where it stays for 30 days. Replay sends it again with one click and clears it if your endpoint answers 2xx. We alert our own team too. Your endpoint keeps receiving new events: we never pause or disable an endpoint because it failed. Only you can do that.
Delivery log
Every attempt is logged for 30 days with the HTTP status your endpoint returned, the time it took to respond, the attempt number, and the first 2 KB of the response body. Open Deliveries next to an endpoint in the console, or use the API:
curl "https://api.joinsimplesms.com/v1/webhooks/deliveries?endpoint_id=we_...&limit=25" \
-H "Authorization: Bearer ssms_sk_test_YOUR_KEY"{
"data": [{
"id": "whd_-O9xk2...",
"object": "webhook_delivery",
"endpoint_id": "we_a1B2c3D4e5F6",
"event_id": "evt_a1B2c3D4e5F6g7H8",
"event_type": "message.received",
"attempt": 2,
"ok": false,
"status_code": 500,
"error": null,
"latency_ms": 212,
"response_body": "Internal Server Error",
"replay": false,
"created_at": "2026-10-01T12:00:00.000Z"
}],
"has_more": false,
"next_cursor": null
}Filter with endpoint_id, event_id (every attempt for one event), or
both. GET /v1/webhooks lists your endpoint ids. error is timeout or
unreachable when your endpoint never answered. We store your endpoint's
response, not a second copy of the event, which is always at
GET /v1/events.
Replay
Send one stored event to one endpoint, now:
curl -X POST https://api.joinsimplesms.com/v1/events/evt_.../replay \
-H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "endpoint_id": "we_..." }'The response has your endpoint's status code and latency. A replay is not retried; if it fails, replay it again. It goes to the endpoint you name even if the endpoint is paused or does not subscribe to that event type, because you asked for it. In the console, use Replay on any row of the delivery log.
Test it
Press Send test next to any endpoint in the console; a signed
test.ping fires immediately and the console shows your endpoint's response
code and latency. In the sandbox, POST /v1/test/inbound emits a real
message.received through the same pipeline, so you can rehearse your
inbound handler before going live.
Delivery log and replay
Every attempt (first try, retries, tests, replays) is logged per endpoint.
Open Deliveries next to an endpoint in the console to filter by result and
event type, replay a single event, or Replay all failed for the last 24
hours to 7 days. Replay-all re-sends each event whose deliveries all failed
in that window (events a retry already recovered are skipped), once per event,
up to 50 per run; they go out within 5 minutes and appear in the log marked
replay. Because replays reuse the original event id, your existing
deduplication handles them.
Events edits an endpoint's subscription in place (same URL, same secret);
Clone copies the subscription to a new endpoint with its own secret. The
whole log exports as CSV from the console or
GET /v1/exports/webhook_deliveries?endpoint_id=we_...&ok=false.