Messaging
Webhooks and signature verification
ShipMsg sends an HTTPS POST to your endpoint for every event you subscribe to: delivery statuses, inbound messages, template reviews and more. Every request is signed so you can prove it came from ShipMsg.
1. Add an endpoint
In the console open Developer → Webhooks and add your HTTPS URL. Choose the events it receives with eventFilters such as message.* or message.received (see the event catalogue); leave them empty to receive everything. The endpoint’s signing secret is shown once — store it like a password.
Endpoints decide where your customers’ data goes, so creating one through the API ( POST /v1/accounts/{AC}/webhooks) also needs a stepUpToken from a signed-in user’s second-factor confirmation; an API key alone cannot add one.
2. Pass the verification challenge
Before an endpoint receives events it must prove it is yours. ShipMsg sends it a signed test.ping event whose data.challenge is a random string; answer 200 with that string, either as the whole body or as JSON:
{"challenge": "Kp1v3v0yXU0p2Hk0Yb7Lm4xq5aVn8QJt"}Start it from the console (Verify) or with POST /v1/accounts/{AC}/webhooks/{WH}/verify. The examples below all answer the challenge.
3. Verify every signature
Each delivery carries these headers:
| Header | Meaning |
|---|---|
X-ShipMessages-Signature | t=<unix seconds>,v1=<hex>, with a second v1= while a secret rotation is in progress. |
X-ShipMessages-Event-Id | The event’s ID (EV…). Use it to ignore duplicates. |
X-ShipMessages-Delivery-Id | This delivery attempt, for the delivery log. |
X-ShipMessages-Api-Version | The API version the body is shaped for. |
The signature is HMAC-SHA256(secret, t + "." + raw body) in hex. Accept the request only if one v1 matches (compare in constant time) and t is within 5 minutes of now. Always verify the raw bytes you received — parsing the JSON and serialising it again changes them.
import express from "express";
import { webhooks } from "@shipmsg/node";
const app = express();
// Read the RAW body: parsing and re-serialising JSON changes the bytes and breaks the signature.
app.post("/shipmsg/webhooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = webhooks.constructEvent(
req.body,
req.header("X-ShipMessages-Signature") ?? "",
process.env.SHIPMESSAGES_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).send("invalid signature");
}
// Endpoint verification: echo the challenge.
if (event.type === "test.ping")
return res.json({ challenge: (event.payload.data as { challenge: string }).challenge });
res.sendStatus(204); // answer fast, then work
handle(event); // deliveries are at-least-once: dedupe on event.id
});The event body
{
"id": "EV…",
"type": "message.delivered",
"apiVersion": "2026-08-21",
"occurredAt": "2026-09-26T22:52:38.000Z",
"createdAt": "2026-09-26T22:52:38.412Z",
"sequence": 12,
"livemode": false,
"accountSid": "AC…",
"subaccountSid": null,
"data": {
"object": "message",
"sid": "SM…",
"status": "delivered",
"direction": "outbound",
"from": "WA…",
"to": { "identifierType": "phone_e164", "value": "+15550000001", "contactSid": "CT…" },
"metadata": { "orderId": "1042" },
…
}
}data is the full resource as it is after the event. On inbound messages from is your own sender and the customer is in to; the text is in data.text.body (see the inbound example).
Delivery rules
- Answer fast. Reply
2xxwithin the endpoint’s timeout, then do the work. Anything else counts as a failure. - At least once. An event can arrive more than once. Deduplicate on
id. - Order. Events can arrive out of order. Use
sequence(it only grows per account) oroccurredAt, and never move a message backwards — adeliveredafter areadchanges nothing. - Retries. A failed delivery is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 4 hours, 12 hours and 24 hours, then daily until 72 hours have passed. An endpoint that keeps failing is suspended and you are notified; resume it from the console and redeliver what it missed.
- Unknown types. New event types are added over time. Answer
2xxand ignore types you do not handle; the SDKs mark them withknownEventType: falseinstead of failing.
Rotating the signing secret
Rotate from the console or with POST /v1/accounts/{AC}/webhooks/{WH}/secret/rotate. For 24 hours every delivery is signed with both the old and the new secret (two v1= values), so you can deploy the new secret without dropping a single event.
Testing locally
Endpoints must be public HTTPS URLs, so use an HTTPS tunnel to reach a server on your laptop. Send a test event from the console, or send any message to +15550000012 to receive a real message.received.