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:

json
{
  "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

TypeFires when
message.sentAn outbound message was accepted by the carrier
message.deliveredThe carrier confirmed delivery
message.failedDelivery failed permanently
message.receivedAn inbound SMS arrived on one of your numbers
number.purchasedA number was added to your account
number.releasedA number was released
number.sender_updatedA live number's standing with the carriers changed (test_only, pending, active, action_needed). See Numbers
verification.sentA verification code was sent
verification.approvedA code was checked successfully
verification.failedA code check failed
verification.blockedA verification was blocked by fraud protection
deliverability.degradedYour delivery rate dropped sharply for all traffic, a carrier, or a sending number (Deliverability)
spend.threshold_reachedEstimated spend crossed one of your spend limit alert thresholds
automation.run.started, automation.run.completed, automation.run.failedAn automation run began, or ended (with reason)
test.pingYou pressed "Send test" in the console

Endpoints receive all events by default; pass an events array when creating one to filter.

message.failed

json
{
  "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.

ts
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:

js
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,
});
python
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:

bash
curl "https://api.joinsimplesms.com/v1/webhooks/deliveries?endpoint_id=we_...&limit=25" \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY"
json
{
  "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:

bash
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.