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.
The scheme
Section titled “The scheme”Midwater-Signature: t=1791493920,v1=5f1410b3599929147ad7559ca8423d05b723079614d80aa9763d17b440a899e3
tis when Midwater signed the delivery, in Unix seconds.v1is the hex HMAC-SHA256 of the string<t>.<raw body>, keyed with the whole signing secret, including itswhsec_prefix.- There may be more than one
v1; accept the delivery if any of them matches.
To verify:
- Take the raw request body, exactly the bytes you received. Parsing the JSON and serializing it again changes the bytes and breaks the signature.
- Compute HMAC-SHA256 of
t + "." + bodywith your secret, as hex. - Compare it with each
v1in constant time. - Reject deliveries whose
tis more than 5 minutes from your clock, so a captured delivery can’t be replayed later.
With an SDK
Section titled “With an SDK”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);});import osfrom flask import Flask, requestfrom midwater import verify_webhook, WebhookVerificationError
app = Flask(__name__)
@app.post("/midwater/webhooks")def midwater_webhook(): try: event = verify_webhook(request.get_data(), request.headers, os.environ["MIDWATER_WEBHOOK_SECRET"]) except WebhookVerificationError as e: return e.reason, 400 if event["type"] == "conversation.evaluated": enqueue(event) return "", 204Both return the parsed event and raise WebhookVerificationError with a reason: missing_header, malformed_header, stale_timestamp, invalid_signature or no_secret.
Without an SDK
Section titled “Without an SDK”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));}import hashlibimport hmacimport time
def verify_midwater_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool: """True when raw_body (exactly as received) carries a valid Midwater-Signature.""" if not header: return False t, v1 = None, [] for part in header.split(","): key, _, value = part.strip().partition("=") if key == "t": t = value elif key == "v1": v1.append(value) if not t or not t.isdigit() or not v1: return False if abs(time.time() - int(t)) > tolerance_seconds: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, sig.lower()) for sig in v1)Rotating the secret
Section titled “Rotating the secret”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.