moeru-ai/airi · error · ApiError

WEBHOOK_ERROR

WEBHOOK_ERROR

Error message

Webhook Error: ${errorMessageFromUnknown(err)}

What it means

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.

Solutions

  1. Verify STRIPE_WEBHOOK_SECRET matches the exact webhook endpoint and mode (test/live) sending the event.
  2. Pass the RAW request body to the handler (disable any JSON body parsing/re-serialization before signature verification).
  3. If replaying old events, re-send via Stripe CLI to get a fresh timestamp.
  4. Check server clock sync (NTP) if timestamp-tolerance errors appear.
  5. Read the embedded err message from the 400 response to identify the precise cause.

Example fix

// before (body parsed before verification)
app.post('/webhooks/stripe', json(), (c) => webhook(c.req.header('stripe-signature'), JSON.stringify(c.req.body)))
// after
app.post('/webhooks/stripe', async (c) => webhook(c.req.header('stripe-signature'), await c.req.text()))
Defensive patterns

Strategy: try-catch

Validate before calling

// Pre-check: verify raw body bytes are unmodified and secret matches endpoint/mode
const rawBody = await req.text()
if (rawBody !== originalDeliveryBody) throw new Error('body was re-serialized; signature will fail')

Try / catch

try {
  await deliverWebhook(signature, rawBody)
} catch (e) {
  if (e.code === 'WEBHOOK_ERROR') {
    // 400: do not blind-retry; fix secret/body handling, then re-deliver via Stripe CLI
    const detail = e.message.replace('Webhook Error: ', '')
  }
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Background: Checksum mismatch errors: "checksum verification failed", "digest mismatch", "expected vs actual checksum" — what they mean and how to fix them — this error's family across 41 libraries.

Related errors


AI-assisted analysis of moeru-ai/airi@438a067dde (2026-09-17). Data as JSON: /api/errors/a2767479ec753e63. Report an issue: GitHub.

Appendix: source

Thrown at server/apps/api/src/routes/stripe/operations/webhook.ts:104

  webhookSecret: string | null,
  payment: PaymentService,
  db: Database,
  metrics: RevenueMetrics | null,
  productEventService: ProductEventService | null,
) {
  return async (signature: string | null, body: string): Promise<{ received: true }> => {
    if (!stripe || !webhookSecret)
      throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED')

    if (!signature)
      throw createBadRequestError('No signature', 'MISSING_SIGNATURE')

    let event: Stripe.Event
    try {
      event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
    }
    catch (err: unknown) {
      throw createBadRequestError(`Webhook Error: ${errorMessageFromUnknown(err)}`, 'WEBHOOK_ERROR')
    }

    logger.withFields({ type: event.type, id: event.id }).log('Webhook event received')
    metrics?.stripeEvents.add(1, { event_type: event.type })

    switch (event.type) {
      case 'checkout.session.completed':
      case 'checkout.session.async_payment_succeeded': {
        const session = parse(checkoutSessionSchema, event.data.object)
        if (session.mode !== 'payment') {
          logger.withFields({ sessionId: session.id, mode: session.mode }).log('Ignoring non-payment checkout session')
          break
        }

        const paymentOrderId = await resolvePaymentOrderId(db, session)
        if (!paymentOrderId) {
          logger.withFields({ sessionId: session.id }).warn('Ignoring checkout session without payment_order_id')
          break

View on GitHub (pinned to 438a067dde)