Outbound API — Security & Trust
This page documents, point for point, how the Crossdeck Outbound Read API protects the data it serves — so a security owner can evaluate it for a bank-grade system from this page alone. The Outbound API lets your backend pull Crossdeck's data (revenue, errors, read-cost, per-host analytics, and the cross-layer cross-match) into your own product. Like Stripe's API, it is server-to-server, secret-key only, and the security boundary is the key — not a blanket restriction on the data.
Summary for a security review
- Server-to-server only. Secret keys (
cd_sk_…); publishable keys are rejected. The browser never talks to the API. - Least privilege by key scope. Each key carries a per-resource scope map (Stripe restricted-key model). A key can read only what it was granted.
- Two tiers. Aggregate (counts/breakdowns) is the default. Row/identity-level data is a separate, harder-gated scope granted on the key at mint — never reachable by a default key.
- Fail-closed on every path; tenant, host and environment isolation by construction.
- Every read is recorded and made tamper-evident (per-call hash-chain for identity reads; daily hash-chain checkpoint for the aggregate access log).
- Leak detection via GitHub Secret Scanning with immediate revocation; rotation is grace-windowed so key hygiene never breaks a live system.
- PII-minimized: aggregates expose no individuals; the identity tier returns your own identifiers plus facts — never an email or name you didn't already hold.
The key is the boundary
A common question: why would a read API ever return individual data? The answer is the same as Stripe's. Stripe's API returns named customers, emails, and full charge histories — row-level, real-money, PII data — and is the bank-grade reference. It is safe not because the data is hidden, but because the key is the security boundary, backed by key hygiene. Crossdeck follows that model: the data a key can read is governed by the key's scope, and every Stripe-grade defense around the key is stood up (below). The customer owns their data and sees it all on the dashboard; the credential is what we scope and guard.
Two tiers: aggregate & identity
| Tier | What it returns | Gating |
|---|---|---|
| Aggregate (default) | Counts, breakdowns, metrics, per-host event segments. Never an individual. | Secret key with the relevant read scope. Near-zero compliance weight; embed freely. |
| Identity (opt-in) | The moat at row level: who an error affected and what they pay; a host's leads. Returns your own identifiers + facts. | A key minted with the reporting:identity scope — granting it requires acknowledging the row-level-data terms in the same step. The key's scope is the boundary (no separate project switch), enforced on every call. |
Restricted keys & scopes
Each secret key carries a per-resource scope map — the Stripe restricted-key model. A key is minted with exactly the scopes it needs:
scopes: {
reporting: "aggregate" | "identity" | "none",
revenue: "read" | "none",
errors: "read" | "none",
buckets: "read" | "none",
crossmatch: "read" | "none"
}
- Default mint is aggregate-only, identity off. Identity is always a conscious mint — you tick it when creating the key and acknowledge the row-level-data terms in the same step. There is no separate project-wide switch; the scope lives on the key.
- Scope is fixed at mint. Enabling anything in settings does not add a scope to a key that already exists — mint (or rotate to) a key with the scope you need.
- Each endpoint declares the minimum scope it requires. A key without it is denied
403 insufficient_scopebefore any data is read. - Mint, rotate, and revoke keys from Developers → API; choose scopes per key, so a key embedded in one system can be strictly narrower than your workspace key.
The controls, point for point
Every position below is stood up — none is deferred. The identity tier does not open with any one of them unmanned.
1. Secret-key only, server-to-server
Endpoints accept only cd_sk_… secret keys, on https://api.cross-deck.com. Publishable keys are rejected (401 secret_key_required) — they live in browsers and cannot be trusted to scope a read of private data. Call the API only from your backend.
2. Fail-closed gate
Every outbound read passes through one shared gate that authenticates, resolves the project from the key, validates scope and resource ownership, rate-limits, and records the read — before the endpoint runs. Any error denies; partial data is never served. One gate, not four, so there is one place to reason about and audit.
3. Tenant, host & environment isolation
- Tenant: the project is derived from the key. You never pass a project id; there is no path by which one project's key reads another's data.
- Host: per-host endpoints validate the
hostagainst your project's owned origins, and return403(not404) for a host you don't own — so the API never confirms or denies the existence of someone else's host (enumeration guard). - Environment: a key is scoped to
productionorsandbox; a mismatched?envis a hard403. Production and sandbox never cross.
4. Rate limits & enumeration guard
~2 requests/second sustained, 120 burst, per key, returning 429 with Retry-After. Generous because every call is an O(range) ledger point-read — capped to make scraping and enumeration uneconomical. Every Prism read shares this per-key limit; identity-tier reads carry the additional control of a per-call hash-chained audit (above).
5. Tamper-evident audit
Every outbound read is recorded to a per-project, append-only access log (key id, product, scope, env, decision, timestamp). Integrity is proven two ways:
- Identity-tier reads (the crown jewels) are written to a hash-chained audit journal per call — each entry links to the prior by hash, so any edit or deletion is detectable.
- The high-volume aggregate access log is checkpointed daily: a worker folds each day's entries into the same hash chain as one signed batch hash. Altering a past row would require forging that day's checkpoint and every later chain entry. We deliberately do not hash-chain every aggregate read inline — that would serialize the whole API through one write path (a self-inflicted cost bottleneck); the daily checkpoint gives the same tamper-evidence without it.
The chain is verifiable end-to-end (genesis → head) at any time.
6. Leak detection & revocation
Crossdeck participates in GitHub's Secret Scanning Partner Program: GitHub scans public pushes for the cd_sk_ pattern and notifies Crossdeck of a match. A confirmed public-repo leak is a real breach, so the key is revoked immediately — and a loud, multi-channel alert fires so you rotate at once. Revocation here is only ever on a confirmed partner-program match, never a heuristic guess; and you can revoke any key yourself, instantly, from the dashboard.
7. Rotation never breaks a live system
There is no blind-timer auto-rotation. When you rotate a key, the old key keeps working for a 7-day grace window while you roll the new one out — so a hygiene rotation never causes downtime in the wild. A rotated key keeps its scopes verbatim (an identity key stays an identity key). Immediate kill is a separate, deliberate action (or the confirmed-leak case above).
8. PII posture
- Aggregates expose no individuals — counts and totals only.
- The identity tier is PII-minimized: it returns your own identifier for the user (the id your SDK sent) plus facts (what they pay, what they cost, what they selected) — never an email or legal name you didn't already provide. The value is the join, not the contact detail.
- Group-by is aggregate-only and self-protecting: you can group counts by any property your events carry, but the result is always a count, never a person. Money- and PII-shaped property names (
amount,email, secrets, …) are hard-locked and can never be a group-by dimension; PII-shaped values and runaway-cardinality properties are dropped automatically. - Event payloads are PII-scrubbed at ingest against a defence-in-depth pattern set (emails, card numbers, secrets, tokens).
Error codes
| HTTP | code | When |
|---|---|---|
401 | secret_key_required / invalid_api_key | missing, non-secret, or unknown key |
403 | insufficient_scope | the key wasn't minted with the scope the endpoint requires (e.g. an identity endpoint called with an aggregate key) |
403 | origin_not_allowed | the host is not owned by your project |
403 | env_mismatch | ?env doesn't match the key's environment |
429 | rate_limited | over the rate limit (see Retry-After) |
Compliance & data roles
For data your app sends to Crossdeck, you are the data controller and Crossdeck is the processor (UK/EU GDPR, POPIA). The Outbound API returns your own data back to your own backend; the identity tier surfaces row-level data only to you, the controller, behind a scoped key — the same posture as a payment processor returning your customers' records to you. Aggregates carry no individual data. All reads are logged and tamper-evident (above). To erase a user end-to-end, see Delete a user's data.
Related
- Reporting API — the endpoints, request params, and responses.
- API keys — issuing, scoping, and rotating
cd_sk_keys. - Delete a user's data — erasure across every layer.