Skip to content

[BUG] Inbound webhook after() callback can finish before triggered automations complete #368

Description

@iuryeng

Summary

Inbound-message automations are started inside the webhook processing callback but are not awaited. The Promise registered with Next.js after() can therefore resolve while triggered automations are still running.

Environment

  • Repository version: 0.8.0
  • Commit: b8677608015790194c14ab7c85943ce26c7f9ef0
  • Reproduction level: route-level Vitest characterization using synthetic data
  • Repeated executions: 3

Steps to reproduce

A focused characterization test:

  1. Mocks runAutomationsForTrigger() with controlled pending Promises.
  2. Delivers one valid synthetic inbound WhatsApp message to the exported webhook POST handler.
  3. Captures and awaits the callback registered through Next.js after().
  4. Checks the automation state immediately after that callback resolves.
npm test -- src/app/api/whatsapp/webhook/route.automation-lifecycle-repro.test.ts

No production credentials, phone numbers, customer content, or external services are used.

Expected behavior

When the callback registered with after() resolves, all automation work started for that inbound delivery should have settled, including rejection handling.

Actual behavior

The callback resolves after starting three automation dispatches while all controlled automation Promises remain pending:

afterCallbackResolved: true
automationStarted:     true
automationCompleted:   false
automationDispatches:  3

The result was identical in three consecutive executions.

Technical evidence

The automation loop starts runAutomationsForTrigger() and attaches only a rejection callback, without awaiting the returned Promise:

// Fire any automations that react to this webhook event. All dispatches
// run here (not earlier) so the contact, conversation, and inbound
// message all exist before any step — including send_message — runs.
// Fire-and-forget: a slow or failing automation must not block the
// webhook's 200 OK response to Meta.
const inboundText = contentText ?? message.text?.body ?? ''
const automationTriggers: (
| 'new_contact_created'
| 'first_inbound_message'
| 'new_message_received'
| 'keyword_match'
| 'interactive_reply'
)[] = []
// Content-level triggers are suppressed when a flow consumed the
// message — see the comment block above.
if (!flowConsumed) {
automationTriggers.push('new_message_received', 'keyword_match')
// Interactive tap → fire the interactive_reply trigger too (only
// meaningful when a button/list reply actually arrived). Enables
// automation-only chained menus; when a Flow owns the menu it will
// have consumed the reply and this is skipped.
if (interactiveReplyId) {
automationTriggers.push('interactive_reply')
}
}
// new_contact_created fires only when the webhook just auto-created the
// contact row. first_inbound_message fires whenever this is the contact's
// first-ever customer-sent message — a superset that also catches
// manually-imported contacts sending for the first time. We dispatch both
// so users can pick whichever semantic they want; an automation that
// listens to only one trigger runs only when that trigger matches.
if (contactOutcome.wasCreated) automationTriggers.unshift('new_contact_created')
if (isFirstInboundMessage) automationTriggers.unshift('first_inbound_message')
for (const triggerType of automationTriggers) {
runAutomationsForTrigger({
accountId,
triggerType,
contactId: contactRecord.id,
context: {
message_text: inboundText,
conversation_id: conversation.id,
// Only set on interactive taps; drives the interactive_reply
// trigger's exact-id match.
interactive_reply_id: interactiveReplyId ?? undefined,
},
}).catch((err) => console.error('[automations] dispatch failed:', err))
}

In the same handler, AI processing is awaited:

// AI auto-reply. Runs only for plain-text inbound the deterministic
// flow runner did NOT consume (flows win over the LLM), and only when
// the account has enabled it. Awaited inside `after()` (same reason as
// the webhook dispatch below); `dispatchInboundToAiReply` owns its
// eligibility gates + try/catch and never throws.
if (!flowConsumed && !interactiveReplyId && inboundText.trim()) {
await dispatchInboundToAiReply({
accountId,
conversationId: conversation.id,
contactId: contactRecord.id,
configOwnerUserId,
})
}

Public webhook delivery is also awaited, with a comment explicitly explaining that detached work inside after() may be frozen before it completes:

// message.received webhook (public API). Awaited — not fire-and-forget
// — because we're inside the route's `after()` block, which only keeps
// the function alive for promises it can see; a detached promise could
// be frozen before it delivers. `dispatchWebhookEvent` early-exits
// when the account has no matching endpoint and never throws.
// (conversation.created is emitted earlier, right after the thread is
// opened.)
await dispatchWebhookEvent(supabaseAdmin(), accountId, 'message.received', {
conversation_id: conversation.id,
contact_id: contactRecord.id,
whatsapp_message_id: message.id,
content_type: contentType,
text: contentText,
})

Impact

On runtimes that stop or freeze work after the Promise registered with after() resolves, inbound-triggered automations can be interrupted even though the webhook was acknowledged successfully. This can produce intermittent missing automation actions and incomplete logs without causing Meta to retry the delivery.

Scope and limitations

This reproduction confirms the Promise lifecycle boundary at the route-logic/unit-integration level. Supabase, Meta, and downstream services are mocked. It proves that automation Promises are not included in the work tracked by after(); it does not measure how frequently a deployment platform interrupts that work in production.

Suggested acceptance criteria

  • The Promise registered with after() does not resolve until every triggered automation settles.
  • One failing automation does not prevent other matching automations from completing.
  • Rejections remain observable in logs without becoming unhandled rejections.
  • A regression test covers multiple pending automation dispatches and a rejected dispatch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions