Docs

Authentication & scopes

AdminUpdated Sep 15, 2026

Authentication & scopes

Every request to the Developer API (https://api.cookiemunch.net/v1/*) is authenticated with an API key. There is no orgId parameter anywhere on this surface — the org is derived from the key itself (tenancy.resolveApiKeyAuth), so a key can never be pointed at the wrong org, accidentally or otherwise.

Key format

Keys are generated as fck_ followed by 48 hex characters (24 random bytes):

fck_8f2a91c0b3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2

Only a SHA-256 hash of the key is stored server-side (hashApiKey) — the raw key is never persisted and is shown to you exactly once, at creation. The prefix (the first 8 characters, e.g. fck_8f2a91c0) is stored in the clear as display metadata so you can tell keys apart in a list without ever seeing the secret again.

Getting a key

Keys are issued from Settings → API keys in the dashboard (POST /api/v1/keys, session-authenticated, requires the manage_members action — admin or owner role — and accepts an optional name, expiresInDays, and scopes). That's the only way to mint a scoped key.

There's also a self-serve bootstrap route on the Developer API itself, POST /v1/keys, authenticated by an existing key rather than a session:

curl -X POST https://api.cookiemunch.net/v1/keys \
  -H "Authorization: Bearer fck_existingkey…"
{ "key": "fck_8f2a91c0b3d4e5f6...", "prefix": "fck_8f2a91c0" }

This route is admin-only (see below — it lives under /v1/keys) and, notably, does not accept a scopes field: every key it issues is an unscoped (legacy full-access) key, regardless of the scopes on the key that called it. If you want a narrowly-scoped key, you have to create it from the dashboard's POST /api/v1/keys, not from /v1.

Sending the key

Two equivalent forms are accepted on every request — pick whichever your HTTP client makes easiest:

# Bearer token
curl https://api.cookiemunch.net/v1/me -H "Authorization: Bearer fck_…"

# ...or the X-API-Key header (checked first if both are present)
curl https://api.cookiemunch.net/v1/me -H "X-API-Key: fck_…"
{ "orgId": "org_9k2m", "plan": "pro", "keyPrefix": "fck_8f2a91c0" }

GET /v1/me needs no scope and is the cheapest way to confirm a key works and see which org it resolves to before wiring up real calls.

Scopes

A key is either unscoped (scopes: null — every legacy key, and every key minted via POST /v1/keys) or scoped to an explicit allow-list of resource:action strings. An unscoped key has full access to everything below; a scoped key can only touch what its list grants. The complete, current catalog (API_SCOPES in packages/saas/src/tenancy.ts) is exactly these 11 values — there is no wildcard and no *:*:

Scope

Grants

sites:read

List/get sites, read a site's config, cookies feed, scan status, A/B results, install snippet; list brand kits.

sites:write

Create/delete a site, verify a domain, brand extraction, save config, run v2 flow ops, toggle ad-personalization elements, trigger a scan; create/delete brand kits.

consent:read

Consent log, stats, CSV export, and chain-verification for a site; a subject's records via subject-export; cross-site subject lookup (GET /v1/subjects/:id/consent); list preference-center records.

consent:write

Crypto-erase a subject (erase-consent); save a preference-center record.

dsar:read

List DSARs.

dsar:write

Create a DSAR; advance a DSAR's status.

vendors:read

List vendors (with computed risk scores).

vendors:write

Add a vendor.

ropa:read

List RoPA (Records of Processing Activities) entries.

ropa:write

Add a RoPA entry.

receipt:read

Fetch one subject's signed consent receipt by stamp (GET /v1/sites/:cbid/receipt/:stamp) — nothing else. Deliberately narrower than consent:read, which is also accepted here (it's a superset), so an integration that only renders receipts doesn't need a key that can export the whole consent log.

Issuing a scoped key from the dashboard API:

curl -X POST https://api.cookiemunch.net/api/v1/keys \
  -H "Authorization: Bearer <session token>" \
  -H "Content-Type: application/json" \
  -d '{ "orgId": "org_9k2m", "name": "nightly-export-job", "scopes": ["consent:read"] }'

A request whose key lacks the required scope gets a 403:

{ "error": "insufficient_scope", "message": "missing required scope: consent:write" }

A gap worth knowing about: /v1/banners has no grantable scope

The account-level banner library (GET/POST /v1/banners, etc.) is scope-checked like everything else — dev-api-auth.ts's resource→scope map requires banners:read (GET) or banners:write (write) to reach it. But banners:read/banners:write are not members of API_SCOPES (see the catalog above — there is no banners:* entry), and areApiScopes() rejects any attempt to save a scope that isn't in that list. The practical effect: no scoped key can ever be issued that satisfies the banner-library check — the dashboard will refuse to save ["banners:read"] on a key, so /v1/banners/* is reachable only with an unscoped (legacy full-access) key today. If you're building an integration that only needs the banner library, be aware you currently have to hold a full-access key for it; treat any docs or key-creation UI that imply a working banners:* scope as aspirational until the scope is added to API_SCOPES.

Admin-only surfaces

Regardless of scope, three resources are only reachable with an unscoped key — there is no scope that grants them, by design, because they're account-administration operations (inviting members, minting other API keys, subscribing webhooks), not data access:

{ "error": "insufficient_scope", "message": "this key may not use admin endpoints; manage them in the dashboard" }

This applies to every route under /v1/members, /v1/keys, and /v1/webhooks. GET /v1/me and GET /v1/usage are the opposite case — they require no scope and work with any key, scoped or not.

Error shapes

Status

Body

When

401

{ "error": "API key required" }

No Authorization/X-API-Key header present.

401

{ "error": "invalid API key" }

The key doesn't hash-match any stored key, or it's past its expiresAt.

403

{ "error": "insufficient_scope", "message": "..." }

A scoped key hit a resource it isn't scoped for, or an admin-only surface.

CORS and the one public route

/v1/* responses always carry permissive CORS headers by default (Access-Control-Allow-Origin: *, since these are server-to-server credentials, not cookie-based — there's nothing to leak cross-origin), unless the deployment sets CORS_ALLOW_ORIGINS, in which case only listed origins are echoed back. The one unauthenticated route on this surface is GET /v1/openapi.json — see OpenAPI.

Next: Conventions & errors for the shared request/response shape, or Sites for the first real resource.

Was this page helpful?