Webhooks
Signed message and opt-out events pushed to your server. Verification, signatures, rotation, retries and replay.
Webhooks send an HTTPS POST to your server when something happens
to your texts: queued, sent, delivered, failed, a reply came in,
someone opted out. Every request is signed so you can prove it came
from SUZKO, and nothing is sent until your server has proved it owns
the URL.
Add an endpoint
Two ways, same rules:
- Console: /dashboard/developers → Webhooks.
- API:
POST /webhookswith a credential that has thewebhooks:managescope.
Create endpoints with a machine key (or in the console), not a personal token. An endpoint keeps working only while its creator does: a personal token expires within 90 days, and its endpoints pause when it does. See When an endpoint pauses.
Rules for an endpoint:
- https only, on port 443 or 8443.
- The URL must resolve to public internet addresses. Private,
loopback and link-local addresses, and URLs with a username or
password, are refused. The address is checked again before every
delivery, so a DNS change that points it somewhere private gets the
delivery
blockedinstead of sent. - Up to 2,048 characters, stored normalised (
https://example.combecomeshttps://example.com/). - At least one event from the list below.
- At most 10 endpoints per account (
409 endpoint_limit). - Adding one needs an active SUZKO Phone plan (
403 plan_required). - The only fields are
urlandevents.
curl https://www.suzko.com/api/v1/webhooks \
-H "Authorization: Bearer szk_mk_YOUR_MACHINE_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/suzko/webhooks",
"events": ["message.delivered", "message.undelivered", "message.failed", "message.received", "contact.opted_out", "contact.opted_in"]
}'
201 Created:
{
"success": true,
"data": {
"object": "webhook_endpoint",
"id": "wh_Xb3Lq9Tz2PkM",
"url": "https://example.com/suzko/webhooks",
"events": ["message.delivered", "message.undelivered", "message.failed", "message.received", "contact.opted_out", "contact.opted_in"],
"status": "pending_verification",
"pausedReason": null,
"disabledReason": null,
"lastError": null,
"consecutiveFailures": 0,
"verifiedAt": null,
"createdAt": "2026-10-20T15:00:00.000Z",
"secret": "whsec_YOUR_SIGNING_SECRET",
"kid": "whk_Rm4Tq8Vz2Lc6"
}
}
The signing secret (
whsec_…) is shown once. Store it straight away, keyed by itskid. It's never returned again. Lost it? Rotate the secret.
Creating an endpoint isn't idempotent: retrying a create that timed
out can add a second endpoint. Check GET /webhooks first.
Verify ownership
A new endpoint is pending_verification and receives nothing yet.
Right after you create it, SUZKO sends it one signed
endpoint.verification event:
{
"id": "evt_Kc2Wn8Rq4Tz1Lm6Vb9Xd3Hp7",
"object": "event",
"type": "endpoint.verification",
"version": 1,
"createdAt": "2026-10-20T15:00:00.000Z",
"data": {
"object": "endpoint_verification",
"endpointId": "wh_Xb3Lq9Tz2PkM",
"challenge": "f8Zq2Lm7Xc4Vb9Nk1Rt5Wy3Hd6Jp0GaE"
}
}
Your server must check the signature, then answer with a 2xx whose body contains the challenge within its first 1 KB:
{ "challenge": "f8Zq2Lm7Xc4Vb9Nk1Rt5Wy3Hd6Jp0GaE" }
- That flips the endpoint to
activeand setsverifiedAt. - There's one attempt, no retries. On any other answer the endpoint
stays where it was and
lastErrorbecomesVerification failed. - Send a fresh challenge with
POST /webhooks/{id}/verify(no body) or Re-verify in the console. It answers202with the endpoint. A newer challenge replaces any unsent one, and an answer to an old challenge doesn't count. - Re-verifying an
activeendpoint keeps it active while the new challenge is answered.
Manage endpoints
All need webhooks:manage. {id} is the endpoint's wh_… id.
A credential only sees the endpoints it created. A machine key
sees every endpoint created by any key of its service account, so
rotating keys never orphans them. Anyone else's endpoint (another
credential's, one made in the console, another account's) is
404 not_found. The account owner sees every endpoint in the console.
| Request | Result |
|---|---|
GET /webhooks | This credential's endpoints, newest first (meta.nextCursor is always null). Never a secret. |
GET /webhooks/{id} | The endpoint plus deliveries: its newest 50. |
DELETE /webhooks/{id} | Removes it with its secrets and deliveries; nothing pending is sent afterwards. { "id": "wh_…", "deleted": true } |
POST /webhooks/{id}/verify | New verification challenge. 202 |
POST /webhooks/{id}/rotate-secret | New signing secret. 200 |
POST /webhooks/{id}/test | Queues a test event. 202 |
POST /webhooks/{id}/deliveries/{deliveryId}/replay | Delivers a finished delivery again. 202 |
The POST actions take no fields (an empty body or {}). An
endpoint SUZKO support disabled can't be deleted, rotated or
re-verified (409 endpoint_locked).
Endpoint status
status | Means |
|---|---|
pending_verification | Waiting for the challenge to be echoed. Nothing is delivered. |
active | Verified. Receives the events it subscribes to. |
paused | Its creator lost access. pausedReason says why. Nothing is delivered. |
disabled | Switched off after 50 failed deliveries in a row, or by SUZKO support. disabledReason says which. |
Only active endpoints get events. Events that happen while an
endpoint isn't active are not queued for it, and anything already
queued is closed as failed without being sent. Catch up with
GET /messages filtered by since.
Events
| Event | Sent when |
|---|---|
message.queued | A text was accepted and queued (any send path: API, dashboard, apps). |
message.sent | The text was handed to the carrier network. sentAt is set; data.status may still read queued, accepted or sending. |
message.delivered | The recipient's carrier confirmed delivery. |
message.undelivered | The carrier couldn't deliver it. errorCode usually says why. |
message.failed | The text never went out. It isn't charged. |
message.received | Someone texted one of your numbers. |
contact.opted_out | A number was added to your opt-out list (a STOP reply, the carrier, or added by hand). |
contact.opted_in | A number came off your opt-out list (a START reply, or removed by hand). |
What an endpoint receives also depends on the credential that created it:
- It needs
messages:read. Without it, events are filtered out (the endpoint isn't paused). - A credential limited to some numbers only gets
message.*events for those numbers.contact.*events are account-wide and always arrive. - Endpoints created in the console by a person aren't narrowed.
The envelope
Every event body is the same envelope:
{
"id": "evt_Hq7Vn2Rk9Ls4Tw8Yp3Mb6Dc1",
"object": "event",
"type": "message.delivered",
"version": 1,
"createdAt": "2026-10-20T15:04:09.000Z",
"data": {
"object": "message",
"id": "msg_Q4f8Zk2LmP7a",
"direction": "outbound",
"from": "+13175550100",
"to": "+13175550123",
"body": "Your order #1042 is ready for pickup.",
"status": "delivered",
"segments": 1,
"errorCode": null,
"metadata": { "orderId": "1042" },
"createdAt": "2026-10-20T15:04:05.000Z",
"sentAt": "2026-10-20T15:04:06.000Z",
"deliveredAt": "2026-10-20T15:04:09.000Z"
}
}
An opt-out event:
{
"id": "evt_Pz4Kc8Wm1Rt6Yb3Nh9Qs2Lv7",
"object": "event",
"type": "contact.opted_out",
"version": 1,
"createdAt": "2026-10-20T16:10:00.000Z",
"data": { "object": "contact", "number": "+13175550123", "at": "2026-10-20T16:10:00.000Z" }
}
idis the event id. It's the same on every retry and replay of the event, and the same across all your endpoints that get it.versionis1.createdAtis when the event happened, not when this attempt was made.message.*data is the message as it was at that moment, in the shapeGET /messages/{id}returns. Fields can change later (the carrier's finalsegments, for example); fetch the message for its current state.- Don't rely on the order of keys in the JSON. Parse it.
Verify the signature
Every request, the verification challenge included, carries:
POST /suzko/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: SUZKO-Webhooks/1.0
SUZKO-Signature: t=1792508649,kid=whk_Rm4Tq8Vz2Lc6,v1=5f2b9c0e4a7d1f3b8e6c2a9d0f4b7e1c3a5d8f2b6e9c1a4d7f0b3e6c9a2d5f8b
SUZKO-Signature: t=<unix seconds>,kid=<kid>,v1=<hex>[,kid=<kid>,v1=<hex>]
- There's one
kid=…,v1=…pair per live signing secret: one normally, two during a rotation, the current secret first. - Each
v1is the hex HMAC-SHA256 of<t>.<raw request body>, keyed with the full secret named by thekidbefore it, including thewhsec_prefix. tis when this attempt was signed, so it's fresh on every retry.
To verify:
- Read the raw body bytes before any JSON parsing. Re-serialising parsed JSON changes the bytes and breaks the signature.
- Split the header on
,. Taket, and pair eachv1with thekidright before it. Ignore keys you don't know (future schemes). - Reject the request if
tis more than 5 minutes from your clock. - For each pair whose
kidyou hold, compute the HMAC of`${t}.${rawBody}`with that secret and compare it tov1in constant time. Accept if any pair matches.
Node.js with Express:
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_SECONDS = 5 * 60;
// Every secret you currently hold, by kid: "whk_new:whsec_new,whk_old:whsec_old".
// Keep both during a rotation.
const SECRETS = new Map(
(process.env.SUZKO_WEBHOOK_SECRETS ?? "")
.split(",")
.map((pair) => pair.trim().split(":"))
.filter(([kid, secret]) => kid && secret),
);
function verifySuzkoSignature(header, rawBody, secrets, nowMs = Date.now()) {
if (typeof header !== "string" || header.length > 4096 || !Buffer.isBuffer(rawBody)) return false;
let timestamp = null;
let kid = null;
const pairs = [];
for (const part of header.split(",")) {
const eq = part.indexOf("=");
if (eq < 1) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "kid") kid = value;
else if (key === "v1") {
if (kid && /^[0-9a-f]{64}$/.test(value)) pairs.push({ kid, v1: value });
kid = null; // a v1 belongs to the kid right before it
}
// anything else (v2, …) is ignored
}
if (timestamp === null || pairs.length === 0) return false;
// Replay protection.
if (Math.abs(Math.floor(nowMs / 1000) - timestamp) > TOLERANCE_SECONDS) return false;
return pairs.some(({ kid, v1 }) => {
const secret = secrets.get(kid);
if (!secret) return false; // a kid you don't hold
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`, "utf8")
.update(rawBody) // the raw bytes
.digest();
const given = Buffer.from(v1, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
const app = express();
// express.raw keeps the exact bytes; don't put express.json() in front of this route.
app.post("/suzko/webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySuzkoSignature(req.get("SUZKO-Signature"), req.body, SECRETS)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Ownership check: echo the challenge.
if (event.type === "endpoint.verification") {
return res.status(200).json({ challenge: event.data.challenge });
}
res.sendStatus(200); // answer fast, then do the work
handleEvent(event).catch(console.error);
});
const seen = new Set(); // use your database in production
async function handleEvent(event) {
if (seen.has(event.id)) return; // duplicate delivery or replay
seen.add(event.id);
switch (event.type) {
case "message.delivered":
// event.data.id is the msg_… id; event.data.metadata is what you sent
break;
case "contact.opted_out":
// stop texting event.data.number
break;
}
}
app.listen(3000);
Rotate the signing secret
POST /webhooks/{id}/rotate-secret (no body), or Rotate secret in
the console:
{
"success": true,
"data": {
"object": "webhook_secret",
"id": "wh_Xb3Lq9Tz2PkM",
"secret": "whsec_YOUR_NEW_SIGNING_SECRET",
"kid": "whk_Gt7Lp3Wz9Qc2",
"previousKid": "whk_Rm4Tq8Vz2Lc6",
"previousKidExpiresAt": "2026-10-21T15:00:00.000Z"
}
}
- The new secret is shown once.
- For 24 hours (until
previousKidExpiresAt) every delivery is signed with both secrets:t=…,kid=<new>,v1=…,kid=<old>,v1=…. After that, only the new one signs. - So: add the new
kidand secret to your receiver, deploy, then remove the old one oncepreviousKidExpiresAthas passed. A receiver that holds either secret keeps verifying the whole time. - Rotating again inside the overlap ends the oldest secret at once: at most two secrets ever sign.
- Lost the secret? Rotate. Your receiver will reject deliveries until it has the new one.
Responding and retries
- Answer with any 2xx within 10 seconds and the delivery is done. Do slow work after you respond.
- Anything else is a failed attempt: a non-2xx status, a timeout, a connection or TLS error. Redirects aren't followed: a 3xx counts as a failure, so give SUZKO the final URL.
- The response body is ignored (except for the verification challenge).
- SUZKO sends at most 2 deliveries at a time to one endpoint and 5 at a time per account.
A failed attempt is retried on this schedule, each delay ±10% so retries don't arrive in lockstep:
| Attempt | When |
|---|---|
| 1 | Right away |
| 2 | 1 minute after attempt 1 fails |
| 3 | 5 minutes after attempt 2 fails |
| 4 | 30 minutes after attempt 3 fails |
| 5 | 2 hours after attempt 4 fails |
| 6 | 8 hours after attempt 5 fails |
| 7 | 24 hours after attempt 6 fails |
If attempt 7 fails, the delivery is dead (dead-lettered): it
won't be retried, and you can replay it. That's
about 35 hours of retries.
If an attempt finds the URL now resolves to a private or reserved
address, the delivery is blocked: no request is made and it
isn't retried. A DNS lookup that simply fails is an ordinary failed
attempt.
Each entry in an endpoint's deliveries:
{
"object": "webhook_delivery",
"id": "whd_Vn5Kq1Ry8Mz3",
"eventId": "evt_Hq7Vn2Rk9Ls4Tw8Yp3Mb6Dc1",
"eventType": "message.delivered",
"status": "succeeded",
"attempts": 1,
"lastStatusCode": 200,
"lastError": null,
"nextAttemptAt": null,
"createdAt": "2026-10-20T15:04:09.000Z",
"updatedAt": "2026-10-20T15:04:10.000Z"
}
status | Means |
|---|---|
pending | Waiting for its first try or a retry (nextAttemptAt says when). |
delivering | An attempt is in flight. |
succeeded | Your server answered 2xx. |
failed | Closed without success: the endpoint wasn't active, or a verification challenge failed or was replaced. |
dead | Every attempt failed. |
blocked | The URL resolved to a non-public address; nothing was sent. |
lastError is deliberately coarse: Timed out, Connection failed,
TLS error, HTTP 500. Delivery history is kept for 30 days.
Automatic disabling
An endpoint is switched off after 50 failed deliveries in a row.
A delivery counts once it's dead or blocked; any successful
delivery resets the count. status becomes disabled,
disabledReason says why, and consecutiveFailures shows the streak.
To turn it back on, fix your server and verify again
(POST /webhooks/{id}/verify or Re-verify in the console). Once the
challenge is echoed it's active again and the count is reset. Missed
events aren't re-sent; replay the dead deliveries you need, or catch up
with GET /messages.
Replay a delivery
POST /webhooks/{id}/deliveries/{deliveryId}/replay (no body), or
Replay in the console's delivery log (the console offers it on
dead, failed, succeeded and blocked deliveries):
{
"success": true,
"data": { "object": "webhook_delivery", "id": "whd_Bz8Lc2Wq5Nt1", "replayOf": "whd_Vn5Kq1Ry8Mz3" }
}
- The replay is a new delivery (new
whd_…id, fresh signature) of the same event: same eventid, same body. A receiver that dedupes on the event id handles it correctly. - Only finished deliveries can be replayed:
succeeded,failed,deadorblocked. A verification challenge can't (409 not_replayable). - The endpoint must be
active(409 endpoint_not_active). - Its creator must still be allowed the event: still has access
(
409 endpoint_pausedotherwise), and still has the scope and the number (409 not_replayableotherwise). - Events are kept 30 days. After that a delivery can't be replayed.
When an endpoint pauses
Every endpoint keeps a record of who created it, and SUZKO re-checks
that creator before delivering anything. If the creator has lost
access, the endpoint becomes paused with one of these
pausedReasons:
| Creator | Paused when |
|---|---|
| Personal token | The token was revoked, or expired, or the person who holds it lost access to the account or the "View phone system" permission. |
| Machine key (service account) | The service account was suspended or disabled, or it has no live key left. |
| A person, in the console | They're no longer on the account, or lost the "View phone system" permission. The account owner keeps access as long as the account is open. |
| Any creator | The SUZKO account was closed. |
Changes on SUZKO's side (a team member removed or their permissions cut, a token revoked by a password change, the account closed) pause the affected endpoints right away.
While paused, nothing is delivered and new events aren't queued for it. To resume:
- Fix the cause (re-enable the service account, create a key,
restore the permission), then verify again with
POST /webhooks/{id}/verifyor Resume in the console. If the creator still lacks access, you get409 endpoint_pausedwith the reason. - Or, as the account owner, adopt it in the console (Webhooks → Adopt). It becomes your endpoint and must verify again before it receives anything. This is the way out for an endpoint whose personal token expired.
Disabled by SUZKO support: disabledReason carries a message
saying SUZKO support disabled it. Don't match on that text (it can
change): a request that gets 409 endpoint_locked is the reliable
signal. Until support restores it, the endpoint can't be deleted,
rotated, re-verified or adopted, from the console or the API: each
answers 409 endpoint_locked. Contact support. When support restores
it, the account owner takes it over and it must verify again before it
receives anything.
Duplicates and ordering
Build your handler for these, because they will happen:
- At-least-once delivery. The same event can arrive more than
once: a lost response, a retry, a replay. Deduplicate on the event
id(evt_…), not on the message id: one message has several events. - No ordering guarantee. Events are delivered independently and
retried on their own schedules.
message.sentcan arrive beforemessage.queued, andmessage.deliveredbeforemessage.sent. Don't treat a later-arriving event as the newer state. FetchGET /messages/{id}when you need the current one.
Test your endpoint
Test event in the console, or:
curl -X POST https://www.suzko.com/api/v1/webhooks/wh_Xb3Lq9Tz2PkM/test \
-H "Authorization: Bearer szk_mk_YOUR_MACHINE_KEY"
{
"success": true,
"data": { "id": "wh_Xb3Lq9Tz2PkM", "eventType": "message.delivered", "queued": true }
}
- The test is a signed
message.deliveredevent, sent to that one endpoint even if it isn't subscribed tomessage.delivered. It's delivered and retried like any other event. - Its
datais a sample message with fictional numbers (from+15555550100to+15555550199), an id startingmsg_test, and an extra field"test": true. Checkdata.testso test events never touch real records. No real message, usage or charge is involved. - The endpoint must be
active. Pending verification or paused:409 endpoint_not_active(verify it first). Disabled:409 endpoint_disabled. - Test events are limited to 5 a minute per endpoint and 30 an
hour per account (console and API combined):
429 rate_limitedwithRetry-After.