API reference
Base URL: https://api.stafett.dev
All job endpoints require:
Authorization: Bearer stf_…
The OpenAPI explorer has the full schema and a built-in Authorize
button. Raw spec: /openapi.json.
Enqueue
POST /enqueue
{
"url": "https://api.example.com/hooks/ship",
"payload": {"order": 42},
"headers": {"x-source": "checkout"},
"queue": {
"delay_seconds": 0,
"dedupe_key": null
}
}
| Field | Required | Contract |
|---|---|---|
url |
yes | HTTPS URL, at most 2,048 characters. No userinfo. Localhost, metadata, and private/link-local destinations are rejected (422 at enqueue; a DNS rebind between accept and deliver marks the job dead without retry) |
payload |
no | Any JSON value, at most 1 MiB when compactly encoded; defaults to null |
headers |
no | Up to 50 string headers; each value at most 4,096 characters. Names must not start with stafett- or webhook- (reserved), and origin-describing names are refused: x-forwarded-*, forwarded, x-real-ip, x-client-ip, x-originating-ip, x-cluster-client-ip, cf-connecting-ip, true-client-ip, fastly-client-ip, host, user-agent |
queue |
no | Queue policy object; omit or pass {} for defaults |
queue.delay_seconds |
no | Non-negative integer; defaults to 0 |
queue.dedupe_key |
no | 1–256 character uniqueness key; while a job with this key is queued or in flight, a second enqueue returns 409 |
Returns 202 Accepted:
{
"id": "018f3b7c-6c4a-7b32-9f21-2c0a5e4d9b11",
"status": "queued",
"status_url": "/jobs/018f3b7c-6c4a-7b32-9f21-2c0a5e4d9b11"
}
Job status
GET /jobs/{id}
Returns:
{
"id": "018f3b7c-6c4a-7b32-9f21-2c0a5e4d9b11",
"status": "delivered",
"attempts": 1,
"created_at": "2026-08-03T16:00:00Z"
}
Possible statuses:
queued: waiting for deliverydelivering: an attempt is in progressretrying: a retryable attempt failed and is backing offdelivered: the destination returned a2xxresponsedead: the failure is terminal or the retry budget is exhaustedcanceled: cancelled before the first delivery attempt
An unknown job ID returns 404.
Following a job live (SSE)
Same URL, same auth. Add Accept: text/event-stream and the response is a
Server-Sent Events stream instead of a single JSON body:
curl -N https://api.stafett.dev/jobs/018f3b7c-6c4a-7b32-9f21-2c0a5e4d9b11 \
-H "Authorization: Bearer stf_…" \
-H "Accept: text/event-stream"
event: status
data: {"id":"018f3b7c-…","status":"queued","attempts":0,"created_at":"2026-08-03T16:00:00Z"}
event: status
data: {"id":"018f3b7c-…","status":"retrying","attempts":1,"created_at":"2026-08-03T16:00:00Z"}
event: status
data: {"id":"018f3b7c-…","status":"delivered","attempts":2,"created_at":"2026-08-03T16:00:00Z"}
event: end
data: delivered
- The first
statusevent carries the same body a plain request returns, so one request answers both "what is it now" and "tell me when it changes" — no separate poll first. - A
statusevent follows every change. Nothing is sent while the job sits still. endarrives once the job isdelivered,dead, orcanceled, and the server closes the stream. A job that is already finished when you connect gets onestatusand oneend.- Requests without that
Acceptheader are unaffected:*/*still returns JSON. - An unknown job ID is still a plain
404, not a stream. - Browser
EventSourcecannot sendAuthorization; use a stream client that can, and close onend.
One connection follows one job. To track many at once, poll GET /jobs/{id}.
Cancel a job
DELETE /jobs/{id}
Best-effort cancel. Returns 204 when the job exists for the authenticated
tenant:
- If the job is still
queued, it becomescanceledand is removed from the delivery queue — no delivery will be sent. - If delivery has already started (
delivering/retrying) or the job is finished, the request succeeds without stopping in-flight work. PollGET /jobs/{id}for the real outcome.
An unknown job ID returns 404.
Delivery and retries
Stafett POSTs the exact JSON payload bytes to url.
Five attempts per cycle; backoff starts at one second, doubles each retry, capped at five minutes; connect timeout five seconds; overall request timeout thirty seconds.
2xxmarks the job delivered.- Connection failures, timeouts,
429, and5xxresponses retry with exponential backoff while attempts remain. - Other non-
2xxresponses move the job directly todead. - A retryable failure on the final attempt also moves to
dead.
Delivery is typically exactly once. An attempt that cannot be verified (a
timeout, or a dropped connection after the request went out) is retried
rather than dropped, so the contract is formally at-least-once and
destinations must tolerate repeats. Dedupe on webhook-id when repeats
matter.
Dead-letter and replay
Exhausted or non-retryable jobs become dead and stay in the dead-letter queue
with every attempt logged. Replay is control-plane only (app.stafett.dev/dlq):
it starts a new cycle with a fresh retry budget. Attempt numbers stay absolute;
history is never rewritten.
Delivery signatures
Every attempt follows the Standard Webhooks signing contract:
webhook-id: 018f3b7c-6c4a-7b32-9f21-2c0a5e4d9b11
webhook-timestamp: 1785772800
webhook-signature: v1,...
stafett-hops: 0
stafett-tenant: 9f24a0e7c31b5d08
user-agent: Stafett/0.1.0 (+https://stafett.dev/abuse)
content-type: application/json
webhook-id is the Stafett job ID and stays stable across retries. Use it as
the idempotency key when you own the endpoint. webhook-timestamp and
webhook-signature are generated for each attempt. Stafett's signing headers
override any conflicting values in the enqueue request.
Verify over the raw request body with a Standard Webhooks-compatible library.
Do not parse and re-encode the JSON before verification. Third-party APIs ignore
these headers; pass their auth in headers instead.
Loop protection
stafett-hops is the job's chain depth: 0 for a job enqueued from outside
Stafett, one more for each enqueue made from inside a delivery handler. If
your handler enqueues follow-up work, forward the header on that enqueue.
Chains keep working; an enqueue more than 3 hops from its origin returns
422 "delivery chain too deep", which is what an accidental enqueue loop
looks like. A fresh enqueue without the header always starts at 0, so the
guard only catches handlers that forward it; do not enqueue what you received
verbatim.
The stafett-* and webhook-* prefixes are reserved: enqueue headers
using them are rejected with 422.
Attribution
Every delivery carries user-agent: Stafett/<version> (+https://stafett.dev/abuse)
and stafett-tenant, an opaque 16-character token that is stable for one
account across jobs and retries. Together they let the receiving side identify
the traffic, block a single account rather than all of Stafett, and quote
something we can trace when reporting abuse — pair stafett-tenant with the
webhook-id of a specific delivery.
The token is a keyed digest, not the account ID, so it discloses nothing about the sender. Stafett sets both headers itself; they cannot be supplied or overridden at enqueue.
Because Stafett is a queue rather than a proxy, it never forwards client-IP
headers: there is no live client behind a delivery, and a destination that
trusts x-forwarded-for from a proxy would otherwise be reading a value the
enqueuing account chose. Those names are rejected at enqueue and stripped
before delivery.
Common errors
401: bearer token missing, invalid, or revoked404: job not found for the authenticated account409: a job with thisqueue.dedupe_keyis already queued or in flight413: the whole request body is over 2 MiB. Thepayloadfield itself is capped at 1 MiB (422); this coarser limit covers everything else in the request422: request validation failed (including an unsafe or non-HTTPSurl)429: enqueue admission control. Either the per-minute rate limit was exceeded — the response carries aRetry-Afterheader in seconds — or the account is at its cap of undelivered jobs, which clears as jobs deliver, die, or are canceled.