Errors Reference
The WATS typed error model: class hierarchy, error code registry, classification rules, and propagation behavior.
active · reviewed 2026-04-21
Run this against a mock 429 in the playground.
Every Graph failure maps to a typed error class. Catch by instanceof, not by parsing Meta's prose — Meta's prose changes.
Graph error class hierarchy
@wats/graph defines a single-inheritance hierarchy of typed errors:
Error
├── GraphApiError (status, code, errorSubcode, fbtraceId, payload, classification)
│ ├── GraphAuthError (OAuth/permission failures; 4xx-only)
│ │ ├── AuthException (code 0 — pywa AuthException)
│ │ ├── APIMethodError (code 3 — pywa APIMethod)
│ │ ├── PermissionDeniedError (code 10 — pywa PermissionDenied)
│ │ ├── ExpiredAccessTokenError (code 190 — pywa ExpiredAccessToken)
│ │ └── APIPermissionError (code 200 representative; pywa range 200..299)
│ ├── GraphRateLimitError (throttling; HTTP 429 OR 4xx + code in RATE_LIMIT_CODES)
│ │ ├── ToManyAPICallsError (code 4 — pywa ToManyAPICalls)
│ │ ├── RateLimitIssuesError (code 80007)
│ │ ├── RateLimitHitError (code 130429)
│ │ ├── SpamRateLimitHitError (code 131048)
│ │ └── TooManyMessagesError (code 131056)
│ ├── GraphRequestValidationError (caller-side validation; construction-time + path/header)
│ ├── GraphSerializationError (JSON boundary)
│ └── <registered subclasses> (per-code; see Error Code Registry)
└── GraphNetworkError (Transport-level, NOT a GraphApiError subclass)Every GraphApiError instance carries:
| Field | Type | Description |
|---|---|---|
status | number | HTTP status code of the response. |
code | number | undefined | Graph error code from payload.error.code. |
errorSubcode | number | undefined | Graph error subcode from payload.error.error_subcode. |
type | string | undefined | Graph error type (e.g. OAuthException). |
fbtraceId | string | undefined | Graph trace id. |
payload | GraphApiErrorPayload | undefined | Original envelope payload. |
classification | "ClientError" | "ServerError" | "Unknown" | Coarse HTTP-status-driven bucket. |
Error Code Registry
@wats/graph exposes three functions:
import {
registerErrorCode,
resolveRegisteredError,
clearErrorRegistry
} from "@wats/graph";
registerErrorCode({
code: 131079, // required: finite non-negative number
subcode: 2494023, // optional: narrower match
errorName: "MyCustomError", // required: non-empty string
factory: (ctx) => new MyCustomError(ctx) // required: function
});
const entry = resolveRegisteredError(131079, 2494023);
// entry?.errorName === "MyCustomError"Input validation (registerErrorCode throws on violation):
codemust be a finite, non-negative integernumber.subcode, when provided, must be a finite, non-negative integernumber.errorNamemust be a non-empty string.factorymust be a function.
Resolution semantics:
- Exact
(code, subcode)match wins. - Falls back to the any-subcode
(code, undefined)entry. - Returns
undefinedwhen no entry matches either key.
Last-writer-wins: re-registering the same (code, subcode?) key replaces the prior entry, so you can override built-in subclasses without a deregister call.
clearErrorRegistry() empties the registry AND resets the built-in-seed guard so a subsequent registerBuiltInErrorCodes() call re-seeds. It is the single public reset hook.
Registering a private subclass:
import {
GraphApiError,
registerErrorCode,
type GraphErrorFactoryContext
} from "@wats/graph";
class MyCustomError extends GraphApiError {
static readonly errorCode = 131079;
constructor(ctx: GraphErrorFactoryContext) {
super({
message: ctx.payload?.message ?? "custom",
status: ctx.status,
payload: ctx.payload
});
this.name = "MyCustomError";
}
}
registerErrorCode({
code: 131079,
errorName: "MyCustomError",
factory: (ctx) => new MyCustomError(ctx)
});Classification decision tree
createGraphApiError({ status, payload, ... }) follows this exact order of checks — no fall-through reordering:
- Registry match. If
payload.coderesolves viaresolveRegisteredError(code, subcode), construct the registered subclass. If the resulting instance is an auth or rate-limit subclass but the HTTP status contradicts (e.g., code 190 at 500), drop to step 4 to avoid a misclassified auth error at 5xx. - Auth axis (4xx-only). If
status === 401 || 403, ORpayload.type === "OAuthException"withstatus ∈ [400..500), ORpayload.code === 190withstatus ∈ [400..500)→GraphAuthError. A 5xx with a strayOAuthExceptiondoes NOT classify as auth — it is a ServerError. - Rate-limit axis (HTTP-coherent). If
status === 429OR (status ∈ [400..500)ANDpayload.code ∈ RATE_LIMIT_CODES) →GraphRateLimitError. A 500 with a stray code 4 does NOT classify as throttling. - Plain GraphApiError.
classificationderives from the status band: 4xx →"ClientError", 5xx →"ServerError", anything else →"Unknown".
RATE_LIMIT_CODES is exported as a ReadonlySet<number>: {4, 80007, 130429, 131048, 131056}.
Seeded error codes (reconciled from pywa/errors.py)
These subclasses are registered at module load, sourced from pywa's errors.py. Each extends the parent indicated by its axis; each has a unique name and a static readonly errorCode.
| Code | Subclass | Parent | Axis | pywa source |
|---|---|---|---|---|
| 0 | AuthException | GraphAuthError | authorization | AuthException |
| 3 | APIMethodError | GraphAuthError | authorization | APIMethod |
| 4 | ToManyAPICallsError | GraphRateLimitError | rate-limit | ToManyAPICalls |
| 10 | PermissionDeniedError | GraphAuthError | authorization | PermissionDenied |
| 100 | InvalidParameterError | GraphApiError | send-message | (WATS augmentation — aliases pywa's 131009) |
| 190 | ExpiredAccessTokenError | GraphAuthError | authorization | ExpiredAccessToken |
| 200 | APIPermissionError | GraphAuthError | authorization | APIPermission (representative of range(200, 300)) |
| 368 | TemporarilyBlockedError | GraphApiError | integrity | TemporarilyBlocked |
| 613 | FetchCallPermissionLimitHitError | GraphApiError | calling | FetchCallPermissionLimitHit (alias of 138013) |
| 80007 | RateLimitIssuesError | GraphRateLimitError | rate-limit | RateLimitIssues |
| 130429 | RateLimitHitError | GraphRateLimitError | rate-limit | RateLimitHit |
| 130472 | UserIsInExperimentGroupError | GraphApiError | send-message | UserIsInExperimentGroup |
| 130497 | AccountRestrictedFromCountryError | GraphApiError | integrity | AccountRestrictedFromCountry |
| 131000 | UnknownError | GraphApiError | send-message | UnknownError |
| 131005 | AccessDeniedError | GraphApiError | send-message | AccessDenied |
| 131008 | MissingRequiredParameterError | GraphApiError | send-message | MissingRequiredParameter |
| 131009 | InvalidParameterError | GraphApiError | send-message | InvalidParameter |
| 131016 | ServiceUnavailableError | GraphApiError | send-message | ServiceUnavailable |
| 131021 | RecipientCannotBeSenderError | GraphApiError | send-message | RecipientCannotBeSender |
| 131026 | MessageUndeliverableError | GraphApiError | send-message | MessageUndeliverable |
| 131030 | RecipientNotInAllowedListError | GraphApiError | send-message | RecipientNotInAllowedList |
| 131031 | AccountLockedError | GraphApiError | integrity | AccountLocked |
| 131042 | BusinessPaymentIssueError | GraphApiError | send-message | BusinessPaymentIssue |
| 131044 | BusinessPaymentIssueError | GraphApiError | send-message | BusinessPaymentIssue (calling-path code) |
| 131045 | IncorrectCertificateError | GraphApiError | send-message | IncorrectCertificate |
| 131047 | ReEngagementMessageError | GraphApiError | send-message | ReEngagementMessage |
| 131048 | SpamRateLimitHitError | GraphRateLimitError | rate-limit | SpamRateLimitHit |
| 131050 | UserStoppedMarketingMessagesError | GraphApiError | send-message | UserStoppedMarketingMessages |
| 131051 | UnsupportedMessageTypeError | GraphApiError | send-message | UnsupportedMessageType |
| 131052 | MediaDownloadError | GraphApiError | send-message | MediaDownloadError |
| 131053 | MediaUploadError | GraphApiError | send-message | MediaUploadError |
| 131056 | TooManyMessagesError | GraphRateLimitError | rate-limit | TooManyMessages |
| 131057 | AccountInMaintenanceModeError | GraphApiError | send-message | AccountInMaintenanceMode |
| 131059 | InvalidTemplateCursorError | GraphApiError | template | message_templates invalid before/after cursor |
| 131064 | TemplateClassificationRateLimitError | GraphApiError | template | template classification rate limit |
| 132000 | TemplateParamCountMismatchError | GraphApiError | template | TemplateParamCountMismatch |
| 132001 | TemplateNotExistsError | GraphApiError | template | TemplateNotExists |
| 132005 | TemplateTextTooLongError | GraphApiError | template | TemplateTextTooLong |
| 132007 | TemplateContentPolicyViolationError | GraphApiError | template | TemplateContentPolicyViolation |
| 132008 | TemplateParamValueInvalidError | GraphApiError | template | TemplateParamValueInvalid |
| 132012 | TemplateParamFormatMismatchError | GraphApiError | template | TemplateParamFormatMismatch |
| 132015 | TemplatePausedError | GraphApiError | template | TemplatePaused |
| 132016 | TemplateDisabledError | GraphApiError | template | TemplateDisabled |
| 132018 | InvalidTemplateParameterError | GraphApiError | template | template message invalid parameters |
| 132068 | FlowBlockedError | GraphApiError | flow | FlowBlocked |
| 132069 | FlowThrottledError | GraphApiError | flow | FlowThrottled |
| 135000 | GenericError | GraphApiError | send-message | GenericError |
| 137000 | RecipientIdentityKeyMismatchError | GraphApiError | send-message | RecipientIdentityKeyMismatch |
| 138000 | CallingNotEnabledError | GraphApiError | calling | CallingNotEnabled |
| 138001 | ReceiverUncallableError | GraphApiError | calling | ReceiverUncallable |
| 138002 | ConcurrentCallsLimitError | GraphApiError | calling | ConcurrentCallsLimit |
| 138003 | DuplicateCallError | GraphApiError | calling | DuplicateCall |
| 138004 | CallConnectionError | GraphApiError | calling | CallConnectionError |
| 138005 | CallRateLimitExceededError | GraphApiError | calling | CallRateLimitExceeded |
| 138006 | CallPermissionNotFoundError | GraphApiError | calling | CallPermissionNotFound |
| 138007 | CallConnectionTimeoutError | GraphApiError | calling | CallConnectionTimeout |
| 138009 | CallPermissionRequestLimitHitError | GraphApiError | calling | CallPermissionRequestLimitHit |
| 138012 | BusinessInitiatedCallsLimitHitError | GraphApiError | calling | BusinessInitiatedCallsLimitHit |
| 138013 | FetchCallPermissionLimitHitError | GraphApiError | calling | FetchCallPermissionLimitHit |
| 138018 | CallingCannotBeEnabledError | GraphApiError | calling | CallingCannotBeEnabled |
| 139000 | FlowBlockedByIntegrityError | GraphApiError | flow | FlowBlockedByIntegrity |
| 139001 | FlowUpdatingError | GraphApiError | flow | FlowUpdatingError |
| 139002 | FlowPublishingError | GraphApiError | flow | FlowPublishingError |
| 139003 | FlowDeprecatingError | GraphApiError | flow | FlowDeprecatingError |
| 139004 | FlowDeletingError | GraphApiError | flow | FlowDeletingError |
| 139100 | BulkBlockingFailedError | GraphApiError | block-user | BulkBlockingFailed |
| 139101 | BlockListLimitReachedError | GraphApiError | block-user | BlockListLimitReached |
| 139102 | BlockListConcurrentUpdateError | GraphApiError | block-user | BlockListConcurrentUpdate |
| 134100 | MarketingMessagesLiteUnsupportedMessageTypeError | GraphApiError | marketing-messages-lite | Marketing Messages Lite unsupported message type |
| 134101 | MarketingMessagesLiteUnsupportedTemplateCategoryError | GraphApiError | marketing-messages-lite | Marketing Messages Lite unsupported template category |
| 134102 | MarketingMessagesLiteInvalidFlowError | GraphApiError | marketing-messages-lite | Marketing Messages Lite invalid Flow |
| 134103 | MarketingMessagesLiteUnsupportedTemplateStructureError | GraphApiError | marketing-messages-lite | Marketing Messages Lite unsupported template structure |
| 139103 | BlockUserInternalError | GraphApiError | block-user | BlockUserInternalError |
Naming convention: pywa class name verbatim with an Error suffix appended unless the pywa class already ends in Error or Exception. So AuthException stays as-is; ExpiredAccessToken → ExpiredAccessTokenError; MediaDownloadError stays.
Adding a new code is a data change — a new entry in packages/graph/src/errorSubclasses.ts's BUILT_IN_SEEDS array plus a class definition.
Ranges in pywa vs discrete registration
pywa's errors.py occasionally binds a range (or a tuple) of error codes to a single class:
APIPermission.__error_codes__ = range(200, 300)— any of 200..299 counts as a permission failure.BusinessPaymentIssue.__error_codes__ = (131042, 131044)— two discrete codes share the class.FetchCallPermissionLimitHit.__error_codes__ = (138013, 613)— WhatsApp changed the code at some point; pywa binds both.
WATS's registry is keyed by discrete (code, subcode?) pairs and does not support range-scoped registrations:
- For
range(200, 300): WATS registers only the start code (200) as a representativeAPIPermissionError. Codes 201..299 fall through to the generic classifier and surface asGraphAuthErrororGraphApiErrorbased on HTTP status, without theAPIPermissionErrorsubclass identity. - For tuple-bound classes: every code in the tuple is registered individually, all pointing at the same class.
BusinessPaymentIssueErrorresolves for both 131042 and 131044;FetchCallPermissionLimitHitErrorresolves for both 138013 and 613.
Retry-After header
GraphErrorFactoryContext carries headers: Headers, so a factory could inspect Retry-After. The built-in subclasses do not: rate-limit classification is based purely on HTTP status + payload code, and no back-off hint is attached to GraphRateLimitError instances. Consumers that need Retry-After today must read it themselves from the response headers.
Template and Marketing Messages diagnostics
The v21-v25 WhatsApp / Marketing Messages diagnostic codes are in the built-in registry: 131050 is UserStoppedMarketingMessagesError; 132018 is InvalidTemplateParameterError; 131059 is InvalidTemplateCursorError; 131064 is TemplateClassificationRateLimitError; 134100-134103 are the Marketing Messages Lite diagnostics listed in the table.
These subclasses preserve the original Graph payload and keep the normal HTTP classification on GraphApiError. They are not broad retry suppressors: remediate the message/template/Flow shape or user marketing preference state, and only apply explicit retry policies documented for a specific code.
InvalidTemplateCursorError (code 131059) fires when message_templates pagination receives an invalid before or after cursor. WATS performs no hidden retry: if your workflow can safely restart from the first page, catch the error and retry without before/after as an explicit opt-in.
The business-management response fields whatsapp_business_manager_messaging_limit and messaging_limit_tier are typed on WABA/phone-number read responses; messaging_limit_tier reflects the business portfolio messaging limit in v24+ semantics.
GraphRequestValidationError
Thrown when request-path or construction-time validation fails:
- Construction-time:
accessTokennon-empty, non-whitespace, ≤ 4096 chars, no CR/LF/NUL/control;apiVersionmatches/^v\d+(\.\d+)?$/;baseUrlparses and has protocolhttp:/https:(nojavascript:/file:/ftp:/data:/about:/blob:). - Request-time: path dot-segments, traversal patterns,
?/#injection, ASCII control characters; CR/LF/NUL in header names or values;authorizationheader override (managed by the client). - Messages endpoint: invalid numeric
phoneNumberIdpath segment.
GraphRequestValidationError extends GraphApiError, so existing instanceof GraphApiError checks remain valid.
GraphSerializationError
Thrown when JSON serialization of a request body fails (e.g. cyclic objects) or when a 2xx response declares JSON but contains invalid JSON.
GraphNetworkError
Thrown when the underlying Transport fails before an HTTP response is received (fetch-level throws, DNS, connection errors, missing fetch runtime). Deliberately NOT a GraphApiError subclass: there is no HTTP status to classify.
scrubErrorCause(err: unknown): unknown
Log/metrics sinks print errors verbatim and accidentally leak the access token when an error message contains Authorization: Bearer .... scrubErrorCause returns a shallow-cloned copy of err with every Bearer <token> substring in message, stack, and the cause chain redacted to Bearer ***.
Guarantees:
- String input: returns the redacted string.
Errorinstance: returns a clone withmessage/stackredacted AND the prototype preserved (soinstanceof Error,instanceof GraphApiError, etc. still succeed on the scrubbed value).- Cause chain:
causeis recursively redacted at unbounded depth with aWeakSet-backed cycle guard, so self-referential causes terminate without stack overflow. - Enumerable own string properties on the cloned error are also redacted.
- Non-Error / non-string values (numbers,
null,undefined, plain objects without a string.message): returned unchanged.
import { scrubErrorCause } from "@wats/graph";
try {
await client.request({ method: "GET", path: "/me" });
} catch (error) {
logger.error("graph.request.failed", scrubErrorCause(error));
throw error; // rethrow the original for upstream handlers
}Scope note: scrubErrorCause does not recurse into arbitrary nested object graphs that are not error-like. If you log a plain Record<string, unknown> containing Bearer … inside a nested field, redact that yourself at the log boundary.
Envelope + predicates
Exports related to mapping:
createGraphApiError(...)— entry point described in the decision tree above.isGraphErrorEnvelope(...)— type guard for{ error: {...} }shape.isGraphApiErrorPayload(...)— type guard for the payload record.GraphApiErrorPayload,GraphErrorEnvelope— typed interfaces.GraphErrorClassification—"ClientError" | "ServerError" | "Unknown".RATE_LIMIT_CODES—ReadonlySet<number>of throttling codes.
The request primitive inspects Graph JSON error envelopes only for non-2xx responses. A 2xx response that happens to contain an error object is treated as a success payload. Non-envelope fallback handling disables subclass classification intentionally: those failures stay base GraphApiError regardless of code: 190-style fields.
HTTP webhook verification errors
@wats/http verification primitives return typed error objects (result unions) rather than throwing for expected failures.
verifyWebhookChallenge error codes:
invalid_expected_verify_token(status: 500) when the configured expected verify token is missing, non-string, empty, or whitespace-only.invalid_mode(status: 403)invalid_verify_token(status: 403)missing_challenge(status: 400)crypto_provider_unavailable(status: 500) wheninput.cryptois omitted AND the defaultcreateCryptoProvider()factory raisesUnsupportedCapabilityError.
validateWebhookSignature error codes:
invalid_app_secretinvalid_raw_body—null/undefined/plain objects/numbers/booleans/arrays/symbols/functions rejected;SharedArrayBuffer-backed views and detached buffers rejected too.missing_signatureinvalid_signature_formatsignature_mismatchcrypto_provider_unavailable
Both functions are async and route cryptographic primitives through the @wats/crypto CryptoProvider seam.
Usage
import {
GraphApiError,
GraphAuthError,
GraphRateLimitError,
InvalidParameterError,
RecipientIdentityKeyMismatchError,
UnsupportedMessageTypeError,
scrubErrorCause
} from "@wats/graph";
try {
await client.messages.sendMessage({ phoneNumberId, to, text });
} catch (error) {
if (error instanceof InvalidParameterError) {
// code 100 — caller fix needed.
} else if (error instanceof UnsupportedMessageTypeError) {
// code 131051 — message type not supported; fall back or drop.
} else if (error instanceof GraphAuthError) {
// rotate access token.
} else if (error instanceof GraphRateLimitError) {
// back off; consult Retry-After.
} else if (error instanceof GraphApiError) {
logger.error("graph.unhandled", scrubErrorCause(error));
} else {
throw error;
}
}