> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.awardspring.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.awardspring.com/_mcp/server.

# Verifying signatures

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
```

| Part | Meaning                                                                                                         |
| ---- | --------------------------------------------------------------------------------------------------------------- |
| `t`  | Unix timestamp in **seconds**, at the moment AwardSpring signed the request.                                    |
| `v1` | Lowercase hex HMAC-SHA256 signature. There can be **more than one** — see [rotation](#rotating-a-secret) 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.

> **Warning**
>
> 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

```python
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)
```

> **Note**
>
> 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.

```javascript
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:

| Mode              | Behaviour                                                                                        |
| ----------------- | ------------------------------------------------------------------------------------------------ |
| **Immediate**     | The old secret stops signing at once. Any endpoint still holding it starts rejecting deliveries. |
| **24-hour grace** | Both 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.