> ## Documentation Index
> Fetch the complete documentation index at: https://v2.docs.conduit.financial/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook signature verifier

> How to verify Conduit webhook signatures locally using Node.js or Python

## How webhook signing works

Every webhook delivery includes an `X-Conduit-Signature` header in the format:

```
t=<unix-timestamp>,v1=<hex-hmac>[,v1=<hex-hmac>]
```

Where each `v1` is `HMAC-SHA256(<unix-timestamp>.<raw-body>, secret)`, computed over the raw request bytes before any JSON parsing. The signing secret includes the `whsec_` prefix; pass it verbatim, never strip it.

A delivery normally carries one `v1`. While you are rotating the endpoint's signing secret, it carries **two** `v1` values for a grace period — one signed with your new secret and one with the previous one — so deliveries keep verifying while you roll your secret over.

To verify:

1. Parse `t` and every `v1` from the `X-Conduit-Signature` header.
2. Re-compute HMAC-SHA256 of `<t>.<raw-request-body>` using your endpoint secret (full `whsec_...` string as the key).
3. Constant-time compare the result against each `v1`; accept if it matches **any** of them.
4. Reject signatures where `t` is older than 300 seconds to protect against replay.

See the [webhooks reference](/webhooks) for the full signing-and-verification protocol.

<Warning>
  Do not paste production endpoint secrets into any hosted page. Use a sandbox
  endpoint secret or a dummy value when testing verification locally.
</Warning>

## Verify locally

Use either snippet below. Replace the three constants with your actual values.

```js Node.js / Bun theme={null}
const crypto = require('crypto');

const SECRET = 'whsec_...';               // full string, do not strip the prefix
const SIGNATURE_HEADER = 't=1736000000,v1=...';
const RAW_BODY = '{"type":"transaction.completed",...}';

const pairs = SIGNATURE_HEADER.split(',').map(p => p.split('='));
const t = pairs.find(([k]) => k === 't')?.[1];
// A delivery carries more than one v1 while you rotate the signing secret;
// accept if your secret matches any of them.
const signatures = pairs.filter(([k]) => k === 'v1').map(([, v]) => v);

// Reject replays older than 300 seconds.
const tNum = parseInt(t, 10);
if (Math.abs(Math.floor(Date.now() / 1000) - tNum) > 300) {
  console.log('invalid — replay window exceeded');
  process.exit(1);
}

const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${RAW_BODY}`).digest('hex');
// Constant-time comparison to avoid timing side-channels.
const expectedBuf = Buffer.from(expected, 'hex');
const valid = signatures.some(v1 => {
  const v1Buf = Buffer.from(v1, 'hex');
  return expectedBuf.length === v1Buf.length && crypto.timingSafeEqual(expectedBuf, v1Buf);
});
console.log(valid ? 'valid' : 'invalid — expected: ' + expected);
```

```python Python 3 theme={null}
import hmac
import hashlib
import time

SECRET = "whsec_..."               # full string, do not strip the prefix
SIGNATURE_HEADER = "t=1736000000,v1=..."
RAW_BODY = '{"type":"transaction.completed",...}'

pairs = [p.split("=", 1) for p in SIGNATURE_HEADER.split(",")]
t = next((v for k, v in pairs if k == "t"), None)
# A delivery carries more than one v1 while you rotate the signing secret;
# accept if your secret matches any of them.
signatures = [v for k, v in pairs if k == "v1"]

# Reject replays older than 300 seconds.
if abs(int(time.time()) - int(t)) > 300:
    print("invalid — replay window exceeded")
    raise SystemExit(1)

expected = hmac.new(SECRET.encode(), f"{t}.{RAW_BODY}".encode(), hashlib.sha256).hexdigest()
valid = any(hmac.compare_digest(expected, v1) for v1 in signatures)
print("valid" if valid else f"invalid — expected: {expected}")
```

Both snippets implement constant-time comparison (`crypto.timingSafeEqual` in Node.js, `hmac.compare_digest` in Python) and enforce the 300-second replay window.

## See also

* [Webhooks reference](/webhooks) - full signature scheme and event topics
* [`failureMessage` symmetry contract](/webhooks#failuremessage-symmetry-contract)
