Webhook receiver.

A FastAPI endpoint that verifies signed job and workflow run webhooks and saves the outputs once.

Isometric 3D illustration of a tiny automated mailroom with conveyor belts sorting colorful parcels, soft pastel palette, plain background
Isometric 3D illustration of a tiny automated mailroom with conveyor belts sorting colorful parcels, soft pastel palette, plain backgroundZ-Image Turbo · seed 42
Stack
Python 3.10+, FastAPI, httpx, SQLite
Needs
API key, signing secret, an https tunnel
Uses
job webhooks, signature verification, assets

Stop polling without missing a job.

Polling every job costs requests and counts toward your rate limit. A webhook tells you when a job or workflow run finishes, but the receiver still has to cope with duplicate deliveries, events out of order, and an endpoint that was down longer than the retry window.

This recipe is a FastAPI service that verifies each delivery, answers at once, saves the outputs exactly once, and runs a slow reconcile loop for anything whose webhook never arrived.

How it fits together.

The recipe in order, from the first request to the saved file.

  1. Record the job at submit

    submit.py passes webhook_url with an Idempotency-Key and stores the request_id or run id as pending in SQLite.

  2. Verify the delivery

    The receiver checks the Standard Webhooks signature against the raw body and rejects timestamps more than 5 minutes off.

  3. Answer fast

    It returns 204 right away and downloads in a background task. Each delivery attempt waits at most 10 seconds.

  4. Save once

    Repeat deliveries are dropped by webhook-id, and a conditional update claims each job, so a retry and the reconcile loop can't both download it.

  5. Reconcile

    Every 5 minutes, work still pending after 10 minutes is fetched from the API and processed the same way.

The core of it.

An excerpt from the recipe. The full version runs as-is.

app.py, excerptpython
WEBHOOK = Webhook(os.environ["WINDPAINT_WEBHOOK_SECRET"])  # whsec_...@app.post("/windpaint/webhook")async def webhook(request: Request, background: BackgroundTasks) -> Response:    raw = await request.body()    try:        # Checks the signature against the raw body and rejects stale timestamps.        event = WEBHOOK.verify(raw, dict(request.headers))    except WebhookVerificationError:        return Response(status_code=400)    if store.first_delivery(request.headers["webhook-id"]):        kind = event["type"].split(".", 1)[0]  # "request" or "run"        background.add_task(process, kind, event["data"])    # Answer within the 10 s timeout; do the work after responding.    return Response(status_code=204)

The full recipe has the SQLite store, the download and reconcile code, a submit script, and steps for testing locally through a tunnel.

Full recipe in the docs

What it copes with.

Webhooks are at-least-once. These are the cases the receiver handles.

Behaviour from the recipe and the job webhooks docs.
CaseHow it's handled
Duplicate deliveryA lost 2xx means the same webhook-id arrives again. first_delivery() records each id and drops repeats.
Events out of orderA retried started can land after the final event. The receiver acts on data.status and ignores anything not terminal.
Endpoint down for a whileWindpaint retries up to 10 times over about 6 hours. The reconcile loop catches anything past that.
Two workers, one jobclaim() moves a job from pending to processing in one conditional update, so only one caller downloads it.
Forged requestNo valid signature, so it gets 400.
Secret rotationThe old secret keeps signing next to the new one for 24 hours by default. The library accepts either.

Questions.

Where do I get the signing secret?

Read it with GET /v1/webhooks/jobs/secret, which needs the webhooks:update permission, or copy it from Settings, Webhooks, Request webhooks in the dashboard. One secret covers every job and workflow run webhook in your organization.

Can I test it locally?

Yes. Run the receiver with uvicorn and expose it over https with a tunnel such as cloudflared or ngrok. Production webhook URLs must be https, and a URL that resolves to a private address is never called.

Are account webhooks the same thing?

No. Account webhooks cover organization events such as members, API keys and low balance, are signed differently and aren't retried. Generation jobs and workflow runs report through the webhook_url you pass on each request.

Start yours.

Request early access. We’ll email you when your account is approved, with 200 free credits to spend in the first 30 days.