Skip to content

Graph API Error Reference

Diagnose once. Fix correctly. Prevent recurrence.

Meta tells you what failed. This guide tells you why and how to stop it happening again.

This reference covers the six errors behind ~80% of WhatsApp Cloud API integration failures. Each includes root cause, copy-paste fix, and a prevention pattern built for production code.

Resolution time with this guide: ~2 minutes
Without: usually hours
API version: v24.0 (Feb 2026) — check Meta's changelog

All code tested: Python 3.12.3 · requests 2.31.0 · February 2026


Scope and sequence

This reference deliberately covers only the six errors responsible for ~80% of real-world WhatsApp Cloud API failures, based on patterns from Meta developer forums, Stack Overflow, and production support tickets.

Covered areas:

  • Authentication and token lifecycle
  • Parameter and ID misuse
  • Permissions and scopes
  • Messaging session / 24-hour window constraints
  • Policy enforcement and temporary blocks
  • Rate limiting

Out of scope:

  • Rare edge cases and sandbox-only limitations
  • Webhook and delivery status callbacks
  • One-off error codes with negligible operational impact

For the complete list, see Meta's official error codes reference.


Error grouping

Errors are divided into two categories used throughout this reference.

Category Meaning
Developer-controlled Fixable immediately in your code or config — no platform interaction needed
Platform-enforced Require waiting, process changes, or appeals — code changes alone will not resolve them

Errors are ordered by observed frequency within each category.


How to use this reference

  1. Run the Pre-flight validation checks before sending any request — they prevent broad classes of errors at the source.
  2. Find your error in the quick lookup table below.
  3. Read Why this happens to confirm the root cause.
  4. Apply the Fix — all examples are copy-paste ready and tested on Python 3.12.3.
  5. Implement the Prevention pattern — these are architectural choices designed to eliminate recurrence, not just suppress the symptom.
  6. If the error doesn't match any section exactly, use the Debugging checklist.

Average resolution time with this guide: ~2 minutes vs. hours on forums or in Meta docs.


Error quick lookup

Code Name Typical cause Common subcodes
190 Invalid OAuth access token Token expired or missing Bearer prefix 460, 463, 467
100 Invalid parameter Wrong ID type or missing required field 33, 2500
200 Permission required Token missing whatsapp_business_messaging scope
131047 Messaging window expired 24-hour window closed; free-form message rejected
368 Temporary block Policy violation: spam reports or missing opt-in
4 / 17 / 32 Rate limit exceeded Too many requests in a short window

Pre-flight validation

Run these before sending requests. They prevent entire categories of errors before a request reaches the API.

Validate phone numbers — E.164 format

All recipient phone numbers must be in strict E.164 format. A malformed number returns Error 100 or Error 131047 with a message that never mentions format — one of the most common silent causes of both errors.

Diff
+ +15551234567      ✓ valid
+ +442071234567     ✓ valid (UK)
- 15551234567       ✗ missing + prefix
- +1 555 123 4567   ✗ spaces not allowed
- +1-555-123-4567   ✗ dashes not allowed
- 00442071234567    ✗ 00-prefix is not valid E.164
Python
# Python 3.9+ | Tested: Python 3.12.3
import re

def validate_phone_e164(phone: str) -> str:
    """Raises ValueError if phone is not valid E.164. Returns phone if valid."""
    if not re.match(r'^\+\d{7,15}$', phone):
        raise ValueError(f"Invalid E.164 phone format: {phone}")
    return phone

Prevents

Error 100 (subcode 2500), Error 131047


Confirm the correct ID type

Meta APIs use multiple distinct numeric IDs that look nearly identical. Using the wrong one returns Error 100 with a misleading "object does not exist" message.

ID type Typical length Used for
Phone Number ID 15 digits Sending messages — /messages endpoint
WABA ID 16 digits Account management — /phone_numbers, templates
App ID Variable Token debugging — /debug_token

To identify an unknown ID:

