Skip to main content
Every call your backend makes to /integrations/* must carry:
HeaderValue
x-partner-slugYour slug, e.g. acme.
x-signaturet=<unix-seconds>,v1=<hex-hmac-sha256>
content-typeapplication/json (omit for empty-body methods).
The same signature scheme protects our outbound webhooks in the other direction (see Webhook 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

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,
});

Common signing mistakes

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.
t is seconds, not milliseconds. Math.floor(Date.now() / 1000) is correct; Date.now() alone will fail the ±5 minute tolerance.
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.
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.