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:
{
"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 |
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) |
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) |
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 alert thresholds |
credits.low, credits.depleted, credits.topped_up, credits.auto_recharge_failed | Your credit balance ran low, ran out, was topped up, or an auto-recharge was declined |
automation.run.started, automation.run.completed, automation.run.failed | An automation run began, or ended (with reason) |
schedule.occurrence_skipped | A recurring schedule 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 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. 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 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:
"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:
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.
- 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. - Send with
"status_callback": "https://example.com/sms/status". - 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
{
"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:
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:
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.
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:
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,
});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:
curl "https://api.jointextkit.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, 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:
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:
npx jointextkit login
npx jointextkit listen --forward-to http://localhost:3000/webhookslogin opens the console for you to approve and stores a test key
(CLI). listen then prints:
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:
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 failsEach 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:
14:02:11 message.received -> 200 (12ms) evt_a1B2c3D4e5F6g7H8What 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.
listenonly reads the event log. Endpoints you configured still get their own deliveries, and nothinglistendoes appears in their delivery logs or retries. - Only new events. It shows what happens after it starts. Earlier events
are in
npx jointextkit eventsor 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
loginstores)listenshows 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-toit prints each event;--jsonprints one JSON object per line;--events message.received,message.failedfilters.
Long polling
listen is a loop around one endpoint, which you can call yourself:
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:
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"] }'{
"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) |
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) |
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 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.
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);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.