Write a webhook handler

We POST events to your endpoint and sign every one. You need your endpoint set and your signing secret in hand first — see Receive webhooks.

Check the signature

Delivery header
X-Msgeasy-Signature: t=1757155200,v1=Zm9vYmFyYmF6...

Every delivery carries it. The SDK helper below checks it for you — pass it the raw body, the header and your signing secret.

Pass the body exactly as it arrived

The signature is computed over the exact bytes we sent, so the check only works on those bytes. Most frameworks parse JSON before your code runs, and re-serialising it changes key order and spacing — enough to fail the check on a request that was genuine.

Use your framework's raw-body accessor on the webhook route only, and parse the JSON yourself afterwards.

A failed check looks identical whether the body was mangled or the request was forged, so when every delivery is being rejected, suspect this first.

Write the route

Express and FastAPI below. Any other framework is the same three steps with its own raw-body accessor.

import express from "express";
import { verifyWebhookSignature, SIGNATURE_HEADER } from "msgeasy";
 
const app = express();
 
// Raw body on this route ONLY, and before any express.json().
app.post(
  "/webhooks/msgeasy",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const raw = req.body.toString("utf8");
 
    if (!verifyWebhookSignature(raw, req.header(SIGNATURE_HEADER) ?? "", process.env.MSGEASY_WEBHOOK_SECRET!)) {
      return res.sendStatus(400);
    }
 
    const event = JSON.parse(raw);
 
    // Answer first, work later.
    res.sendStatus(200);
    void handle(event).catch((err) => console.error(err));
  },
);
 
app.use(express.json());   // everything else

The helper returns a boolean and never raises — a missing or malformed header, a wrong secret, a tampered body and an expired timestamp are all just false. Answer a failed check with a 4xx, process nothing, and do not log the body.

The signature covers a timestamp as well as the body, so an old capture cannot be replayed. Anything older than five minutes is rejected; widen that if your host's clock drifts:

verifyWebhookSignature(raw, header, SECRET, { toleranceSeconds: 600 });

Answer first, then do the work

We give up on the request after 10 seconds and count it as a failure, so hand the work off rather than doing it inline:

async function handle(event) {
  switch (event.type) {
    case "message.delivered":
      return markDelivered(event.data.messageId);
 
    case "message.failed":
      return markFailed(event.data.messageId);
 
    case "inbound.received":
      return storeReply(event.data);
 
    case "usage.threshold":
      return alertOps(event.data);
 
    default:
      return;    // an unknown type is normal — we add events over time
  }
}

Match the event back to your own record by data.messageId — the msg_ id you stored when you sent it.

Handle repeats and out-of-order events

Events can arrive twice, and out of order. The payload carries what you need for both: store each id and drop repeats, and ignore any sequence that is not higher than the one you already have.

if (seen.has(event.id)) return;
if (event.sequence <= stored.sequence) return;

If nothing arrives

Check in this order:

  1. Is the URL set, with Deliver events on, under Settings → Console → Webhook endpoint?
  2. Is it public HTTPS and reachable from the internet, answering 2xx? We do not follow redirects.
  3. Are you answering within 10 seconds?
  4. Is the signature check rejecting everything? That is almost always the raw body.

Every failure retries the same way — a 4xx, a 5xx and a timeout are not distinguished. Five attempts total. Each retry waits twice as long as the last — from one minute up to a six-hour cap, with jitter. After the fifth the delivery is marked failed and dropped.