Core concepts
Authentication
Every request carries four headers and an HMAC signature over the raw body. This is the part integrations get wrong most often, so it is worth reading once, carefully.
Connections and secrets
Every merchant × store pair is one connection in Perx, and every connection has its own secret — a 64-character hex string. The merchant creates it in their dashboard, copies it, and pastes it into your product alongside their store ID.
A merchant with three outlets creates three connections, so Perx can attribute each sale to the right outlet for branch-scoped rewards. Your settings UI must therefore be per store. If you build a single global settings screen, multi-outlet merchants cannot use your integration correctly.
- Secrets are rotatable from the Perx dashboard. Your settings screen must accept a new secret without redoing setup.
- Treat the secret as a credential: encrypt at rest, never log it, never expose it to a browser.
The four headers
| Header | Value |
|---|---|
X-Perx-Vendor | Your vendor slug, assigned by Perx at onboarding, e.g. acmepos. Lowercase and permanent. |
X-Perx-Store | The store identifier on your side, exactly as the merchant entered it in Perx. This is what routes the request to the right merchant and outlet. |
X-Perx-Timestamp | Unix epoch seconds at the moment of signing, as a decimal string. Not milliseconds. |
X-Perx-Signature | v1= followed by the base64 signature below. |
Computing the signature
The signature covers the timestamp and the raw body, joined by a single ASCII period. Binding the timestamp into the signature is what makes a captured request un-replayable.
signingString = X-Perx-Timestamp + "." + rawRequestBody
signature = "v1=" + base64( HMAC_SHA256( signingString, connectionSecret ) )Serialising to JSON, signing the result, then re-serialising to send produces a different byte sequence — different key order, different whitespace, different unicode escaping — and the signature will fail. Serialise once into a buffer or string, sign that, send that. This single mistake accounts for most first-week 401s.
Reference implementations
const crypto = require('crypto');
function signedRequest(payload, vendorSlug, storeId, secret) {
// Serialise ONCE. Sign this buffer, send this buffer.
const raw = Buffer.from(JSON.stringify(payload), 'utf8');
const ts = Math.floor(Date.now() / 1000).toString();
const sig = crypto.createHmac('sha256', secret)
.update(ts + '.')
.update(raw)
.digest('base64');
return {
headers: {
'Content-Type': 'application/json',
'X-Perx-Vendor': vendorSlug,
'X-Perx-Store': storeId,
'X-Perx-Timestamp': ts,
'X-Perx-Signature': 'v1=' + sig,
},
body: raw,
};
}The replay window
Perx rejects any request whose X-Perx-Timestamp is more than 300 seconds from server time, in either direction. Keep your servers on NTP.
This has a consequence for queued deliveries: if a webhook sits in your retry queue for an hour, you must re-sign it with a fresh timestamp before sending, not replay the original headers. The event’s own occurredAt stays as it was — backdated events are accepted and earn correctly.
Transport and data handling
- TLS 1.2 or higher. Perx does not accept plaintext HTTP.
- Send only the customer identifier you already hold. Do not send addresses, notes, or any payment instrument data — there is no field for it and unknown properties are rejected.
- The payload limit is 1 MB, enforced at the gateway. A larger body is truncated and then fails parsing as a
400.
Debugging a 401
In order of likelihood: you re-serialised the body after signing; you used milliseconds instead of seconds; your clock has drifted; you signed the body without the timestamp + "." prefix; you omitted the v1= prefix; the merchant rotated their secret.
The signature playground takes your secret, body and timestamp and shows the exact expected value, so you can diff it against what your code produced.