Skip to content
All skills
COOK ops v1.0.0 · Apache-2.0

Stripe Sdk 15 Webhook Compat

Diagnose and fix Stripe webhook handlers crashing with AttributeError get or KeyError get after a Stripe Python SDK 15.x upgrade. SDK 15.x StripeObject (including Event) no longer supports dict-style .get() / [] mapping access cleanly — every handler that does event.get(type) or event[data][object] dies on the first webhook delivery. Symptom from the user side is "payment thinking… then error message". Fix is to convert the verified Event to a plain dict immediately after stripe.Webhook.construct_event so all downstream handlers work unchanged. Validated 2026-04-29 on recipes.wisechef.ai (4× consecutive 500s → 200 OK in one patch + restart).

Audited
Source
SHA-256
Last reviewed
How we audit →

Install in your agent

Tell your agent: "install the recipes skill, then add stripe-sdk-15-webhook-compat"
Or via curl: curl -sL https://recipes.wisechef.ai/skill -o ~/.claude/skills/recipes/SKILL.md

Full skill source · SKILL.md

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:

  1. 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: get
    

    Or KeyError: 'get' from line 224 (__getitem__). Both come from the same root cause.

  2. Stripe Dashboard → Developers → Events shows the event was delivered with response code 500, often 3–4 retries within 30s.

  3. 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 Decimal values (amounts, tax rates) that json.dumps can't handle
  • to_dict() exists but to_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 UPDATE the 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.updated via 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

  1. 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 on customer.subscription.updated events that contain Decimal tax rates: 'decimal.Decimal' object is not iterable. The walker MUST handle Decimal explicitly. If the first 200 OK comes from customer.subscription.created (no Decimals) but later 500s on .updated, this is what's happening.

  2. to_dict_recursive() does NOT exist in SDK 15.x. It was a 14.x method. Don't try event.to_dict_recursive() — only to_dict() (single level) is available, which is why the recursive walker is needed.

  3. Stripe API doesn't expose webhook event resend. Both /v1/events/{id}/retry and /v1/webhook_endpoints/{id}/events/{evt}/resend return 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.

  4. 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]=..." fires customer.subscription.updated immediately, exercises the same verification + dict-conversion path, and is idempotent.

  5. 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-check WR_STRIPE_WEBHOOK_SECRET against https://api.stripe.com/v1/webhook_endpoints filtered to the correct URL.

  6. Idempotency table will be empty during the bug window. If you check stripe_event_ids BEFORE applying the fix, expect zero rows. The handler crashes BEFORE the idempotency insert. This isn't a separate bug.

  7. current_period_end moved off the subscription root. Around Stripe API version 2026-01-28.clover (and before), subscription.current_period_end was canonical. In newer API versions / 15.x SDK responses, look in subscription.items.data[0].current_period_end. Both can coexist depending on Stripe-Version header. Code that hardcodes the old path returns None after the SDK upgrade — silent bug, harder to spot than the .get() crash.

  8. Don't echo the Stripe secret to local stdout. Always pull WR_STRIPE_SECRET from the host's .env over SSH and use it inside the SSH session. Hermes log redaction is best-effort and sk_live_* patterns sometimes leak into agent stdout/Discord. The price-swap skill made this mistake once; this skill explicitly inherits that discipline.

  9. Repo + deployed file divergence after migration. The recipes platform was migrated host-to-host before this bug surfaced; the deployed app/stripe_service.py was 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. Check git log matches mtime on 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.paid events all returned 500
  • Root cause line: event_type = event.get("type", "") in creator_routes.py:488
  • First-pass fix (json.dumps with to_dict default) → 200 on first event, then 500 on customer.subscription.updated with Decimal in tax_rates
  • Second-pass fix (recursive _to_plain walker handling Decimal) → 200 OK, event recorded in stripe_event_ids
  • Manual sync: one UPDATE users SET subscription_* for Adam's row using subscription ID + period_end pulled from GET /v1/subscriptions
  • Fix committed as 7c37d26 to wisechef-ai/recipes-api main
  • Total elapsed from symptom → fully synced + repo pushed: ~25 minutes