Base: /api/v1. JSON only. JWT for the dashboard, API keys for programmatic enqueue.
Cursor pagination, never OFFSET. Offset breaks on a table with constant inserts — rows shift between pages and you both skip and duplicate records. Cursor is an opaque base64 of (created_at, id).
GET /queues/:id/jobs?status=failed&limit=50&cursor=eyJ0IjoiMjAyNi0wOC0yMCJ9
→ { "data": [...], "next_cursor": "...", "has_more": true }
One error envelope, everywhere:
{ "error": {
"code": "QUEUE_PAUSED",
"message": "Queue is paused and cannot accept jobs",
"details": { "queue_id": "..." },
"request_id": "01J8X..."
} }Status codes: 200 ok · 201 created · 202 enqueue accepted · 400 malformed · 401 unauthenticated · 403 unauthorized · 404 not found · 409 idempotency conflict · 422 validation failed (with per-field details) · 429 rate limited · 500 internal (never a stack trace — log it, return the request_id).
Headers: X-Request-Id on every request and response, threaded into every log line. Idempotency-Key accepted on enqueue.
POST /auth/register { email, password, name, org_name } → 201
POST /auth/login { email, password } → 200 {access, refresh}
POST /auth/refresh { refresh_token } → 200
POST /auth/logout → 204
GET /me → 200
GET /orgs POST /orgs
GET /orgs/:id/members POST /orgs/:id/members
PATCH /orgs/:id/members/:userId DELETE /orgs/:id/members/:userId
GET /projects ?org_id= POST /projects
GET /projects/:id PATCH /projects/:id DELETE /projects/:id
POST /projects/:id/api-keys DELETE /api-keys/:id
API key secret is returned once, at creation. Never retrievable again.
GET /projects/:id/queues POST /projects/:id/queues
GET /queues/:id PATCH /queues/:id DELETE /queues/:id
POST /queues/:id/pause POST /queues/:id/resume
GET /queues/:id/stats
stats → { depth, running, completed_1h, failed_1h, dlq_size, avg_duration_ms, p95_duration_ms, throughput_per_min }
GET /retry-policies ?project_id= POST /retry-policies
PATCH /retry-policies/:id DELETE /retry-policies/:id
POST /queues/:id/jobs
{ type, payload, priority?, run_at?, delay_ms?, max_attempts?,
idempotency_key?, depends_on? } → 202
POST /queues/:id/jobs/batch { jobs: [...], batch_name? } → 202
GET /queues/:id/jobs ?status=&type=&from=&to=&cursor=&limit=
GET /jobs/:id → job + executions[] + logs[]
POST /jobs/:id/retry → 202 (manual retry, resets to queued)
POST /jobs/:id/cancel → 200 (only from scheduled | queued)
GET /batches/:id
run_at and delay_ms are mutually exclusive — 422 if both are present. Immediate = neither.
GET /queues/:id/schedules POST /queues/:id/schedules
{ name, cron_expression, timezone, job_type, payload }
PATCH /schedules/:id DELETE /schedules/:id
POST /schedules/:id/preview → next 5 fire times (validate before saving)
GET /workers ?status= GET /workers/:id
GET /workers/:id/heartbeats ?since=
GET /queues/:id/dlq ?cursor=
POST /dlq/:id/replay { payload? } ← optional edited payload
DELETE /dlq/:id
POST /queues/:id/dlq/replay-all
Replay with an edited payload is the operator feature nobody builds. Every on-call engineer has wanted it at 3am.
GET /projects/:id/metrics ?from=&to=&bucket=1m
GET /healthz liveness — process alive
GET /readyz readiness — DB reachable, migrations applied
GET /metrics Prometheus exposition
GET /docs Swagger UI
/healthz and /readyz are different things. Liveness failing means restart me. Readiness failing means stop routing to me. Conflating them causes restart loops during a DB blip.
| Credential | Header | Scope |
|---|---|---|
| Access JWT | Authorization: Bearer <jwt> |
15m, all dashboard routes |
| Refresh token | body | 7d, rotating, stored hashed, revocable |
| API key | X-API-Key: cdty_... |
project-scoped, enqueue + read only |
org_id is always derived from the credential. Never from a request parameter. This is the vulnerability that actually kills multi-tenant apps.
| Route | Limit |
|---|---|
/auth/login, /auth/register |
5 / 15min / IP |
POST /queues/:id/jobs |
1000 / min / project |
| everything else | 300 / min / user |
429 returns Retry-After.