Skip to content
On this page

API Reference

Complete REST API reference for Spooled Cloud. Every endpoint requires authentication, and REST takes the credential — API key or JWT — only in the Authorization: Bearer … header. Credentials in the query string are rejected with 401; the four realtime routes below are the sole exception.

Base URL: https://api.spooled.cloud

Quick Examples

Common operations across all languages:

Create a Job

curl -X POST https://api.spooled.cloud/api/v1/jobs \
  -H "Authorization: Bearer sp_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "queue_name": "my-queue",
    "payload": {
      "event": "user.created",
      "user_id": "usr_123",
      "email": "alice@example.com"
    },
    "idempotency_key": "user-created-usr_123"
  }'

Claim Jobs

# Claim up to 5 jobs
curl -X POST https://api.spooled.cloud/api/v1/jobs/claim \
  -H "Authorization: Bearer sp_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "queue_name": "my-queue",
    "worker_id": "worker-1",
    "limit": 5
  }'

Complete a Job

# Complete a job (echo lease_id from the claim response)
curl -X POST https://api.spooled.cloud/api/v1/jobs/job_xyz123/complete \
  -H "Authorization: Bearer sp_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "worker_id": "worker-1",
    "lease_id": "lease_abc",
    "result": {"processed": true}
  }'

Authentication

Include your API key or JWT token in the Authorization header:

# All API requests require authentication
curl -X POST https://api.spooled.cloud/api/v1/jobs \
  -H "Authorization: Bearer sp_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"queue_name": "my-queue", "payload": {...}}'

Passing the credential as ?api_key=… or ?token=… instead returns 401 Unauthorized — query strings are recorded by proxy and CDN access logs, tracing spans, browser history and the Referer header, and API keys do not expire by default. Only /api/v1/ws, /api/v1/events, /api/v1/events/jobs/{id} and /api/v1/events/queues/{name} still accept it in the URL, because browser EventSource and WebSocket clients cannot set headers.

See Authentication for details on obtaining tokens.

Endpoints

Authentication

Method Endpoint Description
POST /api/v1/auth/login Exchange API key for JWT tokens
POST /api/v1/auth/refresh Refresh access token
GET /api/v1/auth/me Get current user/organization info
POST /api/v1/auth/logout Logout and invalidate tokens
POST /api/v1/auth/email/start Start passwordless email login
POST /api/v1/auth/email/verify Verify email login code

Jobs

The list takes filters (status as one value or a comma-separated list, q, job_type, created_after, created_before, tag) and answers 400 for an invalid value or 400 QUERY_TIMEOUT for a list too slow to answer: see Filtering jobs. complete, fail and heartbeat require the lease_id from the claim response and answer 422 without it.

Method Endpoint Description SDK
POST /api/v1/jobs Create/enqueue a new job client.jobs.create(...)
GET /api/v1/jobs List jobs with filtering and paging (X-Has-More header) client.jobs.list(...)
GET /api/v1/jobs/:id Get job details client.jobs.get(id)
GET /api/v1/jobs/:id/history Lifecycle events recorded for a job (order_dir, limit, offset; X-Has-More header)
POST /api/v1/jobs/bulk Bulk enqueue multiple jobs client.jobs.bulkEnqueue(...)
POST /api/v1/jobs/claim Claim jobs for processing client.jobs.claim(...)
POST /api/v1/jobs/:id/complete Mark job as completed client.jobs.complete(id, ...)
POST /api/v1/jobs/:id/fail Mark job as failed client.jobs.fail(id, ...)
POST /api/v1/jobs/:id/heartbeat Extend job lease client.jobs.heartbeat(id, ...)
POST /api/v1/jobs/:id/retry Manually retry a job client.jobs.retry(id)
PUT /api/v1/jobs/:id/priority Boost job priority client.jobs.boostPriority(id, ...)
DELETE /api/v1/jobs/:id Cancel a job client.jobs.cancel(id)
GET /api/v1/jobs/stats Get job statistics (filter: queue_name or alias queue) client.jobs.getStats(...)
GET /api/v1/jobs/status Batch status for up to 100 job IDs client.jobs.batchStatus(...)

Dead Letter Queue

Method Endpoint Description SDK
GET /api/v1/jobs/dlq List DLQ jobs (the job-list filters except status; X-Has-More header) client.jobs.dlq.list(...)
POST /api/v1/jobs/dlq/retry Retry DLQ jobs client.jobs.dlq.retry(...)
POST /api/v1/jobs/dlq/purge Purge DLQ jobs client.jobs.dlq.purge(...)

Queues

