API reference
The stafett-enqueue image serves this HTTP API on port 8000. In Kubernetes
the base URL is the Service, e.g.
http://stafett-worker-enqueue.stafett:8000. In the quickstart it is
http://localhost:8000.
There is no authentication. Anyone who can reach the port can enqueue a POST to any URL, so keep it off the public internet and limit who can reach it inside your network.
FastAPI serves the schema at /openapi.json and an explorer at /docs on the
same port.
Enqueue
POST /enqueue
The request body is the payload, sent to the destination byte for byte. Everything else is a request header.
curl -X POST http://localhost:8000/enqueue \
-H 'content-type: application/json' \
-H 'stafett-url: https://hooks.example.com/orders' \
-H 'stafett-dedupe-key: order-42' \
-H 'stafett-header-authorization: Bearer …' \
--data-binary '{"order": 42}'
| Part | Required | Contract |
|---|---|---|
| Body | no | Any UTF-8 text, at most 1 MiB. Stafett never parses or re-encodes it. Empty by default. |
Content-Type |
no | Sent as the delivery's content-type. Without it, none is sent. |
Stafett-Url |
yes | http or https, at most 2,048 characters. |
Stafett-Header-<name> |
no | Sent as <name>. Up to 50, counting Content-Type. Each value at most 4,096 characters. See "Headers" below. |
Stafett-Delay-Seconds |
no | Wait this long before the first attempt. Non-negative integer, default 0. |
Stafett-Dedupe-Key |
no | 1 to 256 characters. While a job with this key is queued or in flight, another enqueue with it answers 409. |
Other request headers, such as your client's user-agent, are not sent on.
An unknown or repeated Stafett-* header is refused with 422, so a typo
cannot silently drop an option.
Answers:
| Status | Meaning |
|---|---|
202 |
Committed. The job is in Postgres and will be delivered. |
409 |
A job with this Stafett-Dedupe-Key is queued or in flight. Nothing was written. |
422 |
The request breaks a limit above. Nothing was written. |
503 |
The database is unavailable. See below. |
{"id": 1234, "status": "queued", "status_url": "/jobs/1234"}
id is an integer, unique within one database.
A 503 is almost always "nothing written". The exception: if the
connection drops during the commit itself, the job may have been committed.
Retrying with a Stafett-Dedupe-Key answers 409 if the first attempt
landed and that job is still queued or in flight. If it was already delivered
or dead, the retry is accepted as a new job, with a new webhook-id when
signing is on.
Enqueue is not part of your transaction. If your service commits its own
data and then crashes before calling POST /enqueue, the job is never
queued. Call enqueue after your commit and retry it until you get 202 or
409.
Job status
GET /jobs/{id}
{"id": 1234, "status": "retrying", "attempts": 2, "created_at": "2026-11-03T09:00:00Z"}
| Status | Meaning |
|---|---|
queued |
Waiting for its first attempt. |
delivering |
A deliver pod is running an attempt now. |
retrying |
At least one attempt failed; waiting for the next. |
delivered |
The destination answered 2xx. |
dead |
Not retryable, or out of attempts. Kept for replay. |
canceled |
Canceled before it was delivered. |
attempts counts every attempt over the job's life, replays included, so it
only ever goes up. Unknown id: 404. Database unavailable: 503.
Cancel
DELETE /jobs/{id}
Answers 204 for any job that exists. A job that is queued or retrying
becomes canceled and is never sent. A job that is delivering, or already
finished, is left alone: check GET /jobs/{id} for what happened. Unknown id:
404.
Health
GET /healthz
{"status": "ok", "version": "0.1.0"}
Served by both images. On deliver it answers 503 once the delivery loop has
stopped, so a liveness probe restarts it.
The delivery request
deliver sends a POST to Stafett-Url. You decide the body and the
headers; Stafett adds only what you left out, and signs the request if
deliver has a signing secret.
With signing on (STAFETT_SIGNING_SECRET set):
POST <url>
content-type: application/json
user-agent: Stafett/0.1.0 (+https://stafett.dev)
<your headers>
webhook-id: 019a4b7c-6c4a-7b32-9f21-2c0a5e4d9b11
webhook-timestamp: 1793350800
webhook-signature: v1,<base64 HMAC-SHA256>
{"order":42}
With signing off, the same request without the three webhook-* lines.
- Body: exactly the bytes you sent.
- Defaults: Stafett's
user-agent, only when you did not set one. There is nocontent-typeguess. - Signing follows Standard Webhooks,
over the exact bytes sent:
webhook-idis a UUID minted at enqueue. It is the same on every retry and replay of the job, and unique across installs. Use it as your idempotency key.webhook-timestampandwebhook-signatureare new on each attempt, so a retry or a replay never carries a stale signature.- These three replace any of the same name you set. Other
webhook-*headers you set are sent as given.
- Redirects are not followed.
Headers
Each Stafett-Header-<name> is sent as <name>, lower-cased, with its
value as given. That includes
authorization, user-agent, content-type, host, x-forwarded-*, and,
with signing off, your own webhook-* signature headers.
Only what HTTP cannot carry is refused at enqueue with 422, and dropped at
delivery if a job got around enqueue:
- framing headers, which the HTTP client sets from the body it sends:
content-length,transfer-encoding,connection,keep-alive,proxy-connection,te,upgrade; - a name that is not a valid HTTP token;
- a value with anything but visible ASCII, space, and tab (a line break would split the request);
- the same name twice.
Headers are stored with the job in your database until it is delivered or
removed, so a token in authorization sits in the stafett schema
meanwhile.
Signing it yourself. With deliver's signing off, you can send your own
signature headers. If your scheme includes a timestamp, remember a job can go
out well after you signed it: a Stafett-Delay-Seconds, a backlog, a
database outage, retries, or a replay from the dead-letter queue. A receiver
that rejects old timestamps (Standard Webhooks allows 5 minutes) answers 4xx, and the job is
dead. Static credentials, such as a bearer token or an API key, never
expire this way.
Retries
| Destination answer | Result |
|---|---|
2xx |
delivered |
429, 5xx, timeout, connection error |
Retry after backoff, while attempts remain; otherwise dead. |
Any other status, including 3xx and 4xx |
dead at once. |
Each failed attempt records a reason in Postgres:
HTTP <code>, timeout, connect error, unsafe url,
unknown envelope version, or missing envelope (a queue row with no
request, written around enqueue). Operations shows how to read
it.
Delivery is at-least-once. A job whose attempt timed out after the
destination processed it is sent again. Make receivers dedupe on
webhook-id, or, with signing off, on an id header of your own.