Verifying signatures

Your endpoint is public. The signature is what makes it yours.

Every delivery carries an X-AwardSpring-Signature header. It proves the body came from AwardSpring and was not altered in transit.

X-AwardSpring-Signature: t=1775227351,v1=5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
PartMeaning
tUnix timestamp in seconds, at the moment AwardSpring signed the request.
v1Lowercase hex HMAC-SHA256 signature. There can be more than one — see rotation below.

The scheme

  1. Take the t value from the header.

  2. Build the signed payload: the timestamp, a literal ., then the raw request body.

    1775227351.{"id":"evt_9f0d...","type":"application.submitted",...}
  3. Compute HMAC-SHA256 over that string using your endpoint’s whsec_... secret as the key.

  4. Hex-encode the result in lowercase and compare it against any v1 value in the header, using a constant-time comparison.

Sign the raw bytes you received. Parsing the JSON and re-serializing it changes whitespace and key order, and the signature will never match. Capture the body as a string before any framework deserializes it.

Example

import hashlib
import hmac
import time
def verify(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(
p.split("=", 1) for p in signature_header.split(",") if "=" in p
)
timestamp = parts.get("t")
if timestamp is None:
return False
# Reject anything too old to be a live delivery. AwardSpring retries for hours,
# so pick a tolerance that suits you - 5 minutes is a common starting point.
if abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
signed_payload = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
# There may be several v1 values while a secret is rotating; any match is valid.
candidates = [
value for key, value in (
p.split("=", 1) for p in signature_header.split(",") if "=" in p
) if key == "v1"
]
return any(hmac.compare_digest(expected, candidate) for candidate in candidates)

A naive dict() of the header parts keeps only the last v1. During a rotation grace window that silently drops the other valid signature, so parse the v1 values as a list, as above.

const crypto = require("crypto");
function verify(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = signatureHeader.split(",").map((p) => p.split("="));
const timestamp = parts.find(([k]) => k === "t")?.[1];
if (!timestamp) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return parts
.filter(([k]) => k === "v1")
.some(([, candidate]) =>
candidate.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate))
);
}

Check the timestamp

The signature alone does not stop a replay: someone who captured a valid delivery could send those exact bytes and headers again, forever. Rejecting deliveries whose t is far from your own clock closes that window.

Choose the tolerance deliberately. Too tight and a legitimate retry — AwardSpring waits up to six hours between attempts — arrives outside it. The retry is signed fresh at each attempt, with a current t, so a few minutes of tolerance is enough for real deliveries; it is the captured copy that gets old.

Rotating a secret

Rotate from Settings → API → Webhooks, in one of two modes:

ModeBehaviour
ImmediateThe old secret stops signing at once. Any endpoint still holding it starts rejecting deliveries.
24-hour graceBoth secrets sign each delivery for 24 hours, then the old one expires.

With the grace window, deliveries carry two signatures:

X-AwardSpring-Signature: t=1775227351,v1=<new-secret-hmac>,v1=<old-secret-hmac>

Verifying against any v1 — as the examples above do — means your integration keeps working across the whole window, and you can deploy the new secret whenever you like inside it. An implementation that reads only the first or last v1 will start failing half its deliveries the moment a rotation begins.

Use immediate rotation only when the secret is believed to be compromised, and expect deliveries to fail until the new one is deployed. Those failures retry, so a fast redeploy usually loses nothing.