wats.sh
Reference

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:

FieldTypeDescription
statusnumberHTTP status code of the response.
codenumber | undefinedGraph error code from payload.error.code.
errorSubcodenumber | undefinedGraph error subcode from payload.error.error_subcode.
typestring | undefinedGraph error type (e.g. OAuthException).
fbtraceIdstring | undefinedGraph trace id.
payloadGraphApiErrorPayload | undefinedOriginal 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):

  • code must be a finite, non-negative integer number.
  • subcode, when provided, must be a finite, non-negative integer number.
  • errorName must be a non-empty string.
  • factory must be a function.

Resolution semantics:

  1. Exact (code, subcode) match wins.
  2. Falls back to the any-subcode (code, undefined) entry.
  3. Returns undefined when 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:

  1. Registry match. If payload.code resolves via resolveRegisteredError(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.
  2. Auth axis (4xx-only). If status === 401 || 403, OR payload.type === "OAuthException" with status ∈ [400..500), OR payload.code === 190 with status ∈ [400..500)GraphAuthError. A 5xx with a stray OAuthException does NOT classify as auth — it is a ServerError.
  3. Rate-limit axis (HTTP-coherent). If status === 429 OR (status ∈ [400..500) AND payload.code ∈ RATE_LIMIT_CODES) → GraphRateLimitError. A 500 with a stray code 4 does NOT classify as throttling.
  4. Plain GraphApiError. classification derives 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.

CodeSubclassParentAxispywa source
0AuthExceptionGraphAuthErrorauthorizationAuthException
3APIMethodErrorGraphAuthErrorauthorizationAPIMethod
4ToManyAPICallsErrorGraphRateLimitErrorrate-limitToManyAPICalls
10PermissionDeniedErrorGraphAuthErrorauthorizationPermissionDenied
100InvalidParameterErrorGraphApiErrorsend-message(WATS augmentation — aliases pywa's 131009)
190ExpiredAccessTokenErrorGraphAuthErrorauthorizationExpiredAccessToken
200APIPermissionErrorGraphAuthErrorauthorizationAPIPermission (representative of range(200, 300))
368TemporarilyBlockedErrorGraphApiErrorintegrityTemporarilyBlocked
613FetchCallPermissionLimitHitErrorGraphApiErrorcallingFetchCallPermissionLimitHit (alias of 138013)
80007RateLimitIssuesErrorGraphRateLimitErrorrate-limitRateLimitIssues
130429RateLimitHitErrorGraphRateLimitErrorrate-limitRateLimitHit
130472UserIsInExperimentGroupErrorGraphApiErrorsend-messageUserIsInExperimentGroup
130497AccountRestrictedFromCountryErrorGraphApiErrorintegrityAccountRestrictedFromCountry
131000UnknownErrorGraphApiErrorsend-messageUnknownError
131005AccessDeniedErrorGraphApiErrorsend-messageAccessDenied
131008MissingRequiredParameterErrorGraphApiErrorsend-messageMissingRequiredParameter
131009InvalidParameterErrorGraphApiErrorsend-messageInvalidParameter
131016ServiceUnavailableErrorGraphApiErrorsend-messageServiceUnavailable
131021RecipientCannotBeSenderErrorGraphApiErrorsend-messageRecipientCannotBeSender
131026MessageUndeliverableErrorGraphApiErrorsend-messageMessageUndeliverable
131030RecipientNotInAllowedListErrorGraphApiErrorsend-messageRecipientNotInAllowedList
131031AccountLockedErrorGraphApiErrorintegrityAccountLocked
131042BusinessPaymentIssueErrorGraphApiErrorsend-messageBusinessPaymentIssue
131044BusinessPaymentIssueErrorGraphApiErrorsend-messageBusinessPaymentIssue (calling-path code)
131045IncorrectCertificateErrorGraphApiErrorsend-messageIncorrectCertificate
131047ReEngagementMessageErrorGraphApiErrorsend-messageReEngagementMessage
131048SpamRateLimitHitErrorGraphRateLimitErrorrate-limitSpamRateLimitHit
131050UserStoppedMarketingMessagesErrorGraphApiErrorsend-messageUserStoppedMarketingMessages
131051UnsupportedMessageTypeErrorGraphApiErrorsend-messageUnsupportedMessageType
131052MediaDownloadErrorGraphApiErrorsend-messageMediaDownloadError
131053MediaUploadErrorGraphApiErrorsend-messageMediaUploadError
131056TooManyMessagesErrorGraphRateLimitErrorrate-limitTooManyMessages
131057AccountInMaintenanceModeErrorGraphApiErrorsend-messageAccountInMaintenanceMode
131059InvalidTemplateCursorErrorGraphApiErrortemplatemessage_templates invalid before/after cursor
131064TemplateClassificationRateLimitErrorGraphApiErrortemplatetemplate classification rate limit
132000TemplateParamCountMismatchErrorGraphApiErrortemplateTemplateParamCountMismatch
132001TemplateNotExistsErrorGraphApiErrortemplateTemplateNotExists
132005TemplateTextTooLongErrorGraphApiErrortemplateTemplateTextTooLong
132007TemplateContentPolicyViolationErrorGraphApiErrortemplateTemplateContentPolicyViolation
132008TemplateParamValueInvalidErrorGraphApiErrortemplateTemplateParamValueInvalid
132012TemplateParamFormatMismatchErrorGraphApiErrortemplateTemplateParamFormatMismatch
132015TemplatePausedErrorGraphApiErrortemplateTemplatePaused
132016TemplateDisabledErrorGraphApiErrortemplateTemplateDisabled
132018InvalidTemplateParameterErrorGraphApiErrortemplatetemplate message invalid parameters
132068FlowBlockedErrorGraphApiErrorflowFlowBlocked
132069FlowThrottledErrorGraphApiErrorflowFlowThrottled
135000GenericErrorGraphApiErrorsend-messageGenericError
137000RecipientIdentityKeyMismatchErrorGraphApiErrorsend-messageRecipientIdentityKeyMismatch
138000CallingNotEnabledErrorGraphApiErrorcallingCallingNotEnabled
138001ReceiverUncallableErrorGraphApiErrorcallingReceiverUncallable
138002ConcurrentCallsLimitErrorGraphApiErrorcallingConcurrentCallsLimit
138003DuplicateCallErrorGraphApiErrorcallingDuplicateCall
138004CallConnectionErrorGraphApiErrorcallingCallConnectionError
138005CallRateLimitExceededErrorGraphApiErrorcallingCallRateLimitExceeded
138006CallPermissionNotFoundErrorGraphApiErrorcallingCallPermissionNotFound
138007CallConnectionTimeoutErrorGraphApiErrorcallingCallConnectionTimeout
138009CallPermissionRequestLimitHitErrorGraphApiErrorcallingCallPermissionRequestLimitHit
138012BusinessInitiatedCallsLimitHitErrorGraphApiErrorcallingBusinessInitiatedCallsLimitHit
138013FetchCallPermissionLimitHitErrorGraphApiErrorcallingFetchCallPermissionLimitHit
138018CallingCannotBeEnabledErrorGraphApiErrorcallingCallingCannotBeEnabled
139000FlowBlockedByIntegrityErrorGraphApiErrorflowFlowBlockedByIntegrity
139001FlowUpdatingErrorGraphApiErrorflowFlowUpdatingError
139002FlowPublishingErrorGraphApiErrorflowFlowPublishingError
139003FlowDeprecatingErrorGraphApiErrorflowFlowDeprecatingError
139004FlowDeletingErrorGraphApiErrorflowFlowDeletingError
139100BulkBlockingFailedErrorGraphApiErrorblock-userBulkBlockingFailed
139101BlockListLimitReachedErrorGraphApiErrorblock-userBlockListLimitReached
139102BlockListConcurrentUpdateErrorGraphApiErrorblock-userBlockListConcurrentUpdate
134100MarketingMessagesLiteUnsupportedMessageTypeErrorGraphApiErrormarketing-messages-liteMarketing Messages Lite unsupported message type
134101MarketingMessagesLiteUnsupportedTemplateCategoryErrorGraphApiErrormarketing-messages-liteMarketing Messages Lite unsupported template category
134102MarketingMessagesLiteInvalidFlowErrorGraphApiErrormarketing-messages-liteMarketing Messages Lite invalid Flow
134103MarketingMessagesLiteUnsupportedTemplateStructureErrorGraphApiErrormarketing-messages-liteMarketing Messages Lite unsupported template structure
139103BlockUserInternalErrorGraphApiErrorblock-userBlockUserInternalError

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; ExpiredAccessTokenExpiredAccessTokenError; 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 representative APIPermissionError. Codes 201..299 fall through to the generic classifier and surface as GraphAuthError or GraphApiError based on HTTP status, without the APIPermissionError subclass identity.
  • For tuple-bound classes: every code in the tuple is registered individually, all pointing at the same class. BusinessPaymentIssueError resolves for both 131042 and 131044; FetchCallPermissionLimitHitError resolves 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: accessToken non-empty, non-whitespace, ≤ 4096 chars, no CR/LF/NUL/control; apiVersion matches /^v\d+(\.\d+)?$/; baseUrl parses and has protocol http:/https: (no javascript:/file:/ftp:/data:/about:/blob:).
  • Request-time: path dot-segments, traversal patterns, ?/# injection, ASCII control characters; CR/LF/NUL in header names or values; authorization header override (managed by the client).
  • Messages endpoint: invalid numeric phoneNumberId path 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.
  • Error instance: returns a clone with message/stack redacted AND the prototype preserved (so instanceof Error, instanceof GraphApiError, etc. still succeed on the scrubbed value).
  • Cause chain: cause is recursively redacted at unbounded depth with a WeakSet-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_CODESReadonlySet<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) when input.crypto is omitted AND the default createCryptoProvider() factory raises UnsupportedCapabilityError.

validateWebhookSignature error codes:

  • invalid_app_secret
  • invalid_raw_bodynull/undefined/plain objects/numbers/booleans/arrays/symbols/functions rejected; SharedArrayBuffer-backed views and detached buffers rejected too.
  • missing_signature
  • invalid_signature_format
  • signature_mismatch
  • crypto_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;
  }
}

On this page