Key lifecycle & security
What you hold
| Credential | Format | Sensitivity |
|---|---|---|
| Key ID | pk_<org>_test_<hex> (sandbox) / pk_<org>_live_<hex> (production) | Identifies you; safe to log |
| HMAC secret | Opaque string, shown once when the key is issued | Proves it is you. Signs every request you send and verifies every webhook we send |
Each environment has its own key. Nothing carries over from sandbox to production — the live key, callback URL, payment artefacts and IP allowlist are all configured again at go-live (onboarding).
Storage rules
- The HMAC secret goes in your secrets manager only — never in code, config repos, logs, or client-side apps. It is shown exactly once at issuance; if you lose it, request a rotation.
- The key ID is public-safe (it identifies; the signature proves).
- Server-side only: signing must never happen in a browser or mobile app you ship to end users.
- Your webhook receiver needs the same secret. Store it once, reference it from both places, and make the receiver's verifier accept a list of secrets (see rotation below).
Rotation (zero-downtime)
Rotate for scheduled hygiene or on suspected exposure. The exact semantics matter, so here they are in order:
- Before you ask for a rotation, make sure your webhook receiver can verify a delivery against more than one secret (try the current secret, then the new one). This is the step teams skip, and it is the only step that can cost you deliveries.
- Request the rotation (write to [email protected]). We issue a new secret, shown once.
- From that moment the new secret is the primary one: webhooks are signed with the new secret immediately. Requests you send are accepted with either the old or the new secret.
- Deploy the new secret to your signer at your own pace, then confirm.
- We retire the old secret. Requests still signed with it now fail as
401 SIGNATURE_INVALID— and count toward the lockout below — so finish step 4 before you confirm.
If step 1 is done, downtime is zero. If it is not, every webhook delivered between step 3 and your receiver picking up the new secret will fail verification on your side — they are retried on the normal ladder and remain replayable, so nothing is lost, but you will be chasing a backlog.
const crypto = require("node:crypto");
const SECRETS = [process.env.FP_SECRET_CURRENT, process.env.FP_SECRET_NEXT].filter(Boolean);
/**
* callbackPath — your callback URL's pathname only (no query string)
* ts — the X-Fieldproof-Timestamp header value, verbatim
* rawBody — the request body exactly as received (string or Buffer), NOT a re-serialised object
* receivedSig — the X-Fieldproof-Signature header value
*/
function verify(callbackPath, ts, rawBody, receivedSig) {
const canonical = Buffer.concat([Buffer.from(`POST\n${callbackPath}\n${ts}\n`, "utf8"), Buffer.from(rawBody)]);
const received = Buffer.from(String(receivedSig || "").toLowerCase());
return SECRETS.some((secret) => {
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(canonical).digest("hex"));
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
});
}
Revocation
If a key is compromised, revocation takes effect within 60 seconds of the
request (that is the key cache TTL). Every call with that key then fails with
401 UNAUTHORIZED, so your integration halts until a replacement is issued.
Prefer rotation unless the secret is definitely exposed.
Automatic protections on our side
| Protection | Behaviour |
|---|---|
| Signature-failure lockout | 10 invalid signatures within 300 s → the key is locked for 900 s. Every request during the lock — including correctly signed ones — gets 429 AUTH_LOCKED with Retry-After set to the seconds remaining. A leaked key ID without its secret is useless and self-limiting. |
| Environment locking | Test keys are rejected on production and live keys on the sandbox (401 UNAUTHORIZED) — a misconfiguration fails loudly, immediately. |
| Replay window | Timestamps more than 5 minutes from server time are rejected (401 TIMESTAMP_SKEW). |
| IP allowlist (optional) | Set at onboarding, per environment. Entries are plain IPv4 addresses or CIDR blocks. We resolve your source address from CF-Connecting-IP (or the last hop of X-Forwarded-For); a request from anywhere else is 401 UNAUTHORIZED. |
| Rate limiting | 60 requests/min per key (429 RATE_LIMITED, Retry-After set); POST /cases additionally 10 batches/hour, dry-runs and idempotent replays included. |
| Request tracing | Every response carries X-Request-Id. Quote it when you contact [email protected]. |
:::warning We do not page your on-call
The platform does not send alerts to your technical contact — not for a
lockout, not for exhausted webhook deliveries, not for a tripped circuit
breaker. Our operations team is alerted internally. On your side, treat
429 AUTH_LOCKED in your client as an incident (stop and back off for
Retry-After seconds — a correct signature does not unlock early), and monitor
delivery health yourself with GET /api/v1/partner/webhooks/deliveries and
GET /api/v1/partner/health.
:::
Allowlisting our traffic on your side
Your callback URL (and, for the org-gateway payment mode, your mint URL) must
be https on a public IP; we do not follow redirects. We do not publish
static egress IPs, so do not build a firewall rule that assumes them — verify
our webhooks by signature instead. If your security policy requires egress
IPs, contact [email protected] before go-live.