Skip to content
On this page

Jobs & Queues

Jobs are the fundamental building blocks of Spooled. Learn how to queue, process, and manage asynchronous work reliably at any scale.

Job Lifecycle

Every job follows a predictable lifecycle from creation to completion. Understanding this lifecycle is key to building reliable systems.

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#ecfdf5', 'primaryTextColor': '#065f46', 'primaryBorderColor': '#10b981', 'lineColor': '#6b7280'}}}%%
stateDiagram-v2
    [*] --> pending: Job created
    [*] --> scheduled: Created with a future scheduled_at
    scheduled --> pending: Time reached
    scheduled --> cancelled: Cancelled
    pending --> processing: Worker claims
    processing --> completed: Success
    processing --> scheduled: Error, retries left
    processing --> deadletter: Error, retries exhausted
    pending --> cancelled: Cancelled
    deadletter --> pending: Retry from DLQ
    completed --> [*]

Job States

State Description Transitions
pending Job is waiting to be processed → processing (by worker)
scheduled Waiting until scheduled_at. A future job starts here, and an automatic retry waits here until its run time, then becomes pending → pending (when scheduled_at is reached), cancelled
processing Worker is processing the job → completed, scheduled (automatic retry until its run time, then pending), deadletter
completed Job finished successfully Terminal state
deadletter Job in dead-letter queue → pending (retry from DLQ)
cancelled Cancelled with DELETE /api/v1/jobs/{id} before it ran Terminal state

Dashboard Tip

Dashboard → Jobs

What to look for:

  • → Filter jobs by status (pending, scheduled, processing, completed, deadletter, cancelled)
  • → View job payload and metadata
  • → See retry count and last error

Creating Jobs

Use the SDK or REST API to create jobs. Each job belongs to a queue and carries a payload that your workers will process.

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"
  }'

Job Properties

Property Type Description
queue_name string Queue name (alphanumeric + hyphens). Required on create.
payload object JSON payload (plan caps: 64 KB free → 1 MB Pro/Enterprise; platform hard cap 1 MiB)
idempotency_key string Prevents duplicate jobs (optional but recommended)
max_retries number Max retry attempts. Omitted → server settings default (typically 3). Explicit 0 allowed on REST (first fail can DLQ).
timeout_seconds number Stored with the job, but not enforced: neither the server nor the SDK workers stop a handler that runs past it. Enforce your own time limit in the handler; the claim lease (lease_duration_secs, extended by heartbeats) is what controls re-delivery.
scheduled_at Date When to process (null = immediately)
priority number Higher values processed first (default: 0)
tags array | object Optional tags for filtering (stored as JSON)
completion_webhook string (URL) Optional per-job callback URL.
completion_webhook_secret string Optional HMAC secret (backend 0.1.109+). When set, delivery includes X-Spooled-Timestamp + X-Spooled-Signature like org outgoing webhooks. Omit for unsigned callbacks.

List and stats filters accept canonical queue_name or the alias queue (e.g. GET /api/v1/jobs?queue=emails and GET /api/v1/jobs/stats?queue_name=emails).

Filtering jobs (including tags)

GET /api/v1/jobs takes these query parameters. They combine with AND, and an empty parameter (?status=) is no filter:

Parameter Meaning
queue_name (alias queue) One queue. Letters, digits, -, _ and . only
status One status or a comma-separated list: pending, scheduled, processing, completed, failed, deadletter, cancelled (case-insensitive), e.g. status=failed,deadletter
q Search text (case-insensitive, at most 255 bytes): matches a job whose id starts with it, or whose queue name or job type contains it
job_type Exact payload.job_type
created_after, created_before RFC 3339 instants: created at or after / before. Percent-encode a + offset as %2B
tag A single tag (any characters, up to 1024 bytes)
order_by, order_dir created_at (default), updated_at, scheduled_at, completed_at, priority, status, queue_name, retry_count; desc (default) or asc
limit, offset Page size (default 50, at most 100) and rows to skip
# Failed and dead-lettered jobs in one queue since a given time
curl -G https://api.spooled.cloud/api/v1/jobs \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "queue_name=emails" \
  --data-urlencode "status=failed,deadletter" \
  --data-urlencode "created_after=2026-10-11T00:00:00Z"

# Jobs tagged "urgent", and DLQ jobs tagged "urgent"
curl "https://api.spooled.cloud/api/v1/jobs?tag=urgent" \
  -H "Authorization: Bearer YOUR_API_KEY"
curl "https://api.spooled.cloud/api/v1/jobs/dlq?tag=urgent" \
  -H "Authorization: Bearer YOUR_API_KEY"

An invalid status (any entry of the list), queue_name, tag, q, job_type, created_after, created_before, order_by or order_dir is a 400 VALIDATION_ERROR. It is not ignored: before API 0.1.121 an invalid filter quietly returned the unfiltered list. Rows that tie on the sort column are ordered by job id, so offset pages never repeat or skip a job. The body is a plain array; its X-Has-More header says whether another page follows (see Pagination). GET /api/v1/jobs/dlq takes the same parameters except status.

Slow lists stop with 400 QUERY_TIMEOUT

A job list runs under a statement timeout (5 seconds by default). A list the database cannot answer in time is stopped and answered 400 with code QUERY_TIMEOUT. Retrying the same request would time out again: narrow it with queue_name, status, job_type, a created window or a longer q. The job-type part of q looks at your first 1,000 distinct job types only; job_type (exact), the id prefix and the queue name still find the rest.

