From 11d1c622d0a57ae57703d55bc0f8253435cb6ad8 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 26 Jun 2026 17:34:44 +0300 Subject: [PATCH] fix(webhook): run inbound processing in after() so it survives serverless freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POST handler ack'd Meta with 200 and then ran processWebhook() as a detached promise. On Vercel the function can be frozen/terminated the moment the response is sent, so those DB writes were not guaranteed to complete. This dropped a non-deterministic subset of inbound messages: the contact and conversation rows (written first) landed, but the message insert (written after) often did not — leaving conversations that appear in the inbox with an empty thread, with no durable logs to explain it (issue #301). Move the work into next/server `after()`, which keeps the function alive until the callback resolves (within the route's maxDuration) while still ack'ing Meta fast enough to avoid retry-driven duplicate inserts. Add maxDuration=60 for headroom since processing can fan out to per-media Meta verification calls. Co-Authored-By: Claude Opus 4.8 --- src/app/api/whatsapp/webhook/route.ts | 31 +++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index ba404f8..aff9761 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server' +import { NextResponse, after } from 'next/server' import { createClient } from '@supabase/supabase-js' import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption' import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api' @@ -12,6 +12,12 @@ import { isTemplateWebhookField, } from '@/lib/whatsapp/template-webhook' +// The `after()` callback in POST runs within this route's max duration. +// Inbound processing can fan out to per-media Meta verification calls, so +// give it headroom beyond the platform default (Vercel clamps this to the +// plan's ceiling). Tune as needed. +export const maxDuration = 60 + // Lazy-initialized to avoid build-time crash when env vars are missing // eslint-disable-next-line @typescript-eslint/no-explicit-any let _adminClient: any = null @@ -182,9 +188,26 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) } - // Process asynchronously so we can ack Meta within their timeout. - processWebhook(body).catch((error) => { - console.error('Error processing webhook:', error) + // Process AFTER the response so we ack Meta within their ~20s timeout + // (a slow ack triggers Meta retries + duplicate inserts), while still + // guaranteeing the work runs to completion. + // + // This MUST use `after()` rather than a detached `processWebhook(body)` + // promise: on serverless platforms (we run on Vercel) the function can + // be frozen or terminated the moment the response is sent, so a floating + // promise's DB writes are not guaranteed to finish. That dropped a + // non-deterministic *subset* of inbound messages — contacts/conversations + // were created but the message insert never landed, leaving conversations + // that show in the inbox with an empty thread, and no logs to explain it + // (see issue #301). `after()` hands the callback to the runtime, which + // keeps the function alive until it resolves (within the route's + // maxDuration). + after(async () => { + try { + await processWebhook(body) + } catch (error) { + console.error('Error processing webhook:', error) + } }) return NextResponse.json({ status: 'received' }, { status: 200 })