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

# HMAC authentication

> Signing server-to-server requests and verifying webhook callbacks.

Every call your backend makes to `/integrations/*` must carry:

| Header           | Value                                             |
| ---------------- | ------------------------------------------------- |
| `x-partner-slug` | Your slug, e.g. `acme`.                           |
| `x-signature`    | `t=<unix-seconds>,v1=<hex-hmac-sha256>`           |
| `content-type`   | `application/json` (omit for empty-body methods). |

The **same** signature scheme protects our outbound webhooks in the other
direction (see [Webhook delivery](/webhooks/delivery)).

## Signing algorithm

```
signedPayload = `${timestamp}.${rawBody}`
v1            = hex(hmac_sha256(secret, signedPayload))
header        = `t=${timestamp},v1=${v1}`
```

* `timestamp` is **integer seconds** since the Unix epoch, captured at the
  moment of signing. Our tolerance is **±5 minutes** (300 seconds).
* `rawBody` is the **exact bytes** of the request body. For methods with no
  body (DELETE), sign the empty string — e.g. `${timestamp}.`
* Use a constant-time comparator when validating our outbound webhooks.

## Rotation

The header parser accepts **multiple** `v1=` values:

```
x-signature: t=1747084800,v1=abcd...,v1=ef01...
```

If your client signs with two secrets during a rotation window, we'll accept
the request as long as *either* signature matches. Same applies to our
outbound webhooks — when we rotate, you should accept the union of secrets
you have on file for that window.

## Example

<CodeGroup>
  ```ts node theme={null}
  import { createHmac } from 'node:crypto';

  function sign(secret: string, body: string): string {
    const t = Math.floor(Date.now() / 1000);
    const v1 = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
    return `t=${t},v1=${v1}`;
  }

  const body = JSON.stringify({ externalUserId: 'usr_123', email: 'a@b.com' });
  const sig = sign(process.env.PARTNER_HMAC_SECRET!, body);

  await fetch('https://voucher.example.com/integrations/users', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-partner-slug': 'acme',
      'x-signature': sig,
    },
    body,
  });
  ```

  ```sh curl + openssl theme={null}
  SECRET='your-shared-secret'
  BODY='{"externalUserId":"usr_123","email":"alice@example.com"}'
  T=$(date +%s)
  V1=$(printf '%s' "${T}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)

  curl -sS -X POST http://localhost:4000/integrations/users \
    -H "content-type: application/json" \
    -H "x-partner-slug: acme" \
    -H "x-signature: t=${T},v1=${V1}" \
    -d "$BODY"
  ```

  ```python python theme={null}
  import hashlib, hmac, time, json, requests

  SECRET = b'your-shared-secret'
  body = json.dumps({'externalUserId': 'usr_123', 'email': 'a@b.com'})
  t = int(time.time())
  v1 = hmac.new(SECRET, f'{t}.{body}'.encode(), hashlib.sha256).hexdigest()

  resp = requests.post(
      'http://localhost:4000/integrations/users',
      headers={
          'content-type': 'application/json',
          'x-partner-slug': 'acme',
          'x-signature': f't={t},v1={v1}',
      },
      data=body,
  )
  ```
</CodeGroup>

## Common signing mistakes

<AccordionGroup>
  <Accordion title="Signing the parsed/serialized body instead of the raw bytes">
    If you `JSON.parse()` then `JSON.stringify()` the body before signing, the
    bytes can differ (key order, whitespace) and the signature won't match
    what arrives over the wire. Sign the **exact string** you'll send.
  </Accordion>

  <Accordion title="Wrong timestamp scale">
    `t` is **seconds**, not milliseconds. `Math.floor(Date.now() / 1000)` is
    correct; `Date.now()` alone will fail the ±5 minute tolerance.
  </Accordion>

  <Accordion title="Stripping the leading dot for empty bodies">
    For DELETE / empty-body requests, the signed payload is `${timestamp}.` —
    don't omit the trailing dot, otherwise our verifier and yours will
    compute different hashes.
  </Accordion>

  <Accordion title="Hex case mismatch">
    The hex output is conventionally lowercase. Some libraries default to
    uppercase. We normalize, but it's a useful thing to check first if you
    see `invalid_signature` errors.
  </Accordion>
</AccordionGroup>
