HTTP API
A narrow HTTP API under /api/v1 covering lead intake, sends, campaigns, suppression, billing, and reseller/customer surfaces. Authenticated with a single per-tenant pk_live_* API key or a Supabase SSR session.
On this page
The API surface is the same set of capabilities the dashboard and the MCP server expose, reachable over HTTP. The full, current contract is generated (not hand-maintained) from a route table in lib/openapi/routes.ts and served live at GET /api/v1/openapi.json, with a rendered version at /api/v1/docs — that is the authoritative endpoint list; the sample below is illustrative, not exhaustive.
Endpoint groups
Routes are organised into OpenAPI tags (lib/openapi/spec.ts). The live groups today are: health, leads, sends, suppressions, dispatch, campaigns, live (ETag/304 dashboard polling), media (internal-only image generation, not documented publicly), tenants, billing, customers (end-customers of a reselling tenant), reseller (fleet-wide consumption + Monopea brain proxies), api-keys, unsubscribe, and webhooks.
GET /api/v1/health public
GET /api/v1/health/email either
POST /api/v1/leads bearer|session, member+
GET /api/v1/leads bearer|session, viewer+
GET /api/v1/sends bearer|session, viewer+
POST /api/v1/campaigns bearer|session, member+
POST /api/v1/campaigns/{id}/run bearer|session, member+
GET /api/v1/suppressions bearer|session, viewer+
POST /api/v1/suppressions bearer|session, member+
GET /api/v1/keys bearer|session, admin+
POST /api/v1/keys bearer|session, admin+
GET /api/v1/webhooks bearer|session, viewer+
POST /api/v1/webhooks bearer|session, admin+
GET /api/v1/billing/usage
GET /api/v1/billing/subscription
POST /api/v1/billing/checkout
POST /api/v1/billing/portal
POST /api/v1/unsubscribe public (HMAC-token-gated)A few routes accept ONLY a Supabase SSR session, never an API key — notably /api/v1/tenants and /api/v1/tenants/{id}/members — because they manage the account's own membership graph rather than tenant-scoped data. Each operation in the generated spec states its minimum role explicitly.
Authentication
There is exactly one API key format: pk_live_<32 random base32 characters> (lib/auth/apiKey.ts). The literal pk_live_ prefix is deliberate — it makes a leaked key grep-able in logs without exposing the secret. The first 8 characters of the random portion are persisted as api_keys.prefix (the indexed lookup key); the full key is hashed at rest with argon2id (memoryCost 19,456 KiB, timeCost 2, parallelism 1 — OWASP's password-storage minimums). Only the last 4 characters are ever shown again after creation, in the dashboard.
Every key carries a role — owner > admin > member > viewer (lib/auth/roles.ts) — resolved on lookup and checked per-route via withRole(minimum). Minting a key enforces a no-escalation rule: the caller can request any role up to and including their own (a viewer-role caller can only mint a viewer key; only an admin+ caller can mint an admin key), and GET/POST /api/v1/keys themselves require admin at minimum regardless of what role is being minted, since key metadata is blast-radius material. A key can also be scoped to one end-customer of a reselling tenant (customer_id set); such a "customer sub-key" is default-denied on every ordinary /v1 route and only reaches the narrow customer-scoped surfaces (usage/credits/topup) that explicitly opt into that frame.
Errors, pagination, and idempotency
Every error response shares one envelope: { error: "<stable_code>", request_id, ...extra } (lib/api-errors.ts) — error is always a machine-stable string a client can switch on, and request_id echoes the X-Request-Id also stamped on the response header, for correlating a failed call to server logs. List endpoints (leads, sends, suppressions, campaigns, webhooks, keys) are cursor-paginated: pass ?limit=&cursor=, and read next_cursor from the response to fetch the next page — there is no page-number pagination. Any write route can be called with an Idempotency-Key header; repeating the same key replays the original response instead of acting twice (lib/api-idempotency.ts).
Rate limits
Writes are rate-limited on two independent axes — per source IP and per API key — so neither a shared IP nor a single leaked credential can exceed a sane ceiling. Concrete examples wired today: leads writes at 60/min per IP + 300/min per key; suppressions writes at 60/min per IP + 300/min per key; image generation at 30/min per IP + 20/min per key; a campaign run at 30/min per key (each run can fan out up to 500 real sends, so this bounds the blast radius of a leaked key); sends reads at 120/min per key; the mailto unsubscribe webhook at 20/min per IP.
POST /api/v1/leads
Authorization: Bearer pk_live_ABCDEFGH...
{ "company_domain": "acme.com", "company_name": "Acme Corp",
"target_product": "cognilead", "best_contact_email": "cto@acme.com",
"jurisdiction": "US" }
409 Conflict
{ "error": "recent_pitch", "request_id": "req_..." }Lead enrichment (paid add-on)
POST /api/v1/leads accepts enrich: true, which buys the "why now" fact from an enrichment vendor before personalising. It runs AFTER the intersect gate, so a suppressed, unverifiable or duplicate lead never costs a lookup, and it is billed per enriched lead — but only when the vendor returns a fact with a source URL behind it. A miss, a vendor error, or an unsourced guess is reported on the response's enrichment field and costs nothing. If no enrichment provider is configured on the deployment, the route returns 503 enrichment_unavailable before creating the lead rather than silently sending an un-enriched pitch.