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, InvalidRequestError, AuthenticationError, PermissionError, NotFoundError, ConflictError, ApiServerError, ConnectionError,} 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 InvalidRequestError) { // err.code === 'invalid_phone_number' | 'missing_required_field' | ... return showFieldError(err.param, err.message) } if (err instanceof AuthenticationError) { return rotateKey() } if (err instanceof PermissionError) { return notify('Forbidden — check plan, suspension, or phone access') } if (err instanceof NotFoundError) { return notify('Resource gone or never existed') } if (err instanceof ConflictError) { return retryWithBackoff() } if (err instanceof ApiServerError) { // 5xx upstream — exponential backoff, alert if persistent. return scheduleRetry(err.requestId) } if (err instanceof ConnectionError) { // Network / DNS / TLS — never reached the API. Safe to retry. return scheduleRetry() } if (err instanceof KirimError) { logger.error({ code: err.code, requestId: err.requestId }, 'Kirimdev error') } throw err}HTTP_RESPONSE=$(curl -sS -w "\n%{http_code}" \ -X POST "https://api.kirimdev.com/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.kirimdev.com/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("Kirimdev error: %s (req=%s)", err["code"], err["request_id"]) raise RuntimeError(err["code"])Both statuses use error.type: "permission_error", but the HTTP status
tells you why access was denied:
| Status | Meaning | Typical code values | What to do |
|---|---|---|---|
| 402 | Your org can use the Public API, but this operation is blocked by billing or plan entitlements. An upgrade or renewal on the same product surface can unblock it. | subscription_inactive, quota_exceeded, feature_not_entitled | Open Billing → upgrade plan, renew subscription, or wait for quota reset. |
| 403 | Access is denied structurally — not a paywall on a single feature. | feature_not_available (plan tier excludes /v1 entirely), organization_suspended | Change plan tier to one that includes the Public API, or contact support for suspension. |
Rule of thumb: branch on error.code, not the status alone.
feature_not_entitled (402) means “your plan is active but this
feature needs a higher tier” (e.g. Customers on Business+).
feature_not_available (403) means “this plan tier does not include
the Public API at all.”
Pre-send compliance rejections on POST /messages return 422 with
codes like marketing_opted_out — they are policy blocks on a specific
send, not org-level billing gates. See the
pre-send compliance changelog.
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_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 | 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. |
| 402 | permission_error | subscription_inactive | Trial ended or subscription inactive. Upgrade to continue. |
| 402 | permission_error | quota_exceeded | Monthly or resource quota for your plan is exhausted. Upgrade or wait for the next period. |
| 402 | permission_error | feature_not_entitled | Active subscription, but this feature is not on your plan tier (e.g. Customers on Business+). |
| 403 | permission_error | feature_not_available | Your plan tier does not include the Public API surface at all. |
| 403 | permission_error | organization_suspended | Org-level block. Contact support. |
| 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. |
| 409 | conflict | setup_link_inactive | Trying to patch or revoke a customer setup link that’s already consumed, expired, or revoked. Generate a new link instead. |
| 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. |
| 422 | invalid_request_error | media_not_found | Referenced media library id is missing, deleted, or belongs to another team. |
| 422 | invalid_request_error | marketing_opted_out | Recipient opted out of marketing; only blocks MARKETING templates. |
| 422 | invalid_request_error | account_restricted | Meta restricted the WhatsApp account (compliance / messaging limits). |
| 422 | invalid_request_error | customer_suspended | Customer (tenant-of-tenant) is suspended or archived. |
| 422 | invalid_request_error | upstream_error | WhatsApp account is disconnected. Reconnect via the dashboard. |
| 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.