Skip to content
Core Concepts

Errors

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"
}
}
FieldStabilityMeaning
typeStableOne of seven broad categories: invalid_request_error, authentication_error, permission_error, not_found, conflict, rate_limit_error, api_error.
codePermanentProgrammatic identifier. Branch on this.
messageMay evolveHuman-readable. Show to operators; never branch on text.
paramWhen relevantJSON-pointer-style hint to the offending field.
request_idAlways presentEchoes 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
}

402 vs 403 — billing gate vs structural denial

Section titled “402 vs 403 — billing gate vs structural denial”

Both statuses use error.type: "permission_error", but the HTTP status tells you why access was denied:

StatusMeaningTypical code valuesWhat to do
402Your 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_entitledOpen Billing → upgrade plan, renew subscription, or wait for quota reset.
403Access is denied structurally — not a paywall on a single feature.feature_not_available (plan tier excludes /v1 entirely), organization_suspendedChange 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/*.

HTTPtypecodeWhen
400invalid_request_errorinvalid_phone_numberto is not E.164 with leading +.
400invalid_request_errorinvalid_template_nameTemplate name does not exist on the calling phone number.
400invalid_request_errormissing_required_fieldRequired field absent. param set.
400invalid_request_errorinvalid_field_valueField present but constraint violated. param set.
400invalid_request_errorinvalid_limitlimit query param outside 1–100.
400invalid_request_errorinvalid_cursorCursor undecodable or tampered.
400invalid_request_errorinvalid_webhook_urlURL is not https or fails reachability sanity check.
400invalid_request_errorinvalid_event_typeWebhook event name not recognised.
401authentication_errormissing_api_keyNo Authorization header.
401authentication_errorinvalid_api_keyMalformed, unknown, or hash mismatch.
401authentication_errorrevoked_api_keyKey has been revoked.
401authentication_errorexpired_api_keyexpires_at passed.
402permission_errorsubscription_inactiveTrial ended or subscription inactive. Upgrade to continue.
402permission_errorquota_exceededMonthly or resource quota for your plan is exhausted. Upgrade or wait for the next period.
402permission_errorfeature_not_entitledActive subscription, but this feature is not on your plan tier (e.g. Customers on Business+).
403permission_errorfeature_not_availableYour plan tier does not include the Public API surface at all.
403permission_errororganization_suspendedOrg-level block. Contact support.
404not_foundresource_not_foundMissing OR belongs to a different organisation. Returned as 404 (not 403) to avoid leaking existence.
409conflictidempotency_in_progressAn identical request with the same Idempotency-Key is still processing.
409conflictwebhook_subscription_disabledTrying to operate on an auto-disabled subscription. Re-enable first.
409conflictsetup_link_inactiveTrying to patch or revoke a customer setup link that’s already consumed, expired, or revoked. Generate a new link instead.
422invalid_request_erroridempotency_key_reuseSame Idempotency-Key was used with a different request body.
422invalid_request_errorwhatsapp_number_not_verifiedPhone number is not yet approved by Meta.
422invalid_request_errorcannot_revoke_last_secretTried to revoke the only signing secret on a subscription. Add a new secret first.
422invalid_request_errormedia_not_foundReferenced media library id is missing, deleted, or belongs to another team.
422invalid_request_errormarketing_opted_outRecipient opted out of marketing; only blocks MARKETING templates.
422invalid_request_erroraccount_restrictedMeta restricted the WhatsApp account (compliance / messaging limits).
422invalid_request_errorcustomer_suspendedCustomer (tenant-of-tenant) is suspended or archived.
422invalid_request_errorupstream_errorWhatsApp account is disconnected. Reconnect via the dashboard.
429rate_limit_errorrate_limit_exceededPer-org token bucket empty. Honour Retry-After. See Rate Limits.
500api_errorinternal_errorUnexpected exception. Logged server-side with request_id. Open a ticket if persistent.
502api_errorwhatsapp_upstream_errorMeta returned 5xx. Retry with backoff.
503api_errorservice_unavailableMaintenance 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.
  • HTTP status, type, and code form the v1 contract. Anything else (param, request_id, response headers) is auxiliary.

Rate limits

Headers, tier table, and 429 handling recipes. Read →

Idempotency

Safely retry POSTs without duplicating side effects. Read →

Handle failed sends

Async delivery failures and the per-error retry playbook. Read →

API reference

Endpoint-by-endpoint error tables. Browse →