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 Kirim has already returned a success response to you.
These are synchronous. Examples:
| HTTP | Code | Meaning | Fix |
|---|---|---|---|
400 | invalid_request | Malformed body or missing required field. | Validate the request body shape against the API reference. |
401 | invalid_api_key | API key revoked, malformed, or wrong env. | Re-issue a key in the dashboard; check kdv_live_ vs kdv_test_. |
403 | phone_number_forbidden | Caller doesn’t have access to {phone_number_id}. | Verify the id belongs to your org via GET /v1/accounts. |
404 | resource_not_found | The {phone_number_id} doesn’t exist. | Check for typos; ensure the account is still connected. |
409 | idempotency_conflict | Same Idempotency-Key used with a different body. | Generate a fresh key for genuinely new requests. |
422 | account_not_connected | The account is pending or degraded, not connected. | Reconnect the account in the Kirim dashboard. |
429 | rate_limit_exceeded | You exceeded your per-key write budget. | Back off using Retry-After; see Rate limits. |
5xx | internal_error | Transient 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:
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. |
template_not_approved | Template 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_mismatch | Parameters 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_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. |
quota_exceeded | Your WABA hit Meta’s per-24h messaging tier cap. | Wait for the next 24h window, or upgrade your messaging tier in WhatsApp Manager. |
media_invalid | The 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_error | Generic Meta error — transient or unclassified upstream issue. | Retry with exponential backoff. Kirim already retried once before surfacing this. |
degraded | Kirim 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:
curl -sS "https://api.kirim.chat/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.kirim.chat/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_approved | No | Pick a different approved template, or wait for approval. |
template_params_mismatch | No | Caller bug. Log + alert; fix the params before sending again. |
recipient_unavailable | No | Flag the contact in your CRM. Don’t try this number again. |
quota_exceeded | Delayed | Wait for next quota window (typically next 24h). |
media_invalid | After fixing | Fix the media URL/format, then retry with a new Idempotency-Key. |
provider_error | Yes | Exponential backoff (e.g. 30s, 2m, 10m, 1h). Cap at 3–5 attempts. |
degraded | Delayed | Pause 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) }}import os, httpx, logging
log = logging.getLogger(__name__)PHONE_ID = os.environ["PHONE_ID"]BASE = "https://api.kirim.chat/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_params_mismatch", "template_not_approved"}: alert_ops("Template problem", msg_id=msg["id"], code=code) return
if code == "quota_exceeded": schedule_resume(msg, delay_seconds=3600) return
if code == "media_invalid": log.error("media_invalid %s %s", msg["id"], msg["error"]["message"]) return
if code == "provider_error": schedule_retry(msg, attempts=5, base_seconds=30) return
if code == "degraded": 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 →