stripe-sdk-15-webhook-compat
TL;DR
Stripe Python SDK 15.x changed StripeObject.__getattr__ to raise AttributeError when a key isn't present — including for .get(). Any code that does event.get("type") or event["data"]["object"]["x"] on a verified webhook event will crash on first delivery. The fix is one place: convert the Event to a plain dict at the verification boundary. All downstream handlers stay unchanged. Total time to fix + verify: ~5 minutes once you have the journal evidence.
The signature symptom
Three things always co-occur:
journalctl shows repeated 500s on the webhook endpoint:
POST /api/stripe/webhook HTTP/1.1 500 Internal Server Error File ".../stripe/_stripe_object.py", line 171, in __getattr__ AttributeError: getOr
KeyError: 'get'from line 224 (__getitem__). Both come from the same root cause.Stripe Dashboard → Developers → Events shows the event was delivered with response code 500, often 3–4 retries within 30s.
Application DB is empty for the relevant tables —
stripe_event_ids,subscriptions, or whatever the handler writes. The handler crashed before recording anything, so even Stripe's automatic retry hits the same crash.
User-facing symptom: payment looks fine on Stripe, but the post-checkout polling page shows "your payment may have gone through but our system hasn't synced yet" because the webhook never updated the DB.
Root cause
Stripe Python SDK 15.x rewrote StripeObject to behave more strictly. Previously:
event = stripe.Webhook.construct_event(payload, sig, secret)
event.get("type", "") # SDK 14.x: works (StripeObject inherits from dict)
event["data"]["object"] # SDK 14.x: works
In SDK 15.x:
event = stripe.Webhook.construct_event(payload, sig, secret)
event.get("type", "")
# AttributeError: get ← StripeObject.__getattr__ no longer falls through to .get
Confirm version on the host:
ssh $HOST 'cd /path/to/api && venv/bin/python -c "import stripe; print(stripe.VERSION)"'
# 15.1.0 ← if this is 15.x, you have the bug
The fix (single point)
Patch verify_webhook_signature (or whatever wraps stripe.Webhook.construct_event) to return a plain dict instead of the Event object. Walk the tree manually because:
- StripeObjects are dict-like but not directly JSON-serializable
- They contain
Decimalvalues (amounts, tax rates) thatjson.dumpscan't handle to_dict()exists butto_dict_recursive()does NOT in 15.x
The recursive walker that handles all three:
def verify_webhook_signature(payload: bytes, sig_header: str) -> dict:
"""Verify and parse a Stripe webhook event.
Returns a plain dict (not a stripe.Event object). Stripe SDK 15.x
Event objects no longer support .get() / [] mapping access cleanly,
so we convert to dict here once and let all downstream handlers use
standard mapping access.
"""
if not settings.STRIPE_WEBHOOK_SECRET:
raise StripeConnectError("Stripe webhook secret not configured")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET,
)
# Walk the tree manually — StripeObjects are dict-like but not JSON-
# serializable, and contain Decimals.
from decimal import Decimal as _Decimal
def _to_plain(o):
if hasattr(o, "to_dict"):
return _to_plain(o.to_dict())
if isinstance(o, dict):
return {k: _to_plain(v) for k, v in o.items()}
if isinstance(o, list):
return [_to_plain(v) for v in o]
if isinstance(o, _Decimal):
if o == o.to_integral_value():
return int(o)
return float(o)
return o
return _to_plain(event)
except stripe.error.SignatureVerificationError:
raise StripeConnectError("Invalid webhook signature")
except Exception as e:
raise StripeConnectError(f"Webhook parsing failed: {e}")
After this patch, every downstream handler works without modification:
# These all work unchanged after the fix:
event_type = event.get("type", "")
event_id = event.get("id")
data = event["data"]["object"]
sub_id = data["id"]
status = data["status"]
Procedure (5 minutes)
1. Confirm the symptom
ssh $HOST 'sudo journalctl -u $SERVICE --since "10 min ago" --no-pager \
| grep -E "stripe/webhook|AttributeError: get|KeyError" | tail -20'
Look for the AttributeError: get line co-located with the 500 response. If you see it, proceed. If not, the bug is something else — check stripe.VERSION and re-read the handler.
2. Check whether the user's payment already cleared on Stripe
If yes, you'll need to manually sync the user's row after fixing — the events you replay will need to include the missed checkout.session.completed. Capture customer + subscription IDs now:
ssh $HOST 'SK=$(grep "^WR_STRIPE_SECRET" /path/.env | cut -d= -f2)
curl -s "https://api.stripe.com/v1/subscriptions?customer=$CUS_ID&limit=5" -u "$SK:" \
| python3 -c "import json,sys
for s in json.load(sys.stdin)[\"data\"]:
item = s[\"items\"][\"data\"][0]
print(s[\"id\"], s[\"status\"], item[\"price\"][\"id\"], item.get(\"current_period_end\"))"'
Note: in Stripe SDK 15.x and recent API versions, current_period_end moved from the subscription root to the subscription item. If you don't find it at the root, check items.data[0].current_period_end.
3. Apply the fix via base64-shipped patch script
Quoting hell makes inline sed painful for multi-line Python replacements. Ship a regex-replacement script via scp instead. Template:
# /tmp/whfix_apply.py
import re, sys
P = "/home/wisechef/wiserecipes-api/app/stripe_service.py"
src = open(P).read()
new_func = '''def verify_webhook_signature(payload: bytes, sig_header: str) -> dict:
"""..."""
# ... (full function as above)
'''
pattern = re.compile(
r'def verify_webhook_signature\(payload: bytes, sig_header: str\) -> dict:.*?raise StripeConnectError\(f"Webhook parsing failed: \{e\}"\)',
re.DOTALL,
)
m = pattern.search(src)
if not m:
print("ERROR: pattern not found", file=sys.stderr); sys.exit(1)
out = src[:m.start()] + new_func.rstrip() + src[m.end():]
open(P, "w").write(out)
print("OK patched")
Then:
scp /tmp/whfix_apply.py $HOST:/tmp/
ssh $HOST 'python3 /tmp/whfix_apply.py && \
sudo systemctl restart $SERVICE && \
sleep 3 && systemctl is-active $SERVICE'
4. Smoke test — fire a real webhook
The cleanest way: touch metadata on any active subscription. This emits customer.subscription.updated immediately:
ssh $HOST 'SK=$(grep "^WR_STRIPE_SECRET" /path/.env | cut -d= -f2)
curl -s -X POST "https://api.stripe.com/v1/subscriptions/$SUB_ID" -u "$SK:" \
-d "metadata[webhook_test]=$(date +%s)" > /dev/null
sleep 5
echo "--- webhook responses ---"
sudo journalctl -u $SERVICE --since "30 sec ago" --no-pager \
| grep "stripe/webhook" | tail -5
echo "--- events recorded ---"
sudo -u postgres psql $DB -c "SELECT event_id, event_type, processed_at \
FROM stripe_event_ids ORDER BY processed_at DESC LIMIT 3;"'
Expect: 200 OK in the logs, new row in stripe_event_ids. If still 500, you have a second bug — most likely the Decimal issue if your first patch only handled to_dict and not Decimals (see pitfall #1).
5. Manually sync any user whose webhook crashed pre-fix
Stripe's API doesn't expose webhook event resend (the /webhook_endpoints/{id}/events/{evt}/resend URL doesn't exist — don't waste time trying). Two options:
(Preferred) — Pull subscription state from Stripe API and
UPDATEthe user row directly:UPDATE users SET subscription_status = 'active', subscription_tier = 'operator', subscription_id = 'sub_...', subscription_current_period_end = to_timestamp(1780082601) WHERE id = '<user_uuid>';Then trigger
customer.subscription.updatedvia metadata-touch (step 4) — webhook fires, idempotency check sees no prior event_id, runs the full handler path including any side effects.(Fallback) — Construct the missed events from
GET /v1/events?limit=15, sign them with the webhook secret, POST to your endpoint locally. More code, more risk.
6. Commit + push the fix
If the deployed dir isn't a git checkout (common after a static deploy), clone the repo fresh, copy the patched file in, commit, push:
ssh $HOST 'cd /tmp && rm -rf $REPO && gh repo clone $ORG/$REPO && \
cp /home/$SVC/$APPDIR/app/stripe_service.py /tmp/$REPO/app/stripe_service.py && \
cd /tmp/$REPO && git diff --stat'
ssh $HOST 'cd /tmp/$REPO && \
git config user.email "..." && git config user.name "..." && \
git add app/stripe_service.py && \
git commit -m "fix(stripe): convert Event object to plain dict for SDK 15.x compat" && \
git push origin main'
This is critical: the deployed file and the repo HEAD must match, or the next deploy reintroduces the bug.
Pitfalls
Decimal serialization is a separate failure — first fix attempt with naive
json.dumps(event, default=lambda o: o.to_dict())works for the basic case but blows up oncustomer.subscription.updatedevents that contain Decimal tax rates:'decimal.Decimal' object is not iterable. The walker MUST handle Decimal explicitly. If the first 200 OK comes fromcustomer.subscription.created(no Decimals) but later 500s on.updated, this is what's happening.to_dict_recursive()does NOT exist in SDK 15.x. It was a 14.x method. Don't tryevent.to_dict_recursive()— onlyto_dict()(single level) is available, which is why the recursive walker is needed.Stripe API doesn't expose webhook event resend. Both
/v1/events/{id}/retryand/v1/webhook_endpoints/{id}/events/{evt}/resendreturn 404 / "unrecognized request URL". The Dashboard's "Send test webhook" button works but only for synthetic events, not historical ones. Manual DB sync is the only practical recovery path for events lost during the bug window.The metadata-touch trick is the cheapest webhook smoke test. Don't run a full Checkout flow to verify the fix.
POST /v1/subscriptions/{id} -d "metadata[ping]=..."firescustomer.subscription.updatedimmediately, exercises the same verification + dict-conversion path, and is idempotent.Multiple webhook endpoints register the same secret. A typical mature Stripe account has 5–10 endpoints (different products on different subdomains). The signature verifies against the secret for the SPECIFIC endpoint that received the call. If you debug with the wrong secret in
.env, you'll see 400 (signature) instead of 500 (the real bug). Cross-checkWR_STRIPE_WEBHOOK_SECRETagainsthttps://api.stripe.com/v1/webhook_endpointsfiltered to the correct URL.Idempotency table will be empty during the bug window. If you check
stripe_event_idsBEFORE applying the fix, expect zero rows. The handler crashes BEFORE the idempotency insert. This isn't a separate bug.current_period_endmoved off the subscription root. Around Stripe API version2026-01-28.clover(and before),subscription.current_period_endwas canonical. In newer API versions / 15.x SDK responses, look insubscription.items.data[0].current_period_end. Both can coexist depending onStripe-Versionheader. Code that hardcodes the old path returnsNoneafter the SDK upgrade — silent bug, harder to spot than the.get()crash.Don't echo the Stripe secret to local stdout. Always pull
WR_STRIPE_SECRETfrom the host's.envover SSH and use it inside the SSH session. Hermes log redaction is best-effort andsk_live_*patterns sometimes leak into agent stdout/Discord. The price-swap skill made this mistake once; this skill explicitly inherits that discipline.Repo + deployed file divergence after migration. The recipes platform was migrated host-to-host before this bug surfaced; the deployed
app/stripe_service.pywas just a file, not a git checkout. After patching, you MUST also push to the repo so the fix isn't lost on next deploy. Checkgit logmatchesmtimeon the deployed file.
Decision rubric
| Symptom | Use this skill? |
|---|---|
AttributeError: get in journalctl on /api/stripe/webhook |
YES |
KeyError: 'get' in journalctl on /api/stripe/webhook |
YES |
decimal.Decimal' object is not iterable after first patch |
YES (pitfall #1) |
| Webhook returns 200 but DB not updating | NO (handler logic bug) |
| Webhook returns 400 | NO (signature — check secret) |
stripe.VERSION < 15.0.0 |
NO (compat bug doesn't exist yet) |
| Founder said "payment thinking… then error" + Stripe shows the event | YES (likely cause) |
Reference: validated 2026-04-29 on recipes.wisechef.ai
- Stripe SDK: 15.1.0
- Symptom: 4×
customer.subscription.created/.updated/invoice.paidevents all returned 500 - Root cause line:
event_type = event.get("type", "")increator_routes.py:488 - First-pass fix (json.dumps with
to_dictdefault) → 200 on first event, then 500 oncustomer.subscription.updatedwith Decimal intax_rates - Second-pass fix (recursive
_to_plainwalker handling Decimal) → 200 OK, event recorded instripe_event_ids - Manual sync: one
UPDATE users SET subscription_*for Adam's row using subscription ID + period_end pulled fromGET /v1/subscriptions - Fix committed as
7c37d26towisechef-ai/recipes-apimain - Total elapsed from symptom → fully synced + repo pushed: ~25 minutes