A webhook arrives at your endpoint as raw bytes — usually JSON. Your first job is to parse it into a Python dict so you can read fields out of it.
import json
payload_str = '{"event": {"type": "item.created", "id": "e_001", "data": {"name": "a", "value": 42}}}'
payload = json.loads(payload_str)
event = payload.get("event", {})
print(event.get("type"))
print(event.get("id"))For every lesson in week 1 the payload comes in as a string variable — the runner doesn't actually receive HTTPS POSTs. The pedagogy is parsing, verifying, and dispatching the bytes you've got, not the receiving infrastructure.
So in production this string would come from request.body or similar?
Right. Whatever HTTP framework you use (Flask, FastAPI, a Vercel Route Handler, a Cloudflare Worker), it gives you the request body. Step one for every handler is json.loads(body) — and then you read fields with .get(key, default) because the field shape can drift across event types.
Why .get instead of []?
Webhook payloads are heterogeneous. A payment.captured event has different keys than a payment.refunded event from the same provider. Defensive .get returns None (or your default) when a key is missing instead of raising KeyError — and that's the correct behavior: you'll usually have a dispatch table that handles only the event types you care about, and you want unknown shapes to fall through cleanly.
The canonical first three lines of any webhook handler:
import json
payload = json.loads(request_body) # bytes/str -> dict
event_type = payload.get("event", {}).get("type")
event_id = payload.get("event", {}).get("id")Four pieces:
json.loads — parses a JSON string (or bytes-decoded-to-string) into a Python dict. Raises JSONDecodeError on malformed JSON..get("event", {}) — defends against missing top-level event key; returns {} so the chained .get doesn't crash..get("type") — returns the type string or None.id.)# fragile
event_type = payload["event"]["type"]
# robust
event_type = payload.get("event", {}).get("type")The robust form lets your handler survive malformed or unexpected payloads with a meaningful log line, instead of a 500.
Different providers nest events differently. The exact path varies — payload["event"]["type"] (Stripe-ish), payload["action"] (GitHub), payload["event_type"] flat, etc. The pattern is the same: parse, defensively read, dispatch.
In production, frameworks usually give you the body as bytes. If so:
payload = json.loads(request_body.decode("utf-8"))
# OR (json.loads accepts bytes since Python 3.6)
payload = json.loads(request_body)For the lessons in week 1, the runner provides the payload as a string — no decode needed.
When things go wrong with webhooks, you usually want to know exactly what arrived. Log event_type and event_id first thing:
print(json.dumps({"step": "parse", "event_type": event_type, "event_id": event_id}))That's enough to trace any webhook through your logs. The structured log format we'll see in week 3.
A webhook arrives at your endpoint as raw bytes — usually JSON. Your first job is to parse it into a Python dict so you can read fields out of it.
import json
payload_str = '{"event": {"type": "item.created", "id": "e_001", "data": {"name": "a", "value": 42}}}'
payload = json.loads(payload_str)
event = payload.get("event", {})
print(event.get("type"))
print(event.get("id"))For every lesson in week 1 the payload comes in as a string variable — the runner doesn't actually receive HTTPS POSTs. The pedagogy is parsing, verifying, and dispatching the bytes you've got, not the receiving infrastructure.
So in production this string would come from request.body or similar?
Right. Whatever HTTP framework you use (Flask, FastAPI, a Vercel Route Handler, a Cloudflare Worker), it gives you the request body. Step one for every handler is json.loads(body) — and then you read fields with .get(key, default) because the field shape can drift across event types.
Why .get instead of []?
Webhook payloads are heterogeneous. A payment.captured event has different keys than a payment.refunded event from the same provider. Defensive .get returns None (or your default) when a key is missing instead of raising KeyError — and that's the correct behavior: you'll usually have a dispatch table that handles only the event types you care about, and you want unknown shapes to fall through cleanly.
The canonical first three lines of any webhook handler:
import json
payload = json.loads(request_body) # bytes/str -> dict
event_type = payload.get("event", {}).get("type")
event_id = payload.get("event", {}).get("id")Four pieces:
json.loads — parses a JSON string (or bytes-decoded-to-string) into a Python dict. Raises JSONDecodeError on malformed JSON..get("event", {}) — defends against missing top-level event key; returns {} so the chained .get doesn't crash..get("type") — returns the type string or None.id.)# fragile
event_type = payload["event"]["type"]
# robust
event_type = payload.get("event", {}).get("type")The robust form lets your handler survive malformed or unexpected payloads with a meaningful log line, instead of a 500.
Different providers nest events differently. The exact path varies — payload["event"]["type"] (Stripe-ish), payload["action"] (GitHub), payload["event_type"] flat, etc. The pattern is the same: parse, defensively read, dispatch.
In production, frameworks usually give you the body as bytes. If so:
payload = json.loads(request_body.decode("utf-8"))
# OR (json.loads accepts bytes since Python 3.6)
payload = json.loads(request_body)For the lessons in week 1, the runner provides the payload as a string — no decode needed.
When things go wrong with webhooks, you usually want to know exactly what arrived. Log event_type and event_id first thing:
print(json.dumps({"step": "parse", "event_type": event_type, "event_id": event_id}))That's enough to trace any webhook through your logs. The structured log format we'll see in week 3.
Create a free account to get started. Paid plans unlock all tracks.