Skip to content
SMSRay

Webhooks

SMSRay POSTs a signed JSON event to your webhook URL whenever a message changes status or a reply arrives.

Setting up

  • In the dashboard, set a webhook URL on your API client. It must use https://.
  • Copy the signing secret (whsec_…) shown when the client was created or the secret was rotated.
  • Each event is a POST with content-type: application/json and a single event object as the body.
  • Respond with any 2xx within 10 seconds. Do slow work after you respond.

Request headers

Webhook request headers
HeaderValue
x-lacspace-signaturet=<unix seconds>,v1=<hex> — HMAC-SHA256 of <t>.<raw body>. A second v1= appears during secret rotation.
x-lacspace-eventmessage.status or message.inbound
x-lacspace-deliveryUnique id of this delivery. Retries of the same event reuse it — use it to deduplicate.
user-agentlacspace-sms-webhooks/1
content-typeapplication/json

message.status

Sent when a message you sent reaches sent, delivered, undelivered or failed.

message.status
{
  "type": "message.status",
  "id": "2b1f6c1e-8d4f-4c55-9a51-0d2b0b7f3a11",
  "status": "delivered",
  "to": "+9779801234567",
  "at": "2026-10-10T04:15:07.000Z",
  "messageType": "transactional",
  "segments": 1
}
  • Never sent on accept — the API reply already told you queued.
  • queued is sent only when a message goes back to queued, for example when delivery is deferred or retried.
  • Never sent twice for the same message and status, even if a late duplicate report arrives.
  • error is included only when the status is failed, undelivered or queued and a reason is known.
  • at is the time of the transition (delivered, sent or failed time), in ISO 8601 UTC.
  • Events can arrive out of order after retries. Compare at before overwriting a later status.

Status mapping

The API has more statuses than the webhook. They map like this:

Message status to webhook status mapping
Message status (GET /sms/messages/:id)Webhook status
submitted, sentsent
delivereddelivered
undeliveredundelivered
failed, rejectedfailed
anything elsequeued

message.inbound

Sent when a recipient replies to a message you sent and the reply is attributed to your API client. inReplyTo links it to the message they answered when known.

message.inbound
{
  "type": "message.inbound",
  "id": "6705a1c2e4b0f3a9d8c71b25",
  "from": "+9779801234567",
  "text": "Thanks",
  "at": "2026-10-10T04:20:00.000Z",
  "inReplyTo": "2b1f6c1e-8d4f-4c55-9a51-0d2b0b7f3a11"
}

Verifying signatures

Always verify before trusting a webhook. Recompute HMAC-SHA256 over <t>.<raw body> with your secret, compare in constant time against each v1, and reject timestamps more than 300 seconds from your clock. Each retry is re-signed with a fresh t, so a slow retry still verifies.

import crypto from "node:crypto";

// x-lacspace-signature: t=<unix>,v1=<hex>[,v1=<hex during secret rotation>]
export function verifySmsrayWebhook(rawBody, header, secret, toleranceSec = 300) {
  const pairs = header.split(",").map((p) => p.split("="));
  const t = Number(pairs.find(([k]) => k === "t")?.[1]);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "hex");
  return pairs
    .filter(([k]) => k === "v1")
    .some(([, v]) => {
      const sig = Buffer.from(v, "hex");
      return sig.length === expected.length && crypto.timingSafeEqual(sig, expected);
    });
}

Use the raw body

Verify the exact bytes you received. Parsing and re-serialising JSON changes spacing and key order and the signature will not match.
import express from "express";
import { verifySmsrayWebhook } from "./verify.js"; // the function above

const app = express();

// Verify against the RAW body — parse JSON only after the check
app.post("/hooks/sms", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  const ok = verifySmsrayWebhook(raw, req.get("x-lacspace-signature") ?? "", process.env.SMSRAY_WEBHOOK_SECRET);
  if (!ok) return res.status(400).send("bad signature");

  res.sendStatus(200); // acknowledge fast, work after

  const event = JSON.parse(raw);
  const deliveryId = req.get("x-lacspace-delivery"); // dedupe on this
  if (event.type === "message.status") await markStatus(event.id, event.status, event.at);
  if (event.type === "message.inbound") await saveReply(event.from, event.text, event.inReplyTo);
});

Secret rotation

Workspace owners can rotate the webhook secret from the dashboard and choose a grace period of 0–168 hours (24 h by default). During the grace period, every delivery carries two signatures: one v1= made with the new secret and one with the previous secret.

During rotation
x-lacspace-signature: t=1760069707,v1=5f2c…(new secret),v1=a91e…(previous secret)
  • Because the verifiers above accept any matching v1, your handler keeps working with the old secret while you deploy the new one.
  • Deploy the new secret within the grace period. After it ends, only the new signature is sent.
  • Choose a grace period of 0 if the old secret may have leaked — it stops working at once.

Retries and timeouts

A delivery that does not get a 2xx within 10 seconds is retried with increasing delays, up to 8 attempts in total.

Backoff between attempts

Not to scale · capped at 6 h

  1. Attempt 1 — right after the event
  2. wait 10 s
  3. wait 30 s
  4. wait 2 min
  5. wait 10 min
  6. wait 30 min
  7. wait 1 h
  8. wait 3 h
  9. wait 6 h
  10. Dead — after 8 attempts
Any 2xx response within 10 s counts as delivered. A permanent 4xx (400–499 except 408 and 429) stops retrying after 3 attempts.
  • The first attempt is made right after the event happens.
  • Delays grow along the schedule above and are capped at 6 hours.
  • After 8 attempts — or 3 attempts if your endpoint answers with a permanent 4xx (anything 400–499 except 408 and 429) — the delivery is marked dead.
  • Delivery history is kept for 7 days.
  • The event body never changes between attempts; only the signature timestamp does.

Handler checklist

  • Verify the signature on the raw body and reject old timestamps.
  • Return 2xx quickly; queue heavy work.
  • Deduplicate on x-lacspace-delivery, and treat status updates as idempotent.
  • Return 5xx (or time out) for temporary problems so we retry; return 2xx for events you choose to ignore.