# Webhooks

Source: https://joinsimplesms.com/docs/webhooks
Index: https://joinsimplesms.com/llms.txt

Add an endpoint URL in the [console](/console/webhooks), or with
[`POST /v1/webhooks`](#manage-endpoints-with-the-api), 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.

Building the handler on your own machine? [Test locally](#test-locally) needs
no tunnel and no public URL.

Events are also pollable via [`GET /v1/events`](/docs/messages) 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

| 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](/docs/numbers#sender-status) |
| `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](/docs/deliverability)) |
| `spend.threshold_reached` | Estimated spend crossed one of your [spend limit](/docs/spend-limits) alert thresholds |
| `automation.run.started`, `automation.run.completed`, `automation.run.failed` | An [automation](/docs/automations) run began, or ended (with `reason`) |
| `schedule.occurrence_skipped` | A [recurring schedule](/docs/schedules) did not send an occurrence: `reason` is `recipient_opted_out` or `missed` |
| `schedule.paused` | A recurring schedule paused itself (`reason`: `recipient_opted_out`, `repeated_failures` or `sender_unavailable`) |
| `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

```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](/docs/errors#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](/console/webhooks) (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](/docs/audit-logs) 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](/docs/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 locally

Two commands, no tunnel, no public URL, nothing to configure in the console:

```bash
npx joinsimplesms login
npx joinsimplesms listen --forward-to http://localhost:3000/webhooks
```

`login` opens the console for you to approve and stores a test key
([CLI](/docs/cli#login)). `listen` then prints:

```text
Ready. Forwarding events to http://localhost:3000/webhooks
Webhook signing secret for this session: whsec_local_3f9c...
```

Set that secret as your handler's webhook secret (for example
`SIMPLESMS_WEBHOOK_SECRET`) and leave your verification code exactly as it
will run in production. In another terminal, make something happen:

```bash
npx joinsimplesms trigger message.received     # a simulated inbound SMS
npx joinsimplesms trigger message.delivered    # a sandbox send that delivers
npx joinsimplesms trigger message.failed       # one that fails
```

Each event is POSTed to your local URL with the same JSON body and the same
`simplesms-signature` / `simplesms-event-id` headers (and the
`dsms-` pair) a real delivery carries, and the terminal shows what your
server answered:

```text
14:02:11  message.received         -> 200  (12ms)  evt_a1B2c3D4e5F6g7H8
```

What to know:

- **Nothing connects in to your machine.** The CLI asks the API for new events
  ([`GET /v1/events/wait`](#long-polling)) and makes the request to
  localhost itself.
- **Your real endpoints are untouched.** `listen` only reads the event
  log. Endpoints you configured still get their own deliveries, and nothing
  `listen` does appears in their delivery logs or retries.
- **Only new events.** It shows what happens after it starts. Earlier events
  are in `npx joinsimplesms events` or the console.
- **The secret is per session.** Pass `--secret whsec_...` to keep one
  across restarts.
- **Events are account-wide.** A test key sends sandbox traffic only, but the
  event log is one per account: if the account also has live traffic,
  `listen` shows those events too.
- Without `--forward-to` it prints each event; `--json` prints one JSON
  object per line; `--events message.received,message.failed` filters.

### Long polling

`listen` is a loop around one endpoint, which you can call yourself:

```bash
curl "https://api.joinsimplesms.com/v1/events/wait" \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY"
# { "object": "event_wait", "data": [], "cursor": "-Oabc...", "has_more": false }

curl "https://api.joinsimplesms.com/v1/events/wait?cursor=-Oabc...&timeout=20" \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY"
```

The first call returns a cursor meaning "from now". Each later call is held
until an event arrives or `timeout` seconds pass (0 to 25, default 20) and
returns the new events oldest first, with the cursor to send next. An event can
be returned more than once, so de-duplicate by `id`, as you would for
webhooks. In the SDKs: `sms.events.wait({ cursor, timeout })` (Node),
`client.events.wait(cursor=..., timeout=...)` (Python).

## Test a deployed endpoint

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.

## Manage endpoints with the API

An API key can create, change, pause and delete endpoints
too, so a deploy script or an agent can set one up without the console:

```bash
curl -X POST https://api.joinsimplesms.com/v1/webhooks \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/simplesms", "events": ["message.received", "message.delivered"] }'
```

```json
{
  "id": "we_a1B2c3D4e5F6",
  "object": "webhook_endpoint",
  "url": "https://example.com/webhooks/simplesms",
  "events": ["message.received", "message.delivered"],
  "active": true,
  "description": null,
  "created_at": "2026-10-05T12:00:00.000Z",
  "secret": "whsec_a1B2c3D4e5F6g7H8a1B2c3D4e5F6g7H8"
}
```

**`secret` is returned once, here.** Store it now; no other API call shows
it. (The console can still reveal or rotate it.)

| Call | Does |
| --- | --- |
| `POST /v1/webhooks` | Create. `url` required; `events` optional (omit, `"*"` or `[]` for everything); `description` optional, 120 characters |
| `GET /v1/webhooks` | List (no secrets) |
| `GET /v1/webhooks/{id}` | One endpoint (no secret) |
| `PATCH /v1/webhooks/{id}` | Change `url`, `events`, `description`, or pause and resume with `active` |
| `DELETE /v1/webhooks/{id}` | Remove it |

The rules are the console's: `https` only, a publicly reachable host (no
`localhost` or private addresses; use [`listen`](#test-locally) for
those), an unknown event type is a `400` rather than a silent no-op, and an
account has at most 5 endpoints. Every change is in the
[audit log](/docs/audit-logs) with the key that made it. A
[restricted key](/docs/authentication) needs the `webhooks:write` scope;
`webhooks` alone is read-only.

```js
const endpoint = await sms.webhooks.create({ url: 'https://example.com/webhooks/simplesms' });
process.env.SIMPLESMS_WEBHOOK_SECRET = endpoint.secret; // shown once
await sms.webhooks.update(endpoint.id, { active: false });
await sms.webhooks.delete(endpoint.id);
```

```python
endpoint = client.webhooks.create("https://example.com/webhooks/simplesms", events=["message.received"])
client.webhooks.update(endpoint["id"], active=False)
client.webhooks.delete(endpoint["id"])
```

## 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`.
