Getting started

Run Stafett on your laptop with Docker Compose, send one job, and watch a receiver verify its signature. It takes about five minutes. For a cluster, continue with Deployment afterwards.

You need Docker with Compose and a checkout of the Stafett repository. The images are not published yet, so Compose builds them from the checkout.

1. Start the stack

From the repository root:

docker compose -f examples/quickstart/docker-compose.yml up --build

This starts five containers:

  db        Postgres 17
  setup     installs schema "stafett", then exits
  enqueue   the stafett-enqueue image: the HTTP API, on 127.0.0.1:8000
  deliver   the stafett-deliver image: signs and sends each job
  receiver  a tiny webhook endpoint that verifies signatures

enqueue and deliver wait for setup to finish. enqueue listens on loopback only, because it has no authentication.

2. Enqueue a job

curl -X POST localhost:8000/enqueue \
  -H 'content-type: application/json' \
  -H 'stafett-url: http://receiver:9000/' \
  --data-binary '{"hello": "world"}'

Stafett answers 202 Accepted once the job is committed to Postgres:

{"id": 1, "status": "queued", "status_url": "/jobs/1"}

202 means the job is stored, not that it was delivered.

3. Check the result

curl localhost:8000/jobs/1
{"id": 1, "status": "delivered", "attempts": 1, "created_at": "2026-11-03T09:00:00Z"}

The receiver logs each delivery it verified:

docker compose -f examples/quickstart/docker-compose.yml logs receiver
receiver-1  | verified 019a4b7c-6c4a-7b32-9f21-2c0a5e4d9b11: {'hello': 'world'}

4. Verify signatures in your own receiver

The quickstart's deliver has a signing secret, so every delivery carries Standard Webhooks headers: webhook-id, webhook-timestamp, and webhook-signature. (Leave STAFETT_SIGNING_SECRET unset and deliver signs nothing: the request carries only the headers you set, plus a default user-agent.) Verify them over the raw body with any Standard Webhooks library, using the same secret deliver has in STAFETT_SIGNING_SECRET:

from standardwebhooks import Webhook

webhook = Webhook("whsec_…")
payload = webhook.verify(raw_body, headers)  # raises if the signature is bad

Verify the bytes you received. Parsing the JSON and re-encoding it first breaks the signature. The quickstart's receiver is examples/quickstart/receiver.py.

Delivery is at-least-once: a job can occasionally arrive more than once, for example after a timeout. Dedupe on webhook-id; it is the same for every retry of one job.

5. See a failure and a replay

Send a job to a destination that does not answer:

curl -X POST localhost:8000/enqueue \
  -H 'content-type: application/json' \
  -H 'stafett-url: http://nothing-here:9000/' \
  --data-binary '{}'

After five attempts (about 20 seconds) GET /jobs/{id} shows dead. List dead jobs and replay one with pgqueuer's pgq tool, which ships in both images:

docker compose -f examples/quickstart/docker-compose.yml exec deliver sh -c \
  'PGDSN=$DATABASE_URL PGQUEUER_SCHEMA=stafett PGQUEUER_CHANNEL=stafett_ch_pgqueuer pgq failed'

docker compose -f examples/quickstart/docker-compose.yml exec deliver sh -c \
  'PGDSN=$DATABASE_URL PGQUEUER_SCHEMA=stafett PGQUEUER_CHANNEL=stafett_ch_pgqueuer pgq requeue <id>'

More in Operations.

Next