> For the complete documentation index, see [llms.txt](https://savefee.gitbook.io/savefee/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://savefee.gitbook.io/savefee/webhooks/verify-and-handle.md).

# Verify and handle

## The signature

```
X-SaveFee-Signature: t=<unix-seconds>,v1=<hex>
```

`v1` is an HMAC-SHA256, keyed with your `whsec_` secret, over the timestamp and the raw body **joined by a dot**:

```
v1 = HMAC_SHA256(secret, t + "." + rawBody)
```

{% hint style="warning" %}
The signed string is `` `${t}.${rawBody}` ``, not the body on its own. Signing only the body produces a digest that will never match.
{% endhint %}

## Verifying it

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SEC = 300; // SaveFee's window is 5 minutes

export function verify(rawBody, header, secret) {
  const m = /^t=(\d+),v1=([a-f0-9]+)$/.exec(header ?? '');
  if (!m) return false;

  const t = Number(m[1]);
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SEC) return false;

  const expected = createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  const got = Buffer.from(m[2], 'utf8');
  const want = Buffer.from(expected, 'utf8');

  // timingSafeEqual throws when lengths differ - check first, then compare
  // in constant time so a wrong-but-same-length digest leaks nothing.
  return got.length === want.length && timingSafeEqual(got, want);
}
```

Three details that matter:

1. **Use the raw body.** In Express, `express.json()` has already discarded it — capture it with `express.raw({ type: 'application/json' })` or the `verify` callback.
2. **Check the timestamp.** Without it, a captured delivery can be replayed indefinitely.
3. **Compare in constant time.** A plain `===` leaks timing information about the digest.

## Retries

If your endpoint does not return `2xx` within 5 seconds, SaveFee retries with an exponential backoff that starts at about one second and grows with each attempt. A delivery is attempted **up to 8 times** on production before it is given up on.

Every retry carries the **same** `X-SaveFee-Delivery-Id`, which is what makes deduplication work. `X-SaveFee-Signature` is recomputed each time, so verify it per request and never use it as an identity.

{% hint style="info" %}
Because the retry window is short, keep polling `GET /v1/orders/{orderId}` as a fallback. That way a delivery that arrives while your endpoint is restarting does not need to be chased.
{% endhint %}

## Deduplication

```javascript
const deliveryId = req.headers['x-savefee-delivery-id'];

if (await seen(deliveryId)) {
  return res.status(200).end(); // already handled - acknowledge, do nothing
}
await remember(deliveryId);
```

Store delivery ids for at least a day. Acknowledge duplicates with `2xx`: an error would only trigger more retries.

## Responding in time

Acknowledge first, work afterwards.

```javascript
res.status(200).end();      // inside the 5-second budget
queue.push(payload);        // real work happens off the request path
```

Avoid database writes, on-chain reads or third-party calls before responding.

## Receiver checklist

* [ ] Raw body captured before any JSON parsing.
* [ ] Signature verified against `` `${t}.${rawBody}` `` with your `whsec_` secret.
* [ ] Timestamp window enforced (300 seconds).
* [ ] Constant-time digest comparison.
* [ ] Event type read from the `X-SaveFee-Event` header.
* [ ] Deduplication keyed on `X-SaveFee-Delivery-Id`.
* [ ] `broadcast.failed` handled for both `failed` and `expired`.
* [ ] `2xx` returned in under 5 seconds, work queued asynchronously.
* [ ] Duplicates answered `2xx`, not an error.
* [ ] Handler is order-independent.
* [ ] Polling fallback in place.
* [ ] Failed verifications logged, and never processed.
