# API signature: HMAC signing for requests and webhooks

> An API signature proves that a message came from the holder of a secret and was not changed on the way. This page explains the scheme behind one real signature header, gives you a known-answer vector to test against, and shows a verifier in Python, Node, Go and PHP.

- **HTML:** https://offendersearch.app/docs/learn/api-signature
- **Base URL (live):** https://api.offendersearch.app — live keys (`os_live_…`)
- **Base URL (sandbox):** https://sandbox.offendersearch.app — test keys (`os_test_…`), never billed
- **Authentication:** `X-API-Key` request header
- **OpenAPI:** https://offendersearch.app/openapi.json · https://offendersearch.app/openapi.yaml
- **Docs index for agents:** https://offendersearch.app/llms.txt

## What an API signature is

An API signature is a short value, sent alongside a message, computed from the message and a secret that only the sender and the receiver know. The receiver recomputes it; if the two match, the message came from someone holding the secret and arrived unchanged. The usual construction is an **HMAC**: a keyed hash (here SHA-256) over a canonical string built from the message.

Signatures appear in two places in an API. On **webhooks**, the service signs what it sends you, so you can tell a real delivery from a forged `POST`. On **requests**, some APIs make the client sign what it sends, instead of or in addition to an API key. The maths is the same; what differs is who holds the secret and what the canonical string contains.

The worked example is the [OffenderSearch](/background-check-api) webhook signature, because it is small and fully specified, and because every webhook the platform sends (FCRA events, monitoring alerts) uses it, so one verifier serves them all.

## The signature scheme, exactly

```http
X-Offendersearch-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>." + body)>
```

| Part | Value | Why |
| --- | --- | --- |
| `secret` | `whsec_` + 48 hex characters, issued once when you register the URL | A 24-byte random key; the prefix makes a leaked one recognisable in a scan. |
| `t` | Unix seconds when the delivery was signed | Signed into the text, so a captured delivery cannot be replayed later. |
| canonical string | `"<t>." + raw body bytes` | The timestamp binds the body to a moment; the dot separates them unambiguously. |
| `v1` | Lower-case hex of the 32-byte HMAC-SHA256 digest | `v1` is a version tag: a future scheme would add `v2=` beside it, and old receivers keep working. |

This is the shape most mature senders use (a timestamp and a versioned HMAC in one header), so a verifier you write for this header needs only the header name and secret changed to verify theirs.

## A known-answer test vector for your verifier

Before you point a verifier at live traffic, make it pass a fixed case. This vector was produced by the server's own signing code; the browser [webhook tester](/tools/webhook-tester) recomputes it on every load as a self-check.

*body (exact bytes, no trailing newline)*
```json
{"createdAt":"2026-10-28T00:00:00+00:00","data":{"orderId":"ord_4f2a9c7e1b3d5f8a0c2e","status":"complete"},"id":"fev_7c1d9e2a4b6f8a0c1e3d","type":"order.completed"}
```

*secret · t · expected header*
```plain
whsec_0123456789abcdef0123456789abcdef0123456789abcdef
1761609600
t=1761609600,v1=2ea9b523e540f3226c7108fb66737e1467804795ed8b655df99e4fc6e5d30124
```

```bash
# Sign any body from a shell — handy for a curl test against your own receiver
BODY='{"createdAt":"2026-10-28T00:00:00+00:00","data":{"orderId":"ord_4f2a9c7e1b3d5f8a0c2e","status":"complete"},"id":"fev_7c1d9e2a4b6f8a0c1e3d","type":"order.completed"}'
SECRET=whsec_0123456789abcdef0123456789abcdef0123456789abcdef
T=1761609600
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET"
# (stdin)= 2ea9b523e540f3226c7108fb66737e1467804795ed8b655df99e4fc6e5d30124
```

> **If your digest differs.** Nine times out of ten the body bytes are not the bytes above: a trailing newline, a re-serialised JSON object with spaces, or a different key order. Hash the exact bytes you received.