Queues are implicit: the first job into a name creates the queue, and there is no POST /api/v1/queues. A new queue counts against the plan queue cap. PUT /queues/:name/config sets the max_retries / default_timeout that jobs omitting those fields inherit. DELETE /queues/:name removes only the configuration and returns 409 while the queue still has jobs; add ?delete_jobs=true to delete its jobs (including DLQ entries) too. Pausing a queue that does not exist is a 404.

Method Endpoint Description SDK
GET /api/v1/queues List all queues client.queues.list()
GET /api/v1/queues/:name Get queue details client.queues.get(name)
PUT /api/v1/queues/:name/config Create or update queue configuration client.queues.updateConfig(name, ...)
GET /api/v1/queues/:name/stats Get queue statistics client.queues.getStats(name)
POST /api/v1/queues/:name/pause Pause queue processing client.queues.pause(name)
POST /api/v1/queues/:name/resume Resume queue processing client.queues.resume(name)
DELETE /api/v1/queues/:name Delete queue configuration (?delete_jobs=true also deletes its jobs) client.queues.delete(name)

Workers

Registration accepts an optional worker_id (1–128 characters from A-Z a-z 0-9 . _ -). Supply a stable id and registration is an upsert, so a restarting worker reuses one row; omit it and the server mints a UUID, leaving the previous row to occupy a slot in your plan worker cap until the stale-worker reaper clears it (about two minutes). Re-registering an id you already own is not charged against the cap; an id owned by a different organization returns 409.

Method Endpoint Description SDK
POST /api/v1/workers/register Register a worker (upsert when worker_id is supplied) client.workers.register(...)
GET /api/v1/workers List active workers, each with its jobs_processed and jobs_failed counters client.workers.list()
POST /api/v1/workers/:id/heartbeat Send worker heartbeat client.workers.heartbeat(id)
POST /api/v1/workers/:id/deregister Deregister a worker client.workers.deregister(id)

Schedules

In GET /api/v1/schedules/:id/history, a run's status follows the job it enqueued: pending or running while the job is in flight, then completed or failed once it finishes (completed_at is empty until then). job_status is the raw job status (pending, scheduled, processing, completed, failed, deadletter, cancelled). See schedule history.

Method Endpoint Description SDK
POST /api/v1/schedules Create a schedule client.schedules.create(...)
GET /api/v1/schedules List schedules client.schedules.list()
GET /api/v1/schedules/:id Get schedule details client.schedules.get(id)
PUT /api/v1/schedules/:id Update schedule client.schedules.update(id, ...)
DELETE /api/v1/schedules/:id Delete schedule client.schedules.delete(id)
POST /api/v1/schedules/:id/pause Pause schedule client.schedules.pause(id)
POST /api/v1/schedules/:id/resume Resume schedule client.schedules.resume(id)
GET /api/v1/schedules/:id/history Runs of a schedule, newest first (limit, default 50, at most 500). status follows the job each run enqueued; job_status is that job's raw status

Workflows

Method Endpoint Description SDK
POST /api/v1/workflows Create a workflow DAG client.workflows.create(...)
GET /api/v1/workflows List workflows client.workflows.list()
GET /api/v1/workflows/:id Get workflow details client.workflows.get(id)
POST /api/v1/workflows/:id/cancel Cancel a workflow client.workflows.cancel(id)
POST /api/v1/workflows/:id/retry Retry a failed workflow client.workflows.retry(id)

Incoming Webhooks

The inbound log lists only requests the endpoint accepted (a wrong token or an over-limit request writes nothing). It is not the delivery history of your outgoing webhooks: that is GET /api/v1/outgoing-webhooks/:id/deliveries. status is created for a new job and duplicate when the idempotency_key matched an existing one. It needs an unrestricted API key: a queue-scoped key gets 403 ACCESS_DENIED.

Method Endpoint Description
POST /api/v1/webhooks/:org_id/custom Ingest Spooled-format JSON (header X-Webhook-Token)
GET /api/v1/webhooks/deliveries INBOUND request log: requests your custom webhook endpoint accepted, newest first, without payloads (status, since, queue_name, limit, offset). Not the outgoing delivery history
GET /api/v1/organizations/webhook-token Get webhook token + URL
POST /api/v1/organizations/webhook-token/regenerate Rotate webhook token

Outgoing Webhooks

Delivery attempts Spooled makes to your URL are at GET /api/v1/outgoing-webhooks/:id/deliveries. GET /api/v1/webhooks/deliveries (above) is the inbound log and never lists them. See which list is which. On PUT, secret is three-state: omit the field to keep the current secret, send "secret": null to clear it, or send a string to replace it. Clearing is destructive — deliveries then go out unsigned, with no X-Spooled-Signature header at all — so a client that serialises unchanged fields as explicit null will wipe the secret. PUT is also how you recover an auto-disabled endpoint ({"enabled": true}), which is charged against the plan webhook cap and can return 429 QUOTA_EXCEEDED. See outgoing webhook delivery for the event list (including job.deadlettered, job.retried, workflow.completed and workflow.failed) and the Slack, Discord and Teams formats.