Bash
curl "https://graph.facebook.com/v24.0/{YOUR_ID}?fields=id,name" \
  -H "Authorization: Bearer {TOKEN}"

Prevents

Error 100, Error 200


Error 190: Invalid OAuth access token

JSON
{
  "error": {
    "message": "Error validating access token: Session has expired",
    "type": "OAuthException",
    "code": 190,
    "error_subcode": 463
  }
}

Why this happens

Subcode tells you which problem you have

Subcode 463 or 460 — platform events. The token expired or the user changed their password. You cannot prevent these; you handle them architecturally.
No subcode — developer mistake. The Bearer prefix is missing, or the token was generated for a different app.

  • Subcode 463 — Short-lived user token expired (default lifetime: ~1–2 hours). Recurring 463 in production means you're using the wrong token type entirely.
  • Subcode 460 — User changed their Facebook password, invalidating all associated tokens. This will happen in any long-running integration.
  • No subcodeAuthorization header is missing the Bearer prefix, or the token was created for a different environment.

Fix

Step 1 — Verify the Authorization header

Bash
# Wrong
Authorization: EAABwzLixnjYBO...

# Correct
Authorization: Bearer EAABwzLixnjYBO...

Step 2 — Debug the token

Bash
curl "https://graph.facebook.com/debug_token?\
input_token={YOUR_TOKEN}&\
access_token={APP_ID}|{APP_SECRET}"

A valid token returns "is_valid": true with the assigned scopes. If is_valid is false, regenerate before any other debugging step.

Step 3 — Switch to a System User token (production)

System User tokens do not expire unless manually revoked. Recurring 463 errors in production mean this step has been skipped.

  1. Business Manager → Business Settings → System Users
  2. Create or select a System User
  3. Assign the WhatsApp Business Account as an asset
  4. Generate a token with required permissions
  5. Store as an environment variable — never hardcode
Bash
export WHATSAPP_TOKEN="EAAG..."

Prevention pattern

Python
# Python 3.9+ | Tested: Python 3.12.3
import os

def get_access_token() -> str:
    """
    Returns the System User token from the environment.
    Fails at startup if the token is missing — prevents silent failures
    reaching the API layer at request time.
    """
    token = os.environ.get("WHATSAPP_TOKEN")
    if not token:
        raise EnvironmentError(
            "WHATSAPP_TOKEN is not set. Generate a non-expiring System User token "
            "in Meta Business Manager. "
            "See: https://developers.facebook.com/docs/whatsapp/cloud-api/get-started"
        )
    return token

Subcodes

Subcode Meaning Action
460 User changed password Handle in error handler; notify or re-authenticate
463 Token expired Regenerate; switch to System User tokens in production
467 Invalid session Token revoked or permissions changed; regenerate

Error 100: Invalid parameter

JSON
{
  "error": {
    "message": "Unsupported post request. Object with ID '123' does not exist",
    "type": "OAuthException",
    "code": 100,
    "error_subcode": 33
  }
}

Why this happens

Developer mistake — fixable in your code without any platform interaction

The "does not exist" message is misleading. This error is almost always a wrong ID type, a missing required field, or a malformed phone number.

Causes in order of frequency:

  • Wrong ID type — WABA ID (16 digits) used where Phone Number ID (15 digits) is required. This is the most common trigger.
  • Missing required fieldmessaging_product, to, type, or text.body absent.
  • Malformed phone number — missing + prefix or invalid E.164 format.
  • Masked permission error — Meta uses subcode 33 for both "doesn't exist" and "you don't have permission." If the ID is correct, check token scopes before assuming the resource is missing.

Fix

Get the correct Phone Number ID from your WABA:

Bash
curl "https://graph.facebook.com/v24.0/{WABA_ID}/phone_numbers?fields=id,display_phone_number" \
  -H "Authorization: Bearer {TOKEN}"
JSON
{
  "data": [{
    "id": "123456789012345",
    "display_phone_number": "+1 555 123 4567"
  }]
}

