You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[4.41/W2] WhatsApp: send_template and typed API errors #237
Add two public WhatsApp APIs upstream shipped in 4.34 and 4.40. send_template() sends pre-approved template messages, the only type the Cloud API accepts outside the 24-hour customer-service window, so business-initiated flows need it. WhatsAppApiError is a typed AdapterError subclass exposing Meta's error envelope (code, subcode, fbtrace_id, details) mapped onto the shared AdapterError.code taxonomy, so callers stop parsing error strings.
Upstream changes
2338a665 feat(whatsapp): add sendTemplate for pre-approved template messages (#588) — chat@4.34.0 — sendTemplate(threadId, template) posts type: "template" with template: {name, language: {code}, components?}; components only when non-empty; emoji placeholders converted in text parameters only (payloads, URLs, media refs stay literal); throws when the response has no message id. New types WhatsAppTemplateMessage, WhatsAppTemplateComponent, WhatsAppTemplateParameter, WhatsAppTemplateButtonParameter. Since 3e6e866a (4.39.0) it spreads ...recipient() instead of to.
31bce0a7 feat(whatsapp): expose typed API errors (#896) — chat@4.40.0
New errors.ts exports WhatsAppApiError extends AdapterError, with fields status, errorCode, providerMessage, type, details, subcode, traceId and raw.
The message is "{label}: {status} {error.message ?? body}", with non-JSON bodies truncated to 500 chars in the message.
Taxonomy:
RATE_LIMITED: 429 or codes 4/17/32/613/80007/130429/131048/131056.
AUTH_FAILED: 401 or codes 0/190.
PERMISSION_DENIED: 403, codes 3/10, or codes 200–299.
NOT_FOUND: 404.
New graphFetchJson wraps transport failures and non-JSON success bodies as NetworkError. It is used by message sends, media upload and media metadata GETs.
Current Python behavior
grep -rniI 'send_template\|WhatsAppTemplate' src/chat_sdk/adapters/whatsapp finds nothing. The only send_template hits are Messenger's unrelated _send_template_message (messenger/adapter.py:479,534).
src/chat_sdk/adapters/whatsapp/adapter.py:1122-1148_graph_api_request treats any status other than 200 as an error, raises a plain AdapterError(f"WhatsApp API error: {status} {body}", "whatsapp") with no code, and lets transport (aiohttp.ClientError) and JSON decode errors escape raw.
adapter.py:680-750download_media raises RuntimeError(f"Failed to get media URL: ...") for a failed metadata GET (:704) and RuntimeError(f"Failed to download media: ...") (:747).
The shared error base is at src/chat_sdk/shared/errors.py:6-12: AdapterError(message, adapter, code=None). NetworkError is at :72-81.
Scope
whatsapp/types.py:
Add snake_case TypedDicts that mirror the wire format: WhatsAppTemplateParameter, covering the text/currency/date_time/image/document/video variants.
Add WhatsAppTemplateButtonParameter (text/payload), WhatsAppTemplateComponent (header/body, or button with sub_type and index) and WhatsAppTemplateMessage (name, language, optional components).
Add WhatsAppGraphError / WhatsAppGraphErrorBody.
Add "template" to the documented inbound type values.
New src/chat_sdk/adapters/whatsapp/errors.py: WhatsAppApiError(AdapterError) with status, error_code, provider_message, type, details, subcode, trace_id and raw. It also holds the private _parse_graph_error, _integer and _taxonomy_code helpers. Export WhatsAppApiError from chat_sdk.adapters.whatsapp.
Keep the except AdapterError compatibility.WhatsAppApiError subclasses AdapterError(message, "whatsapp", code). The message format changes slightly when Meta returns JSON: upstream now uses error.message instead of the raw JSON body. Match upstream and call it out in the CHANGELOG.
_integer() must reject bool, because isinstance(True, int) is true in Python. Accept only int, or a str matching ^-?\d+$. Reject floats, including 4.0, to match Number.isInteger combined with the string regex. Code 0 is valid: test "preserves error code zero without requiring optional fields". Use is not None checks and never truthiness.
raw holds the parsed JSON when the body parses, and the original text otherwise. Only the message is truncated to 500 chars plus ….
Status check: upstream uses !response.ok, which is 2xx. Python checks != 200, so switch it to 200 <= status < 300. That is a small behavior change for a 201/204 response; note it.
For components, use if template.get("components"):, which matches components?.length. Build new dicts and do not mutate the caller's template.
Emoji conversion: convert_emoji_placeholders(text, "whatsapp") applies only to parameters with type == "text", in body/header and button components alike. Do not JSON-dump the whole template the way cards do, because that would rewrite :payload:-like strings.
Tests
Upstream packages/adapter-whatsapp/src/index.test.ts and errors.test.ts are not fidelity-mapped. Port into tests/test_whatsapp_api.py and a new tests/test_whatsapp_errors.py:
describe("sendTemplate"): "sends a template with name and language", "includes components when provided", "converts emoji placeholders in text parameters", "does not emoji-convert quick reply payloads", "omits components when the array is empty", "throws when the API returns no message ID", "throws on invalid thread ID", "sends templates to BSUID recipients" (from 3e6e866a).
describe("WhatsAppApiError"): "preserves the error contract and raw Meta response", "maps status $status and Meta code $code to $expected" (all 11 upstream rows), "accepts numeric strings from proxies in front of the Cloud API", "retains non-JSON response %j without masking the HTTP error", "bounds a long non-JSON body in the message and keeps it whole in raw", "falls back to the body when Meta omits a message", "ignores invalid field shapes in %j", "preserves error code zero without requiring optional fields".
Python-specific: True and 4.0 as code give error_code is None; isinstance(err, AdapterError) holds. Use AsyncMock/async context-manager session mocks, no real network.
Every Graph JSON call raises WhatsAppApiError on non-2xx and NetworkError on transport or JSON failure. Existing except AdapterError handlers still catch it.
Summary
Add two public WhatsApp APIs upstream shipped in 4.34 and 4.40.
send_template()sends pre-approved template messages, the only type the Cloud API accepts outside the 24-hour customer-service window, so business-initiated flows need it.WhatsAppApiErroris a typedAdapterErrorsubclass exposing Meta's error envelope (code, subcode,fbtrace_id, details) mapped onto the sharedAdapterError.codetaxonomy, so callers stop parsing error strings.Upstream changes
2338a665feat(whatsapp): add sendTemplate for pre-approved template messages (#588) — chat@4.34.0 —sendTemplate(threadId, template)poststype: "template"withtemplate: {name, language: {code}, components?};componentsonly when non-empty; emoji placeholders converted in text parameters only (payloads, URLs, media refs stay literal); throws when the response has no message id. New typesWhatsAppTemplateMessage,WhatsAppTemplateComponent,WhatsAppTemplateParameter,WhatsAppTemplateButtonParameter. Since3e6e866a(4.39.0) it spreads...recipient()instead ofto.31bce0a7feat(whatsapp): expose typed API errors (#896) — chat@4.40.0errors.tsexportsWhatsAppApiError extends AdapterError, with fieldsstatus,errorCode,providerMessage,type,details,subcode,traceIdandraw."{label}: {status} {error.message ?? body}", with non-JSON bodies truncated to 500 chars in the message.RATE_LIMITED: 429 or codes 4/17/32/613/80007/130429/131048/131056.AUTH_FAILED: 401 or codes 0/190.PERMISSION_DENIED: 403, codes 3/10, or codes 200–299.NOT_FOUND: 404.graphFetchJsonwraps transport failures and non-JSON success bodies asNetworkError. It is used by message sends, media upload and media metadata GETs.Current Python behavior
grep -rniI 'send_template\|WhatsAppTemplate' src/chat_sdk/adapters/whatsappfinds nothing. The onlysend_templatehits are Messenger's unrelated_send_template_message(messenger/adapter.py:479,534).src/chat_sdk/adapters/whatsapp/adapter.py:1122-1148_graph_api_requesttreats any status other than 200 as an error, raises a plainAdapterError(f"WhatsApp API error: {status} {body}", "whatsapp")with nocode, and lets transport (aiohttp.ClientError) and JSON decode errors escape raw.adapter.py:680-750download_mediaraisesRuntimeError(f"Failed to get media URL: ...")for a failed metadata GET (:704) andRuntimeError(f"Failed to download media: ...")(:747).src/chat_sdk/shared/errors.py:6-12:AdapterError(message, adapter, code=None).NetworkErroris at:72-81.Scope
whatsapp/types.py:WhatsAppTemplateParameter, covering the text/currency/date_time/image/document/video variants.WhatsAppTemplateButtonParameter(text/payload),WhatsAppTemplateComponent(header/body, or button withsub_typeandindex) andWhatsAppTemplateMessage(name,language, optionalcomponents).WhatsAppGraphError/WhatsAppGraphErrorBody."template"to the documented inboundtypevalues.src/chat_sdk/adapters/whatsapp/errors.py:WhatsAppApiError(AdapterError)withstatus,error_code,provider_message,type,details,subcode,trace_idandraw. It also holds the private_parse_graph_error,_integerand_taxonomy_codehelpers. ExportWhatsAppApiErrorfromchat_sdk.adapters.whatsapp.adapter.py: add a_graph_fetch_json(method, url, *, label, context, json=None, data=None, headers)helper.aiohttp.ClientErrororasyncio.TimeoutErrorbecomesNetworkError("whatsapp", f"{label}: request failed", original_error=...).WhatsAppApiError(label, status, body_text).NetworkError(... "response was not valid JSON")._graph_api_request(label"WhatsApp API error") and thedownload_mediametadata GET (label"Failed to get media URL") through it.adapter.py: addasync def send_template(self, thread_id: str, template: WhatsAppTemplateMessage) -> RawMessage.await self._recipient(thread_id, user_wa_id), which lands in [4.41/W1] WhatsApp: business-scoped user ids (BSUID) and inbound context variants #236.componentsonly when non-empty.type == "text"parameters.RawMessagewhose rawmessage.typeis"template".send_templatein theopen_dmdocstring, as upstream does.docs/UPSTREAM_SYNC.mdthe snake_case field names onWhatsAppApiError(error_code,trace_id, …) against upstream's camelCase.Out of scope
_graph_fetch_jsonwhen [4.41/W3] WhatsApp: outbound files/media, CTA URL LinkButton, no duplicate card title #238 adds uploads, and [4.41/W3] WhatsApp: outbound files/media, CTA URL LinkButton, no duplicate card title #238 must reuse the helper.download_media, and itsRuntimeErrorat:747: [4.41/W4] WhatsApp & Messenger: mark_as_read, native replies, code fences, guarded downloads #239 replaces it with [4.41/SH1] Shared guarded downloader (redirect policy, byte cap, timeout, credential host binding) #204. Changing that exception type here is optional; if you change it, useNetworkError.Porting notes
except AdapterErrorcompatibility.WhatsAppApiErrorsubclassesAdapterError(message, "whatsapp", code). The message format changes slightly when Meta returns JSON: upstream now useserror.messageinstead of the raw JSON body. Match upstream and call it out in the CHANGELOG._integer()must rejectbool, becauseisinstance(True, int)is true in Python. Accept onlyint, or astrmatching^-?\d+$. Reject floats, including4.0, to matchNumber.isIntegercombined with the string regex. Code0is valid: test"preserves error code zero without requiring optional fields". Useis not Nonechecks and never truthiness.rawholds the parsed JSON when the body parses, and the original text otherwise. Only the message is truncated to 500 chars plus….!response.ok, which is 2xx. Python checks!= 200, so switch it to200 <= status < 300. That is a small behavior change for a 201/204 response; note it.components, useif template.get("components"):, which matchescomponents?.length. Build new dicts and do not mutate the caller's template.convert_emoji_placeholders(text, "whatsapp")applies only to parameters withtype == "text", in body/header and button components alike. Do not JSON-dump the whole template the way cards do, because that would rewrite:payload:-like strings.Tests
Upstream
packages/adapter-whatsapp/src/index.test.tsanderrors.test.tsare not fidelity-mapped. Port intotests/test_whatsapp_api.pyand a newtests/test_whatsapp_errors.py:describe("sendTemplate"):"sends a template with name and language","includes components when provided","converts emoji placeholders in text parameters","does not emoji-convert quick reply payloads","omits components when the array is empty","throws when the API returns no message ID","throws on invalid thread ID","sends templates to BSUID recipients"(from3e6e866a).describe("WhatsAppApiError"):"preserves the error contract and raw Meta response","maps status $status and Meta code $code to $expected"(all 11 upstream rows),"accepts numeric strings from proxies in front of the Cloud API","retains non-JSON response %j without masking the HTTP error","bounds a long non-JSON body in the message and keeps it whole in raw","falls back to the body when Meta omits a message","ignores invalid field shapes in %j","preserves error code zero without requiring optional fields".describe("API errors"):"preserves Meta error fields for $name","wraps transport failures for $name","wraps non-JSON success bodies for $name", parametrized overmessage sendsandmedia metadata requests. Themedia uploadsrow lands in [4.41/W3] WhatsApp: outbound files/media, CTA URL LinkButton, no duplicate card title #238.Trueand4.0ascodegiveerror_code is None;isinstance(err, AdapterError)holds. UseAsyncMock/async context-manager session mocks, no real network.Acceptance criteria
send_templatesends the upstream payload shape, uses the [4.41/W1] WhatsApp: business-scoped user ids (BSUID) and inbound context variants #236 recipient routing, and is exported and documented.WhatsAppApiErroron non-2xx andNetworkErroron transport or JSON failure. Existingexcept AdapterErrorhandlers still catch it.docs/UPSTREAM_SYNC.mdupdated (field-name casing, status-range change).download_mediametadata failures now raiseWhatsAppApiError, where they used to raiseRuntimeError.Dependencies
Blocked by #236. Blocks #238.
Metadata
sync/4.41-w2Part of #184.