NYC
skills/microck/ordinary-claude-skills/integrating-stripe-webhooks

integrating-stripe-webhooks

SKILL.md

Integrating Stripe Webhooks

Overview

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.

When to Use

Use this skill when:

  • Getting "Raw body not available" errors from Stripe webhooks
  • Webhook signature verification fails with 400 errors
  • Implementing new Stripe webhook endpoints
  • Getting TypeError: Cannot read property 'current_period_start' from subscription events
  • Webhooks return 404 (route registration issues)

Don't use for:

  • General Stripe API integration (not webhooks)
  • Frontend Stripe Elements implementation
  • Stripe checkout session creation (use Stripe docs)

Quick Reference

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()

Critical: Raw Body Parsing

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.

Framework Examples

Node.js - Fastify (most common for new projects):

// 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:

// 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:

@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.

Common Mistakes

1. Subscription Period Fields Missing

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:

// ❌ 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),
});

2. Route Not Found (404)

Cause: Webhook routes registered outside API prefix.

// ❌ 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' });
}

3. URL Encoding in Checkout URLs

Error: "body/successUrl must match format 'uri'"

Cause: Organization names or parameters with spaces not URL-encoded.

// ❌ 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`;

Implementation Checklist

Server Setup:

  • Configure raw body parser BEFORE routes
  • Register webhook routes inside API prefix (if using one)
  • Set STRIPE_WEBHOOK_SECRET environment variable
  • Verify webhook secret is configured before processing

Webhook Handler:

  • Validate stripe-signature header exists
  • Access raw body (not parsed JSON)
  • Use stripe.webhooks.constructEvent() for verification
  • Handle SignatureVerificationError separately
  • Return 200 for received events (even if processing fails)
  • Log all events with ID and type

Subscription Events:

  • Get period dates from subscription.items.data[0]
  • Cast to any to access TypeScript-missing fields
  • Fallback to billing_cycle_anchor if items missing
  • Store org_id in subscription metadata
  • Update verification status based on subscription status

Frontend:

  • URL-encode all dynamic parameters
  • URL-encode organization names in success/cancel URLs
  • Handle checkout errors gracefully
  • Poll for verification after checkout success

Testing Locally

# 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

Real-World Impact

Before applying these patterns:

  • Webhooks fail with 400 "Invalid signature"
  • Subscription updates crash with undefined property errors
  • Hours debugging TypeScript type mismatches
  • Checkout fails with URL validation errors

After applying:

  • Webhooks verify successfully
  • Subscription data extracts correctly
  • Type-safe with explicit casting
  • Checkout URLs work with any organization name

References

Weekly Installs
5
First Seen
Jan 24, 2026
Installed on
gemini-cli4
windsurf4
codex4
opencode4
trae3
antigravity3