HTTP API
The dashboard's /api/* routes power the UI and the extension. Authentication depends on the route prefix.
Auth
- Cookie session (
pitchbox_session) whenPITCHBOX_AUTH=on- covers everything except the two prefixes below. - Extension per-device token (
Authorization: Bearer <token>) - required for every/api/extension/*call. Each paired device gets its own token inextension_devices; the side panel mints one viaPOST /api/extension/auto-pairusing the dashboard session cookie. There is no shared singleton token. - Public -
/api/auth/login,/api/auth/logout, and/api/extension/auto-pair(which authenticates with the dashboard session cookie, not a bearer token).
Selected endpoints
# Dispatch
POST /api/run # { campaignId, trigger? } → { runId, alreadyRunning? }
DELETE /api/run/[id] # cancel a running run
GET /api/runs/[id]/events # a run's event log
# Drafts
PATCH /api/drafts/[id] # edit body or transition state (optimistic-locked)
POST /api/drafts/[id]/regenerate # re-run the drafter for this draft
POST /api/drafts/bulk-approve # approve many drafts at once
POST /api/drafts/bulk-reschedule # reschedule many drafts
# Runners
GET /api/runners # detection results (cached)
POST /api/runners # clears cache, re-detects
GET /api/settings/runner-config
PUT /api/settings/runner-config # { slug, config }
# Notifications
GET /api/notifications # { notifications, unread }
POST /api/notifications # mark all read
PUT /api/settings/webhooks # { url } | { url: null }
# Playbooks
GET /api/playbooks
POST /api/playbooks
GET /api/playbooks/[id]
PATCH /api/playbooks/[id]
DELETE /api/playbooks/[id]
# Auth
POST /api/auth/login # { username, password }
POST /api/auth/logout
# Extension - pairing
POST /api/extension/pair # public → redeem a short-lived pairing code for a token
POST /api/extension/auto-pair # cookie-auth → mints a per-device token
POST /api/extension/handshake # bearer-auth → liveness ping
# Extension - drafts (bearer-auth, per-device)
GET /api/extension/draft/[id] # fetch draft for the compose UI
POST /api/extension/draft/[id]/armed # flip to 'armed' (compose page opened)
POST /api/extension/draft/[id]/sent # flip to 'sent' (user submitted on Reddit)
# Extension - reply sync (bearer-auth, per-device)
POST /api/extension/dm-sync # inbox + chat poll → match drafts + status heartbeat
GET /api/extension/dm-sync/status # last sync stamp + per-channel liveness
# Export
GET /api/export/[resource]?format=csv # resource ∈ { drafts, contacts, conversations }GET /api/export/[resource]
Streams a UTF-8 CSV download (RFC 4180 quoting) for the given resource. The endpoint mirrors the filters used on the matching dashboard page so the export reflects exactly what the user sees.
| Resource | Honored query params | Columns |
|---|---|---|
drafts | state, kind, run, campaign, project, platform | id, created_at, state, platform, account_handle, target_user, target_subreddit, campaign_id, run_id, body |
contacts | platform, q | id, platform, account_handle, target_user, first_contacted_at, last_contacted_at, outcome |
conversations | filter (all/replied/awaiting), kind (all/dm/post_comment) | thread_id, account_handle, target_user, kind, last_message_at, message_count |
The only supported format today is csv. Response headers set Content-Type: text/csv; charset=utf-8 and a dated Content-Disposition attachment filename (e.g. drafts-2026-05-12.csv).
See web/src/routes/api/ for the full surface - every route file is the source of truth.
Optimistic locking on draft state transitions
Every state-changing draft endpoint (PATCH /api/drafts/[id], POST /api/extension/draft/[id]/sent) reads the drafts.version column, bumps it inside the same UPDATE … WHERE id = $1 AND version = $2, and returns one of two outcomes:
200 OK- the update committed; the new row'sversionis the previous one plus one.409 Conflictwith body{ "error": "version_conflict", "current_version": <int> }- another writer beat us to it. Callers should re-fetch the draft (thecurrent_versionhint is purely advisory) and retry once with the fresh version.
Clients MAY include "version": <int> in their request body to opt in to strict checking. When omitted, the server falls back to the row's current version - the contract is still safe under cross-tab races where at least one writer supplies an explicit version, and the extension auto-retries once after re-fetching GET /api/extension/draft/[id].
Live updates: /api/stream
GET /api/stream is a Server-Sent Events endpoint the dashboard uses to refresh in real time after runs and draft changes.
- The server sends a
:pingSSE comment every 15 s so reverse proxies keep the connection alive. - The client wrapper at
web/src/lib/realtime/sse.tstracks the last event timestamp. If no named event lands for 30 s it closes the underlyingEventSourceand reconnects with capped exponential backoff (1 s → 30 s max). The wrapper exposes alive/reconnecting/closedstatus the sidebar indicator renders. - Event kinds currently published:
hello,run:started,run:log,run:finished,drafts:changed,project:description:updated, plus future ones registered vialib/server/events.ts. run:startedandrun:finishedcarry{ runId, campaignId?, projectId?, exitCode?, error? }. The campaign detail page subscribes to both and callsinvalidateAll()when the event'scampaignIdmatches its own, so the "Setup required" / "In progress" banner and the "Run now" button reflect the new state without a manual reload. Other surfaces (Projects overview, Inbox) wire the same events in the same way.