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
- 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.
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
- 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.
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')
breakView on GitHub (pinned to 438a067dde)