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
POSTwithcontent-type: application/jsonand a single event object as the body. - Respond with any 2xx within 10 seconds. Do slow work after you respond.
Request headers
| Header | Value |
|---|---|
x-lacspace-signature | t=<unix seconds>,v1=<hex> — HMAC-SHA256 of <t>.<raw body>. A second v1= appears during secret rotation. |
x-lacspace-event | message.status or message.inbound |
x-lacspace-delivery | Unique id of this delivery. Retries of the same event reuse it — use it to deduplicate. |
user-agent | lacspace-sms-webhooks/1 |
content-type | application/json |
message.status
Sent when a message you sent reaches sent, delivered, undelivered or failed.
{
"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. queuedis 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.
erroris included only when the status isfailed,undeliveredorqueuedand a reason is known.atis the time of the transition (delivered, sent or failed time), in ISO 8601 UTC.- Events can arrive out of order after retries. Compare
atbefore overwriting a later status.
Status mapping
The API has more statuses than the webhook. They map like this:
| Message status (GET /sms/messages/:id) | Webhook status |
|---|---|
submitted, sent | sent |
delivered | delivered |
undelivered | undelivered |
failed, rejected | failed |
| anything else | queued |
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.
{
"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
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.
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
- Attempt 1 — right after the event
- wait 10 s
- wait 30 s
- wait 2 min
- wait 10 min
- wait 30 min
- wait 1 h
- wait 3 h
- wait 6 h
- Dead — after 8 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.