Rate Limits & Quotas
Spooled Cloud enforces rate limits and quotas to ensure fair usage and platform stability. Limits vary by plan tier and can be increased for Enterprise customers.
Plan Comparison
| Resource | Free | Starter | Pro | Enterprise |
|---|---|---|---|---|
| Jobs per day | 1,000 | 10,000 | 100,000 | Unlimited |
| Active jobs | 10 | 500 | 5,000 | Unlimited |
| Queues | 2 | 10 | 50 | Unlimited |
| Workers | 1 | 5 | 25 | Unlimited |
| API keys | 2 | 5 | 25 | Unlimited |
| Schedules | 1 | 10 | 50 | Unlimited |
| Workflows | — | 5 | 25 | Unlimited |
| Webhooks | 1 | 5 | 20 | Unlimited |
| Max payload size | 64 KB | 256 KB | 1 MB | 1 MB |
| API rate (req/sec) | 5 | 25 | 100 | 500 |
| API burst | 10 | 50 | 200 | 1000 |
| Job retention | 3 days | 14 days | 30 days | 90 days |
| History retention | 1 days | 7 days | 30 days | 90 days |
Job retention is how long jobs are kept. Completed jobs are deleted after the plan's window. A cancelled job is kept while its workflow row still exists; otherwise cancelled jobs are deleted after that window. Failed and dead-lettered jobs are kept past that window only while a live dependent (a pending, scheduled, or processing job) or their workflow still exists. Their dead-letter queue payload copies are deleted on the plan's window regardless, even when the job row is kept. You can retry or purge them sooner. History retention covers the supporting records — including outgoing webhook delivery history — which a per-organization sweep deletes once they pass the window. On Free that is a single day, so pull anything you need for debugging the same day it happens.
Rate Limiting
API rate limits use a token bucket per organization (a steady rate per second plus a burst allowance). When you exceed the limit, requests return
429 Too Many Requests with code RATE_LIMIT_EXCEEDED and a Retry-After header (seconds to wait before retrying).
A plan limit that is full is also a 429 (QUOTA_EXCEEDED, no Retry-After): see
Two kinds of 429.
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 Two kinds of 429
429 does not always mean "slow down". Spooled answers 429 for two different conditions,
and only one of them clears if you wait a few seconds. Tell them apart by the code in the body (and by
the Retry-After header), never by the status alone:
| Aspect | RATE_LIMIT_EXCEEDED | QUOTA_EXCEEDED |
|---|---|---|
| What it is | Too many requests in a short window (the token bucket above). Short-lived | A plan limit is full: jobs per day, active jobs, queues, workers, API keys, schedules, webhooks (see Quota Enforcement) |
Retry-After header | Yes: whole seconds to wait. Exception: too many open realtime streams has none and clears when one of your streams closes | No. There is no wait that is guaranteed to fix it |
| Body | retry_after_secs, limit, remaining, reset_at | resource, current, limit, plan and upgrade_to, the plan to move to (left out when there is no higher plan) |
| Does retrying help? | Yes. Wait Retry-After seconds, then send the same request again | No, not as it stands. The same request keeps failing until usage drops below the limit (finish, cancel or delete jobs, remove unused keys, schedules or webhooks; the daily job count resets at UTC midnight) or you move to upgrade_to |
So retry a 429 only when code is RATE_LIMIT_EXCEEDED. On
QUOTA_EXCEEDED, stop retrying, surface the error and act on it: free capacity or follow
upgrade_to (see Increasing Limits). The official SDKs already do this:
they never retry a QUOTA_EXCEEDED response, and in the Node.js SDK
RateLimitError.isQuota is true for it.
Rate-limit headers
Every authenticated response, allowed or refused, says where you stand, so a client can slow down before it is refused:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The bucket size: your plan's API burst from the table above, 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). On a 429 it equals reset_at in the body |
X-RateLimit-Scope | organization or api_key: which limit the three headers above describe |
Retry-After | On a 429 only: the wait in whole seconds. X-RateLimit-Reset is a point in time, Retry-After is a duration: do not mix them up |
An API key with its own rate_limit (requests per minute) is checked after the organization bucket, so it
can only lower what that key may do; the headers then describe whichever limit is closer to running out.
Unauthenticated routes carry no X-RateLimit-* headers, so treat them as optional. Browsers can read
them, Retry-After, X-Request-Id and X-Has-More on cross-origin calls.
// Pace yourself from the headers instead of waiting for a 429
const res = await fetch('https://api.spooled.cloud/api/v1/jobs?limit=1', {
headers: { Authorization: `Bearer ${process.env.SPOOLED_API_KEY}` },
});
const remaining = Number(res.headers.get('X-RateLimit-Remaining'));
const resetAt = Number(res.headers.get('X-RateLimit-Reset')); // Unix seconds
if (res.status === 429) {
const waitSeconds = Number(res.headers.get('Retry-After')); // a duration
await sleep(waitSeconds * 1000);
} else if (remaining === 0) {
await sleep(Math.max(0, resetAt * 1000 - Date.now())); // a point in time
} Handling Rate Limits
Production Ready: All official SDKs (Node.js, Python, Go, PHP) include built-in rate limit handling with automatic retries. See the SDK documentation for details.
import { SpooledClient, RateLimitError } from '@spooled/sdk';
const client = new SpooledClient({ apiKey: process.env.SPOOLED_API_KEY! });
async function createJobWithRetry(payload, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await client.jobs.create(payload);
} catch (error) {
if (error instanceof RateLimitError) {
const retryAfter = error.getRetryAfter();
console.log(`Rate limited, retrying in ${retryAfter}s...`);
await sleep(retryAfter * 1000);
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded');
} Quota Enforcement
Quotas are enforced at different granularities. Exceeding a plan resource limit returns
429 Too Many Requests with code QUOTA_EXCEEDED and a stable body shape (field values vary
by resource). Unlike a rate limit it has no Retry-After header, and retrying the same
request does not help until you free capacity or upgrade: see Two kinds of 429.
{
"error": "limit_exceeded",
"code": "QUOTA_EXCEEDED",
"message": "daily job limit reached (1000/1000). Upgrade to starter for higher limits.",
"resource": "jobs_per_day",
"current": 1000,
"limit": 1000,
"plan": "free",
"upgrade_to": "starter"
} resource values the API can return, and the endpoints that trigger each:
| resource | What it caps | Triggered by |
|---|---|---|
jobs_per_day | Jobs created today (UTC midnight reset) | POST /api/v1/jobs, POST /api/v1/jobs/bulk, gRPC Enqueue, schedule/cron trigger, DLQ retry, inbound webhook enqueue |
active_jobs | Pending + processing jobs | Same job-creation paths as jobs_per_day |
queues | Distinct queues — configured, holding jobs, or targeted by an active schedule | The first job into a new queue name (POST /api/v1/jobs, /jobs/bulk, gRPC Enqueue), a schedule or workflow that targets a new queue, and PUT /api/v1/queues/{name}/config for a queue that does not exist yet. Jobs into queues you already have are never blocked. There is no POST /api/v1/queues: queues are created implicitly. |
workers | Registered workers | POST /api/v1/workers/register, gRPC worker register |
api_keys | API keys | POST /api/v1/api-keys |
schedules | Cron schedules | POST /api/v1/schedules |
workflows | Workflow DAGs (disabled on free) | POST /api/v1/workflows |
webhooks | Outgoing webhook endpoints | POST /api/v1/outgoing-webhooks, PUT /api/v1/outgoing-webhooks/{id} when it re-enables a disabled endpoint |
The queues cap counts every distinct queue name, so model a tenant, store or customer as one
queue (orders.store-42) and put finer routing, such as which printer or station, in the payload
or job type instead of in more queues. API keys scoped by a queue prefix (["orders.*"]) give each group of
locations its own key without a queue per device; see
one queue per location.
The workers cap counts registered rows, not running processes. A worker that restarts
without a stable worker_id registers a new row and abandons the old one, which keeps
holding a slot until the stale-worker reaper clears it (about two minutes) — on a tight plan a
crash-looping worker can lock itself out of registering. Pass a stable worker_id and
registration becomes an upsert: the worker reuses its row, and re-registering an id you already own
is not charged against the cap. See worker registration.
An outgoing webhook that was auto-disabled after 20
consecutive failed deliveries is turned back on with
PUT /api/v1/outgoing-webhooks/{id} and body
{"enabled": true}. That request is charged against this cap like a creation,
so at the cap it returns 429 QUOTA_EXCEEDED instead of restoring the endpoint.
Payload Size
Job payloads exceeding the plan limit are rejected with 413 (not the
QUOTA_EXCEEDED shape above):
{
"error": "payload_too_large",
"code": "PAYLOAD_TOO_LARGE",
"message": "Payload size (128000 bytes) exceeds plan limit (65536 bytes). Upgrade for larger payloads.",
"current": 128000,
"limit": 65536,
"plan": "free",
"upgrade_to": "starter"
} Automatic Limit Enforcement
Spooled automatically enforces plan limits before operations are executed, ensuring you never exceed your quotas unexpectedly.
Operations with Automatic Enforcement
- • POST /api/v1/jobs
- • POST /api/v1/jobs/bulk
- • gRPC Enqueue
- • POST /api/v1/workflows
- • Counts all jobs in DAG
- • POST /api/v1/schedules/{id}/trigger
- • Automatic cron triggers
- • POST /api/v1/jobs/dlq/retry
- • Validates before reactivating
For gRPC operations, the status code is RESOURCE_EXHAUSTED with the same error details in the message.
Monitoring Usage
Track your usage via the API or dashboard:
# Get current usage and plan limits
curl https://api.spooled.cloud/api/v1/organizations/usage \
-H "Authorization: Bearer YOUR_API_KEY"
# Response (abbreviated)
{
"plan": "starter",
"plan_display_name": "Starter",
"limits": {
"tier": "starter",
"max_jobs_per_day": 10000,
"max_active_jobs": 500,
"max_queues": 10,
"max_workers": 5,
"max_payload_size_bytes": 262144
// ...the other plan limits
},
"usage": {
"jobs_today": { "current": 1250, "limit": 10000, "percentage": 12.5, "is_disabled": false },
"active_jobs": { "current": 45, "limit": 500, "percentage": 9.0, "is_disabled": false },
"queues": { "current": 8, "limit": 10, "percentage": 80.0, "is_disabled": false },
"workers": { "current": 3, "limit": 5, "percentage": 60.0, "is_disabled": false }
// ...api_keys, schedules, workflows, webhooks
},
"warnings": [
{ "resource": "queues", "message": "queues at 80% of limit", "severity": "warning" }
]
} The response includes your plan, its limits, current usage per resource and any limit warnings. It does not include API call statistics.
Increasing Limits
Upgrade Your Plan
The easiest way to get higher limits is to upgrade to a paid plan at /pricing.
Enterprise Custom Limits
Enterprise customers can negotiate custom limits. Contact sales@spooled.cloud to discuss your requirements.
Self-Hosted
Self-hosted deployments can configure plan limits via environment variables on the backend. The values shown above are Spooled Cloud defaults; your self-hosted instance may differ.
Backend env vars: configure plan defaults with
SPOOLED_PLAN_LIMITS_JSON, SPOOLED_PLAN_<TIER>_LIMITS_JSON, or per-field
SPOOLED_PLAN_<TIER>_* variables. Per-org DB overrides (custom_limits) still take highest precedence.
See spooled-backend/.env.example for the full list and precedence.
Best Practices
- Batch operations — Use bulk endpoints (
/jobs/bulk) to reduce API calls - Implement backoff — Use exponential backoff when rate limited
- Monitor usage — Check
GET /api/v1/organizations/usage(or the dashboard) before you hit quotas; Spooled does not send quota alerts - Use idempotency keys — Safely retry without creating duplicates
- Consolidate payloads — Keep payloads small to stay within limits