Platform
Rate limits
Halyard limits how fast each project can call the API, so one runaway loop can't slow down everyone else. Limits are generous, visible in every response, and easy to handle.
On this page
Overview
Limits apply per project and per environment: your test keys and live keys have separate budgets, and all keys in a project share one. Most apps never see a 429. When they do, it is usually a backfill script or a retry loop without a delay.
- Requests per second cover every API call, measured with a token bucket that allows short bursts.
- Jobs and events per minute cap how much work you can enqueue, counting each job in a batch.
- Worker connections cap how many long-polling workers can be connected at once.
Limits per plan
| Plan | Sustained req/s | Burst | Jobs / min | Events / min | Workers |
|---|---|---|---|---|---|
| Hobby | 25 | 50 | 1,500 | 600 | 10 |
| Team | 100 | 200 | 10,000 | 5,000 | 100 |
| Business | 500 | 1,000 | 60,000 | 30,000 | 1,000 |
| Enterprise | Custom | Custom | Custom | Custom | Custom |
Test keys get the Hobby limits on every plan. Worker polling calls don't count toward requests per second; they are covered by the worker connection limit instead.
Endpoint limits
A few endpoints have their own, lower limits on top of the plan limit:
| Endpoint | Limit | Why |
|---|---|---|
POST /jobs/batch | 10 req/s | Each request can carry up to 500 jobs. |
GET /jobs | 25 req/s | List queries scan more data; use webhooks instead of polling. |
POST /queues/:id/purge | 1 req/min | Purging is expensive and rarely needs repeating. |
POST /keys | 10 req/hour | Protects against leaked keys minting more keys. |
Rate limit headers
Every response tells you where you stand, following the IETF RateLimit header fields. Read them to slow down before you are limited, not after.
HTTP/2 201Content-Type: application/jsonRateLimit-Policy: 100;w=1, 200;burstRateLimit-Limit: 100RateLimit-Remaining: 87RateLimit-Reset: 1Halyard-Request-Id: req_4Tn8Wc2Ke7Headers
RateLimit-LimitintegerRateLimit-Remaininginteger0, the next request is limited.RateLimit-ResetintegerRateLimit-Remaining refills.RateLimit-Policystring100;w=1, 200;burst: 100 per one-second window, bursts up to 200.Retry-Afterinteger429 responses. Seconds to wait before retrying. Always honor it.When you hit a limit
A limited request fails fast with 429 Too Many Requests and does nothing: no job is created, no event is published. The body is a standard error object with type rate_limited.
HTTP/2 429Content-Type: application/jsonRateLimit-Limit: 100RateLimit-Remaining: 0RateLimit-Reset: 2Retry-After: 2Halyard-Request-Id: req_8Lc3Vp6Yh1 { "error": { "type": "rate_limited", "code": "requests_per_second", "message": "Too many requests. Retry after 2 seconds.", "request_id": "req_8Lc3Vp6Yh1", "doc_url": "https://docs.halyard.example/docs/rate-limits" }}Retry with backoff
The official SDKs already retry 429 and 5xx responses, honoring Retry-After. If you call the API directly, wait for Retry-After when it is present and use exponential backoff with jitter when it isn't. Jitter spreads retries out so many clients don't come back at the same instant.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); export async function withBackoff(request, options = {}) { const { maxAttempts = 5, baseMs = 500, capMs = 30_000 } = options; for (let attempt = 1; ; attempt++) { const res = await request(); if (res.status !== 429 && res.status < 500) return res; if (attempt === maxAttempts) return res; // Prefer the server's hint; otherwise exponential backoff with full jitter. const retryAfter = Number(res.headers.get("Retry-After")); const delay = retryAfter > 0 ? retryAfter * 1000 : Math.random() * Math.min(capMs, baseMs * 2 ** attempt); await sleep(delay); }}import randomimport time import httpx def with_backoff(request, max_attempts=5, base=0.5, cap=30.0) -> httpx.Response: for attempt in range(1, max_attempts + 1): res = request() if res.status_code != 429 and res.status_code < 500: return res if attempt == max_attempts: return res # Prefer the server's hint; otherwise exponential backoff with full jitter. retry_after = float(res.headers.get("Retry-After", 0)) delay = retry_after or random.uniform(0, min(cap, base * 2**attempt)) time.sleep(delay)func withBackoff(ctx context.Context, do func() (*http.Response, error)) (*http.Response, error) { const maxAttempts = 5 base, limit := 500*time.Millisecond, 30*time.Second for attempt := 1; ; attempt++ { res, err := do() if err != nil || (res.StatusCode != 429 && res.StatusCode < 500) || attempt == maxAttempts { return res, err } res.Body.Close() // Prefer the server's hint; otherwise exponential backoff with full jitter. delay := time.Duration(rand.Int64N(int64(min(limit, base<<attempt)))) if s, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && s > 0 { delay = time.Duration(s) * time.Second } select { case <-time.After(delay): case <-ctx.Done(): return nil, ctx.Err() } }}def with_backoff(max_attempts: 5, base: 0.5, cap: 30.0) 1.upto(max_attempts) do |attempt| res = yield return res if res.code.to_i != 429 && res.code.to_i < 500 return res if attempt == max_attempts # Prefer the server's hint; otherwise exponential backoff with full jitter. retry_after = res["Retry-After"].to_f sleep(retry_after.positive? ? retry_after : rand * [cap, base * 2**attempt].min) endendBurst vs sustained
Each project has a bucket of tokens. Every request takes one; the bucket refills at the sustained rate and holds at most the burst size. A quiet project builds up a full bucket, so it can spend 200 requests in a moment on the Team plan, then continue at 100 per second.
In practice: short spikes, like a deploy that enqueues a few hundred jobs, are absorbed by the burst. Long imports need to stay under the sustained rate, or use batch create.
Send fewer requests
- Batch your creates.
POST /jobs/batchtakes up to 500 jobs and counts as one request against requests per second. - Use webhooks, not polling. Subscribe to
job.succeededandjob.failedrather than callingGET /jobs/:idin a loop. - Publish events for fan-out. One event can start jobs on many queues through subscriptions.
- Need more? Business and Enterprise plans can raise limits. Write to support@halyard.example with your project ID and expected peak.
curl https://api.halyard.example/v3/jobs/batch \ -H "Authorization: Bearer $HALYARD_KEY" \ -H "Content-Type: application/json" \ -d '{ "jobs": [ { "queue": "emails", "task": "send-digest", "payload": { "user_id": "usr_8KfQ2" } }, { "queue": "emails", "task": "send-digest", "payload": { "user_id": "usr_1Mx4P" } } ] }'