{"record":{"id":"a2767479ec753e63","repo":"moeru-ai/airi","slug":"webhook-error","errorCode":"WEBHOOK_ERROR","errorMessage":"Webhook Error: ${errorMessageFromUnknown(err)}","messagePattern":"Webhook Error: (.+?)","errorType":"http","errorClass":"ApiError","httpStatus":400,"severity":"error","filePath":"server/apps/api/src/routes/stripe/operations/webhook.ts","lineNumber":104,"sourceCode":"  webhookSecret: string | null,\n  payment: PaymentService,\n  db: Database,\n  metrics: RevenueMetrics | null,\n  productEventService: ProductEventService | null,\n) {\n  return async (signature: string | null, body: string): Promise<{ received: true }> => {\n    if (!stripe || !webhookSecret)\n      throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')\n\n    if (!signature)\n      throw createBadRequestError('No signature', 'MISSING_SIGNATURE')\n\n    let event: Stripe.Event\n    try {\n      event = stripe.webhooks.constructEvent(body, signature, webhookSecret)\n    }\n    catch (err: unknown) {\n      throw createBadRequestError(`Webhook Error: ${errorMessageFromUnknown(err)}`, 'WEBHOOK_ERROR')\n    }\n\n    logger.withFields({ type: event.type, id: event.id }).log('Webhook event received')\n    metrics?.stripeEvents.add(1, { event_type: event.type })\n\n    switch (event.type) {\n      case 'checkout.session.completed':\n      case 'checkout.session.async_payment_succeeded': {\n        const session = parse(checkoutSessionSchema, event.data.object)\n        if (session.mode !== 'payment') {\n          logger.withFields({ sessionId: session.id, mode: session.mode }).log('Ignoring non-payment checkout session')\n          break\n        }\n\n        const paymentOrderId = await resolvePaymentOrderId(db, session)\n        if (!paymentOrderId) {\n          logger.withFields({ sessionId: session.id }).warn('Ignoring checkout session without payment_order_id')\n          break","sourceCodeStart":86,"sourceCodeEnd":122,"githubUrl":"https://github.com/moeru-ai/airi/blob/438a067dde47aa0bdb46c2323d1fe293dc805218/server/apps/api/src/routes/stripe/operations/webhook.ts#L86-L122","documentation":"stripe.webhooks.constructEvent verifies the signature and parses the payload. Any failure (bad signature, timestamp outside tolerance, malformed JSON, wrong secret) is caught and rethrown as 400 WEBHOOK_ERROR with the underlying message embedded.","triggerScenarios":"POSTing to the webhook endpoint with a signature that fails verification: body altered in transit (proxy re-serializing JSON), wrong STRIPE_WEBHOOK_SECRET for the environment, replayed/expired event (timestamp tolerance), or a truncated payload.","commonSituations":"Using the test-mode secret while Stripe sends live-mode events (or vice versa); middleware (e.g. body parsers) re-serializing the body so bytes no longer match the signature; copying whsec_ from the wrong webhook endpoint; clock skew on the server.","solutions":["Verify STRIPE_WEBHOOK_SECRET matches the exact webhook endpoint and mode (test/live) sending the event.","Pass the RAW request body to the handler (disable any JSON body parsing/re-serialization before signature verification).","If replaying old events, re-send via Stripe CLI to get a fresh timestamp.","Check server clock sync (NTP) if timestamp-tolerance errors appear.","Read the embedded err message from the 400 response to identify the precise cause."],"exampleFix":"// before (body parsed before verification)\napp.post('/webhooks/stripe', json(), (c) => webhook(c.req.header('stripe-signature'), JSON.stringify(c.req.body)))\n// after\napp.post('/webhooks/stripe', async (c) => webhook(c.req.header('stripe-signature'), await c.req.text()))","handlingStrategy":"try-catch","validationCode":"// Pre-check: verify raw body bytes are unmodified and secret matches endpoint/mode\nconst rawBody = await req.text()\nif (rawBody !== originalDeliveryBody) throw new Error('body was re-serialized; signature will fail')","typeGuard":null,"tryCatchPattern":"try {\n  await deliverWebhook(signature, rawBody)\n} catch (e) {\n  if (e.code === 'WEBHOOK_ERROR') {\n    // 400: do not blind-retry; fix secret/body handling, then re-deliver via Stripe CLI\n    const detail = e.message.replace('Webhook Error: ', '')\n  }\n}","preventionTips":["Read the request body as raw text and pass those exact bytes to constructEvent.","Register separate webhook endpoints (and secrets) per environment and mode.","Keep server clocks NTP-synced to avoid timestamp tolerance failures.","Parse the embedded error message from the 400 response to pinpoint the cause."],"tags":["stripe","webhook","signature-verification","bad-request"],"backgroundTag":"checksum-mismatch","analyzedSha":"438a067dde47aa0bdb46c2323d1fe293dc805218","analyzedAt":"2026-09-17T01:14:42.644Z","contentChangedAt":"2026-09-17T01:14:42.644Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}