C.W.K.
Stream
Lesson 05 of 05 · published

Webhooks & Async-202 — Inverted REST and the Polling Pattern

~10 min · streaming-async, webhooks, 202, async

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"REST is request/response. Webhooks invert that — the server becomes a client of YOUR endpoint. 202 + Location is REST's way of saying 'this will take a while; poll here for updates.' Both let you escape synchronous request timeouts without leaving HTTP."

Webhooks — The Inverted Call

A webhook is just an HTTP POST your service makes to a URL provided by your customer when an event of interest happens. Stripe pings your endpoint when a payment succeeds; GitHub pings yours when someone opens a pull request; SendGrid pings yours when an email bounces.

The shape: customer registers a URL with you; when the event happens, you POST a JSON body to that URL. The customer's endpoint returns 2xx for success. Everything else is your problem.

Three Hard Things About Sending Webhooks

1. Reliability. The customer's server might be down, slow, or wrong. Your job as sender: retry with exponential backoff (e.g., immediately, then 1m, 5m, 30m, 1h, 6h, 24h up to 3 days), then dead-letter (log + alert). Stripe retries for 3 days; GitHub for ~8 hours.

2. Verification. The customer's endpoint receives an unsolicited POST from your IP. How do they know it's really you and not an attacker pretending? Sign each payload with HMAC-SHA256 using a shared secret; include the signature in a header. The customer verifies. Stripe's Stripe-Signature, GitHub's X-Hub-Signature-256.

3. Idempotency. Retries mean the same event might be delivered 2-7 times. Each delivery has a unique event_id the receiver can dedupe on. The receiver's job is to make their handler idempotent (or use the event_id to skip duplicates).

What the Receiver Has to Do

  • Respond 2xx fast. Webhook senders expect a response within seconds; slow responses cause timeouts and retries. Accept the payload, enqueue the work, return 200 immediately.
  • Verify the signature. Compute HMAC of the raw body with the shared secret; compare to the header signature. Reject 401 if mismatch.
  • Dedupe by event_id. Store seen IDs (Redis with TTL works). If you see a duplicate, return 200 without re-processing.
  • Be public. The endpoint must be reachable from the internet. Local development needs ngrok or similar to expose localhost.

The Async-202 Pattern — REST Without Webhooks

Sometimes you don't have a customer URL; you just have a slow operation. 202 Accepted is REST's answer:

  1. Client POSTs the work. Server enqueues it, immediately returns 202 Accepted with Location: /jobs/abc.
  2. Client polls GET /jobs/abc until status is done (or failed).
  3. The job resource carries status, progress, and (when done) a result or a link to the result.

This is how Stripe handles payouts, AWS handles long batch operations, Cloudflare handles bulk DNS updates. The client never holds a long-lived HTTP connection waiting; it polls a cheap status endpoint.

Webhooks and 202+Location are the two REST patterns for 'this work doesn't fit in one request/response.' Webhook = server pushes to YOU when something happens. 202 = server tells YOU to poll a status URL. Both let async work live inside HTTP without WebSockets or persistent connections.

cwkPippa's Async Reality

cwkPippa uses 202 + Location for council finalization — a council of 4 brains takes 30-90 seconds, way past any reasonable HTTP timeout. The finalize POST returns 202 with a job URL; the frontend polls every 2s until the job carries status: done with a link to the finalized round. cwkPippa doesn't currently send webhooks (no third-party consumers), but it RECEIVES them from upstream providers — Anthropic and OpenAI both ping cwkPippa's webhook receivers for billing events. The receivers verify signatures, dedupe by event_id, enqueue the work, return 200 fast. Standard pattern.

Code

Webhook receiver: verify HMAC, dedupe by id, enqueue, return 2xx fast·python
# Server side — receive webhook with HMAC signature verification
import hashlib, hmac
from fastapi import FastAPI, Request, Header, HTTPException, status

app = FastAPI()
WEBHOOK_SECRET = b'shared-secret-from-env'
_seen_event_ids: set[str] = set()  # use Redis with TTL in production

@app.post('/webhooks/stripe')
async def stripe_webhook(
    request: Request,
    stripe_signature: str = Header(None, alias='Stripe-Signature'),
):
    raw_body = await request.body()

    # 1. Verify signature
    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(stripe_signature.split('=')[-1], expected):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail='invalid signature')

    payload = await request.json()
    event_id = payload.get('id')

    # 2. Dedupe by event_id (retries deliver the same id)
    if event_id in _seen_event_ids:
        return {'received': True, 'duplicate': True}
    _seen_event_ids.add(event_id)

    # 3. Enqueue work; return 2xx fast
    enqueue_payment_processed(payload['data']['object'])
    return {'received': True}
Async 202 pattern: enqueue, return Location, expose status endpoint·python
# 202 + Location — async REST pattern for long-running operations
import asyncio, uuid
from fastapi import FastAPI, BackgroundTasks, Response, status, HTTPException

app = FastAPI()
_JOBS: dict[str, dict] = {}

async def do_long_work(job_id: str, payload: dict):
    _JOBS[job_id]['status'] = 'running'
    await asyncio.sleep(30)  # imagine real work
    _JOBS[job_id]['status'] = 'done'
    _JOBS[job_id]['result'] = {'processed': True, **payload}

@app.post('/jobs', status_code=status.HTTP_202_ACCEPTED)
async def start_job(payload: dict, response: Response, bg: BackgroundTasks):
    job_id = str(uuid.uuid4())
    _JOBS[job_id] = {'status': 'queued', 'created_at': 'now'}
    bg.add_task(do_long_work, job_id, payload)
    response.headers['Location'] = f'/jobs/{job_id}'
    return {'job_id': job_id, 'status': 'queued'}

@app.get('/jobs/{job_id}')
async def get_job(job_id: str):
    job = _JOBS.get(job_id)
    if not job:
        raise HTTPException(status.HTTP_404_NOT_FOUND)
    return job
Client: poll the Location URL until status reaches a terminal state·python
# Client — poll the 202 status URL until done
import httpx, time

resp = httpx.post('https://api.example.com/jobs', json={'do': 'something'})
assert resp.status_code == 202
job_url = resp.headers['Location']
print(f'job started: {job_url}')

# Poll until done or failed
while True:
    poll = httpx.get(f'https://api.example.com{job_url}')
    status_val = poll.json()['status']
    print(f'status: {status_val}')
    if status_val == 'done':
        print('result:', poll.json()['result'])
        break
    if status_val == 'failed':
        raise RuntimeError(poll.json().get('error'))
    time.sleep(2)  # polite polling interval

External links

Exercise

Build TWO things in FastAPI: (1) a webhook receiver at POST /webhooks/test that verifies an HMAC-SHA256 signature against a shared secret, dedupes by an event_id from the payload, and returns 200 fast; (2) an async job endpoint at POST /jobs that returns 202 + Location + a job_id, with GET /jobs/{id} reporting status: queued / running / done. Test (1) by sending the same webhook twice with the same event_id — second one should return 'duplicate'. Test (2) by starting a job and polling until done.
Hint
HMAC signature: hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest(). Verify with hmac.compare_digest (constant-time). For dedupe, an in-memory set is fine for the exercise; production uses Redis with TTL. The async job uses BackgroundTasks; the status endpoint is just a dict lookup. Polling the status URL with 2s sleep is the canonical client pattern — see how it never holds a long HTTP connection waiting.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.