Flavoso APIWebhooks and eventsVerifying signatures
Verifying signatures
Every delivery carries Flavoso-Signature, an HMAC-SHA256 over timestamp and raw body. Verification code for Node.js, Python, PHP, Go and C#.
The scheme
Every delivery carries the header Flavoso-Signature: t=1759579200,v1=5257a8…. t is the time in seconds since 1970 and v1 an HMAC-SHA256, hexadecimal in lower case:
- Key: your signing secret, the whole string including
whsec_, as UTF-8. - Message:
t, a dot, the raw body. So1759579200.{"id":"evt_…",…}.
Check the signature before you trust the content. Then you know the delivery comes from Flavoso and was not altered on the way. It is the same scheme Stripe uses: if you have built it there, you already know it.
Step by step
- 1
Take the raw body
Exactly the bytes that arrived, before anything reads them as JSON.
- 2
Split the header
Split at commas, then at the first
=.tappears once,v1once or twice. - 3
Check the age
If
tdiffers from your clock by more than 300 seconds, reject. Otherwise a recorded delivery could be replayed later. - 4
Compute the expected value
HMAC-SHA256 with the secret over
t+.+ body, as hex. - 5
Compare
Compare with every
v1in constant time. If one matches, the delivery is genuine.
Verification code
// flavoso-signature.js
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the body exactly as received. Never JSON.stringify(req.body) again.
export function verifyFlavosoSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(",").map((part) => part.trim().split("="));
const t = Number(parts.find(([key]) => key === "t")?.[1]);
const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value);
if (!Number.isInteger(t) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`, "utf8").digest("hex");
return signatures.some(
(s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
}Getting the raw body
Most frameworks parse JSON automatically. Turning the body back into text afterwards gives different characters (whitespace, order, special characters) and a wrong signature. This is how you get the bytes:
| Environment | Raw body |
|---|---|
| Express | express.raw({ type: "application/json" }) on this route |
| Next.js (App Router) | await request.text() |
| Flask | request.get_data() |
| FastAPI | await request.body() |
| Django | request.body |
| PHP | file_get_contents('php://input') |
| Laravel | $request->getContent() |
| Go | io.ReadAll(r.Body) |
| ASP.NET Core | A StreamReader over Request.Body, before any model binding |
// app/api/flavoso/webhook/route.js (App Router)
import { verifyFlavosoSignature } from "@/lib/flavoso-signature";
export async function POST(request) {
const raw = await request.text(); // exactly as received, before any JSON parsing
const header = request.headers.get("flavoso-signature") ?? "";
if (!verifyFlavosoSignature(raw, header, process.env.FLAVOSO_WEBHOOK_SECRET)) {
return new Response("invalid signature", { status: 400 });
}
await enqueue(JSON.parse(raw)); // answer fast, work in the background
return new Response(null, { status: 200 });
}Test vector
Use it to test your verification without Flavoso: with this secret, this body and t as “now”, it must accept the signature, and reject it with one character of the body changed.
# Your verification must accept this example with t = 1759579200 as "now".
# openssl prints the same v1 (after "SHA2-256(stdin)="):
printf '%s' '1759579200.{"id":"evt_test","type":"ping"}' \
| openssl dgst -sha256 -hmac 'whsec_test_secret'
# secret: whsec_test_secret
# body: {"id":"evt_test","type":"ping"}
# header: Flavoso-Signature: t=1759579200,v1=d5a5a8b08d9a25070a9e22c9fa06ad59b4082eb039b25cee3d83319e00d8201aChanging the secret
After “Create new signing secret”, Flavoso signs with the new and the old secret at the same time for 24 hours, the new one first: the header then carries two v1. It is enough if one matches. Put the new secret in place during that time; after that only the new one applies.
Common mistakes
- The body was parsed as JSON and turned back into text.
- The wrong secret: your app's webhook and a restaurant's own webhooks have different ones.
- The server clock is wrong. Set it with NTP.
- Upper-case hex compared, or spaces around the header parts not removed.
Flavoso-Event-Idtaken as proof. The id is not a secret; only the signature proves anything.
Still unclear? Write to support@flavoso.com and include the id from X-Request-Id. It lets us find your request in the log.