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.
- Spooled Cloud (TLS):
grpc.spooled.cloud:443 - Self-hosted / local:
localhost:50051(orGRPC_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 apps | REST API |
| Dashboard/admin | REST API |
| High-throughput workers | gRPC |
| Streaming job delivery | gRPC |
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:
400VALIDATION_ERROR— the JSON parsed but a rule was broken (length, allowed characters, ranges).400INVALID_JSON— the body is empty or is not valid JSON.422VALIDATION_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?
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.