Webhooks

Add an endpoint URL in the console, or with POST /v1/webhooks, 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 needs no tunnel and no public URL.

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",
  "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

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, by text or email (channel; fallback: true when a text verification moved to email)
verification.approvedA code was checked successfully (channel, phone and/or email)
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)
compliance.flaggedMore 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)
compliance.pausedMore than 4% opted out (at least 100 sent): marketing from the registration's numbers is paused until our team resumes it. Same fields
compliance.resumedOur team resumed marketing on the registration. registration_id, paused_at
spend.threshold_reachedEstimated spend crossed one of your spend limit alert thresholds
credits.low, credits.depleted, credits.topped_up, credits.auto_recharge_failedYour credit balance ran low, ran out, was topped up, or an auto-recharge was declined
automation.run.started, automation.run.completed, automation.run.failedAn automation run began, or ended (with reason)
schedule.occurrence_skippedA recurring schedule did not send an occurrence: reason is recipient_opted_out or missed
schedule.pausedA recurring schedule paused itself (reason: recipient_opted_out, repeated_failures or sender_unavailable)
message.blockedA send was refused by one of our 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.deferredA message was held for quiet hours. Carries scheduled_message_id, recipient_local_time, timezone and send_after. message.sent follows when it goes out
message.link_clickedSomeone opened a short link 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.pingYou 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); code and reason are kept for existing integrations.

Verify signatures

Every request carries a textkit-signature header:

text
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 (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 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 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). 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) 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).
  • 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.)

CallDoes
POST /v1/webhooksCreate. 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)
GET /v1/webhooksList (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_secretNew signing secret, returned once; expire_previous_in keeps the old one signing (rotation)
POST /v1/webhooks/{id}/testSend a signed test.ping now; returns the endpoint's status code, latency and first 2 KB of its answer
POST /v1/webhooks/{id}/replayQueue 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 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 with the key that made it. A restricted key 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.