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.Split the header on
,and readt(unix seconds) andv1(the signature). - 2.Build the signed payload as
${t}.${rawBody}— the raw bytes, before any JSON parsing. - 3.Compute
HMAC-SHA256(signing_secret, signedPayload)and hex-encode it. - 4.Compare against
v1in constant time. - 5.Reject anything where
tis 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 ?? ""));
});