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 /webhooks with a credential that has the webhooks:manage scope.

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 blocked instead of sent.
  • Up to 2,048 characters, stored normalised (https://example.com becomes https://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 url and events.
Shell
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:

JSON
{
  "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 its kid. 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:

JSON
{
  "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:

JSON
{ "challenge": "f8Zq2Lm7Xc4Vb9Nk1Rt5Wy3Hd6Jp0GaE" }
  • That flips the endpoint to active and sets verifiedAt.
  • There's one attempt, no retries. On any other answer the endpoint stays where it was and lastError becomes Verification failed.
  • Send a fresh challenge with POST /webhooks/{id}/verify (no body) or Re-verify in the console. It answers 202 with the endpoint. A newer challenge replaces any unsent one, and an answer to an old challenge doesn't count.
  • Re-verifying an active endpoint 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.

RequestResult
GET /webhooksThis 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}/verifyNew verification challenge. 202
POST /webhooks/{id}/rotate-secretNew signing secret. 200
POST /webhooks/{id}/testQueues a test event. 202
POST /webhooks/{id}/deliveries/{deliveryId}/replayDelivers 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

statusMeans
pending_verificationWaiting for the challenge to be echoed. Nothing is delivered.
activeVerified. Receives the events it subscribes to.
pausedIts creator lost access. pausedReason says why. Nothing is delivered.
disabledSwitched 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

EventSent when
message.queuedA text was accepted and queued (any send path: API, dashboard, apps).
message.sentThe text was handed to the carrier network. sentAt is set; data.status may still read queued, accepted or sending.
message.deliveredThe recipient's carrier confirmed delivery.
message.undeliveredThe carrier couldn't deliver it. errorCode usually says why.
message.failedThe text never went out. It isn't charged.
message.receivedSomeone texted one of your numbers.
contact.opted_outA number was added to your opt-out list (a STOP reply, the carrier, or added by hand).
contact.opted_inA 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:

JSON
{
  "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:

JSON
{
  "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" }
}
  • id is 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.
  • version is 1.
  • createdAt is when the event happened, not when this attempt was made.
  • message.* data is the message as it was at that moment, in the shape GET /messages/{id} returns. Fields can change later (the carrier's final segments, 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:

HTTP
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
TEXT
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 v1 is the hex HMAC-SHA256 of <t>.<raw request body>, keyed with the full secret named by the kid before it, including the whsec_ prefix.
  • t is when this attempt was signed, so it's fresh on every retry.

To verify:

  1. Read the raw body bytes before any JSON parsing. Re-serialising parsed JSON changes the bytes and breaks the signature.
  2. Split the header on ,. Take t, and pair each v1 with the kid right before it. Ignore keys you don't know (future schemes).
  3. Reject the request if t is more than 5 minutes from your clock.
  4. For each pair whose kid you hold, compute the HMAC of `${t}.${rawBody}` with that secret and compare it to v1 in constant time. Accept if any pair matches.

Node.js with Express:

JavaScript
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:

JSON
{
  "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 kid and secret to your receiver, deploy, then remove the old one once previousKidExpiresAt has 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:

AttemptWhen
1Right away
21 minute after attempt 1 fails
35 minutes after attempt 2 fails
430 minutes after attempt 3 fails
52 hours after attempt 4 fails
68 hours after attempt 5 fails
724 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:

JSON
{
  "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"
}
statusMeans
pendingWaiting for its first try or a retry (nextAttemptAt says when).
deliveringAn attempt is in flight.
succeededYour server answered 2xx.
failedClosed without success: the endpoint wasn't active, or a verification challenge failed or was replaced.
deadEvery attempt failed.
blockedThe 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):

JSON
{
  "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 event id, same body. A receiver that dedupes on the event id handles it correctly.
  • Only finished deliveries can be replayed: succeeded, failed, dead or blocked. 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_paused otherwise), and still has the scope and the number (409 not_replayable otherwise).
  • 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:

CreatorPaused when
Personal tokenThe 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 consoleThey'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 creatorThe 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}/verify or Resume in the console. If the creator still lacks access, you get 409 endpoint_paused with 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.sent can arrive before message.queued, and message.delivered before message.sent. Don't treat a later-arriving event as the newer state. Fetch GET /messages/{id} when you need the current one.

Test your endpoint

Test event in the console, or:

Shell
curl -X POST https://www.suzko.com/api/v1/webhooks/wh_Xb3Lq9Tz2PkM/test \
  -H "Authorization: Bearer szk_mk_YOUR_MACHINE_KEY"
JSON
{
  "success": true,
  "data": { "id": "wh_Xb3Lq9Tz2PkM", "eventType": "message.delivered", "queued": true }
}
  • The test is a signed message.delivered event, sent to that one endpoint even if it isn't subscribed to message.delivered. It's delivered and retried like any other event.
  • Its data is a sample message with fictional numbers (from +15555550100 to +15555550199), an id starting msg_test, and an extra field "test": true. Check data.test so 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_limited with Retry-After.