## The canonical string: why raw bytes matter

The sender signed a specific sequence of bytes. Any transformation on your side before hashing (parsing JSON and re-encoding it, trimming whitespace, normalising line endings, decoding and re-encoding UTF-8) produces different bytes and a different digest, even when the data is "the same". So:

- Read the request body as bytes **before** any JSON middleware touches it: `express.raw()`, `await request.body()`, `php://input`, `request.raw_post`.
- Take `t` from the header, not from your clock. The sender signed its own timestamp.
- Build `"<t>."` + bytes and hash that. Do not hash the body alone, and do not hash `t` alone.
- Compare against the full header string (or the `v1` hex), never against a prefix.

When you design a request-signing scheme for your own API, the same discipline applies in reverse: define the canonical string precisely (method, path, sorted query, body hash, timestamp) and publish it, because every client has to rebuild it byte for byte.

## The timestamp window and replay defence

A signature alone proves origin and integrity; it does not prove freshness. Someone who captures a signed delivery could send it to you again next week, and it would still verify. The timestamp in the canonical string closes that: refuse any delivery whose `t` is more than a tolerance from your clock. Five minutes (300 s) is the conventional window, wide enough for clock skew and a slow network, narrow enough that a replay is useless.

Within the window, replays are the sender's own retries, and the right defence is [idempotency](/docs/learn/webhook-example): deduplicate on the event id. A signature does not make a delivery unique; the id does.

> **Reject stale before you compare.** Checking the window first costs nothing and means a replayed capture never reaches your HMAC code. The order in the verifiers below is deliberate: parse, check time, then compare.

## Constant-time comparison, and why `==` is wrong

A normal string comparison stops at the first byte that differs. Measured over many requests, the time it takes leaks how many leading bytes of a guessed signature were right, and that is enough to recover a valid signature byte by byte. Every language has a comparison that always walks the full length: `hmac.compare_digest` (Python), `crypto.timingSafeEqual` (Node), `hmac.Equal` (Go), `hash_equals` (PHP). Use it, and compare values of equal length.

## Verify the signature in Python

```python
import hmac, hashlib, time

def sign(secret: str, t: int, body: bytes) -> str:
    mac = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256)
    return f"t={t},v1={mac.hexdigest()}"

def verify(secret: str, header: str, body: bytes, tolerance_s: int = 300) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        t = int(parts["t"])
    except Exception:
        return False                                   # malformed header is simply not valid
    if abs(int(time.time()) - t) > tolerance_s:
        return False                                   # outside the replay window
    return hmac.compare_digest(sign(secret, t, body), header)
```

## Verify the signature in Node.js

```javascript
import crypto from "node:crypto";

export function sign(secret, t, body) {           // body: Buffer or string of the RAW bytes
  const hex = crypto.createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex");
  return `t=${t},v1=${hex}`;
}

export function verify(secret, header, body, toleranceS = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
  const expected = sign(secret, t, body);
  return expected.length === header.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}
```

In the browser or an edge runtime without `node:crypto`, WebCrypto's `crypto.subtle` does the same work; the open-source `lib/webhook-signature.ts` behind the [webhook tester](/tools/webhook-tester) is a complete example.

## Verify the signature in Go

```plain
package hooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"math"
	"strconv"
	"strings"
	"time"
)

func Sign(secret string, t int64, body []byte) string {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(fmt.Sprintf("%d.", t)))
	mac.Write(body)
	return fmt.Sprintf("t=%d,v1=%s", t, hex.EncodeToString(mac.Sum(nil)))
}

func Verify(secret, header string, body []byte, toleranceS float64) bool {
	parts := map[string]string{}
	for _, p := range strings.Split(header, ",") {
		kv := strings.SplitN(p, "=", 2)
		if len(kv) != 2 { return false }
		parts[kv[0]] = kv[1]
	}
	t, err := strconv.ParseInt(parts["t"], 10, 64)
	if err != nil { return false }
	if math.Abs(float64(time.Now().Unix()-t)) > toleranceS { return false }
	return hmac.Equal([]byte(Sign(secret, t, body)), []byte(header))   // constant time
}
```

