How Stafett works
Stafett is an outbound webhook queue that runs inside your own infrastructure. Your services hand it a payload, the headers to send, and a destination URL. Stafett stores the job in your Postgres, then delivers it as an HTTP POST with retries, backoff, and a dead-letter queue. It can sign each attempt with Standard Webhooks, or leave the request exactly as you built it.
Nothing leaves your network except the deliveries themselves. Stafett never calls stafett.dev.
The pieces
Stafett is two container images, one per role. You deploy each separately, and both point at the same Postgres database.
YOUR NETWORK (e.g. one Kubernetes namespace)
┌────────────────────────────────────────────────────────────────┐
│ │
│ your services │
│ │ POST /enqueue │
│ ▼ │
│ ┌──────────────┐ commit job ┌───────────────────────┐ │
│ │ stafett- │────────────────►│ your Postgres │ │
│ │ enqueue │ │ schema "stafett" │ │
│ └──────────────┘ │ pgqueuer │ │
│ │ pgqueuer_log │ │
│ └───────┬───────────▲───┘ │
│ NOTIFY, dequeue │ │ result │
│ ▼ │ │
│ ┌───────────────────┴───┐ │
│ │ stafett-deliver │ │
│ │ │ │
│ └───────────┬───────────┘ │
└───────────────────────────────────────────────┼────────────────┘
│ POST, signed if
│ deliver has a secret
▼
destination (internal service or third party)
stafett-enqueueservesPOST /enqueue,GET /jobs/{id}, andDELETE /jobs/{id}. It answers202only after the job is committed. It never holds the signing secret.stafett-deliverpicks jobs up from Postgres, POSTs them (signed, if it has a signing secret), and records the result. It serves only/healthz.- Postgres is yours. Stafett keeps everything in one schema,
stafett, using pgqueuer as the queue engine. There is no other datastore, broker, or cache.
They are two images, one per role, because the roles scale on different
signals. Enqueue scales with your request rate; delivery scales with backlog
and how fast destinations answer. A deliver pod that crashes never takes
POST /enqueue down with it.
One job, start to finish
your service enqueue Postgres deliver destination
│ │ │ │ │
│ POST /enqueue │ │ │ │
│───────────────►│ validate │ │ │
│ │ INSERT job │ │ │
│ │──────────────►│ │ │
│ │ COMMIT │ NOTIFY │ │
│ │──────────────►│─────────────────►│ │
│ 202 {id} │ │ │ │
│◄───────────────│ │ dequeue, lock │ │
│ │ │◄─────────────────│ │
│ │ │ │ POST │
│ │ │ │──────────────►│
│ │ │ │ 2xx │
│ │ │ delete row, │◄──────────────│
│ │ │ log "successful" │ │
│ │ │◄─────────────────│ │
enqueuevalidates the request, writes the job, and commits. Only then does it answer202with the job id.- Postgres
NOTIFYwakes adeliverpod within milliseconds. One that misses the notification finds the job on its next poll, at most 5 seconds later. deliverlocks the job, so no other replica takes it, and POSTs the payload. With a signing secret, it adds Standard Webhooks signature headers.- A
2xxremoves the job from the queue and logs it as delivered. Anything else either schedules a retry or moves the job to the dead-letter queue.
Retries and the dead-letter queue
POST /enqueue
│
▼
┌──────────────┐ DELETE /jobs/{id} ┌────────────┐
│ queued │──────────────────────►│ canceled │
└──────┬───────┘ └────────────┘
│ deliver picks it up ▲
▼ │ DELETE
┌──────────────┐ 5xx, 429, timeout, ┌─────┴──────┐
│ delivering │── connect error ─────►│ retrying │
│ │◄──────────────────────│ │
└──┬────────┬──┘ backoff elapsed └────────────┘
│ │
2xx │ │ 3xx or 4xx, or a retryable
│ │ failure on the last attempt
▼ ▼
┌───────────┐ ┌──────┐
│ delivered │ │ dead │
└───────────┘ └──┬───┘
│
└── pgq requeue <id> (replay) ──► retrying
- Retryable: a timeout, a connection error,
429, and any5xx. - Not retryable: every other non-
2xxstatus, including3xx(redirects are not followed) and4xx. The job goes straight todead. - Default budget: 5 attempts. Waits between them are 1 s, 2 s, 4 s, 8 s,
doubling up to a 5-minute cap. A retry never starts early, but it can start
up to 5 seconds late, when
deliverpicks it up on its 5-second poll instead of on time. Waits under 5 seconds, such as the first three defaults, often stretch to about 5 seconds. All of it is configurable (Configuration). - A
deadjob stays in Postgres with its full request, so you can replay it once the destination is fixed. A replay gives it a fresh retry budget. The attempt countGET /jobs/{id}reports keeps climbing across replays.
Delivery guarantee
Delivery is at-least-once, and typically once. If an attempt times out or
the connection drops after the request went out, Stafett cannot know whether
the destination processed it, so it retries. Receivers must tolerate repeats:
dedupe on the webhook-id header, which stays the same across every retry
and replay of one job. With signing off, Stafett adds no id, so send one of
your own as a Stafett-Header-<name> header.
Stafett does not guarantee ordering. Jobs run concurrently across replicas, and a retrying job falls behind newer ones.
What Stafett stores
Everything lives in schema stafett in your database, created by pgqueuer's
own upgrade (setup):
| Table | Holds |
|---|---|
stafett.pgqueuer |
One row per job that is queued, in flight, or dead. Deleted when the job is delivered. |
stafett.pgqueuer_log |
History: enqueue, every attempt with its reason, cancel, replay. Grows until you prune it (Operations). |
stafett.pgqueuer_statistics |
Per-second counts rolled up from the log. Also grows until pruned. |
stafett.pgqueuer_schedules |
pgqueuer's cron schedules. Stafett does not use it. |
setup also creates pgqueuer's status type, and the function and trigger that
send NOTIFY on new jobs.
The signing secret lives only in deliver's environment, never in the
database. Someone who can read the database still cannot forge a signed
delivery. Headers you enqueue, tokens included, are stored with the job.
Stafett's NOTIFY channel is stafett_ch_pgqueuer, so it does not collide
with a pgqueuer you already run in the same database.