Reference
Backend API reference
Every endpoint: method, path, auth, request, response.
docs/API.md
The contract between @verjson/web and @verjson/api. Change a shape here in the same commit
you change the code — and in the same commit you change
packages/contracts/src/index.ts, which is the executable
version of this document.
Conventions
| Base URL | NEXT_PUBLIC_API_URL — default http://localhost:4000/api/v1. The API is a different origin from the web app, so CORS applies and CORS_ORIGINS must list the web origin. |
| Roles | internal in the table below means owner, admin or member. agency and client are project-scoped, and read-only except for content proposals — they may compose a post and suggest a date, never schedule or publish one (F-110, see PERMISSIONS.md). |
| Auth | Authorization: Bearer <access JWT> (HS256; sub = userId, type = access|refresh, plus org and role claims). The client auto-refreshes once on a 401 — see apps/web/src/lib/api.ts. |
| Agents | Authorization: Bearer <scoped API key> on the same endpoints. Keys are hashed at rest and carry a project scope. (F-072, not yet built.) |
| JSON | snake_case in both directions. Money as integer minor units. Timestamps ISO-8601 UTC. |
| Errors | { "detail": "..." }. Validation failures are 422 with { "detail": [{ "msg": "..." }] }. |
| Rate limits | Per-IP fixed window. 429 with the same { detail } shape. |
Status codes
| Code | Means |
|---|---|
| 200 / 201 | Success |
| 401 | Missing, malformed or expired credentials |
| 403 | Authenticated but not permitted (wrong role, out of project scope) |
| 404 | Not found or out of tenant scope — deliberately indistinguishable |
| 409 | Conflict (e.g. email already registered) |
| 422 | Body failed the contract schema |
| 429 | Rate limited |
| 503 | Dependency down (health only) |
Endpoints — live
| Method | Path | Auth | Request | Response | Notes |
|---|---|---|---|---|---|
| GET | /health | none | — | { status, db, queue, worker, worker_last_seen_at } | 200 when db: "up" (503 when down); queue/worker never gate the status code — a degraded broker or worker is reported, not an outage. queue is only whether THIS process holds a broker connection; worker is whether some worker process has written a heartbeat within the last 45s (core/heartbeat.ts) — the signal that actually determines whether a crawl or queued job gets picked up. The web app polls this to show "background worker offline" instead of a crawl that hangs silently. |
| GET | /version | none | — | { commit, release } | Which build this process runs (#380). commit is the full SHA the image was built from (SOURCE_COMMIT, baked at build); release is the vX.Y.Z it was deployed as (RELEASE_VERSION, set by the deploy at runtime). Either is null when unset, as in a local or PR build. Cache-Control: no-store: the release promoter (#385) compares this with what it pinned before promoting, so a cached reply from before a roll would be a wrong answer. |
| POST | /auth/signup | none | { organization_name, full_name, email, password } | 201 { user, tokens } | Creates org + owner in one transaction. Rate-limited 10/min. |
| POST | /auth/login | none | { email, password } | { user, tokens } | Same 401 for unknown email and wrong password — no user enumeration. Rate-limited 10/min. |
| POST | /auth/refresh | none | { refresh_token } | { access_token, refresh_token, token_type } | |
| GET | /auth/me | bearer | — | user | Current session. |
| GET | /projects | bearer | — | project_detail[] | Org-scoped; an agency/client user sees only projects they are a member of. |
| POST | /projects | bearer, internal | { name, description?, website_url?, currency, timezone, channels[] } | 201 project_detail | Slug derived from the name, unique per org. |
| GET | /projects/{id} | bearer | — | project_detail | 404 when out of scope — never 403, so ids cannot be probed. |
| PATCH | /projects/{id} | bearer, internal | partial create body + status | project_detail | 409 if dropping a channel that still holds a budget. content_bucket_targets is REPLACED wholesale, not merged — a merge would make a target impossible to delete. 422 if the targets total over 100%. |
| DELETE | /projects/{id} | bearer, internal | — | 204 | Budgets and memberships cascade. |
| GET | /projects/{id}/content-mix | bearer | — | content_mix | Planned-vs-actual content distribution (F-141). One row per ContentBucket in fixed order — present even at zero — plus a trailing bucket: null row when the project has unassigned posts. Shares are over EVERY post in the project, unclassified included, so the denominator cannot flatter a project that has classified three posts out of fifty. 404 out of scope, never an empty mix. |
| GET · PUT · DELETE | /projects/{id}/slack | bearer (read) · project lead (write) | — / projectSlackUpsertSchema / — | {configured, …} / 201 / 204 | The PROJECT's Slack channel (F-142) — distinct from /user/slack, which is personal and unchanged. GET returns 200 with configured: false when none is set, never 404: "not configured" is a normal state the page renders as a Connect card, and making the client read an error as success hides the real ones. PUT sends a test card BEFORE persisting and stores verified: false on rejection rather than refusing the save. The webhook URL is write-only — reads return webhook_url_masked. Every response carries can_manage. |
| POST | /projects/{id}/slack/test | project lead | — | {result, integration} | Sends a test card to the stored hook. A successful test is also the re-enable — it clears disabled_at and the failure count, because the recovery move is "fix it in Slack, then press Send test". 422 when the stored URL can no longer be decrypted (rotated ENCRYPTION_KEY). |
| POST | /projects/{id}/slack/disable | project lead | — | project_slack_integration | Stop sending without forgetting the configuration — the reversible half of DELETE. |
| GET · POST | /projects/{id}/webhooks | bearer (read) · project lead (write) | — / projectOutboundWebhookCreateSchema | {items, can_manage} / 201 | The PROJECT's outbound webhooks. raw_signing_secret is returned exactly once, in the create response, same contract as ApiKey.raw_key; later reads carry only signing_secret_masked. endpoint_url must be https://. |
| PATCH · DELETE | /projects/{id}/webhooks/{webhookId} | project lead | projectOutboundWebhookUpdateSchema / — | project_outbound_webhook / 204 | Scoped by BOTH ids, so a hook id from another project 404s rather than being edited. disabled: false also clears consecutive_failures — a hook one failure from the threshold would otherwise switch straight back off. |
| POST | /projects/{id}/webhooks/{webhookId}/test | project lead | — | {result} | Fires a sample envelope on demand. The outcome is returned, not recorded: a deliberate test against an unfinished receiver is not a delivery failure, and counting it toward auto-disable would let someone switch their own integration off by testing it. |
| GET · PUT · DELETE | /projects/{id}/email-sender | bearer (read) · project lead (write) | — / projectEmailSenderUpsertSchema / — | {configured, status, …} / 201 / 204 | The project's broadcast sender identity (F-144) — From address, display name, optional Reply-To. Carries no SMTP fields in either direction, and the upsert schema is strictObject, so a body containing smtp_host / smtp_user / smtp_pass is a 422, not a silently-stripped 201: credentials belong to the deployment and asking for them here is an error, not a no-op. GET returns 200 with configured: false when unset. PUT mails a verification link when the address is new or changed, and clears verification whenever the address changes — proving one mailbox says nothing about another. smtp_managed: true is returned so the UI's "no credentials needed" claim comes from the server. |
| POST | /projects/{id}/email-sender/verify | project lead | — | {sent, message, sender} | Send (or re-send) the verification email. 409 if already verified. The mail goes out from the DEPLOYMENT's address, never from the address under test — being able to send as it is exactly what is unproven. |
| GET | /projects/{id}/email-sender/readiness | bearer | — | broadcast_sender_readiness | Can this project send, and if not why. Exists so the composer can disable Send and explain BEFORE a campaign is written instead of surfacing the same 422 after. |
| POST | /email-sender/verify | none | { token } | {verified, from_email, project_id} | The verification callback. Deliberately unauthenticated: the owner of a shared inbox is often not a Marketing Studio user, and requiring a session would make such an address impossible to verify. One link, one use — the token is consumed on success so a forwarded email cannot re-verify an address since changed. A bad or expired token gets the same flat 400 revealing nothing, so this cannot be used to probe which addresses are registered. |
| GET · PUT · DELETE | /projects/{id}/resend-account | bearer (read) · project lead (write) | — / projectResendAccountUpsertSchema / — | {connected, status, …} / 201 / 204 | The project's own Resend account (F-145). The API key is never returned by any route — reads carry api_key_last4 only. PUT tests the key against Resend before storing it, so a bad key is a 422 here rather than a failed broadcast hours later; it is strictObject, so extra fields are refused rather than stripped. DELETE falls back to the platform transport, which is a working state. GET returns 200 with connected: false when none — sending over Marketing Studio's transport is normal, not an error. |
| POST | /projects/{id}/resend-account/test | project lead | — | {ok, message, verified_domains, account} | Re-check a stored key and record the outcome. Distinguishes connected from no_verified_domain — the key can be perfectly valid while the account has no domain to sign with, and those have different fixes (DNS vs the key). |
| PUT | /projects/{id}/budgets | bearer, internal | { channel, period_start, period_end, amount_minor, currency?, notes? } | budget | Upsert on (project, channel, period) — replaces, never stacks. 422 if the channel is not in the mix. |
| DELETE | /projects/{id}/budgets/{budgetId} | bearer, internal | — | 204 | Scoped by project id, so a foreign budget id 404s. |
| GET | /projects/{id}/members | bearer | — | project_member[] | |
| POST | /projects/{id}/members | bearer, admin+ | { user_id, role } | 201 project_member | This is the agency scoping mechanism. 404 if the user is not in the org. |
| DELETE | /projects/{id}/members/{userId} | bearer, admin+ | — | 204 | |
| GET | /campaigns | bearer | ?project_id=… | campaign[] | Project-scoped list. |
| POST | /campaigns | bearer, internal | { project_id, name, channels[], status?, start_date?, end_date?, budget_minor? } | 201 campaign | Channels must be a subset of the project's mix. |
| GET · PATCH · DELETE | /campaigns/{id} | bearer, internal | — / partial / — | campaign / 204 | Illegal transitions (e.g. completed → live) refused 422. |
| GET | /posts | bearer | ?project_id=…&campaign_id=…&content_bucket=… | post_detail[] | Each entry includes its platform_posts[] variants + derived aggregate status. content_bucket takes one of the six buckets or the literal unassigned; an unrecognised value is 400, never ignored — a dropped filter would answer with the full list, which reads as "everything is in that bucket". |
| POST | /posts | bearer, internal | postCreateSchema (see @verjson/contracts/posts.ts) | 201 post_detail | Creates one PlatformPost per social_account_ids[]; seeds each variant's platform_extra from default_platform_extra. |
| GET · PATCH · DELETE | /posts/{id} | bearer, internal | — / partial / — | post_detail / 204 | Cascade delete removes PlatformPost children. content_bucket is content-NEUTRAL: setting it creates no revision and does not invalidate a live approval. null clears it back to unassigned. |
| PATCH | /posts/{postId}/platform-posts/{id} | bearer, internal | { status?, platform_caption?, platform_media?, platform_extra?, scheduled_at? } | post_detail | Status transitions via sole-writer path; illegal edges 422; concurrent edit 409. |
| POST | /posts/{postId}/platform-posts/{id}/publish | bearer, internal | — | post_detail | Publish now (skips queue). draft/failed → publishing → published (or failed). 502 on adapter error. |
| GET | /posts/{id}/metrics | bearer | — | { by_platform_post_id: Record<id, snapshot | null> } | Latest PostMetricSnapshot per variant. Only published variants have data. |
| GET | /social-accounts | bearer | ?project_id=…&platform=…? | social_account[] | Tenant + project-membership scoped. |
| POST | /social-accounts | bearer, internal | { project_id, platform, account_platform_id, account_name, account_handle?, avatar_url? } | 201 social_account | Manual-connect path (OAuth is the primary path via /oauth/:key/*). Re-connecting a currently connected account 409s; re-connecting a disconnected one revives that same row (same id) so its published posts and metrics reattach. |
| DELETE | /social-accounts/{id} | bearer, internal | — | 200 social_account | Disconnect, not delete. Wipes the OAuth credentials and sets connection_status=disconnected + disconnected_at. Published posts, PostMetricSnapshot, PostMetricDaily and AccountMetricSnapshot are all kept and stay readable; scheduled variants are left dormant (the publisher skips disconnected accounts) and resume on reconnect. |
| DELETE | /social-accounts/{id}?purge=true | bearer, internal | — | 204 | Permanent delete. Cascades PlatformPost variants, all metric history, scheduling slots and queues. Irreversible — the only path that destroys analytics. |
| GET | /social-accounts/{id}/slots · POST · PATCH · DELETE | bearer, internal | { day_of_week, hour, minute, label? } | slot | Recurring posting slot in the project's timezone. |
| POST · DELETE | /social-accounts/{id}/queues | bearer, internal | { name } | 201 queue / 204 | A named queue that assigns items into the account's slots. |
| POST | /queues/{id}/entries | bearer, internal | { platform_post_id } | 201 queue_entry | Runs nextSlotDatetimes; SELECT FOR UPDATE on SocialAccount serialises cross-queue ops. |
| GET | /channels | bearer | — | [{ key, display_name, platform, supported_post_types[], max_caption_length }] | Which channel adapters are registered server-side (i.e. have OAuth creds configured). Web renders "Sign in with X" based on this. |
| POST | /oauth/{adapterKey}/start | bearer | { project_id, redirect_after?, credential_id? } | { auth_url } | Signs pending state (JWT, 5-min TTL, channel-oauth audience); returns the platform's authorize URL. |
| GET | /oauth/{adapterKey}/callback | none | ?code=…&state=… OR ?error=… | 302 | Verifies state, exchanges code, upserts SocialAccount with AES-256-GCM encrypted tokens at rest, redirects to redirect_after with ?connected=<platform> (or ?oauth_error=<code>). |
| GET | /projects/{id}/channel-credentials | bearer | — | [{ provider, label, configured, client_id, secret_last4, …, multiple_allowed, apps[] }] | Every provider, configured or not. apps[] (F-152) lists each app the project registered for it, primary first: { id, label, primary, client_id, secret_last4, enabled, webhook_url, verify_token_set, account_count }. Top-level fields describe the primary. Secrets never returned. |
| PUT · DELETE | /projects/{id}/channel-credentials/{provider} | project lead | { client_id, client_secret, developer_token?, extra?, label? } / — | summary / 204 | The project's PRIMARY app: create-or-replace it; DELETE removes every app for the provider. |
| POST | /projects/{id}/channel-credentials/{provider}/apps | project lead | as above | 201 summary | F-152 · add another app for the provider. 422 for providers that allow one (Google Ads, Google Analytics). |
| PUT · DELETE | /projects/{id}/channel-credentials/{provider}/apps/{appId} | project lead | as above / — | summary / 204 | F-152 · change or remove one app. 404 if it isn't this project's. DELETE is 409 while accounts are connected through the app. |
| POST | /uploads | bearer | multipart file | 201 { key, url, content_type, kind: "image"|"video", size } | Caps: image 8 MB, video 200 MB. Whitelisted types. Composer POSTs one per file. |
| GET | /uploads/{key} | none | — | file bytes | Gateway so LinkedIn/etc. can fetch uploaded assets from localhost during publish. Key is UUID-prefixed → unguessable. |
| GET | /projects/{id}/attribution/paths | bearer | ?from&to&ad_campaign_id&utm_source&utm_campaign&entry_path&ad_only&max_depth | { sessions_analysed, truncated, entries[], edges[] } | Where visits went after they landed. Filters apply to the visit's first touchpoint — the ad that started it. edges[].to_path: null is the exit (they left), which makes the sessions out of a node sum to the sessions in. truncated: true when the range exceeded the 50k-touchpoint read ceiling. |
| POST | /growth/projects/{id}/prompts/suggest | bearer, owner/admin | — | { items: [{ prompt, expected_domains }] } | Generates up to 5 starting AI-citation prompts from the project's own name/domain/industry/description — for the "AI Citations" tab's empty state. Synchronous (one chat() call, not the four-engine sweep a real check runs); nothing is persisted. expected_domains defaults to the project's own host. 422 if the project has no website_url. Suggestions duplicating an already-tracked prompt are filtered out. |
| POST | /projects/{id}/competitors/instagram/lookups | bearer, analytics:read | { username, post_limit?=25 (1–50), account_id? } | 201 snapshot: { id, username, fetched_at, profile, metrics, media_error } | F-105. Instagram Business Discovery through the project's own connected Instagram account. Live Graph calls: rate-limited 20/min per user, audited competitor.lookup. 402 org plan below Growth (limits.competitor_analysis), checked before any Graph call; 409 no Instagram account connected; 404 no Business/Creator account by that name; 422 personal account or bad username; 424 Instagram token dead or missing a permission (never 401) |
| GET | /projects/{id}/competitors/instagram/lookups | bearer, analytics:read | ?limit=20 | { items: [{ id, username, fetched_at, followers_count, media_count, posts_analyzed }] } | Stored lookups, newest first — a database read, no Instagram call. Our own snapshots are excluded |
| GET | /projects/{id}/competitors/instagram/lookups/{lookupId} | bearer, analytics:read | — | snapshot + posts[] | Reopen a stored lookup without spending quota. Cross-project id → 404 |
| POST | /projects/{id}/competitors/instagram/lookups/{lookupId}/compare | bearer, analytics:read | { post_limit?=25 } | { ours, theirs, comparison: { rows[], summary, content_mix, busiest_day, hashtags } } | Our account fetched fresh through the same Business Discovery call and maths, against the STORED competitor snapshot. A metric either side lacks has no difference and no leader. 402 plan below Growth; 422 when the lookup is our own account |
| GET | /listening/probe | bearer, platform admin | ?only=&target=&format=markdown | capability report | Read-only probe of every connected platform; 404 to non-admins. |
| GET | /listening/explore/platforms | bearer | — | [{ platform, label, endpoints: [{ id, label, kind, group, credits, depends_on, runnable }] }] | What the explorer will try; drives the SocialCrawl source picker and its credit costs. |
| POST | /listening/explore | bearer, platform admin | { keyword, platforms?, socialcrawl_sources? } | { keyword, generated_at, platforms: [...] } | Live, read-only. SocialCrawl is never in the default set; choosing it without socialcrawl_sources is a 422 (it bills per call). |
| GET | /projects/{id}/listening/terms | bearer, project scope | — | { items: [term + mention_count, estimated_credits], configured } | Tracked listening terms (F-104.2). configured is false without SOCIALCRAWL_API_KEY. |
| POST | /projects/{id}/listening/terms | bearer, project scope; owner/admin | { term, role?=brand or competitor, label?, sources[], cadence_hours?=24 (6–168), enabled? } | 201 term | Unknown or empty sources → 422; duplicate term in the project → 409. |
| PATCH | /projects/{id}/listening/terms/{termId} | bearer, project scope; owner/admin | any of role, label, sources, cadence_hours, enabled | term | A term from another project → 404. |
| DELETE | /projects/{id}/listening/terms/{termId} | bearer, project scope; owner/admin | — | { ok } | Cascades the term's runs and mentions. |
| POST | /projects/{id}/listening/terms/{termId}/run | bearer, project scope; owner/admin | — | run | Runs now, spending credits. 424 when SocialCrawl is not configured. |
| GET | /projects/{id}/listening/terms/{termId}/runs | bearer, project scope | ?limit=30 | { items: [{ trigger, status, credits_used, credits_remaining, item_count, new_count, summary[] }] } | Newest first. |
| GET | /projects/{id}/listening/mentions | bearer, project scope | ?term_id&platform&kind=post or comment&q&from&to&sort=newest, oldest, engagement, views or first_seen&limit≤200&offset | { items: [mention], total, platforms: [{ platform, count }], limit, offset } | Mention explorer (F-104.7). One row per (term, platform, post). q matches text, title and author (case-insensitive); from/to are ISO dates or timestamps on the published date (a bare to covers the whole day; undated mentions never match a window). engagement sorts on the stored interaction total, unknown last. Each item adds metrics (only what the platform reported, in its words — Reddit upvotes), thumbnail_url, author_avatar_url, duration_seconds, media_count, flags. platforms counts under every filter except the platform one. Reads stored rows only — no SocialCrawl call. |
| GET | /projects/{id}/listening/mentions/{mentionId} | bearer, project scope | — | mention + { body, media_urls, details: [{ key, label, value, format }], raw } | One mention with its platform-specific fields read from raw (TikTok country, downloads, music; Reddit subreddit, upvote ratio, flair; YouTube subscribers, Short…) and the original SocialCrawl payload. Another project's mention → 404. |
| GET | /projects/{id}/listening/overview | bearer, project scope | ?term_id&from&to (same published-date window as the mentions list) | { totals: { mentions, views, views_reported, engagement, engagement_reported, avg_engagement, creators, new_7d }, collecting_since, last_collected_at, platforms[], volume: { unit: day or week, points[] }, top_posts[5], top_viewed[5], top_creators[5], creator_sizes, countries, languages, communities, intent, sponsored } | Brand overview (F-104.3). Aggregates of stored mentions only — no SocialCrawl call. Sums no mention reported are null; every split is { labelled, items } so shares are over the mentions it could classify. Volume is daily up to 92 days, weekly beyond; the last 30 days when no window is given. No sentiment. |
| GET | /projects/{id}/listening/usage | bearer, project scope | — | { used, limit, remaining, period_start, period_end, configured, can_manage } | The organization's SocialCrawl spend this calendar month (UTC) against LISTENING_MONTHLY_CREDITS_PER_ORG, summed from listening_runs. |
| POST | /projects/{id}/listening/explore | bearer, project scope; SocialCrawl needs owner/admin | { keyword, platforms?, socialcrawl_sources? } | { keyword, generatedAt, platforms: [...] } | Live one-off search. Official APIs use only THIS project's connected accounts. Choosing SocialCrawl without sources → 422; by a member → 403; past the monthly budget → 402. A SocialCrawl search is recorded as a run (trigger = explore) and audited. Rate-limited 10/min. |
Endpoints — planned
Listed so the frontend can be built against a known shape. Each lands with its feature.
| Method | Path | Auth | Feature |
|---|---|---|---|
| GET | /projects/{id}/calendar | bearer | F-021 |
| POST | /projects/{id}/plan/generate | bearer, admin+ | F-022 |
| GET · POST | /posts | bearer | F-030 |
| POST | /posts/{id}/submit | bearer | F-035 |
| POST | /posts/{id}/approve · /reject | bearer, approver | F-035 |
| POST | /posts/{id}/schedule | bearer | F-036 |
| GET · POST | /media | bearer | F-033 |
| POST | /ai/compose | bearer | F-031 |
| POST | /ai/video | bearer | F-032 |
| POST | /ai/chat | bearer · projects:read | F-154 — one message to the studio assistant; history is loaded from storage, never taken from the caller. Returns the stored turns, and each assistant turn carries actions[] — the things it prepared, as cards. An action carries title, detail, risk, status and a server-computed expired, and deliberately no arguments: the browser can say yes or no to an id, never replay a request with different values. Rate-limited per user under ai-chat. |
| GET | /ai/chat/conversations[/{id}] | bearer · projects:read | F-154 — the reader's own threads, and one thread with every turn and its action cards. Another user's thread answers exactly like a missing one. |
| POST | /ai/actions/{id}/confirm | bearer · projects:read | F-154 — carry out a proposal. Single-use: a conditional update from proposed, this user, not expired, so a double-click or a second tab runs it once (409 for the loser). Another user's action 404s; an expired one is 410. The action runs through the domain as the confirming user, so the permission that governs it is the one the screen enforces — guarded here by projects:read only, deliberately, since a second coarser copy of those rules is the one that drifts. A refusal by the domain is recorded on the card as failed with the domain's own message, not raised as a 500, because the confirm itself worked. Audited as chat_action with the kind and outcome, so "who sent that" answers "the assistant proposed it and this person confirmed it". Not rate-limited with chat: it calls no model. |
| POST | /ai/actions/{id}/dismiss | bearer · projects:read | F-154 — decline a proposal. Also single-use; a dismissed card can never be confirmed afterwards. |
| GET · POST | /channels/accounts | bearer | F-045 |
| GET | /channels/{platform}/oauth/start · /callback | bearer | F-045 |
| POST | /webhooks/{platform} | signature | F-047 |
| GET | /inbox | bearer | F-046 |
| GET | /social-accounts/{id}/inbox | bearer | F-046 (also ?sentiment=, ?tag= from batch 22 T3) |
| GET | /social-accounts/{id}/inbox/tags | bearer | F-103 (autocomplete source for tag combobox) |
| PATCH | /inbox/{id} | bearer | F-103 (edit tags) |
| GET | /inbox/{id}/post-context | bearer | F-046 (the post a comment sits on; null for a DM. Matches our own PlatformPost first, falls back to one platform fetch, caches on the item) |
| POST | /inbox/{id}/ai-reply | bearer | F-103 (three tone-labelled reply suggestions, rate-limited 20/hr) |
| GET · POST · PATCH · DELETE | /saved-replies · /saved-replies/{id} | bearer (writes need inbox:manage_saved_replies) | F-103 |
| GET · POST | /projects/{id}/email-categories | bearer, internal to write | F-111.1 — this project's own audience categories. POST takes name + optional description / color; 409 on a name that folds onto an existing one or that the CSV importer already reads as a built-in segment |
| PATCH · DELETE | /email-categories/{id} | bearer, internal | F-111.1. DELETE takes ?reassign_to=<audience key>; without it a category still holding contacts answers 409 with contact_count so the client can offer a destination rather than a bare confirm |
| GET · POST | /projects/{id}/email-tags | bearer, internal to write | F-146 — the project's tag vocabulary. POST takes name + optional description / color; the name is folded the same way a category's is (audienceSlug), so Webinar, webinar and Webinar collide with a 409 rather than becoming three tags a segment can only match one of. Capped at 300 per project — a cap that exists for the import and automation paths, not for the person typing. |
| PATCH · DELETE | /email-tags/{id} | bearer, internal | F-146. PATCH renames, recolours or re-describes; a rename keeps every membership, which is the whole reason tags are rows. DELETE takes no ?reassign_to= unlike a category — a contact with one tag fewer is an ordinary contact, while a contact with no category is not representable — and the head count of contacts untagged goes into the audit, because "why did 400 people stop matching that segment" is asked after the tag is gone. |
| POST | /email-contacts/{id}/tags | bearer, internal | F-146 — apply tags, by id only: a name would have to be resolved or created at apply time, and an endpoint that silently invents vocabulary is how a typo becomes a permanent tag. Every id is proved to belong to the CONTACT's project, and one foreign id fails the whole request (404) rather than applying the valid subset — a caller told nothing while believing a label landed is how the wrong list gets mailed. Re-applying is a no-op, because an automation re-runs its tag action on every re-entry. Returns the contact. |
| DELETE | /email-contacts/{id}/tags/{tagId} | bearer, internal | F-146 — remove one tag. A membership that was never there is not an error: the end state is what the caller asked for. Returns the contact. |
| — | (broadcast targeting) | — | F-146 — emailBroadcastCreateSchema / …UpdateSchema gain segment_id as a fourth mutually-exclusive target beside category, custom_category_id and custom_recipients. The refinement counts the named targets rather than comparing them pairwise (three targets need three pairwise clauses, four need six, and the one nobody adds is the one that lets a row hold two audiences); the database CHECK is written the same way. A segment target is resolved at send time, not at draft time — that is what makes it dynamic, and a draft-time snapshot would mail people who unsubscribed in between. Sending to a segment nobody matches today is a 422 that says so. The FK is ON DELETE SET NULL so a SENT broadcast survives its segment being deleted, with audience_label as the snapshot that keeps the history readable. |
| GET | /e/o/{token}.gif | public | F-147 — the open pixel. Always 200 with the image, valid token or not: a 404 for a bad token would be an oracle confirming which tokens name real sends, and would render a broken image in the inbox of the person being tracked. no-store, because a cached pixel is one open recorded and then invisible forever, and a shared proxy attributes one person's open to everybody behind it. Append-only — four fetches are four rows, and the per-broadcast count de-duplicates by send. |
| GET | /e/c/{token} | public | F-147 — the click redirect. The destination is resolved from email_broadcast_links server-side and is never read from the request: a redirect that takes its target from a parameter is an open redirect wearing our own domain. A forged token is a 404, not a redirect to a default — there is no safe default, and sending somebody to the homepage is most of what an open redirect is worth. The click is recorded before the 302, and a failure to record does not stop the redirect. |
| GET · POST | /e/u/{token} | public | F-147 — unsubscribe. GET only describes; POST acts. Mail clients, Outlook Safe Links and corporate scanners fetch every url in a message, so a one-click GET would unsubscribe people who never opened the email. GET returns a MASKED address (j•••@example.com) plus the project name — the token arrived in that person's mailbox so showing it to them is not a leak, but a forwarded message carries it to somebody else. POST writes three things: the suppression row (what every future send consults), the contact's unsubscribed flag (what the audience screen reads), and an unsubscribed event (what says which campaign caused it). Idempotent. |
| GET | /email-broadcasts/{id}/engagement | bearer, internal | F-147 — opens, clicks and unsubscribes for one broadcast, counted over distinct sends: somebody who opens four times is one person who opened it, and a raw event count reads as four times the reach. bounced / complained are present and always 0 until webhooks land. |
| GET | /email-contacts/{id}/deliverability | bearer, internal | F-147 — why is this person not receiving email, the question the requirements ask by name. Returns the REASON, not a boolean: suppressed (with which reason), flagged on the row, or nothing wrong. Most-specific first — a suppressed contact usually also carries the flag, because unsubscribe sets both, so reporting the flag would answer "somebody unsubscribed them" when the truth might be "their address bounced". Plus the send history that explains it. |
| GET · POST · DELETE | /projects/{id}/email-suppressions[/{email}] | bearer, internal to write | F-147 — the per-project suppression list. POST takes an address and an optional note and always records reason manual: letting a caller claim an address "bounced" would put a fact into the record that nothing observed. DELETE un-suppresses but deliberately does not clear the contact's unsubscribed flag — undoing an administrative mistake and overriding somebody's request to be left alone are different acts, and one button for both is how the second happens by accident. |
| GET · POST | /projects/{id}/automations | bearer, internal to write | F-148 — journeys. POST takes name, trigger_kind and a trigger_config whose ids are proved to be this project's (404 otherwise, never 403), plus the entry_rule §11 calls important to prevent accidental duplicate automation runs — once by default, because the failure it prevents is noticed by the recipient. The trigger node is created automatically: an automation without one cannot be activated, and making the author add it means the first thing the builder shows is an error about a node they have never heard of. |
| GET · PATCH · DELETE | /automations/{id} | bearer, internal | F-148. Editing is draft-only, enforced in the domain so no caller can route around it: a live automation has people standing on its nodes, and an edit that deletes the node somebody is waiting at ends their journey silently, days later, with nothing connecting the two. DELETE refuses while active. |
| POST · PATCH · DELETE | /automations/{id}/nodes, /automation-nodes/{id} | bearer, internal | F-148 — the steps. Configs are validated with the same schema the executor parses with, so a node that saves cannot fail in the sweep three days later on a live journey. Deleting a step is refused while live enrollments point at it rather than cascaded — cascading would end those journeys silently and leave the author with a tidier canvas and no idea what it cost. |
| POST · DELETE | /automations/{id}/edges, /automation-nodes/{id}/edges/{branch} | bearer, internal | F-148 — connections. @@unique(from_node_id, branch) makes the graph deterministic: a node with two next edges has no defined answer to "where now". Only a condition may carry yes/no, and a condition may not carry next. Since parallel paths: a step may have several edges on one branch, so POST adds (the same pair twice returns the existing edge) and DELETE /automation-edges/{id} removes one; the branch-wide DELETE still removes all of a branch's edges. |
| GET | /automations/{id}/validate | bearer, internal | F-148 (§18) — every problem at once, never just the first: a validator that stops at the first fault turns fixing an automation into a guessing game played one save at a time. Catches a missing trigger, two triggers, an automation that never sends, an unreachable step, a condition with only one branch wired (the quiet one — everybody who answers the other way runs off the end and is recorded as completed), and a loop with no wait in it (the executor advances immediately between non-waiting nodes, so it would spin forever). |
| POST | /automations/{id}/{activate,pause,draft} | bearer, internal | F-148 (§13). Activation runs the validator and refuses — the requirements say show a clear message rather than allowing an incomplete automation to go live, and an incomplete one goes live against real people. activated_at is stamped so nothing is retro-enrolled: switching a journey on must not mail the entire back catalogue. What a pause does to the people already inside is the automation's own pause_behaviour, because stop letting new people in and freeze everybody mid-journey are different intentions. |
| GET | /automations/{id}/dashboard | bearer, internal | F-148 (§14) — entered, active, held, completed, exited, failed, plus who is where. F-149 adds counts.unsubscribed (a subset of exited) and emails[]: per send step, sent and the number of SENDS with at least one opened / clicked / delivered / bounced / complained event — per send, not per event, so a rate cannot exceed 100%. Steps that never sent are listed with zeros. |
| GET | /email-contacts/{id}/journeys | bearer, internal | F-148 (§19) — the per-contact timeline. Every enrollment, every step, in order. The only thing that can answer why somebody received an email they should not have. F-149 adds timeline[] per enrollment: steps and the email events of the sends those steps made, merged and ordered server-side (a step before the open it caused when they share a timestamp). |
| GET · POST | /projects/{id}/email-forms | bearer, internal to write | F-150 — lead-capture forms. fields must include email (always stored as required); tag ids, custom-field ids and audience_key are proved to be this project's (404 otherwise). Each response carries token, submission_count and last_submitted_at. |
| GET · PATCH · DELETE | /email-forms/{id} | bearer, internal | F-150. DELETE is refused with 409 while an ACTIVE automation starts from the form. |
| GET | /public/forms/{token} | none — rate limited per IP | F-150 — what the public page renders: heading, submit_label, and fields[] with an input type (checkbox, select/radio with options, hidden with the url param to read — a hidden field's fixed value is never sent). Names neither the project nor the form id. A closed or unknown form is 404 either way. |
| POST | /public/forms/{token}/submit | none — rate limited per IP, and 5/hour per address per form | F-150 — { values: {<field key>: string}, website?, page? } — page is the landing page's slug, trusted only if it is published, this project's, and carries this form, where website is a honeypot. Answers { message } (the form's thank-you) identically for a new address, an existing one, an unsubscribed one and a caught bot; 422 only for a missing required field or a malformed address. Creates or updates the contact, applies the form's tags, records an EmailFormSubmission, then raises contact_added (new only), tag_added (newly applied tags only) and form_submitted. |
| GET · POST | /projects/{id}/landing-pages | bearer, internal to write | F-153 — landing pages. blocks and theme are validated by the contract (links limited to http(s)/mailto/tel/#anchor); a form block's form_id must be this project's (404). slug is unique across the deployment — 409 on a clash. |
| GET · PATCH · DELETE | /landing-pages/{id} | bearer, internal | F-153. PATCH on a published page changes it live. |
| POST | /landing-pages/{id}/{publish,unpublish} | bearer, internal | F-153. Publish refuses an empty page (422) and is audited. |
| GET | /public/pages/{slug} | none — rate limited per IP | F-153 — { title, description, theme, blocks }. Form blocks become { form_token }; a closed or deleted form is dropped. Names neither the project nor any id. Unpublished or unknown is 404. Counts a view. |
| GET | /public/demo-settings | none | #400 — { form_token, calendly_url } for the landing page's "Get a demo" dialog, each null when unset. Read from DEMO_FORM_TOKEN / DEMO_CALENDLY_URL on the API, so the web image carries neither and one image serves every environment. Both are public by nature (a published form's token, a booking page). Cache-Control: public, max-age=300. |
| GET · POST | /projects/{id}/email-segments | bearer, internal to write | F-146 — saved rule sets, evaluated at use time. POST takes name, match (all/any) and rules[] of field → operator → value. At least one rule: zero means "everybody", which the audience tab already is, and saving it would make the whole list a target a broadcast can pick by a second name. Tag and custom-field ids inside are proved to be this project's on WRITE (404 otherwise), and the whole set is compiled once at save time so an unevaluable rule — greater_than against text — is a 422 where the person is standing, not a failure in front of a mailing list. GET returns each segment's contact_count, computed per request; there is no stored count, because a dynamic segment whose size is cached lies after the next import. |
| PATCH · DELETE | /email-segments/{id} | bearer, internal | F-146. Renaming folds through the same audienceSlug as categories and tags, so two segments cannot differ only by case. Rules are re-validated and re-compiled when EITHER rules or match changes — flipping all to any is a different query even with the rules untouched. DELETE moves no contacts: nobody is filed into a segment, so there is no destination picker. |
| GET | /email-segments/{id}/preview | bearer, internal | F-146 — who a saved segment matches now: total, sendable (excluding unsubscribes) and a sample. The two counts are separate deliberately — a number including people who can never be mailed overstates the reach of every broadcast built on it. |
| POST | /projects/{id}/email-segments/preview | bearer, internal | F-146 — count an unsaved rule set, which is what the builder shows while somebody types. Writes nothing. A POST because the rules are an array of objects: as a query string they would be JSON-in-a-URL and would land in access logs. Unlike create it allows zero rules, so a half-built segment reads as a count rather than an error. |
| GET · POST | /projects/{id}/email-fields | bearer, internal to write | F-146 — the project's custom contact fields. POST takes key, label, type (text/number/date/boolean/select) and, for select only, options. key is constrained to ^[a-z][a-z0-9_]*$ rather than folded from the label, because it is typed into email bodies by hand as {{key}} and a key holding a space or a capital gives two spellings of one merge tag, one of which renders nothing. A duplicate key is a 409. Capped at 50 per project. strictObject, so an unknown field is a 422 rather than quietly dropped. |
| PATCH · DELETE | /email-fields/{id} | bearer, internal | F-146. PATCH takes label, description and (choice fields only) options — key and type are refused, not ignored: the key is named by email bodies with no way to find them, and the type decides how every value ALREADY stored was written, so flipping text to number would leave the comparable column null on every existing row and make a "greater than" segment match nobody while looking merely empty. Removing a choice is not cascaded — contacts already set to it keep the value. DELETE cascades every stored value and puts the count in the audit. |
| PUT | /email-contacts/{id}/fields | bearer, internal | F-146 — write a contact's custom values, keyed by field id. A PUT for the whole set rather than a PATCH per field: the contact form saves everything it rendered at once, and N requests leave a contact half-updated when the third fails. Each value is coerced against its field's type and refused if it cannot be ("about 40" in a number field is a row no condition will ever match). null or "" CLEARS — the sparse row is deleted rather than blanked, so is empty keeps meaning "never set". A field id belonging to another project 404s and writes nothing at all, rather than being skipped. |
| GET · POST | /projects/{id}/email-contacts | bearer, internal to write | F-111 (?category=, ?q=, ?tag_id=, ?segment_id= — F-146; a segment is compiled server-side and applied as one more AND term, so it NARROWS an already-filtered view rather than replacing it, and a foreign id 404s rather than answering empty). ?category= takes an audience key — active_clients or custom:<uuid>; an unknown key 422s and another project's category 404s (never an empty list, which would read as "that segment is empty") F-146 ?tag_id= filters to contacts carrying one tag; a tag id belonging to another project 404s rather than answering an empty list, matching how ?category= treats a foreign custom:<uuid> — an empty list reads as "nobody carries that tag", which is a wrong answer said confidently and a way to probe another project's ids. |
| GET | /projects/{id}/email-contacts/stats | bearer | F-111 (per-segment total / sendable / unsubscribed). F-111.1 — the three built-ins first, then every custom category, each row self-describing (key, label, hint, color, custom) so one component renders both kinds |
| POST | /projects/{id}/email-contacts/import | bearer, internal | F-111 — multipart: file (CSV) + category (an audience key: the default for rows that don't name one) + F-111.1 auto_create_categories (opt-in). Returns created / updated / skipped / rejected[] / created_categories[] / unmatched_categories[], rejections carrying their row number |
| POST | /projects/{id}/email-contacts/import-google-sheet | bearer, internal, rate-limited 10/min | F-111.2 — JSON: sheet_url + category (audience key) + optional auto_create_categories. Fetches the sheet through Google's gviz/tq CSV export and feeds it to the same parser a file upload uses; returns the identical EmailImportResult. The server never fetches the pasted URL — it extracts a [A-Za-z0-9_-]{20,200} id and builds its own URL against a hardcoded origin with redirect: "manual". 422 with actionable text for a non-Google link, a private sheet, a missing sheet, or one over the size cap |
| POST | /email-broadcasts/{id}/test | bearer, owner/admin, rate-limited 5/min | F-113.4 — JSON: optional to (defaults to the caller's own address). Sends ONE copy with sample merge values and a [TEST] subject prefix. Writes no recipient rows, moves no status, touches no counters, never resolves the audience. Returns { broadcast_id, to, sent, error } — a transport refusal is sent: false with the transport's words, not a failed request |
| GET | /projects/{id}/email-templates | bearer, internal | F-113.5 — saved subject + body pairs, most-recently-updated first |
| POST | /projects/{id}/email-templates | bearer, internal | name + subject + body_html. Body sanitised on write. 409 when the name is taken in this project (unique per project, not per org). No audience on a template, by design |
| PATCH | /email-templates/{id} | bearer, internal | Partial. Renaming into a taken name is 409 |
| DELETE | /email-templates/{id} | bearer, internal | 204. Broadcasts written from it are untouched |
| GET | /projects/{id}/email-signature | bearer, internal | F-113.5 — { project_id, signature_html }. Empty string when none is set |
| PUT | /projects/{id}/email-signature | bearer, internal | Not PATCH: there is one field and clearing it is a first-class action. Sanitised on write; a contenteditable's empty <p><br></p> normalises to "" |
| GET | /email-contacts/template.csv | none | F-111 (the blank spreadsheet, text/csv download). Deliberately unauthenticated — an <a download> cannot carry a localStorage bearer — and therefore deliberately generic. The project-aware template listing a project's own categories is built in the browser from buildContactTemplateCsv |
| PATCH · DELETE | /email-contacts/{id} | bearer, internal | F-111. The address is immutable — editing it would move delivery history onto a different person |
| GET · POST | /projects/{id}/email-broadcasts | bearer, internal to write | F-111. F-111.1 — target either category (built-in) or custom_category_id, never both (422); a category id from another project 404s. The audience name is snapshotted onto audience_label at draft time. Also takes custom_recipients[] — typed addresses belonging to no segment, a THIRD target that is mutually exclusive with both category halves (422 if combined). Validated per address (the error names the one that is wrong), capped at 50, and re-normalised server-side (trimmed, lowercased, de-duplicated); reads back as audience_key: "custom_recipients" |
| GET · PATCH · DELETE | /email-broadcasts/{id} | bearer, internal to write | F-111. PATCH only on a draft — a sent broadcast is a record of what went out. F-113 — create and PATCH also take body_html (sanitised on write; body_text is derived from it and a supplied one is ignored) and attachments[] ({key,url,filename,content_type,size}, ≤10 files / ≤15MB total, every key checked against the caller's own org prefix or 404). name and subject may be empty — autosave writes from the first keystroke and completeness is enforced at send, which refuses each missing field by name. A body in neither form is 422; sending body_text alone clears body_html |
| GET | /ad-accounts/{id}/pixels | bearer, internal | E6.3 — { items: [{ id, name, last_fired_at }] }, the account's Meta conversion pixels, read off Graph's adspixels edge for the ad-set builder's dropdown. 422 when the platform has no pixel concept (named, not an empty list — "this platform doesn't have them" and "this account has none" are different statements), 409 for an account with no stored token or a revoked one, 429 on a platform rate limit, 404 for another tenant's account |
| POST | /track/conversion | none (tracker key on the body) | E6 — records the conversion and fires attribution; E6.3 also forwards it to Meta's Conversions API, once per distinct pixel named by the project's ad sets. Forwarding is fire-and-forget: a Graph failure never changes this response |
| POST | /email-broadcasts/{id}/send | bearer, owner/admin, rate-limited 20/hr | F-111. Its own verb, not a status field on PATCH: sending is irreversible. 409 if already claimed, 422 on an empty segment or a disabled mail transport. F-111.1 — also 422 when the custom category the draft targeted was deleted (naming it), rather than guessing an audience. With custom_recipients: each address is matched against this project's unsubscribed contacts and dropped if suppressed (a hand-typed address is how somebody who opted out gets mailed again), 422 when that empties the list or when none was typed; a typed address that is a subscribed contact links to it so merge tags resolve, and one that is not gets a recipient row with contact_id: null. The ceiling is new with typed recipients: an endpoint that mails addresses named in the request is a relay |
| GET | /ads/accounts | bearer | F-050 · F-051 |
| GET | /ads/benchmarks | bearer | F-052 |
| GET | /ads/campaigns · POST | bearer | F-053 |
| GET | /analytics/kpis | bearer | F-062 |
| GET | /analytics/reports | bearer | F-063 |
| GET | /auth/oauth/{provider}/start · /callback | none | F-015 |
| POST | /billing/checkout | bearer, admin+ | F-016 |
| GET | /billing/portal | bearer, admin+ | F-016 |
| POST | /billing/webhook | signature | F-016 |
| GET | /audit-logs — filters: action, entity_type, actor, from, to, project_id, post_id, cursor, limit. post_id returns one post's whole life (the post, its platform_post variants, its approval, and rows naming it in meta); org-scoped, so another tenant's post reads as empty rather than 403 | bearer | J5 |
| GET | /growth/keywords · /competitors | bearer | F-091 |
| POST | /growth/contacts/enrich | bearer | F-092 |
| GET | /growth/seo/audit | bearer | F-093 |
| GET | /growth/aeo/readiness | bearer | F-094 |
| GET · POST | /github/repos | bearer | F-095 |
| POST | /github/pull-requests | bearer | F-095 |
| GET · POST | /whatsapp/templates | bearer | F-048 |
| POST | /whatsapp/broadcasts | bearer | F-048 |
| POST | /ads/campaigns/{id}/launch · /pause | bearer, approver | F-059 |
| GET · POST · DELETE | /agents/keys | bearer, admin+ | F-072 |
| GET · POST | /agents/runs | bearer or key | F-073 · F-075 |
| ALL | /mcp | key | F-071 |
Client-side dev route (not part of the API)
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /api/dev/theme (on :3000) | none, dev-only | Style Lab writes a theme back into globals.css. 404s in production. Not on the API service. |