Skip to main content
Every webhook delivery carries an HMAC-SHA256 signature in the X-Awardee-Signature header. Verify it on every request before parsing or acting on the body. A request that fails verification is either a misconfiguration or an attempt to impersonate Awardee.

Signing secret

The signing secret is shown on the webhook endpoint’s detail page in the dashboard. It looks like:
whsec_4f8a3c7e2d1b9a5c6f8e3d2c1b4a9f7e8a5f3c7e2d1b9a5c6f8e3d2c1b4a9f7e
Treat it as a server-side credential. If it leaks, rotate it from the dashboard. Each endpoint has its own secret — leaking one doesn’t compromise the others.

Signature format

The X-Awardee-Signature header is a comma-separated list of key/value pairs:
X-Awardee-Signature: t=1716461700,v1=8a5f3c7e2d1b9a5c6f8e3d2c1b4a9f7e4f8a3c7e2d1b9a5c6f8e3d2c1b4a9f7e
  • t — Unix seconds at signing time. Also sent in X-Awardee-Timestamp.
  • v1 — HMAC-SHA256, lowercase hex, of the string <t>.<raw_body>, signed with the endpoint’s whsec_… secret.
The leading scheme prefix (v1=) leaves room for a future hash upgrade. Match by prefix, not by position.

Verifying

The verification recipe is: rebuild the signed string, HMAC it, compare in constant time.
import crypto from "node:crypto"

export function verifyAwardeeSignature(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=", 2)),
  )
  const timestamp = parts.t
  const signature = parts.v1
  if (!timestamp || !signature) return false

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex")

  // Constant-time compare. Both buffers must be the same length.
  const a = Buffer.from(signature, "hex")
  const b = Buffer.from(expected, "hex")
  if (a.length !== b.length) return false
  return crypto.timingSafeEqual(a, b)
}
HMAC the raw request body. Re-serializing JSON before signing — even just to add a trailing newline or normalize key order — invalidates the signature. Express, FastAPI, Rails, and Next.js all require explicit raw-body access:
  • Express. app.use(express.raw({ type: "application/json" })) for the webhook route.
  • FastAPI. Read await request.body() before any await request.json().
  • Next.js (app router). Read await req.text() and parse JSON yourself.
  • Rails. request.raw_post.
Whatever your framework, verify your handler receives the exact bytes Awardee sent.

Replay attacks

The t= timestamp lets you reject deliveries that are stale. Compare it to your server clock and refuse anything older than five minutes:
const skew = Math.abs(Date.now() / 1000 - Number(timestamp))
if (skew > 300) return res.status(400).end()
Pair this with delivery idempotency so a legitimately retried delivery (which carries the same X-Awardee-Delivery) is still recognized and ignored, while a captured-and-replayed request is rejected by the timestamp check.

Rotating the secret

Rotating the signing secret invalidates the old secret immediately on the next delivery. To rotate without dropping events:
1

Generate a new secret

The dashboard offers “Rotate signing secret” on each endpoint. The old secret stays valid for a 24-hour grace window.
2

Update verification

Roll your service so it accepts either the old or the new secret. Return success if either validates.
3

Cut over

Once you’ve confirmed deliveries are verifying with the new secret in your logs, remove the old one.