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

- 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.
Record the job at submit
submit.pypasseswebhook_urlwith anIdempotency-Keyand stores therequest_idor run id as pending in SQLite.Verify the delivery
The receiver checks the Standard Webhooks signature against the raw body and rejects timestamps more than 5 minutes off.
Answer fast
It returns
204right away and downloads in a background task. Each delivery attempt waits at most 10 seconds.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.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.
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.
What it copes with.
Webhooks are at-least-once. These are the cases the receiver handles.
| Case | How it's handled |
|---|---|
| Duplicate delivery | A lost 2xx means the same webhook-id arrives again. first_delivery() records each id and drops repeats. |
| Events out of order | A retried started can land after the final event. The receiver acts on data.status and ignores anything not terminal. |
| Endpoint down for a while | Windpaint retries up to 10 times over about 6 hours. The reconcile loop catches anything past that. |
| Two workers, one job | claim() moves a job from pending to processing in one conditional update, so only one caller downloads it. |
| Forged request | No valid signature, so it gets 400. |
| Secret rotation | The 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.