Skip to content
Guides

Handle Failed Sends

Sending a WhatsApp message has two failure surfaces, and confusing them is the most common integration mistake. This guide separates them, catalogues the stable error codes you can branch on, and walks through a production-ready handler.

SurfaceWhenWhat to do
HTTP-levelPOST /v1/{phone_number_id}/messages returns 4xx or 5xx.Caller bug or transient. Fix the request shape, then retry. Nothing was queued.
Async deliveryThe POST returned 200 with status: "pending", but Meta later rejects it. The message ends up status: "failed" with an error object.Branch on error.code and pick a retry or fallback strategy.

The async failure path is where things get interesting — Meta may take seconds to minutes to reject a send after Kirimdev has already returned a success response to you.

These are synchronous. Examples:

HTTPCodeMeaningFix
400invalid_field_valueMalformed body, bad query/path param, or a field that failed schema validation.Validate the request shape against the API reference; the message names the offending field.
401invalid_api_keyAPI key revoked, malformed, or wrong env.Re-issue a key in the dashboard; check kdv_live_ vs kdv_test_.
404resource_not_foundThe {phone_number_id} doesn’t exist or belongs to another organization.Verify the id belongs to your org via GET /v1/accounts. The response is identical for “unknown” and “not yours” — this is deliberate, so callers cannot probe for other tenants’ ids.
409idempotency_in_progressAn identical request with the same Idempotency-Key is still being processed.Wait and retry; the first request is finishing.
422idempotency_key_reuseSame Idempotency-Key sent with a different body.Generate a fresh key for genuinely new requests.
422whatsapp_number_not_verifiedThe account resolved, but it is not in a connected state (disconnected, error, connecting…).Reconnect / finish verifying the number in the Kirimdev dashboard.
429rate_limit_exceededYou exceeded your per-key write budget.Back off using Retry-After; see Rate limits.
5xxinternal_errorTransient Kirimdev or Meta hiccup.Retry with exponential backoff (with the same Idempotency-Key).

These never produce a msg_… id. There is nothing to track or follow up on — just fix the request and try again.

When the POST returned 200 with status: "pending", the message is in Kirimdev’s outbound queue. Meta acts on it asynchronously, and the result flows back to you in one of two ways:

  1. Webhook (preferred). A message.status event arrives with status: "failed" and an error object.
  2. Polling. GET /v1/{phone_number_id}/messages/{id} returns the same error object on the message resource.

The error object shape:

{
"data": {
"id": "msg_01HXYZABCDEFGHJKMNPQRSTVWX",
"status": "failed",
"error": {
"code": "outside_24h_window",
"message": "Conversation window closed; free-form text rejected.",
"provider_code": 131047
}
}
}

Always branch on error.code. The string is stable forever. The provider_code is Meta’s raw number — exposed for debugging only, don’t branch on it (Meta renumbers occasionally).

These are the codes you’ll see most often in production. The full catalogue lives in the Errors concept page.

error.codeMeaningRecommended action
outside_24h_windowFree-form message sent after the 24-hour conversation window closed.Re-engage via an approved template (type: "template"). Don’t retry the free-form send. See 24h window FAQ (ID).
template_not_foundTemplate name / language does not exist or is not approved for this WABA.Resubmit the template in WhatsApp Manager and wait for approval; pick a different approved template meanwhile.
template_param_mismatchParameter count doesn’t match the approved template’s shape.Validate against GET /v1/{phone_number_id}/templates/{name} before sending. Caller bug — never retry as-is.
template_param_format_mismatchA parameter value violates the template’s expected format (currency, date, etc.).Fix the parameter shape, then retry with a fresh Idempotency-Key.
recipient_unavailableRecipient is not on WhatsApp, has an old client, or has blocked your business.Don’t retry. Flag the contact as unreachable in your CRM; reach out via another channel.
Billing / payment method missingMeta cannot charge the WABA for template or paid conversations.Add and verify a Business payment method in Meta Business Suite — see WhatsApp Business payment setup.
media_upload_failedWhatsApp could not fetch the media, or its Content-Type is not one it supports.Images must return image/jpeg or image/png — WebP is sticker-only. Beware CDNs that auto-negotiate format (auto=format serves WebP). Then retry.
upstream_errorGeneric Meta error — transient or unclassified upstream issue.Retry with exponential backoff. Kirimdev already retried before surfacing this.
account_in_maintenanceThe sender account is temporarily in maintenance mode at Meta.Wait for the account to recover. Kirimdev retries automatically; you only see this when retries exhaust.
subscription_inactiveYour Kirimdev subscription is expired, canceled, or past due. The send was refused before it reached Meta, so provider_code is null.Settle the outstanding invoice in Billing, then retry. Waiting does not help — this is not a quota that resets.

