Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Use when implementing Stripe webhook endpoints and getting 'Raw body not available' or signature verification errors - provides raw body parsing solutions and subscription period field fixes across frameworks
.claude/skills/microck-integrating-stripe-webhooks/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 59% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 11% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 347% | 0% |
| case-16 | ✗→✓ | ▲ Improved | 42% | 0% |
| case-21 | ✓→✓ | = Same ✓ | 30% | 0% |
Stripe webhooks require raw request bodies for signature verification. Most web frameworks parse JSON automatically, breaking verification. This skill provides framework-specific solutions for the raw body problem and documents common TypeScript type mismatches.
Use this skill when:
TypeError: Cannot read property 'current_period_start' from subscription eventsDon't use for:
| Problem | Solution | |---------|----------| | Raw body not available | Configure custom body parser (see framework examples) | | Signature verification fails | Use raw body bytes/buffer, not parsed JSON | | 404 on webhook endpoint | Register webhook route inside API prefix | | current_period_start undefined | Access from subscription.items.data[0] not root | | URI validation errors | URL-encode dynamic parameters with encodeURIComponent() |
THE PROBLEM: Stripe's constructEvent() requires the exact bytes received to verify the signature. JSON parsing modifies the body, breaking verification.
THE SOLUTION: Access raw body before any parsing middleware.
Node.js - Fastify (most common for new projects):
typescript// In main server file, BEFORE registering routes server.addContentTypeParser('application/json', { parseAs: 'buffer' }, async (req: any, body: Buffer) => { req.rawBody = body; // Store for webhooks return JSON.parse(body.toString('utf8')); // Parse for other routes } ); // In webhook handler const rawBody = (request as any).rawBody; const event = stripe.webhooks.constructEvent( rawBody, signature, webhookSecret );
Node.js - Express:
javascript// Define webhook route BEFORE express.json() middleware app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => { const event = stripe.webhooks.constructEvent( req.body, // Already raw Buffer req.headers['stripe-signature'], webhookSecret ); } ); app.use(express.json()); // After webhook route
Python - FastAPI:
python@app.post('/webhooks/stripe') async def stripe_webhook(request: Request): payload = await request.body() # Use .body() not .json() signature = request.headers.get('stripe-signature') event = stripe.Webhook.construct_event( payload, signature, webhook_secret )
General Pattern: Get raw bytes/buffer → verify signature → use parsed event from Stripe.
Error: TypeError: Cannot read property 'current_period_start' of undefined
Cause: Stripe returns period dates in subscription.items.data[0], not at subscription root. TypeScript types don't include these fields on SubscriptionItem.
Fix:
typescript// ❌ WRONG - fields don't exist here new Date(subscription.current_period_start * 1000) // ✅ CORRECT - get from first subscription item const firstItem = subscription.items.data[0] as any; const periodStart = firstItem?.current_period_start || subscription.billing_cycle_anchor; const periodEnd = firstItem?.current_period_end || subscription.billing_cycle_anchor; await updateOrg({ start_date: new Date(periodStart * 1000), end_date: new Date(periodEnd * 1000), });
Cause: Webhook routes registered outside API prefix.
typescript// ❌ WRONG - creates /webhooks/stripe instead of /api/v1/webhooks/stripe export async function registerRoutes(server) { server.register(async (api) => { await api.register(subscriptionRoutes, { prefix: '/subscriptions' }); }, { prefix: '/api/v1' }); await server.register(webhookRoutes, { prefix: '/webhooks' }); // Outside! } // ✅ CORRECT - inside API prefix export async function registerRoutes(server) { server.register(async (api) => { await api.register(subscriptionRoutes, { prefix: '/subscriptions' }); await api.register(webhookRoutes, { prefix: '/webhooks' }); // Inside }, { prefix: '/api/v1' }); }
Error: "body/successUrl must match format 'uri'"
Cause: Organization names or parameters with spaces not URL-encoded.
typescript// ❌ WRONG - "Broke Org" creates invalid URL const successUrl = `${origin}/orgs?name=${orgName}&subscription=success`; // ✅ CORRECT - encode dynamic parameters const successUrl = `${origin}/orgs?name=${encodeURIComponent(orgName)}&subscription=success`;
Server Setup:
STRIPE_WEBHOOK_SECRET environment variableWebhook Handler:
stripe-signature header existsstripe.webhooks.constructEvent() for verificationSignatureVerificationError separatelySubscription Events:
subscription.items.data[0]any to access TypeScript-missing fieldsbilling_cycle_anchor if items missingorg_id in subscription metadataFrontend:
bash# Install Stripe CLI brew install stripe/stripe-cli/stripe # Forward webhooks to local server stripe listen --forward-to localhost:3000/api/v1/webhooks/stripe # Trigger test events stripe trigger customer.subscription.created stripe trigger customer.subscription.updated stripe trigger invoice.paid
Before applying these patterns:
After applying:
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-21 | pass→pass | 13,989 | 7,752 | -45% | 1 | 1 | 0% | 2,315 | 3,019 | +30% | 0 | 0 | — |
case-03 | pass→pass | 13,274 | 9,991 | -25% | 1 | 1 | 0% | 2,336 | 3,619 | +55% | 0 | 0 | — |
case-01 | fail→pass | 14,011 | 23,072 | +65% | 1 | 1 | 0% | 2,520 | 4,004 | +59% | 0 | 0 | — |
case-02 | fail→pass | 16,079 | 7,416 | -54% | 1 | 1 | 0% | 2,810 | 3,125 | +11% | 0 | 0 | — |
case-04 | pass→pass | 14,968 | 11,948 | -20% | 1 | 1 | 0% | 2,637 | 4,315 | +64% | 0 | 0 | — |
case-05 | pass→pass | 12,413 | 11,065 | -11% | 1 | 1 | 0% | 2,070 | 3,593 | +74% | 0 | 0 | — |
case-06 | pass→pass | 11,815 | 8,668 | -27% | 1 | 1 | 0% | 2,050 | 3,329 | +62% | 0 | 0 | — |
case-07 | pass→pass | 9,284 | 7,239 | -22% | 1 | 1 | 0% | 1,726 | 3,109 | +80% | 0 | 0 | — |
case-12 | pass→pass | 5,391 | 2,519 | -53% | 1 | 1 | 0% | 848 | 2,163 | +155% | 0 | 0 | — |
case-08 | pass→pass | 12,685 | 6,408 | -49% | 1 | 1 | 0% | 2,358 | 2,891 | +23% | 0 | 0 | — |
case-09 | pass→pass | 7,965 | 5,425 | -32% | 1 | 1 | 0% | 1,441 | 2,654 | +84% | 0 | 0 | — |
case-10 | pass→pass | 10,727 | 10,595 | -1% | 1 | 1 | 0% | 1,793 | 3,395 | +89% | 0 | 0 | — |
case-11 | fail→pass | 3,004 | 2,772 | -8% | 1 | 1 | 0% | 481 | 2,151 | +347% | 0 | 0 | — |
case-13 | pass→pass | 3,446 | 2,082 | -40% | 1 | 1 | 0% | 486 | 2,031 | +318% | 0 | 0 | — |
case-14 | pass→pass | 9,586 | 6,725 | -30% | 1 | 1 | 0% | 1,840 | 3,050 | +66% | 0 | 0 | — |
case-15 | pass→pass | 5,221 | 2,945 | -44% | 1 | 1 | 0% | 844 | 2,274 | +169% | 0 | 0 | — |
case-16 | fail→pass | 10,756 | 5,912 | -45% | 1 | 1 | 0% | 1,954 | 2,779 | +42% | 0 | 0 | — |
case-17 | pass→pass | 7,758 | 3,445 | -56% | 1 | 1 | 0% | 1,397 | 2,385 | +71% | 0 | 0 | — |
case-18 | pass→pass | 8,876 | 4,630 | -48% | 1 | 1 | 0% | 1,535 | 2,538 | +65% | 0 | 0 | — |
case-19 | pass→pass | 12,395 | 11,044 | -11% | 1 | 1 | 0% | 1,946 | 3,615 | +86% | 0 | 0 | — |
case-20 | pass→pass | 4,895 | 3,131 | -36% | 1 | 1 | 0% | 740 | 1,942 | +162% | 0 | 0 | — |
case-22 | pass→pass | 7,137 | 3,111 | -56% | 1 | 1 | 0% | 594 | 2,297 | +287% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +18 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.