Contents

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:

your response
{"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:

HeaderMeaning
X-ShipMessages-Signaturet=<unix seconds>,v1=<hex>, with a second v1= while a secret rotation is in progress.
X-ShipMessages-Event-IdThe event’s ID (EV…). Use it to ignore duplicates.
X-ShipMessages-Delivery-IdThis delivery attempt, for the delivery log.
X-ShipMessages-Api-VersionThe 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

message.delivered
{
  "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 2xx within 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) or occurredAt, and never move a message backwards — a delivered after a read changes 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 2xx and ignore types you do not handle; the SDKs mark them with knownEventType: false instead 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.