Errors reference
Full catalogue of error codes, response envelope shape, and fallback buckets. Read the concept →
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.
| Surface | When | What to do |
|---|---|---|
| HTTP-level | POST /v1/{phone_number_id}/messages returns 4xx or 5xx. | Caller bug or transient. Fix the request shape, then retry. Nothing was queued. |
| Async delivery | The 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:
| HTTP | Code | Meaning | Fix |
|---|---|---|---|
400 | invalid_field_value | Malformed 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. |
401 | invalid_api_key | API key revoked, malformed, or wrong env. | Re-issue a key in the dashboard; check kdv_live_ vs kdv_test_. |
404 | resource_not_found | The {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. |
409 | idempotency_in_progress | An identical request with the same Idempotency-Key is still being processed. | Wait and retry; the first request is finishing. |
422 | idempotency_key_reuse | Same Idempotency-Key sent with a different body. | Generate a fresh key for genuinely new requests. |
422 | whatsapp_number_not_verified | The account resolved, but it is not in a connected state (disconnected, error, connecting…). | Reconnect / finish verifying the number in the Kirimdev dashboard. |
429 | rate_limit_exceeded | You exceeded your per-key write budget. | Back off using Retry-After; see Rate limits. |
5xx | internal_error | Transient 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:
message.status event arrives with
status: "failed" and an error object.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.code | Meaning | Recommended action |
|---|---|---|
outside_24h_window | Free-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_found | Template 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_mismatch | Parameter 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_mismatch | A parameter value violates the template’s expected format (currency, date, etc.). | Fix the parameter shape, then retry with a fresh Idempotency-Key. |
recipient_unavailable | Recipient 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 missing | Meta 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_failed | WhatsApp 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_error | Generic Meta error — transient or unclassified upstream issue. | Retry with exponential backoff. Kirimdev already retried before surfacing this. |
account_in_maintenance | The 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_inactive | Your 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:
curl -sS "https://api.kirimdev.com/v1/$PHONE_ID/messages/msg_01HXYZ…" \ -H "Authorization: Bearer $KIRIM_KEY"const phone = kirim.phoneNumbers(process.env.PHONE_ID!)const msg = await phone.messages.retrieve('msg_01HXYZ…')
if (msg.status === 'failed') { console.log('failed:', msg.error?.code, msg.error?.message)}import os, httpx
r = httpx.get( f"https://api.kirimdev.com/v1/{os.environ['PHONE_ID']}/messages/msg_01HXYZ…", headers={"Authorization": f"Bearer {os.environ['KIRIM_KEY']}"},)msg = r.json()["data"]if msg["status"] == "failed": print("failed:", msg["error"]["code"])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.code | Retry? | Strategy |
|---|---|---|
outside_24h_window | No (don’t retry the same text) | Switch to a template send to the same recipient. |
template_not_found | No | Pick a different approved template, or wait for approval. |
template_param_mismatch | No | Caller bug. Log + alert; fix the params before sending again. |
template_param_format_mismatch | After fixing | Correct the parameter value, then retry with a new Idempotency-Key. |
recipient_unavailable | No | Flag the contact in your CRM. Don’t try this number again. |
media_upload_failed | After fixing | Serve image/jpeg or image/png from a public URL, then retry with a new Idempotency-Key. |
upstream_error | Yes | Exponential backoff (e.g. 30s, 2m, 10m, 1h). Cap at 3–5 attempts. |
account_in_maintenance | Delayed | Pause sends to that account; resume when its status returns to connected. |
subscription_inactive | After paying | Stop 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) }}import os, httpx, logging
log = logging.getLogger(__name__)PHONE_ID = os.environ["PHONE_ID"]BASE = "https://api.kirimdev.com/v1"HEADERS = {"Authorization": f"Bearer {os.environ['KIRIM_KEY']}"}
def send_template(to: str, name: str, language: str = "id_ID"): return httpx.post( f"{BASE}/{PHONE_ID}/messages", headers=HEADERS, json={ "messaging_product": "whatsapp", "to": to, "type": "template", "template": {"name": name, "language": {"code": language}}, }, )
def handle_failed_send(msg: dict): code = msg["error"]["code"] to = msg["to"]
if code == "outside_24h_window": send_template(to, "reengagement") return
if code == "recipient_unavailable": flag_contact_unreachable(to, code) return
if code in { "template_param_mismatch", "template_param_format_mismatch", "template_not_found", }: alert_ops("Template problem", msg_id=msg["id"], code=code) return
if code == "media_upload_failed": # Usually Content-Type, not connectivity: image/jpeg or # image/png only (WebP is sticker-only). log.error("media_upload_failed %s %s", msg["id"], msg["error"]["message"]) return
if code == "upstream_error": schedule_retry(msg, attempts=5, base_seconds=30) return
if code == "account_in_maintenance": pause_account(PHONE_ID) return
log.warning("Unrecognized error code %s: %s", code, msg["error"]["message"])Errors reference
Full catalogue of error codes, response envelope shape, and fallback buckets. Read the concept →
Idempotency
Safely retry without duplicating sends. Read the concept →
Rate limits
Per-key budgets, Retry-After header, backoff guidance.
Read the concept →
Subscribe to webhooks
React to message.status events instead of polling.
Read the guide →