Host-to-Host

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.

javascript
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();
Rotating your webhook secret has a 24-hour grace window. During it every delivery is signed with both secrets — 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

HSTSmax-age 2 years, includeSubDomains, preload
Outbound webhooksHTTPS only; the URL is checked against private ranges on registration
IP family pinningPer subscription: auto | ipv4 | ipv6
EgressA single static IP per tenant — ask us for it to allowlist

Webhook 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.

ControlState
IP allowlisting on API keysNot available. A leaked secret key works from any address — rotate immediately if one is exposed.
Per-day / per-month velocity limitsNot available. Limits are per transaction (min/max per method).
Access-token revocationA live token cannot be revoked before it expires (1 hour). Rotating the key stops new tokens being minted.
Refund executed by usA refund opens a dispute; the money is returned by the provider on the original rail, not by us.