Use the id value (15 digits) in all message requests:

Bash
# Correct — Phone Number ID (15 digits)
POST /v24.0/123456789012345/messages

# Wrong — WABA ID (16 digits) — returns Error 100
POST /v24.0/1234567890123456/messages

Every WhatsApp text message requires these five fields:

Bash
curl -X POST "https://graph.facebook.com/v24.0/{PHONE_NUMBER_ID}/messages" \
  -H "Authorization: Bearer {TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "messaging_product": "whatsapp",
    "recipient_type": "individual",
    "to": "+15551234567",
    "type": "text",
    "text": { "body": "Hello from the API" }
  }'

Prevention pattern

Validate the ID type and phone format before building the request. Catching this locally saves a network round-trip and gives you a clear error message instead of a misleading API response.

Python
# Python 3.9+ | Tested: Python 3.12.3
import re

def validate_phone_e164(phone: str) -> str:
    """Raises ValueError if phone is not valid E.164. Returns phone if valid."""
    if not re.match(r'^\+\d{7,15}$', phone):
        raise ValueError(f"Invalid E.164 phone format: {phone}")
    return phone

def build_text_message(phone_number_id: str, recipient: str, body: str) -> dict:
    """
    Builds and validates a WhatsApp text message payload.
    Raises ValueError for wrong ID type or invalid phone format.
    """
    if not phone_number_id.isdigit() or len(phone_number_id) != 15:
        raise ValueError(
            f"Expected 15-digit Phone Number ID, got: '{phone_number_id}'. "
            "Retrieve it from /v24.0/{WABA_ID}/phone_numbers."
        )
    return {
        "messaging_product": "whatsapp",
        "recipient_type": "individual",
        "to": validate_phone_e164(recipient),
        "type": "text",
        "text": {"body": body},
    }

Subcodes

Subcode Meaning
33 Object doesn't exist, was deleted, or a permission error is being masked
2500 Required parameter missing from the request body

Error 200: Permission required

JSON
{
  "error": {
    "message": "(#200) Requires whatsapp_business_messaging permission",
    "type": "OAuthException",
    "code": 200
  }
}

Why this happens

Developer mistake — token configuration, not a platform restriction

App Review approval does not add scopes to existing tokens. Approval determines what your app may request. The token must be regenerated after approval to include the new scope. This is the root cause of almost every Error 200.

The failure pattern: App Review approves a scope → requests still fail → an hour of debugging → the token was generated before the approval.

Fix

Check what scopes the token actually has:

Bash
curl "https://graph.facebook.com/debug_token?\
input_token={YOUR_TOKEN}&\
access_token={APP_ID}|{APP_SECRET}"
JSON
{
  "data": {
    "scopes": ["whatsapp_business_management"]
  }
}

If whatsapp_business_messaging is missing, regenerate the token and select both required scopes:

Scope Required for
whatsapp_business_messaging Sending and receiving messages
whatsapp_business_management Managing templates, phone numbers, account settings

Prevention pattern

Validate scopes at application startup — before serving any requests. This converts a silent runtime failure into a loud startup error.

Server-side only

This function calls /debug_token with your app_secret. Never run it in client-side or browser code. Call it once at server startup, not per-request.

Python
# Python 3.9+ | Tested: Python 3.12.3
import requests

REQUIRED_SCOPES = {
    "whatsapp_business_messaging",
    "whatsapp_business_management",
}

