Skip to content

Verifying signatures

Every delivery is signed with your environment’s signing secret (whsec_…, on the Developers page). Verify the signature before you trust the body, on your server: like an API key, the signing secret must never be in a browser or a mobile app.

Midwater-Signature: t=1791493920,v1=5f1410b3599929147ad7559ca8423d05b723079614d80aa9763d17b440a899e3

  • t is when Midwater signed the delivery, in Unix seconds.
  • v1 is the hex HMAC-SHA256 of the string <t>.<raw body>, keyed with the whole signing secret, including its whsec_ prefix.
  • There may be more than one v1; accept the delivery if any of them matches.

To verify:

  1. Take the raw request body, exactly the bytes you received. Parsing the JSON and serializing it again changes the bytes and breaks the signature.
  2. Compute HMAC-SHA256 of t + "." + body with your secret, as hex.
  3. Compare it with each v1 in constant time.
  4. Reject deliveries whose t is more than 5 minutes from your clock, so a captured delivery can’t be replayed later.
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@midwater/sdk/webhooks";
const app = express();
app.post("/midwater/webhooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, process.env.MIDWATER_WEBHOOK_SECRET!);
} catch (e) {
if (e instanceof WebhookVerificationError) return res.status(400).send(e.reason);
throw e;
}
res.sendStatus(204); // answer within 5 seconds, then do the work
if (event.type === "conversation.evaluated") enqueue(event);
});

Both return the parsed event and raise WebhookVerificationError with a reason: missing_header, malformed_header, stale_timestamp, invalid_signature or no_secret.

import { createHmac, timingSafeEqual } from "node:crypto";
/** True when `rawBody` (a string or Buffer, exactly as received) carries a valid Midwater-Signature. */
export function verifyMidwaterSignature(rawBody, header, secret, toleranceSeconds = 300) {
if (!header) return false;
let t;
const v1 = [];
for (const part of header.split(",")) {
const [k, v] = part.split("=", 2).map((s) => s.trim());
if (k === "t") t = v;
else if (k === "v1") v1.push(v);
}
if (!/^\d+$/.test(t ?? "") || v1.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return v1.some((sig) => /^[0-9a-f]{64}$/i.test(sig) && timingSafeEqual(Buffer.from(sig, "hex"), expected));
}

Signing secrets can’t be rotated from the app yet. Write your verification so it can hold more than one secret: when rotation arrives, you’ll accept a delivery that verifies with either the old or the new secret until the old one is retired. Accepting any matching v1 also keeps you working if Midwater signs with both secrets during a rotation.