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:

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

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:

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.

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