Skip to content

Latest commit

 

History

History
134 lines (107 loc) · 5.01 KB

File metadata and controls

134 lines (107 loc) · 5.01 KB

API_CONTRACT.md

Base: /api/v1. JSON only. JWT for the dashboard, API keys for programmatic enqueue.

Conventions

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.

Endpoints

Auth

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

Organizations & projects

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.

Queues

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 }

Retry policies

GET /retry-policies ?project_id=   POST /retry-policies
PATCH /retry-policies/:id          DELETE /retry-policies/:id

Jobs

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.

Schedules

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)

Workers

GET /workers ?status=            GET /workers/:id
GET /workers/:id/heartbeats ?since=

DLQ

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.

System

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.

Auth model

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.

Rate limits

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.