Job history

GET /api/v1/jobs/{id}/history returns the lifecycle events recorded for one job, oldest first (order_dir=desc for newest first; order works as an alias). limit is 1 to 500 (default 100), offset skips events, and the X-Has-More header says whether more follow.

curl "https://api.spooled.cloud/api/v1/jobs/job_550e8400e29b41d4/history?order_dir=desc&limit=20" \
  -H "Authorization: Bearer YOUR_API_KEY"

[
  {
    "id": "b2a4e7d0-5c1f-4a38-9e63-0d7f4c2a9b11",
    "job_id": "job_550e8400e29b41d4",
    "event_type": "retry_scheduled",
    "worker_id": null,
    "details": {
      "error": "SMTP timeout",
      "retry_count": 1,
      "next_run": "2026-10-12T09:00:09Z",
      "organization_id": "org_xxxxx"
    },
    "created_at": "2026-10-12T09:00:07Z"
  },
  {
    "id": "6f0c1a52-9a0e-4c0e-8d52-3c0f6a7f1d10",
    "job_id": "job_550e8400e29b41d4",
    "event_type": "processing",
    "worker_id": "emails-worker-1",
    "details": { "worker_id": "emails-worker-1", "lease_id": "lease_7f3c9a", "organization_id": "org_xxxxx" },
    "created_at": "2026-10-12T09:00:01Z"
  }
]

event_type is processing, completed, retry_scheduled or deadlettered. Events are recorded by REST claim, complete and fail and by the scheduler's dead-letter sweeps, so the history is sparse for jobs handled over gRPC and a job has no created event. A job of another organization, an unknown id and a job in a queue outside the key's scope all answer 404.

Claiming & Processing Jobs

Workers claim jobs to process them. Claimed jobs are locked for a visibility timeout to prevent duplicate processing.

# 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
  }'

Important: Visibility Timeout

If a worker doesn't complete or fail a job within the visibility timeout, the job becomes available for other workers. Set this higher than your expected processing time.

Lease fencing

Claim/dequeue responses include a lease_id (SDK: leaseId). Pass it back on complete, fail, and heartbeat: it is required (a request without it is a 422 VALIDATION_ERROR, and INVALID_ARGUMENT over gRPC), and it keeps a stale worker after lease expiry from acking a job another worker already holds (409 LEASE_EXPIRED). Official worker runtimes do this automatically.

Completing Jobs

# 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}
  }'

Failing Jobs

# Fail a job (will retry if retries remaining; echo lease_id from claim)
curl -X POST https://api.spooled.cloud/api/v1/jobs/job_xyz123/fail \
  -H "Authorization: Bearer sp_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "worker_id": "worker-1",
    "lease_id": "lease_abc",
    "error": "Connection timeout"
  }'

Heartbeat (Extend Lease)

For long-running jobs, extend the lease to prevent the job from being released:

# Extend job lease (heartbeat; echo lease_id from claim)
curl -X POST https://api.spooled.cloud/api/v1/jobs/job_xyz123/heartbeat \
  -H "Authorization: Bearer sp_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "worker_id": "worker-1",
    "lease_id": "lease_abc",
    "lease_duration_secs": 300
  }'

Batch Operations

Queue multiple jobs in a single API call for better performance. All jobs in a batch share the same idempotency key scope.

# Bulk enqueue up to 100 jobs
curl -X POST https://api.spooled.cloud/api/v1/jobs/bulk \
  -H "Authorization: Bearer sp_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "queue_name": "notifications",
    "jobs": [
      {"payload": {"type": "email", "to": "a@example.com"}},
      {"payload": {"type": "sms", "to": "+1234567890"}},
      {"payload": {"type": "push", "token": "abc123"}}
    ]
  }'

Scheduled Jobs

Schedule jobs for future execution. Perfect for reminders, delayed notifications, or recurring tasks.

# Schedule a job for later
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": "reminders",
    "payload": {"type": "cart-abandoned", "user_id": "usr_123"},
    "scheduled_at": "2030-01-15T09:00:00Z"
  }'

Idempotency

Idempotency keys prevent duplicate job processing. When you create a job with an idempotency key:

  • If no job exists with that key, a new job is created
  • If a job exists and is still pending/processing, the existing job is returned
  • If a job exists and completed, the completed job is returned (no duplicate)

Best Practice

Always use meaningful idempotency keys derived from your business logic, like process-order-{orderId} or send-welcome-email-{userId}.

Working with Queues

Queues are logical groupings of related jobs. Use separate queues for different job types to enable independent scaling and monitoring.

Queue Naming

  • Use lowercase letters, numbers, and hyphens
  • Maximum 64 characters
  • Be descriptive: payment-processing, email-notifications

Recommended Queue Structure

  • High-priority queue — Critical operations (payments, security alerts)
  • Default queue — Standard background jobs
  • Low-priority queue — Analytics, cleanup, non-urgent tasks

Dashboard Tip

Dashboard → Queues

What to look for:

  • → Jobs pending vs processing
  • → Throughput rate
  • → Pause/resume controls

Actions:

  • Pause a queue during maintenance
  • Monitor queue depth for scaling decisions

Next Steps

Last updated 2026-10-16