Verifying signatures▾

Verifying signatures

Every webhook is signed with HMAC-SHA256. Verify it before you trust the payload.

Your endpoint is a public URL — anyone can POST to it. Every Sendport request carries a Sendport-Signature header computed over the raw body with your endpoint's signing_secret.

Header
Sendport-Signature: t=1757153462,v1=5f4d3c2b1a09e8d7c6b5a49382716f5e4d3c2b1a09e8d7c6b5a4938271605f4d
  1. 1.Split the header on , and read t (unix seconds) and v1 (the signature).
  2. 2.Build the signed payload as ${t}.${rawBody} — the raw bytes, before any JSON parsing.
  3. 3.Compute HMAC-SHA256(signing_secret, signedPayload) and hex-encode it.
  4. 4.Compare against v1 in constant time.
  5. 5.Reject anything where t is more than 5 minutes old, to block replays.

Verify the raw body

JSON.parse then JSON.stringify reorders keys and changes whitespace, which breaks the signature. Keep the raw buffer — in Express that means express.raw(), not express.json().
import crypto from "node:crypto";

export function verify(rawBody: Buffer, header: string | undefined, secret: string) {
  if (!header) return false;

  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody.toString("utf8")}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

The verification challenge

When you register an endpoint, Sendport sends a one-off GET with ?challenge=<token>. Echo the token back as the plain-text body with a 200. Until that succeeds the endpoint stays pending and receives nothing.

Node
app.get("/hooks/sendport", (req, res) => {
  res.type("text/plain").send(String(req.query.challenge ?? ""));
});