# Webhooks

Source: https://jointextkit.com/docs/webhooks
Index: https://jointextkit.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",
  "mode": "test",
  "created_at": "2026-08-13T00:41:00.000Z",
  "data": {
    "message_id": "msg_x9Y8z7W6v5U4t3S2",
    "from": "+14155550132",
    "to": "+15005550100",
    "body": "Hey, got your message!",
    "conversation": "5005550100_4155550132",
    "simulated": true
  }
}
```

`simulated` is `true` only for a message a test key simulated
(`POST /v1/test/inbound`); a real inbound SMS has `false`. Act only on
`false` in production.

Every event also has `mode`: `"test"` for sandbox traffic (your test keys'
sends, including the real text to your own verified phone, and simulated
inbound), `"live"` for live traffic. Events about the account itself
(credits, spend, registrations, consent, deliverability and compliance
alerts) are `live` once the account has live access.

## Test and live endpoints

Once your account has live access, an endpoint receives only one mode's
events. One made with a test key gets test events, one made with a live key
live events; in the console, a live account chooses **Live events** or **Test
events** when adding it. A key lists, changes and replays only its own mode's
endpoints, so a test key can never point live traffic at a URL of its
choosing. Endpoints added before 2026-10-22 count as live. Before live access
every endpoint receives every event, as before.

## 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, by text or email (`channel`; `fallback: true` when a text verification moved to email) |
| `verification.approved` | A code was checked successfully (`channel`, `phone` and/or `email`) |
| `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)) |
| `compliance.flagged` | More than 0.5% of a registration's recipients opted out in 24 hours (at least 200 sent). Nothing is paused yet. `registration_id`, `opt_out_rate`, `sent`, `opt_outs`, `window_hours` ([opt-out rate](/docs/compliance-checks#opt-out-rate)) |
| `compliance.paused` | More than 4% opted out (at least 100 sent): marketing from the registration's numbers is paused until our team resumes it. Same fields |
| `compliance.resumed` | Our team resumed marketing on the registration. `registration_id`, `paused_at` |
| `spend.threshold_reached` | Estimated spend crossed one of your [spend limit](/docs/spend-limits) alert thresholds |
| `credits.low`, `credits.depleted`, `credits.topped_up`, `credits.auto_recharge_failed` | Your [credit balance](/docs/credits) ran low, ran out, was topped up, or an auto-recharge was declined |
| `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`) |
| `message.blocked` | A send was refused by one of our [checks](/docs/compliance-checks) before any carrier saw it. `reason` is the code (`opted_out`, `quiet_hours`, `consent_required`, `reconfirmation_pending`, `reconfirmation_expired`, `sender_not_registered`, `content_blocked`, ...), `message_id` is the blocked message in your log, and `failure` is the readable object. A held message's row is announced twice: once as `reconfirmation_pending` when it is held, and again as `reconfirmation_expired` (same `message_id`) if nobody answers; likewise `held_for_review`, then `review_rejected` or `review_expired` if it is dropped |
| `message.deferred` | A message was held for [quiet hours](/docs/compliance-checks#quiet-hours). Carries `scheduled_message_id`, `recipient_local_time`, `timezone` and `send_after`. `message.sent` follows when it goes out |
| `message.link_clicked` | Someone opened a [short link](/docs/messages#link-shortening) in your message. Carries `message_id`, `url`, `short_url`, `clicked_at`, `device` (`mobile`, `desktop` or `unknown`), `clicks` (the total so far) and `first_click`. Link previews do not fire it |
| `test.ping` | You pressed "Send test" in the console, or called `POST /v1/webhooks/{id}/test` |

Events about one message (`message.sent`, `.delivered`, `.failed`,
`.received`, `.blocked`) carry its `price`, the same object the message
shows, and its `tags` when it was sent with any. So you can book what each
message cost, per campaign, without fetching it:

```json
"price": { "total": 0.0082, "currency": "usd",
           "breakdown": [{ "label": "Outbound SMS (1 segment)", "amount": 0.0032 },
                         { "label": "Carrier fee (Verizon)", "amount": 0.005 }] }
```

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

## One endpoint per number

Pass `numbers` to send only some numbers' events to an endpoint, the way a
number's own inbound URL works elsewhere:

```bash
curl https://api.jointextkit.com/v1/webhooks \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://support.example.com/sms","events":["message.received"],"numbers":["+14155550132"]}'
```

Message events match on their `from` or `to`, number events on
`phone_number`. Events about no number (batches, credits, registrations) go
only to endpoints without a filter. Change it with `PATCH /v1/webhooks/{id}`;
`[]` clears it.

## Status callback on one message

Add `status_callback` to `POST /v1/messages` and that message's
`message.sent`, `message.delivered`, `message.failed` and
`message.blocked` events also go to that URL, on top of your endpoints.

1. Create the signing secret once: `POST /v1/webhooks/callback_secret`. It is
   returned once. Calling it again rotates it; the old one signs for 24 more hours.
2. Send with `"status_callback": "https://example.com/sms/status"`.
3. Verify each request exactly like an endpoint's (below).

Until the secret exists, a send with `status_callback` returns
`400 callback_secret_required`. Callbacks are logged and retried like an
endpoint's: `GET /v1/webhooks/deliveries?endpoint_id=status_callback` lists
the attempts. Immediate sends only: for
scheduled messages, subscribe an endpoint.

### 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 `textkit-signature` header:

```
textkit-signature: t=1755043260,v1=5257a869e7...
```

The event id (`evt_...`, the same across retries) is in
`textkit-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 `textkit-` pair.

`v1` is `HMAC-SHA256(secret, \`${t}.${rawBody}\`)`, the same scheme
Stripe uses. Your signing secret (`whsec_...`) is next to the endpoint in
the console, by its last 4 characters; admins choose **Reveal** to see and
copy it, which the [audit log](/docs/audit-logs) records as
`webhook.secret_viewed`.

To replace a secret, choose **Rotate secret** on the endpoint in the
[console](/console/webhooks) (admins only), or call
`POST /v1/webhooks/{id}/rotate_secret`. From the console the old secret
stops verifying immediately, and retries of earlier failures are signed with
the new secret too. From the API you can keep both working for a while:

```bash
curl -X POST https://api.jointextkit.com/v1/webhooks/we_.../rotate_secret \
  -H "Authorization: Bearer ssms_sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "expire_previous_in": 86400 }'
```

The response has the new `secret` (shown once) and
`previous_secret_expires_at`. Until then every delivery carries two
signatures, `t=...,v1=<new>,v1=<old>`, and a receiver holding either secret
accepts it. Up to 7 days; 0, the default, retires the old secret now. Anything
your receiver rejects is retried on the usual schedule and can be replayed.
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 parts = header.split(",").map((p) => p.split("="));
  const t = parts.find(([k]) => k === "t")?.[1];
  const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v); // two during a rotation
  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 sigs.some((v1) => 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 'jointextkit';

const event = await verifyWebhook({
  payload: rawBody,                          // string or Buffer, unparsed
  signature: req.headers['textkit-signature'],
  secret: process.env.TEXTKIT_WEBHOOK_SECRET,
});
```

```python
from jointextkit import verify_webhook

event = verify_webhook(
    request.body,                            # raw bytes, unparsed
    request.headers["textkit-signature"],
    os.environ["TEXTKIT_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.jointextkit.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, and `blocked` when its
address is not on the public internet (we deliver only to public addresses,
on port 443, and do not follow redirects). 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.jointextkit.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 jointextkit login
npx jointextkit 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
`TEXTKIT_WEBHOOK_SECRET`) and leave your verification code exactly as it
will run in production. In another terminal, make something happen:

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

Each event is POSTed to your local URL with the same JSON body and the same
`textkit-signature` / `textkit-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 jointextkit events` or the console.
- **The secret is per session.** Pass `--secret whsec_...` to keep one
  across restarts.
- **Only your key's mode.** With a test key (what `login` stores) `listen`
  shows sandbox events only, even on an account with live traffic; run it
  with a live key to watch live events ([test and live data](/docs/sandbox#test-and-live-data)).
- 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.jointextkit.com/v1/events/wait" \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY"
# { "object": "event_wait", "data": [], "cursor": "-Oabc...", "has_more": false }

curl "https://api.jointextkit.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.jointextkit.com/v1/webhooks \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/textkit", "events": ["message.received", "message.delivered"] }'
```

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

**`secret` is returned once, here.** Store it now; only a rotation returns a
secret again. (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. Its `mode` is the key's: it receives only that mode's events ([test and live endpoints](#test-and-live-endpoints)) |
| `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 |
| `POST /v1/webhooks/{id}/rotate_secret` | New signing secret, returned once; `expire_previous_in` keeps the old one signing ([rotation](#verify-signatures)) |
| `POST /v1/webhooks/{id}/test` | Send a signed `test.ping` now; returns the endpoint's status code, latency and first 2 KB of its answer |
| `POST /v1/webhooks/{id}/replay` | Queue every event that failed to reach it between `since` and `until` (default the last 24 hours; up to 7 days, 50 a call; `more: true` means call again) |

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/textkit' });
process.env.TEXTKIT_WEBHOOK_SECRET = endpoint.secret; // shown once
await sms.webhooks.test(endpoint.id);                       // signed test.ping
await sms.webhooks.rotateSecret(endpoint.id, { expirePreviousIn: 86400 });
await sms.webhooks.replayFailed(endpoint.id);               // last 24 hours
await sms.webhooks.update(endpoint.id, { active: false });
await sms.webhooks.delete(endpoint.id);
```

```python
endpoint = client.webhooks.create("https://example.com/webhooks/textkit", events=["message.received"])
client.webhooks.test(endpoint["id"])
client.webhooks.rotate_secret(endpoint["id"], expire_previous_in=86400)
client.webhooks.replay_failed(endpoint["id"])
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`.