Method Endpoint Description
POST /api/v1/outgoing-webhooks Create org outgoing webhook (events, optional format json|slack|discord|teams, optional HMAC X-Spooled-Signature)
GET /api/v1/outgoing-webhooks List outgoing webhooks
GET /api/v1/outgoing-webhooks/:id Get webhook details
PUT /api/v1/outgoing-webhooks/:id Update webhook: URL, events, format, enabled, secret (null clears it)
DELETE /api/v1/outgoing-webhooks/:id Delete webhook
POST /api/v1/outgoing-webhooks/:id/test Test webhook delivery
GET /api/v1/outgoing-webhooks/:id/deliveries OUTGOING delivery attempts Spooled made to this webhook's URL: newest 100, no query parameters, unrestricted API key only
POST /api/v1/outgoing-webhooks/:id/retry/:delivery_id Re-send one failed or pending delivery (at most 10 manual retries per delivery)

Real-time

These four routes are the only ones that accept the credential in the query string (?api_key= or ?token=), because browser EventSource and WebSocket clients cannot set headers. Every other endpoint on this page is header-only and returns 401 for a query-string credential.

Method Endpoint Description
GET /api/v1/events SSE stream of all org events (?queue= ?job_id= ?events=)
GET /api/v1/events/jobs/:id SSE for specific job
GET /api/v1/events/queues/:name SSE for specific queue
WS /api/v1/ws WebSocket connection

System

Method Endpoint Description
GET /health Health check (not version proof)
GET /health/live Liveness probe
GET /health/ready Readiness probe
GET /metrics Prometheus metrics

gRPC API

Spooled also provides a gRPC API for high-performance worker communication using HTTP/2 + Protobuf. The gRPC API supports streaming for real-time job delivery.

Endpoints
  • Spooled Cloud (TLS): grpc.spooled.cloud:443
  • Self-hosted / local: localhost:50051 (or GRPC_PORT)

gRPC Services

Service RPC Description
QueueService Enqueue Create a new job
QueueService Dequeue Claim jobs for processing (batch)
QueueService Complete Mark job as completed
QueueService Fail Mark job as failed (retry: false dead-letters it now; omit retry to retry per max_retries)
QueueService RenewLease Extend a job lease (heartbeat)
QueueService GetJob Fetch a job by id
QueueService GetQueueStats Queue depth / status counts
QueueService StreamJobs Server streaming job delivery
QueueService ProcessJobs Bidirectional stream (claim + complete/fail on one connection)
WorkerService Register Register a worker (optional stable workerId, same rules as REST)
WorkerService Heartbeat Send worker heartbeat
WorkerService Deregister Deregister a worker

Proto JSON field names use camelCase (jobId, leaseId). Send the leaseId from dequeue/claim on Complete / Fail / RenewLease: it is required (a call without one fails with INVALID_ARGUMENT, as REST answers 422), and it fences stale workers (409 LEASE_EXPIRED). An omitted lease duration on dequeue, stream or renew is 300 seconds. Omitting maxRetries / timeoutSeconds on Enqueue applies server settings defaults (typically 3 retries / 300s); gRPC cannot express “zero retries” the way REST max_retries: 0 can.

gRPC payloads are Protobuf Struct values

payload and result are google.protobuf.Struct fields, so over gRPC only a JSON object arrives. A job whose payload (or result) is an array, string, number, boolean or null reaches a gRPC worker with the field absent: no error, and nothing that tells it from a job that had none. Integers of 253 (9007199254740992) or more cannot be held exactly and come back as rounded floats. REST keeps both exactly.

Send such jobs over REST, or wrap them: put the array in an object ({"items": [1, 2]}) and send large integers (snowflake ids, 64-bit counters) as strings. Whole numbers below 253 come back as integers.

gRPC Authentication

Include your API key in gRPC metadata as x-api-key (API keys only — JWT is not accepted on the gRPC path). REST uses Authorization: Bearer … (API key or JWT); X-API-Key is not a REST header.

x-api-key: sp_live_your_api_key

When to Use gRPC vs REST

Use Case Recommended
Web/mobile appsREST API
Dashboard/adminREST API
High-throughput workersgRPC
Streaming job deliverygRPC

Response Format

All responses are JSON. Successful responses include the data directly:

{
  "id": "job_abc123",
  "queue_name": "emails",
  "status": "pending",
  "payload": { "to": "user@example.com" },
  "created_at": "2024-01-15T10:30:00Z"
}