def validate_token_scopes(token: str, app_id: str, app_secret: str) -> None:
    """
    Confirms the token has all required scopes.
    Call once at application startup, not per-request.
    Raises PermissionError with an actionable message if scopes are missing.
    Raises requests.exceptions.RequestException on network failure.
    """
    try:
        response = requests.get(
            "https://graph.facebook.com/debug_token",
            params={
                "input_token": token,
                "access_token": f"{app_id}|{app_secret}",
            },
            timeout=10,  # Prevents hanging on slow or unreachable network
        )
        response.raise_for_status()
    except requests.exceptions.RequestException as exc:
        raise RuntimeError(f"Failed to reach Meta token debug endpoint: {exc}") from exc

    data = response.json().get("data", {})

    if not data.get("is_valid"):
        raise PermissionError(
            "Token is invalid. Regenerate it before checking scopes."
        )

    missing = REQUIRED_SCOPES - set(data.get("scopes", []))
    if missing:
        raise PermissionError(
            f"Token is missing required scopes: {missing}. "
            "Regenerate the token with all required permissions selected. "
            "App Review approval does not add scopes to existing tokens."
        )

Error 131047: Messaging window expired

JSON
{
  "error": {
    "message": "Message failed to send because more than 24 hours have passed since the customer last replied to this number",
    "code": 131047
  }
}

Why this happens

Platform constraint — architectural response required

Meta allows free-form messages only within 24 hours of the user's last inbound message. After that window closes, only approved templates can be sent. This is unconditional — there is no override.

The 24-hour clock resets each time the user sends a message. Any workflow that initiates outbound contact at arbitrary times — reminders, order updates, follow-ups — must store last_user_message_at and fall back to templates automatically when the window is closed.

Rule of thumb

If you initiate contact, always use a template. If the user messaged first and it was within the last 24 hours, free-form is allowed. When in doubt, use a template — they work in both states.

Fix

When the 24-hour window has expired, send an approved template instead:

Bash
curl -X POST "https://graph.facebook.com/v24.0/{PHONE_NUMBER_ID}/messages" \
  -H "Authorization: Bearer {TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "messaging_product": "whatsapp",
    "recipient_type": "individual",
    "to": "+15551234567",
    "type": "template",
    "template": {
      "name": "hello_world",
      "language": { "code": "en_US" }
    }
  }'

For templates with variables:

JSON
{
  "type": "template",
  "template": {
    "name": "order_confirmation",
    "language": { "code": "en_US" },
    "components": [{
      "type": "body",
      "parameters": [{ "type": "text", "text": "ORDER-12345" }]
    }]
  }
}

Prevention pattern

Encapsulate the window check in a single function. Callers should never need to know whether the window is open — the routing decision belongs in the messaging layer, not scattered across business logic.

Python
# Python 3.9+ | Tested: Python 3.12.3
import re
import requests
from datetime import datetime, timezone, timedelta

def validate_phone_e164(phone: str) -> str:
    """Raises ValueError if phone is not valid E.164. Returns phone if valid."""
    if not re.match(r'^\+\d{7,15}$', phone):
        raise ValueError(f"Invalid E.164 phone format: {phone}")
    return phone

