Integration security
Credentials, isolation, signatures and transport — with the honest state of each control.
This section is written for the person who has to sign off on the integration. Every control below is one you can verify from the outside.
Credentials
Two kinds, and only one moves money
Each shop holds four credentials: a publishable and a secret key, in sandbox and in production. Only secret keys can create charges, refund, read buyer data or send payouts. A publishable key on any of those returns 403 key_kind_not_allowed. The only endpoint that accepts either is GET /ping, which exists so you can validate a credential and has no side effects.
At rest
Keys are stored encrypted with AES-256-GCM, keyed from a master key held in the environment, never in the database. Secrets are truncated in logs (first 10 characters and last 4) and never written to an error record.
Tokens
The bearer token is HS256, signed with a key derived specifically for this purpose. Verification recomputes the HMAC unconditionally and never reads the alg header, so algorithm-confusion and alg:none are not reachable. Comparison is constant-time.
Isolation
Environment
The key decides the environment, and the seal is hard in both directions: a sk_test_ against a shop in production is refused with 403 environment_mismatch, and vice versa. There is no header, flag or body field that overrides it. Twenty-four tables carry the environment as a partition column.
Tenant and shop
A shop-scoped key cannot read a sibling shop's transaction. Fetching one returns 404, not 403 — deliberately, so the response cannot be used to confirm that a record exists under another shop. A merchantId in the request body is only honoured when it matches the authenticated one; it can never widen scope.
Webhook signatures
Every delivery carries X-Key2Pay-Signature: t=<unix>,v1=<hex>, where the MAC is HMAC-SHA256 over <timestamp>.<raw body>. The timestamp is inside the MAC, so it cannot be altered without invalidating the signature.
const [, ts, sig] = /t=(\d+),v1=([0-9a-f]+)/.exec(header);
// 1. Reject anything older than 5 minutes — this is your replay defence.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return reject();
// 2. Recompute over the RAW body. Parsing first and re-serialising
// changes the bytes and the signature will never match.
const expected = crypto.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`).digest("hex");
// 3. Constant-time compare.
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return reject();v1= with the new one and v0= with the previous — so you can deploy the new secret without a coordinated cutover and without dropping a single event.Transport
max-age 2 years, includeSubDomains, preloadHTTPS only; the URL is checked against private ranges on registrationPer subscription: auto | ipv4 | ipv6A single static IP per tenant — ask us for it to allowlistWebhook URLs are resolved when you register them and refused if they point at a private, loopback, link-local or cloud-metadata range.
What we don't offer yet
Stating this plainly is more useful to your security review than discovering it during certification.
| Control | State |
|---|---|
| IP allowlisting on API keys | Not available. A leaked secret key works from any address — rotate immediately if one is exposed. |
| Per-day / per-month velocity limits | Not available. Limits are per transaction (min/max per method). |
| Access-token revocation | A live token cannot be revoked before it expires (1 hour). Rotating the key stops new tokens being minted. |
| Refund executed by us | A refund opens a dispute; the money is returned by the provider on the original rail, not by us. |