// delivery & webhooks

Delivery & webhooks

Pass a callbackUrl with a send and EK Message posts a signed webhook to it when the message is delivered or fails, so you do not have to poll.

Message statuses

StatusMeaning
QUEUEDAccepted and charged; waiting for the send worker. Device-route messages stay QUEUED until the phone reports it sent them.
SENTHanded to the upstream network (or sent by the device); awaiting a delivery receipt.
DELIVEREDConfirmed delivered to the handset.
UNDELIVEREDThe network reported non-delivery.
FAILEDRejected upstream or failed after retries; credits refunded.

The webhook

When you set callbackUrl on a send, EK Message POSTs a JSON payload to it when the message reaches a final state. The X-EKSMS-Event header and the event field name it:

EventSent when
message.deliveredThe network confirms delivery. deliveredAt is set.
message.failedThe message failed and its credits were refunded. failureReason says why.

Both events carry the same keys:

POST https://your-app.com/webhooks/sms
X-EKSMS-Signature: 9f86d081884c7d659a2feaa0c55ad015...
X-EKSMS-Event: message.delivered
Content-Type: application/json

{
  "event": "message.delivered",
  "messageId": "a1b2c3d4-...",
  "batchId": "e5f6a7b8-...",
  "recipient": "9800000000",
  "status": "DELIVERED",
  "deliveredAt": "2026-06-20T05:51:00.000Z",
  "failureReason": null
}

Verify the signature

Every webhook carries an X-EKSMS-Signature header - an HMAC-SHA256 of the raw request body, keyed with your account's own signing secret. An organization owner can reveal or rotate it under Settings in the dashboard. Verify the header before trusting the payload, using a constant-time compare:

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, signature, secret) {
  const expected = createHmac("sha256", secret)
    .update(rawBody)        // the RAW request body, not re-serialized
    .digest("hex");
  return timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature),
  );
}

// Express: use express.raw() so you get the exact bytes we signed.
app.post("/webhooks/sms", express.raw({ type: "*/*" }), (req, res) => {
  const ok = verify(req.body, req.get("X-EKSMS-Signature"), process.env.EKSMS_WEBHOOK_SECRET);
  if (!ok) return res.status(401).end();
  const event = JSON.parse(req.body.toString());
  // ... handle event.status
  res.status(200).end();
});

Sign over the raw bytes

Compute the HMAC over the exact raw body we sent, before any JSON re-serialization. Re-stringifying the parsed object can change whitespace and break the signature.

Rotating the secret

After a rotation the old secret keeps working for 24 hours: each webhook also carries X-EKSMS-Signature-Previous, signed with the old secret. Accept either header until you have deployed the new secret.

Respond fast, then process

Return 2xx quickly to acknowledge receipt. Do slow work asynchronously - we make up to 3 attempts on non-2xx with a short backoff, so make your handler idempotent.

Next: Errors · API reference