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 Kirim has already returned a success response to you.

These are synchronous. Examples:

HTTPCodeMeaningFix
400invalid_requestMalformed body or missing required field.Validate the request body shape against the API reference.
401invalid_api_keyAPI key revoked, malformed, or wrong env.Re-issue a key in the dashboard; check kdv_live_ vs kdv_test_.
403phone_number_forbiddenCaller doesn’t have access to {phone_number_id}.Verify the id belongs to your org via GET /v1/accounts.
404resource_not_foundThe {phone_number_id} doesn’t exist.Check for typos; ensure the account is still connected.
409idempotency_conflictSame Idempotency-Key used with a different body.Generate a fresh key for genuinely new requests.
422account_not_connectedThe account is pending or degraded, not connected.Reconnect the account in the Kirim dashboard.
429rate_limit_exceededYou exceeded your per-key write budget.Back off using Retry-After; see Rate limits.
5xxinternal_errorTransient Kirim 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 Kirim’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.
template_not_approvedTemplate name / language not approved by Meta for this WABA.Resubmit the template in WhatsApp Manager and wait for approval; pick a different approved template meanwhile.
template_params_mismatchParameters don’t match the approved template’s shape (wrong count or wrong type).Validate against GET /v1/{phone_number_id}/templates/{name} before sending. Caller bug — never retry as-is.
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.
quota_exceededYour WABA hit Meta’s per-24h messaging tier cap.Wait for the next 24h window, or upgrade your messaging tier in WhatsApp Manager.
media_invalidThe media link was unreachable, the wrong MIME type, or over Meta’s size limit.Check the URL is HTTPS, publicly fetchable, and serves the correct Content-Type. Then retry.
provider_errorGeneric Meta error — transient or unclassified upstream issue.Retry with exponential backoff. Kirim already retried once before surfacing this.
degradedKirim flagged this account temporarily degraded — quota / quality cooldown.Wait for the account to recover. Sends are short-circuited during cooldown to protect your quota.

Subscribe to message.status once and let Kirim 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.kirim.chat/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_approvedNoPick a different approved template, or wait for approval.
template_params_mismatchNoCaller bug. Log + alert; fix the params before sending again.
recipient_unavailableNoFlag the contact in your CRM. Don’t try this number again.
quota_exceededDelayedWait for next quota window (typically next 24h).
media_invalidAfter fixingFix the media URL/format, then retry with a new Idempotency-Key.
provider_errorYesExponential backoff (e.g. 30s, 2m, 10m, 1h). Cap at 3–5 attempts.
degradedDelayedPause sends to that account; resume when its status returns to connected.

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_params_mismatch':
case 'template_not_approved':
// Caller bug or template issue. Alert ops, don't retry.
await alertOps('Template problem', { msgId: msg.id, code: msg.error.code })
return
case 'quota_exceeded':
// Pause sends until the next quota window.
await scheduleResume(msg, { delayMs: 1000 * 60 * 60 })
return
case 'media_invalid':
// Likely caller bug — log full context for investigation.
console.error('media_invalid', { id: msg.id, message: msg.error.message })
return
case 'provider_error':
// Transient — exponential backoff up to 5 attempts.
await scheduleRetry(msg, { attempts: 5, baseMs: 30_000 })
return
case 'degraded':
// Account-wide cooldown. 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 →