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. So 1759579200.{"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. 1

    Take the raw body

    Exactly the bytes that arrived, before anything reads them as JSON.

  2. 2

    Split the header

    Split at commas, then at the first =. t appears once, v1 once or twice.

  3. 3

    Check the age

    If t differs from your clock by more than 300 seconds, reject. Otherwise a recorded delivery could be replayed later.

  4. 4

    Compute the expected value

    HMAC-SHA256 with the secret over t + . + body, as hex.

  5. 5

    Compare

    Compare with every v1 in 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:

EnvironmentRaw body
Expressexpress.raw({ type: "application/json" }) on this route
Next.js (App Router)await request.text()
Flaskrequest.get_data()
FastAPIawait request.body()
Djangorequest.body
PHPfile_get_contents('php://input')
Laravel$request->getContent()
Goio.ReadAll(r.Body)
ASP.NET CoreA 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=d5a5a8b08d9a25070a9e22c9fa06ad59b4082eb039b25cee3d83319e00d8201a

Changing 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.

If a secret has leaked, rotate it immediately. A delivery signed with the old one is then still possible for 24 hours, not longer.

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-Id taken 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.