# Outbound API — Security & Trust

> Source: https://cross-deck.com/docs/outbound-api-security/ · Crossdeck developer docs (generated from the published page).

Security
For your security review
·
Built Stripe-grade

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_scope` before 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 `host` against your project's owned origins, and return `403` (not `404`) 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 `production` or `sandbox`; a mismatched `?env` is a hard `403`. 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](https://cross-deck.com/docs/delete-a-users-data/index.html).

## Related

- [Reporting API](https://cross-deck.com/docs/reporting-api/index.html) — the endpoints, request params, and responses.

- [API keys](https://cross-deck.com/docs/api-keys/index.html) — issuing, scoping, and rotating `cd_sk_` keys.

- [Delete a user's data](https://cross-deck.com/docs/delete-a-users-data/index.html) — erasure across every layer.