Error responses carry a machine-readable code and a message; some add details. Branch on code, not on the message text. A request body that fails validation comes back as one of three cases:

  • 400 VALIDATION_ERROR — the JSON parsed but a rule was broken (length, allowed characters, ranges).
  • 400 INVALID_JSON — the body is empty or is not valid JSON.
  • 422 VALIDATION_ERROR — the JSON parsed but a required field is missing or has the wrong type.

A body rejected while the request is parsed has an error summary, the code, a one-line message and a details array with one { field, message, code } entry per problem. (A check made later, inside an endpoint, is the short { code, message } form, with the same 400 and VALIDATION_ERROR.) A rule violation, here a queue name that starts with a dash:

HTTP/1.1 400 Bad Request

{
  "error": "Validation failed",
  "code": "VALIDATION_ERROR",
  "message": "queue_name: Queue name cannot start with dots or dashes",
  "details": [
    {
      "field": "queue_name",
      "message": "Queue name cannot start with dots or dashes",
      "code": "invalid_queue_name"
    }
  ]
}

A missing required field. A wrongly typed one is also 422, with details[].code invalid_type:

HTTP/1.1 422 Unprocessable Entity

{
  "error": "Validation failed",
  "code": "VALIDATION_ERROR",
  "message": "Field `queue_name` is required",
  "details": [
    {
      "field": "queue_name",
      "message": "Field `queue_name` is required",
      "code": "missing_field"
    }
  ]
}

Other errors (401, 403, 404, 409, 429, 5xx) are { "code": "...", "message": "..." } and may add details; see Common Error Codes.

Rate Limiting

API requests are rate-limited per organization with a token bucket sized by your plan. A request over the limit is answered 429 Too Many Requests with code RATE_LIMIT_EXCEEDED, a Retry-After header (whole seconds to wait) and the same facts in the body: retry_after_secs, limit, remaining and reset_at.

Not every 429 is a rate limit. A plan limit that is full (daily jobs, active jobs, queues, workers, API keys, schedules, webhooks) is also 429, but with code QUOTA_EXCEEDED, no Retry-After header and a body with resource, current, limit, plan and upgrade_to. Waiting and retrying the same request does not help: free capacity or move to upgrade_to. Branch on code; see Two kinds of 429.

Authenticated responses, allowed or refused, also say where you stand:

Header Meaning
X-RateLimit-Limit The bucket size: your plan burst (Free 10, Starter 50, Pro 200, Enterprise 1,000), or the API key's own per-minute limit when that is the one that answered
X-RateLimit-Remaining Requests you may still make right now, after this one
X-RateLimit-Reset The Unix time in seconds at which Remaining next goes up (the current time while the bucket is full). It is a point in time, not a wait; on a 429 it equals reset_at in the body
X-RateLimit-Scope organization or api_key: which limit the other three describe
HTTP/1.1 200 OK
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 187
X-RateLimit-Reset: 1791788683
X-RateLimit-Scope: organization
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1791788695
X-RateLimit-Scope: organization

{
  "error": "Rate limit exceeded",
  "code": "RATE_LIMIT_EXCEEDED",
  "retry_after_secs": 12,
  "limit": 200,
  "remaining": 0,
  "reset_at": 1791788695
}

When an API key also has its own per-minute rate_limit, the headers describe whichever limit is closer to running out. Requests that skip the limiter (unauthenticated routes) carry no X-RateLimit-* headers, so treat them as optional and fall back to Retry-After on a 429. Browsers can read these headers, Retry-After, X-Request-Id and X-Has-More on cross-origin calls.

See Rate Limits for plan-specific RPS / burst / quota numbers.

Rate Limited?

Account → Usage

What to look for:

  • → Current request rate
  • → Daily job count
  • → Retry-After header value

Actions:

  • Implement exponential backoff
  • Use bulk operations
  • Consider upgrading your plan

Pagination

GET /api/v1/jobs, GET /api/v1/jobs/dlq and GET /api/v1/jobs/{id}/history use limit/offset paging. There is no cursor and no total count: the body is a plain array, and the X-Has-More response header (true or false) says whether another page follows. Raise offset until it reads false; without the header, until a page comes back with fewer than limit items.

  • limit — items per page (jobs: default 50, max 100; job history: default 100, max 500)
  • offset — number of items to skip (default: 0)
GET /api/v1/jobs?limit=50&offset=100

HTTP/1.1 200 OK
X-Has-More: true

A cursor parameter is not supported and is ignored. Other list endpoints have their own limit defaults and caps; see the endpoint's section. The job list also filters by status, text, job type and creation time: see Filtering jobs.

Need more details?

Last updated 2026-10-16