Rate limits
Headers, tier table, and 429 handling recipes. Read →
Every error from /v1/* ships in the same envelope. One branch in your
client handles them all — switch on error.code, log request_id,
and you’re done.
{ "error": { "type": "invalid_request_error", "code": "invalid_phone_number", "message": "Phone number must be in E.164 format (e.g. +628111222333).", "param": "to", "request_id": "req_01HXYZABCDEFGHJKMNPQRSTVWX" }}| Field | Stability | Meaning |
|---|---|---|
type | Stable | One of seven broad categories: invalid_request_error, authentication_error, permission_error, not_found, conflict, rate_limit_error, api_error. |
code | Permanent | Programmatic identifier. Branch on this. |
message | May evolve | Human-readable. Show to operators; never branch on text. |
param | When relevant | JSON-pointer-style hint to the offending field. |
request_id | Always present | Echoes the X-Request-Id response header. Quote in support tickets. |
import { Kirim, KirimError, RateLimitError, ValidationError, AuthenticationError, NotFoundError, ConflictError, ApiError,} from '@kirimdev/sdk'
const kirim = new Kirim({ apiKey: process.env.KIRIM_KEY! })const phone = kirim.phoneNumbers('106540352242922')
try { await phone.messages.send({ messaging_product: 'whatsapp', to: '+628111222333', type: 'text', text: { body: 'Halo!' }, })} catch (err) { if (err instanceof RateLimitError) { // SDK already retried; budget exhausted. return scheduleLater(err.retryAfterMs) } if (err instanceof ValidationError) { // err.code === 'invalid_phone_number' | 'missing_required_field' | ... return showFieldError(err.param, err.message) } if (err instanceof AuthenticationError) { return rotateKey() } if (err instanceof NotFoundError) { return notify('Resource gone or never existed') } if (err instanceof ConflictError) { return retryWithBackoff() } if (err instanceof ApiError) { // 5xx upstream — exponential backoff, alert if persistent. return scheduleRetry(err.requestId) } if (err instanceof KirimError) { logger.error({ code: err.code, requestId: err.requestId }, 'Kirim error') } throw err}HTTP_RESPONSE=$(curl -sS -w "\n%{http_code}" \ -X POST "https://api.kirim.dev/v1/106540352242922/messages" \ -H "Authorization: Bearer $KIRIM_KEY" \ -H "Content-Type: application/json" \ -d '{ "messaging_product": "whatsapp", "to": "+628111222333", "type": "text", "text": { "body": "Halo!" } }')
STATUS=$(echo "$HTTP_RESPONSE" | tail -n1)BODY=$(echo "$HTTP_RESPONSE" | sed '$d')
if [ "$STATUS" -ge 400 ]; then CODE=$(echo "$BODY" | jq -r '.error.code') REQ_ID=$(echo "$BODY" | jq -r '.error.request_id') echo "Failed: $CODE (request_id=$REQ_ID)" >&2 exit 1fiimport httpx
resp = httpx.post( "https://api.kirim.dev/v1/106540352242922/messages", headers={"Authorization": f"Bearer {KIRIM_KEY}"}, json={ "messaging_product": "whatsapp", "to": "+628111222333", "type": "text", "text": {"body": "Halo!"}, },)
if resp.is_error: err = resp.json()["error"] match err["code"]: case "invalid_phone_number": raise ValueError(f"Bad number on field {err['param']}") case "rate_limit_exceeded": delay = int(resp.headers.get("retry-after", "1")) time.sleep(delay) # retry… case "whatsapp_upstream_error": logger.warning("Meta hiccup, backing off (req=%s)", err["request_id"]) case _: logger.error("Kirim error: %s (req=%s)", err["code"], err["request_id"]) raise RuntimeError(err["code"])This catalogue covers errors returned by the HTTP layer — any non-2xx
response from /v1/*.
| HTTP | type | code | When |
|---|---|---|---|
| 400 | invalid_request_error | invalid_phone_number | to is not E.164 with leading +. |
| 400 | invalid_request_error | invalid_phone_number_id | Path {phone_number_id} is malformed or unknown to your org. |
| 400 | invalid_request_error | invalid_template_name | Template name does not exist on the calling phone number. |
| 400 | invalid_request_error | missing_required_field | Required field absent. param set. |
| 400 | invalid_request_error | invalid_field_value | Field present but constraint violated. param set. |
| 400 | invalid_request_error | missing_messaging_product | Body missing "messaging_product": "whatsapp". |
| 400 | invalid_request_error | invalid_limit | limit query param outside 1–100. |
| 400 | invalid_request_error | invalid_cursor | Cursor undecodable or tampered. |
| 400 | invalid_request_error | invalid_webhook_url | URL is not https or fails reachability sanity check. |
| 400 | invalid_request_error | invalid_event_type | Webhook event name not recognised. |
| 401 | authentication_error | missing_api_key | No Authorization header. |
| 401 | authentication_error | invalid_api_key | Malformed, unknown, or hash mismatch. |
| 401 | authentication_error | revoked_api_key | Key has been revoked. |
| 401 | authentication_error | expired_api_key | expires_at passed. |
| 403 | permission_error | feature_not_available | Your plan does not include the Public API. |
| 403 | permission_error | organization_suspended | Org-level block. Contact support. |
| 403 | permission_error | phone_number_access_denied | {phone_number_id} belongs to a different org. |
| 404 | not_found | resource_not_found | Missing OR belongs to a different organisation. Returned as 404 (not 403) to avoid leaking existence. |
| 409 | conflict | idempotency_in_progress | An identical request with the same Idempotency-Key is still processing. |
| 409 | conflict | webhook_subscription_disabled | Trying to operate on an auto-disabled subscription. Re-enable first. |
| 422 | invalid_request_error | idempotency_key_reuse | Same Idempotency-Key was used with a different request body. |
| 422 | invalid_request_error | whatsapp_number_not_verified | Phone number is not yet approved by Meta. |
| 422 | invalid_request_error | cannot_revoke_last_secret | Tried to revoke the only signing secret on a subscription. Add a new secret first. |
| 429 | rate_limit_error | rate_limit_exceeded | Per-org token bucket empty. Honour Retry-After. See Rate Limits. |
| 500 | api_error | internal_error | Unexpected exception. Logged server-side with request_id. Open a ticket if persistent. |
| 502 | api_error | whatsapp_upstream_error | Meta returned 5xx. Retry with backoff. |
| 503 | api_error | service_unavailable | Maintenance or temporary overload. Honour Retry-After if present. |
code strings never change. New codes are added only for new failure modes; existing codes keep their meaning forever.message text may evolve — wording polish, i18n, hints. Don’t substring-match.type, and code form the v1 contract. Anything else (param, request_id, response headers) is auxiliary.