def send_message(
    phone_number_id: str,
    recipient: str,
    text: str,
    last_user_message_at: datetime,
    token: str,
    fallback_template: str = "hello_world",
    fallback_language: str = "en_US",
) -> dict:
    """
    Sends a free-form message if within the 24-hour window,
    or an approved template if the window has expired.

    Args:
        phone_number_id: 15-digit Phone Number ID.
        recipient: Recipient phone in E.164 format.
        text: Message body for free-form sends.
        last_user_message_at: Timezone-aware UTC datetime of the user's last inbound message.
                              Must include tzinfo — pass datetime.now(timezone.utc) or equivalent.
        token: System User access token.
        fallback_template: Approved template name for out-of-window sends.
        fallback_language: BCP-47 language code for the template.

    Raises:
        ValueError: If last_user_message_at is naive (missing tzinfo).
        requests.exceptions.RequestException: On network failure.
    """
    # Guard against naive datetimes — comparing naive and aware datetimes raises TypeError
    if last_user_message_at.tzinfo is None:
        raise ValueError(
            "last_user_message_at must be timezone-aware. "
            "Use datetime.now(timezone.utc) or .replace(tzinfo=timezone.utc)."
        )

    validated_recipient = validate_phone_e164(recipient)
    window_open = datetime.now(timezone.utc) < last_user_message_at + timedelta(hours=24)

    if window_open:
        payload: dict = {
            "messaging_product": "whatsapp",
            "recipient_type": "individual",
            "to": validated_recipient,
            "type": "text",
            "text": {"body": text},
        }
    else:
        payload = {
            "messaging_product": "whatsapp",
            "recipient_type": "individual",
            "to": validated_recipient,
            "type": "template",
            "template": {
                "name": fallback_template,
                "language": {"code": fallback_language},
            },
        }

    response = requests.post(
        f"https://graph.facebook.com/v24.0/{phone_number_id}/messages",
        headers={"Authorization": f"Bearer {token}"},
        json=payload,
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

Error 368: Temporary block

JSON
{
  "error": {
    "message": "Temporary block for policy violation",
    "code": 368
  }
}

Why this happens

Platform enforcement — no code fix. Requires a process fix.

Error 368 cannot be resolved by changing a request or regenerating a token. Meta blocks sending in response to messaging behavior violations. The block lifts in 24–48 hours, but without fixing the root cause it will recur — and repeated violations escalate to a permanent ban.

Common triggers:

  • Sending to recipients without a recorded opt-in
  • Templates submitted with the wrong category (marketing submitted as utility)
  • Sustained spam reports driving a low quality rating
  • No opt-out mechanism in outbound message sequences

Fix

Step 1 — Check your quality rating

Meta Business Suite → WhatsApp Manager → Insights → Quality Rating

Rating Required action
Green No immediate action — maintain current practices
Yellow Audit recent outbound messages immediately
Red Pause non-essential sends; fix opt-in flows before resuming

Step 2 — Audit recent outbound messages

Look for: messages without a recorded opt-in, bulk sends with miscategorized templates, no opt-out path provided, off-topic template variable content.

Step 3 — Wait

Temporary blocks resolve within 24–48 hours. There is no API endpoint to lift one early.

Step 4 — Fix the root cause before resuming full volume

  • Log opt-in timestamp and channel for every recipient
  • Include a clear opt-out path in every outbound sequence
  • Submit templates with the correct category — marketing-as-utility is the most common trigger
  • Respond promptly to inbound messages

Prevention pattern

Python
# Python 3.9+ | Tested: Python 3.12.3
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional

@dataclass
class ConsentRecord:
    phone: str
    opted_in_at: datetime
    opt_in_channel: str                  # e.g. "website_form", "whatsapp_keyword"
    opted_out_at: Optional[datetime] = field(default=None)

def can_send_to(record: Optional[ConsentRecord]) -> bool:
    """
    Returns True only if an active opt-in exists.
    A record with opted_out_at set is treated as inactive, regardless of opted_in_at.
    """
    if record is None:
        return False
    return record.opted_in_at is not None and record.opted_out_at is None

Call can_send_to at the point where a message is queued — not at send time. If a message can reach the queue without passing a consent check, the guard is in the wrong place.


Error 4 / 17 / 32: Rate limit exceeded

JSON
{
  "error": {
    "message": "Application request limit reached",
    "type": "OAuthException",
    "code": 4
  }
}

Why this happens

Platform-enforced constraint — distribute volume across time, don't just reduce it

Meta enforces rate limits at multiple scopes simultaneously. Hitting one doesn't mean the others are clear. A common pattern: a single burst of messages (batch notification, marketing send) exhausts the app-level or WABA-level limit, then every subsequent request fails until the window resets — typically within 1 hour, though Meta does not publish exact reset windows.

The instinct to "just retry" without backoff makes this significantly worse: rapid retries from multiple instances stack up and extend the block.

Code Scope Typical trigger
4 App-level Too many total API calls across your whole application
17 User-level Too many calls from a single user token in a short window
32 WABA-level Too many calls against a specific WhatsApp Business Account

Message sending throughput is also governed by your account tier. See Meta's throughput documentation for current tier limits.

Fix

Back off with exponential delay and jitter on retry. Jitter prevents a thundering herd when multiple instances retry simultaneously.

Python
# Python 3.9+ | Tested: Python 3.12.3
import random
import time
import requests

RATE_LIMIT_CODES = {4, 17, 32}

def post_with_backoff(
    url: str,
    payload: dict,
    headers: dict,
    max_retries: int = 5,
    base_delay: float = 1.0,
) -> dict:
    """
    POST with exponential backoff + jitter on rate limit errors (codes 4, 17, 32).
    Jitter prevents thundering herd when multiple instances retry at the same time.
    Raises RuntimeError if max_retries is exhausted.
    Raises requests.exceptions.RequestException on network failure.

    Note on idempotency: WhatsApp message sends are not idempotent by default.
    If a request succeeds at the server but the response is lost in transit,
    this function will retry and may deliver a duplicate message. For
    business-critical sends, track message IDs and verify delivery status
    via webhooks before retrying after a network error.
    """
    for attempt in range(max_retries):
        try:
            response = requests.post(url, json=payload, headers=headers, timeout=10)
        except requests.exceptions.RequestException as exc:
            raise RuntimeError(f"Network error on attempt {attempt + 1}: {exc}") from exc

        if response.ok:
            return response.json()

        error_code = response.json().get("error", {}).get("code")
        if error_code in RATE_LIMIT_CODES:
            delay = base_delay * (2 ** attempt) * random.uniform(0.8, 1.2)
            time.sleep(delay)
            continue

        response.raise_for_status()

    raise RuntimeError(f"Rate limit not cleared after {max_retries} retries.")

Prevention pattern

Throttle at the application layer. Send at a controlled rate rather than in bursts.

Python
# Python 3.9+ | Tested: Python 3.12.3
import time
import requests
from dataclasses import dataclass
from typing import Optional

@dataclass
class SendResult:
    recipient: str
    success: bool
    message_id: Optional[str]
    error: Optional[dict]

def send_bulk_messages(
    messages: list[dict],
    phone_number_id: str,
    token: str,
    requests_per_second: float = 80.0,
) -> list[SendResult]:
    """
    Sends messages at a controlled rate and returns explicit success/failure
    results for every message.

    Default 80 req/s stays safely below the typical 100 req/s tier limit.
    For high-volume production use, consider asyncio + aiohttp for non-blocking sends.

    Returns:
        List of SendResult — one per message. Inspect result.success to identify
        failures. Failed results include the full error dict for logging or retry.
        This function does not raise on individual message failures — check results.

    Note on idempotency: Retrying individual failures may deliver duplicate messages
    if the original request reached Meta's servers but the response was lost.
    Track message IDs and verify via webhooks before retrying.
    """
    interval = 1.0 / requests_per_second
    results: list[SendResult] = []

    for msg in messages:
        recipient = msg.get("to", "unknown")
        try:
            response = requests.post(
                f"https://graph.facebook.com/v24.0/{phone_number_id}/messages",
                headers={"Authorization": f"Bearer {token}"},
                json=msg,
                timeout=10,
            )
            data = response.json()

            if response.ok:
                message_id = data.get("messages", [{}])[0].get("id")
                results.append(SendResult(
                    recipient=recipient,
                    success=True,
                    message_id=message_id,
                    error=None,
                ))
            else:
                results.append(SendResult(
                    recipient=recipient,
                    success=False,
                    message_id=None,
                    error=data.get("error"),
                ))
        except requests.exceptions.RequestException as exc:
            results.append(SendResult(
                recipient=recipient,
                success=False,
                message_id=None,
                error={"message": str(exc)},
            ))

        time.sleep(interval)

    return results

Debugging checklist

Work through this in order before opening a support ticket.

Start here

Authentication items (first group) resolve approximately 70% of errors. Work top to bottom — don't skip ahead.

Authentication — do these first

  • Authorization header includes Bearer prefix
  • Token passes /debug_token"is_valid": true
  • Token includes whatsapp_business_messaging scope
  • Token includes whatsapp_business_management scope
  • Using a System User token in production, not a short-lived user token

Request construction

  • Using Phone Number ID (15 digits), not WABA ID (16 digits)
  • Recipient phone is in E.164 format with + prefix
  • messaging_product, recipient_type, to, and type are all present
  • text.body is included when type is text

Messaging policy

  • User's last inbound message was within 24 hours — or request uses an approved template
  • Recipient has a logged opt-in on record
  • Quality rating is Green or Yellow in the Business Suite dashboard
  • Template category matches the content type (marketing ≠ utility)

Observability

Logging the error code is not enough. Log the error_subcode — it's the difference between knowing a token failed and knowing why it failed. Subcode 463 (expired) vs 460 (password change) require completely different responses; without it in your logs, you're guessing.

Using Python's standard logging:

Python
# Python 3.9+ | Tested: Python 3.12.3
import logging

logger = logging.getLogger(__name__)

def log_api_error(response_json: dict, context: dict | None = None) -> None:
    """
    Logs a structured Graph API error event with full diagnostic fields.
    Pass context with request metadata: endpoint, phone_number_id, recipient, etc.
    Maps directly to Sentry's 'Additional Data' panel via the extra= dict.
    """
    error = response_json.get("error", {})
    logger.error(
        "Graph API error",
        extra={
            "error_code": error.get("code"),
            "error_subcode": error.get("error_subcode"),  # Critical — don't omit this
            "error_message": error.get("message"),
            "error_type": error.get("error_user_title", error.get("type")),
            **(context or {}),
        },
    )

Using structlog (recommended for production JSON logging):

Python
# Python 3.9+ | Tested: Python 3.12.3
# pip install structlog
import structlog

logger = structlog.get_logger()

def log_api_error(response_json: dict, context: dict | None = None) -> None:
    """
    Structured log for Graph API errors. Output is JSON in production,
    human-readable in development — no code changes needed.
    """
    error = response_json.get("error", {})
    logger.error(
        "graph_api_error",
        error_code=error.get("code"),
        error_subcode=error.get("error_subcode"),  # Critical — don't omit this
        error_message=error.get("message"),
        error_type=error.get("error_user_title", error.get("type")),
        **(context or {}),
    )

For OpenTelemetry, set these as span attributes: error.code, error.subcode, error.message. For Logfire, pass as keyword arguments to logfire.error().


When to escalate

Escalate to Meta Business Support when:

  • Error 368 persists beyond 72 hours with no quality rating change
  • Error 190 recurs on a valid System User token (possible account-level revocation)
  • Error 100 subcode 33 appears on an ID that definitely exists with correct permissions
  • App Review rejections recur after addressing stated reasons

When opening a ticket, include: the full JSON error response, the endpoint and HTTP method, a sanitized request payload, and the output of /debug_token for the token in use.


Reference


Changelog

Version Date Changes
v1.4 Feb 2026 Fixed send_bulk_messages to return explicit per-message success/failure results via SendResult dataclass; added idempotency warnings to rate limit retry functions; fixed leading whitespace in Scope section
v1.3 Feb 2026 Added Scope and Sequence section; How to Use section; free-form vs template rule of thumb in 131047; expanded rate limit Why section with prose and trigger descriptions; checklist priority note; structlog observability example
v1.2 Feb 2026 Added observability section; timezone guard in send_message; jitter in post_with_backoff; timeout=10 on all network calls; subcodes column in lookup table; server-side warning on validate_token_scopes
v1.1 Feb 2026 Added rate limit section (errors 4/17/32); tightened E.164 regex; send_message includes full POST; split validate_phone_e164 into reusable helper
v1.0 Feb 2026 Initial release — errors 190, 100, 200, 131047, 368

All examples tested: Python 3.12.3 · requests 2.31.0 · WhatsApp Cloud API v24.0 · February 2026
Not affiliated with Meta Platforms, Inc.

Last updated: February 2026