feat: auto-retry with phone variants on Meta "not in allowed list"

Meta WhatsApp returns error #131030 ("Recipient phone number not in
allowed list") in two situations:
  1. Sandbox: the recipient isn't registered as a test number.
  2. The registered number format differs from what we sent, usually
     because of trunk-prefix 0 quirks (e.g. Lithuanian numbers stored
     as +370063949836 vs international +37063949836).

Today that error bubbles up to the UI and the user has to manually
edit the contact. This PR makes the send pipeline self-heal: on 131030
it retries with alternate phone-number formats, and if an alternate
works it persists the corrected format back to the contact so future
sends are a single API call.

src/lib/whatsapp/phone-utils.ts
- Added phoneVariants(sanitized) — yields original, then variants with
  a trunk 0 inserted/removed after each plausible country-code length
  (1-3 digits). Deduplicated. Original is always first so the fast
  path for correctly-formatted numbers is one attempt.
- Added isRecipientNotAllowedError(message) helper — matches
  code 131030 or the English error text, so we only retry when the
  failure is actually about the recipient (other errors fail fast).

src/app/api/whatsapp/send/route.ts
- Reply send now iterates phoneVariants() and stops at the first that
  succeeds. If a non-original variant works, contacts.phone is updated
  to the working format.

src/app/api/whatsapp/broadcast/route.ts
- Same retry applied per recipient in the broadcast loop.
- Also fixed a separate bug: broadcast was calling
  sendTemplateMessage(accessToken, phone_number_id, ...) — wrong order.
  Correct signature is (phoneNumberId, accessToken, to, templateName,
  language?, params?). This was silently rejecting every broadcast.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-16 21:34:53 +03:00
parent 0a5d274e02
commit e97d5b5064
No known key found for this signature in database
3 changed files with 173 additions and 37 deletions

View File