## Verify the signature in PHP

```plain
<?php
function os_sign(string $secret, int $t, string $body): string {
    return "t=$t,v1=" . hash_hmac('sha256', "$t." . $body, $secret);
}

function os_verify(string $secret, string $header, string $body, int $toleranceS = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $p) {
        [$k, $v] = array_pad(explode('=', $p, 2), 2, null);
        $parts[$k] = $v;
    }
    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) return false;
    if (abs(time() - (int)$parts['t']) > $toleranceS) return false;
    return hash_equals(os_sign($secret, (int)$parts['t'], $body), $header);   // constant time
}
// $body must be file_get_contents('php://input'), never json_encode(json_decode(...)).
```

## Generating and storing a webhook secret

A webhook secret should be random, long, and shown once. The platform mints `whsec_` + 48 hex characters (24 random bytes) and returns it in exactly one response: the `PUT /v1/fcra/webhook-endpoint` that set the URL, or the `201` that created a monitor. It is not readable again through any endpoint; to replace it, send `"rotateSecret": true` to the same `PUT`, or `POST /v1/monitors/{monitorId}/webhook-secret`. The old secret stops verifying at once.

- Store it with your other credentials (a secrets manager or an environment variable), never in source control.
- Keep one secret per sender and per environment: the sandbox issues its own.
- Rotate on any suspicion of exposure, and after staff changes if the secret was ever handled by hand.
- If you generate secrets for your own webhook senders, use the operating system's CSPRNG (`secrets.token_hex(24)`, `crypto.randomBytes(24)`), and give them a recognisable prefix.

## Signing requests vs sending an API key

For calls **to** the API you send `X-API-Key: os_live_…` over TLS and nothing is signed; the key identifies and authenticates you, and TLS protects it in transit. Request signing (the client HMACs each request) adds value when a request passes through parties you do not trust, when a key cannot be kept out of a URL, or when you want to bind each call to a timestamp. It costs every client a canonicalisation step that is easy to get wrong. A webhook is the case where signing is clearly worth it: the receiver has no TLS client certificate to check and the body may be logged by anyone in between.

|  | API key | HMAC signature | OAuth 2.0 |
| --- | --- | --- | --- |
| Identifies | The application | The holder of the shared secret | A user's grant to an application |
| Protects against tampering | No (TLS does) | Yes | No (TLS does) |
| Replay protection | No | Yes, with a timestamp | Short-lived tokens |
| Right for | Calls to an API you trust over TLS | Webhooks; requests crossing untrusted hops | Acting on behalf of a user |

The full comparison, including client-credentials flows, is on [API key vs OAuth](/docs/learn/api-key-vs-oauth).

## Webhook authentication: HMAC vs API key vs OAuth

Developers searching for "webhook api key" or "oauth webhook" are usually asking how the receiver should authenticate the sender. The options, in order of how well they work:

1. **HMAC signature with a timestamp** (this page). Proves origin and integrity, defends replay, costs one header. The standard answer.
2. **A secret token in a header** sent with every delivery. Proves origin only; the body can be altered in transit by anyone who can read the token. Acceptable over TLS when the sender cannot sign.
3. **A secret in the URL**. Proves origin weakly and leaks into every access log along the path. Use only as a second factor.
4. **OAuth**. Designed for a client obtaining a user's delegated access, not for a server announcing an event. A sender that "authenticates with OAuth" is sending a bearer token, which is option 2 with extra steps. Mutual TLS is the heavyweight alternative when both sides can manage certificates.

## Common signature verification mistakes

| Mistake | Symptom | Fix |
| --- | --- | --- |
| Hashing a parsed-and-re-encoded body | Every signature mismatches | Read raw bytes before JSON middleware |
| Using your clock for `t` | Mismatch after any delay | Take `t` from the header; check it against your clock separately |
| Comparing with `==` | Nothing visible; a timing side channel | Constant-time compare |
| No timestamp check | Captured deliveries replay forever | Refuse `abs(now − t) > 300` |
| Hashing the body without the `"<t>."` prefix | Mismatch | Canonical string is `"<t>."` + body |
| Upper-case hex | Mismatch on string compare | Compare lower-case hex, or compare bytes |
| Secret from the wrong environment | Sandbox deliveries fail in production or vice versa | One secret per environment |
| Logging the body before verifying | An attacker's body in your logs | Verify first; log after |

## Test your verifier

Three checks, in order: the known-answer vector above must verify; the same header with one hex digit changed must not; the same header with `t` set to an hour ago must be refused as stale. The [webhook tester](/tools/webhook-tester) produces all three cases in the browser so you can paste them into your receiver, and the [webhook example](/docs/learn/webhook-example) has a complete receiver to compare against.

## API signature questions developers ask

### What is an API signature?

A value computed from a message and a shared secret, sent with the message, that lets the receiver prove the message came from a holder of the secret and was not altered. Usually an HMAC-SHA256 over a canonical string that includes a timestamp.

### What is HMAC API authentication?

Authenticating each request or delivery by its HMAC signature instead of, or as well as, a static key. The receiver recomputes the HMAC with the same secret and accepts the message only if it matches and is fresh.

### How do I generate a webhook secret?

Use the operating system's random source for at least 24 bytes and encode them as hex or base64, with a recognisable prefix. On this platform the secret is generated for you and returned once when you register the webhook URL; you never generate it yourself.

### How do I verify a webhook signature?

Read the raw body, parse the header into `t` and `v1`, refuse `t` outside a five-minute window, compute HMAC-SHA256 over `"<t>." + body` with your secret, and compare to `v1` in constant time. The code above does exactly this in four languages.

### Should a webhook use an API key or a signature?

A signature. A key in a header proves only that the sender knew the key; a signature with a timestamp also proves the body was not altered and the delivery is fresh.

### What are webhook authentication best practices?

HTTPS only; HMAC over the raw body with a timestamp; a five-minute window; constant-time comparison; one secret per sender and environment, stored outside code; rotate on exposure; verify before parsing or logging; deduplicate on the event id.

## What this page does not cover

- Asymmetric (public-key) webhook signatures, where the sender publishes a verification key instead of sharing a secret. Same canonical-string discipline, different primitive.
- JWT bearer tokens and OAuth flows: see [API key vs OAuth](/docs/learn/api-key-vs-oauth).
- The unsigned `webhookUrl` on async sex offender searches, which uses a token in the URL: see [async & webhooks](/docs/async-and-webhooks).
- Signing calls to this API: there is none; use the [API key](/docs/learn/api-token-example) over TLS.

## Related

- [Webhook example: a complete receiver](https://offendersearch.app/docs/learn/webhook-example)
- [Test a webhook (browser tool)](https://offendersearch.app/tools/webhook-tester)
- [API key vs OAuth client credentials](https://offendersearch.app/docs/learn/api-key-vs-oauth)
- [API token example](https://offendersearch.app/docs/learn/api-token-example)
- [API errors](https://offendersearch.app/docs/learn/api-errors)
- [FCRA webhooks & events reference](https://offendersearch.app/docs/fcra/webhooks)
- [Monitoring alert delivery](https://offendersearch.app/docs/monitoring/delivery)
- [Async jobs & webhooks (platform)](https://offendersearch.app/docs/platform/async-and-webhooks)
- [Authentication & keys](https://offendersearch.app/docs/platform/authentication)
- [Public API for testing (the sandbox)](https://offendersearch.app/sandbox)
- [Background check API](https://offendersearch.app/background-check-api)
- [Learn APIs by example](https://offendersearch.app/docs/learn)