Subscribe to message.status once and let Kirimdev push the failure to you. No polling, sub-second latency, no read quota burn:

// Inside your webhook handler — after signature verification + dedup.
if (event.type === 'message.status' && event.data.status === 'failed') {
await handleFailedSend(event.data)
}

The message.status event payload mirrors GET /v1/{phone_number_id}/messages/{id}, so the same handleFailedSend function works for both code paths. See the Webhooks Guide for the full subscription flow.

For quick scripts or batch jobs where webhooks aren’t worth the setup:

Terminal window
curl -sS "https://api.kirimdev.com/v1/$PHONE_ID/messages/msg_01HXYZ…" \
-H "Authorization: Bearer $KIRIM_KEY"

Polling burns read quota and adds latency. Use webhooks for anything production.

Not every failure deserves a retry, and the few that do need different strategies. The recipe below is what most integrations end up with:

error.codeRetry?Strategy
outside_24h_windowNo (don’t retry the same text)Switch to a template send to the same recipient.
template_not_foundNoPick a different approved template, or wait for approval.
template_param_mismatchNoCaller bug. Log + alert; fix the params before sending again.
template_param_format_mismatchAfter fixingCorrect the parameter value, then retry with a new Idempotency-Key.
recipient_unavailableNoFlag the contact in your CRM. Don’t try this number again.
media_upload_failedAfter fixingServe image/jpeg or image/png from a public URL, then retry with a new Idempotency-Key.
upstream_errorYesExponential backoff (e.g. 30s, 2m, 10m, 1h). Cap at 3–5 attempts.
account_in_maintenanceDelayedPause sends to that account; resume when its status returns to connected.
subscription_inactiveAfter payingStop sending and alert an operator. Backoff will not clear it — every attempt fails identically until the invoice is settled.

A realistic handler that branches by code, falls back to templates, flags unreachable contacts, and schedules retries:

import { Kirim } from '@kirimdev/sdk'
const kirim = new Kirim({ apiKey: process.env.KIRIM_KEY! })
const phone = kirim.phoneNumbers(process.env.PHONE_ID!)
type FailedMessage = {
id: string
to: string
status: 'failed'
error: { code: string; message: string; provider_code: number | null }
}
export async function handleFailedSend(msg: FailedMessage) {
switch (msg.error.code) {
case 'outside_24h_window':
// Re-engage via an approved template instead.
await phone.messages.send({
messaging_product: 'whatsapp',
to: msg.to,
type: 'template',
template: {
name: 'reengagement',
language: { code: 'id_ID' },
},
})
return
case 'recipient_unavailable':
// Mark the contact unreachable; stop trying this number.
await flagContactUnreachable(msg.to, msg.error.code)
return
case 'template_param_mismatch':
case 'template_param_format_mismatch':
case 'template_not_found':
// Caller bug or template issue. Alert ops, don't retry.
await alertOps('Template problem', { msgId: msg.id, code: msg.error.code })
return
case 'media_upload_failed':
// Usually a Content-Type problem, not connectivity: WhatsApp
// takes image/jpeg and image/png only (WebP is sticker-only).
console.error('media_upload_failed', { id: msg.id, message: msg.error.message })
return
case 'upstream_error':
// Transient — exponential backoff up to 5 attempts.
await scheduleRetry(msg, { attempts: 5, baseMs: 30_000 })
return
case 'account_in_maintenance':
// Account-wide cooldown at Meta. Pause everything to this phone.
await pauseAccount(process.env.PHONE_ID!)
return
default:
// Unknown code — don't crash, log + surface.
console.warn('Unrecognized error code', msg.error.code, msg.error.message)
}
}

Errors reference

Full catalogue of error codes, response envelope shape, and fallback buckets. Read the concept →

Subscribe to webhooks

React to message.status events instead of polling. Read the guide →