@ -2,7 +2,12 @@ import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption'
import { sanitizePhoneForMeta, isValidE164 } from '@/lib/whatsapp/phone-utils'
import {
sanitizePhoneForMeta,
isValidE164,
phoneVariants,
isRecipientNotAllowedError,
} from '@/lib/whatsapp/phone-utils'
interface BroadcastResult {
phone: string
@ -77,30 +82,55 @@ export async function POST(request: Request) {
continue
}
// Correct sendTemplateMessage signature is:
// (phoneNumberId, accessToken, to, templateName, language?, params?)
// Previously the args were passed in the wrong order (accessToken first,
// params before language), which made Meta reject every broadcast.
//
// Retry with phone variants on "not in allowed list" so numbers that
// differ only in a trunk-prefix 0 still reach recipients.
const variants = phoneVariants(sanitized)
let sentMessageId: string | null = null
let lastError: string | null = null
for (const variant of variants) {
try {
const result = await sendTemplateMessage(
accessToken,
config.phone_number_id,
sanitized,
accessToken,
variant,
template_name,
template_params || [],
template_language || 'en_US'
template_language || 'en_US',
template_params || []
)
results.push({
phone,
status: 'sent',
whatsapp_message_id: result.messageId,
})
sentCount++
sentMessageId = result.messageId
lastError = null
break
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error'
console.error(`Failed to send broadcast to ${phone}:`, error)
if (!isRecipientNotAllowedError(errorMessage)) {
lastError = errorMessage
break // unrelated error — stop retrying
}
lastError = errorMessage
// retry with next variant
}
}
if (sentMessageId) {
results.push({
phone,
status: 'sent',
whatsapp_message_id: sentMessageId,
})
sentCount++
} else {
console.error(`Failed to send broadcast to ${phone}:`, lastError)
results.push({
phone,
status: 'failed',
error: errorMessage,
error: lastError || 'Unknown error',
})
failedCount++
}

View File

@ -2,7 +2,12 @@ import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import { decrypt } from '@/lib/whatsapp/encryption'
import { sanitizePhoneForMeta, isValidE164 } from '@/lib/whatsapp/phone-utils'
import {
sanitizePhoneForMeta,
isValidE164,
phoneVariants,
isRecipientNotAllowedError,
} from '@/lib/whatsapp/phone-utils'
export async function POST(request: Request) {
try {
@ -99,43 +104,81 @@ export async function POST(request: Request) {
const accessToken = decrypt(config.access_token)
// Send via Meta API
// Function signatures are:
// sendTextMessage(phoneNumberId, accessToken, to, text)
// sendTemplateMessage(phoneNumberId, accessToken, to, templateName, language?, params?)
// The args were previously swapped (accessToken before phoneNumberId),
// causing Meta to reject every send.
let waMessageId: string
// Send via Meta API — retry with phone-number variants if Meta rejects
// with "recipient not in allowed list" (common in sandbox / when a
// number was registered with/without a trunk 0). If an alternate
// format succeeds, we persist it back to the contact row so the
// next send goes through on the first attempt.
let waMessageId = ''
let workingPhone = sanitizedPhone
try {
const attempt = async (phone: string): Promise<string> => {
if (message_type === 'template') {
const result = await sendTemplateMessage(
config.phone_number_id,
accessToken,
sanitizedPhone,
phone,
template_name,
undefined, // use default language (en_US)
undefined, // default language (en_US)
template_params || []
)
waMessageId = result.messageId
} else {
return result.messageId
}
const result = await sendTextMessage(
config.phone_number_id,
accessToken,
sanitizedPhone,
phone,
content_text
)
waMessageId = result.messageId
return result.messageId
}
try {
const variants = phoneVariants(sanitizedPhone)
let lastError: unknown = null
for (const variant of variants) {
try {
waMessageId = await attempt(variant)
workingPhone = variant
lastError = null
break
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
// Only retry when the failure is specifically that the
// recipient isn't in Meta's allowed list. Any other error
// (bad token, invalid template, etc.) bubbles up immediately.
if (!isRecipientNotAllowedError(message)) {
throw err
}
lastError = err
console.warn(`[whatsapp/send] variant "${variant}" rejected by Meta, trying next…`)
}
}
if (lastError) throw lastError
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown Meta API error'
console.error('Meta API send failed:', message)
console.error('Meta API send failed for all variants:', message)
return NextResponse.json(
{ error: `Meta API error: ${message}` },
{ status: 502 }
)
}
// If a non-original variant succeeded, update the contact so future
// sends go straight through. sanitizePhoneForMeta on workingPhone
// will yield workingPhone itself, so re-storing preserves it.
if (workingPhone !== sanitizedPhone) {
console.log(
`[whatsapp/send] Auto-corrected contact phone: ${sanitizedPhone} → ${workingPhone}`
)
await supabase
.from('contacts')
.update({ phone: workingPhone })
.eq('id', contact.id)
}
// Insert message into DB — field names MUST match the messages schema
// (see supabase/migrations/001_initial_schema.sql):
// conversation_id, sender_type, content_type, content_text,

View File

@ -39,3 +39,66 @@ export function phonesMatch(phone1: string, phone2: string): boolean {
export function isValidE164(phone: string): boolean {
return /^\+?[1-9]\d{6,14}$/.test(phone)
}
/**
* Generate plausible phone number variants for retry when Meta's
* sandbox rejects a number with error #131030 ("not in allowed list").
*
* Many countries use a "trunk prefix" 0 for domestic dialing that is
* meant to be dropped in international format (e.g. Lithuanian
* "+370 063 949 836" domestically → "+370 63 949 836" international).
* But some sandboxes register the number with the trunk 0 included,
* causing sends to the correct international format to fail.
*
* This helper yields up to 3 variants:
* 1. The original sanitized number (first attempt)
* 2. With a trunk 0 inserted after the country code
* 3. With a trunk 0 removed after the country code
*
* Country-code lengths of 1, 2, and 3 digits are tried because we
* don't know the user's country ahead of time.
*
* @param sanitized - digits-only phone number (from sanitizePhoneForMeta)
* @returns deduplicated list of variants, original first
*/
export function phoneVariants(sanitized: string): string[] {
if (!sanitized) return []
const seen = new Set<string>()
const push = (v: string) => {
if (v && !seen.has(v)) seen.add(v)
}
// 1. Original
push(sanitized)
// 2. Insert a 0 after each plausible country-code length
for (const ccLen of [1, 2, 3]) {
if (sanitized.length <= ccLen) continue
const cc = sanitized.slice(0, ccLen)
const rest = sanitized.slice(ccLen)
if (!rest.startsWith('0')) {
push(cc + '0' + rest)
}
}
// 3. Remove a leading 0 after each plausible country-code length
for (const ccLen of [1, 2, 3]) {
if (sanitized.length <= ccLen + 1) continue
const cc = sanitized.slice(0, ccLen)
const rest = sanitized.slice(ccLen)
if (rest.startsWith('0')) {
push(cc + rest.slice(1))
}
}
return [...seen]
}
/**
* Returns true when the Meta API error indicates the recipient
* phone number isn't in the allowed list (sandbox restriction).
* Detected via error code 131030 or the standard error text.
*/
export function isRecipientNotAllowedError(message: string): boolean {
return /131030|not in allowed list|not in the allowed list/i.test(message)
}