{"data":{"analytics":{"failure":"A Graph refusal keeps the stored-credential slugs: 190 is 400 Messaging_GraphTokenInvalid, 200 or 10 is 403 Messaging_GraphAccessDenied, anything else is 502 Messaging_GraphError, and an unreachable Graph is 502 Messaging_GraphUnavailable. Meta's code, subcode and details ride in the message. The WABA status is never moved by this read","families":[{"family":"messaging","graph_shape":"node_field","lookback":"1 year","meta_field":"analytics","paginated":false,"parameters":[{"form":"unix_seconds","max_items":null,"name":"start","required":true,"values":[]},{"form":"unix_seconds","max_items":null,"name":"end","required":true,"values":[]},{"form":"enum","max_items":null,"name":"granularity","required":true,"values":["HALF_HOUR","DAY","MONTH"]},{"form":"phone_number_list","max_items":null,"name":"phone_numbers","required":false,"values":[]},{"form":"integer_list","max_items":null,"name":"product_types","required":false,"values":[0,2,100]},{"form":"country_code_list","max_items":null,"name":"country_codes","required":false,"values":[]}]},{"family":"conversation","graph_shape":"node_field","lookback":"1 year","meta_field":"conversation_analytics","paginated":false,"parameters":[{"form":"unix_seconds","max_items":null,"name":"start","required":true,"values":[]},{"form":"unix_seconds","max_items":null,"name":"end","required":true,"values":[]},{"form":"enum","max_items":null,"name":"granularity","required":true,"values":["HALF_HOUR","DAILY","MONTHLY"]},{"form":"phone_number_list","max_items":null,"name":"phone_numbers","required":false,"values":[]},{"form":"enum_list","max_items":null,"name":"metric_types","required":false,"values":["COST","CONVERSATION"]},{"form":"enum_list","max_items":null,"name":"conversation_categories","required":false,"values":["AUTHENTICATION","MARKETING","SERVICE","UTILITY"]},{"form":"enum_list","max_items":null,"name":"conversation_types","required":false,"values":["FREE_ENTRY_POINT","FREE_TIER","REGULAR"]},{"form":"enum_list","max_items":null,"name":"conversation_directions","required":false,"values":["BUSINESS_INITIATED","USER_INITIATED","UNKNOWN"]},{"form":"enum_list","max_items":null,"name":"dimensions","required":false,"values":["CONVERSATION_CATEGORY","CONVERSATION_DIRECTION","CONVERSATION_TYPE","COUNTRY","PHONE"]}]},{"family":"pricing","graph_shape":"node_field","lookback":"1 year","meta_field":"pricing_analytics","paginated":false,"parameters":[{"form":"unix_seconds","max_items":null,"name":"start","required":true,"values":[]},{"form":"unix_seconds","max_items":null,"name":"end","required":true,"values":[]},{"form":"enum","max_items":null,"name":"granularity","required":true,"values":["HALF_HOUR","DAILY","MONTHLY"]},{"form":"phone_number_list","max_items":null,"name":"phone_numbers","required":false,"values":[]},{"form":"country_code_list","max_items":null,"name":"country_codes","required":false,"values":[]},{"form":"enum_list","max_items":null,"name":"metric_types","required":false,"values":["COST","VOLUME"]},{"form":"enum_list","max_items":null,"name":"pricing_types","required":false,"values":["FREE_CUSTOMER_SERVICE","FREE_ENTRY_POINT","REGULAR"]},{"form":"enum_list","max_items":null,"name":"pricing_categories","required":false,"values":["AUTHENTICATION","AUTHENTICATION_INTERNATIONAL","MARKETING","MARKETING_LITE","SERVICE","UTILITY","REFERRAL_CONVERSION"]},{"form":"enum_list","max_items":null,"name":"dimensions","required":false,"values":["COUNTRY","PHONE","PRICING_CATEGORY","PRICING_TYPE","TIER"]}]},{"family":"template","graph_shape":"edge","lookback":"90 days","meta_field":"template_analytics","paginated":true,"parameters":[{"form":"unix_seconds_or_date","max_items":null,"name":"start","required":true,"values":[]},{"form":"unix_seconds_or_date","max_items":null,"name":"end","required":true,"values":[]},{"form":"enum","max_items":null,"name":"granularity","required":true,"values":["DAILY"]},{"form":"id_list","max_items":10,"name":"template_ids","required":true,"values":[]},{"form":"enum_list","max_items":null,"name":"metric_types","required":false,"values":["COST","CLICKED","DELIVERED","READ","SENT","APP_ACTIVATIONS","APP_ADD_TO_CART","APP_CHECKOUTS_INITIATED","APP_PURCHASES","APP_PURCHASES_CONVERSION_VALUE","WEBSITE_ADD_TO_CART","WEBSITE_CHECKOUTS_INITIATED","WEBSITE_PURCHASES","WEBSITE_PURCHASES_CONVERSION_VALUE"]},{"form":"enum","max_items":null,"name":"product_type","required":false,"values":["CLOUD_API","MARKETING_MESSAGES_API_FOR_WHATSAPP"]},{"form":"boolean","max_items":null,"name":"use_waba_timezone","required":false,"values":[]},{"form":"cursor","max_items":null,"name":"after","required":false,"values":[]}]},{"family":"template_group","graph_shape":"edge","lookback":"90 days","meta_field":"template_group_analytics","paginated":true,"parameters":[{"form":"unix_seconds_or_date","max_items":null,"name":"start","required":true,"values":[]},{"form":"unix_seconds_or_date","max_items":null,"name":"end","required":true,"values":[]},{"form":"enum","max_items":null,"name":"granularity","required":true,"values":["DAILY"]},{"form":"id_list","max_items":10,"name":"template_group_ids","required":true,"values":[]},{"form":"enum_list","max_items":null,"name":"metric_types","required":false,"values":["cost","clicked","delivered","read","sent"]},{"form":"boolean","max_items":null,"name":"use_waba_timezone","required":false,"values":[]},{"form":"cursor","max_items":null,"name":"after","required":false,"values":[]}]},{"family":"call","graph_shape":"node_field","lookback":null,"meta_field":"call_analytics","paginated":false,"parameters":[{"form":"unix_seconds","max_items":null,"name":"start","required":true,"values":[]},{"form":"unix_seconds","max_items":null,"name":"end","required":true,"values":[]},{"form":"enum","max_items":null,"name":"granularity","required":true,"values":["HALF_HOUR","DAILY","MONTHLY"]},{"form":"phone_number_list","max_items":null,"name":"phone_numbers","required":false,"values":[]},{"form":"country_code_list","max_items":null,"name":"country_codes","required":false,"values":[]},{"form":"enum_list","max_items":null,"name":"directions","required":false,"values":["USER_INITIATED","BUSINESS_INITIATED"]},{"form":"enum_list","max_items":null,"name":"dimensions","required":false,"values":["phone","direction","country"]},{"form":"enum_list","max_items":null,"name":"metric_types","required":false,"values":["COUNT","COST","AVERAGE_DURATION"]}]},{"family":"group","graph_shape":"edge","lookback":"90 days","meta_field":"group_analytics","paginated":true,"parameters":[{"form":"unix_seconds","max_items":null,"name":"start","required":true,"values":[]},{"form":"unix_seconds","max_items":null,"name":"end","required":true,"values":[]},{"form":"id_list","max_items":1,"name":"group_ids","required":true,"values":[]},{"form":"enum_list","max_items":null,"name":"metric_types","required":true,"values":["SENT","DELIVERED","READ","PARTICIPANTS_JOINED","PARTICIPANTS_LEFT"]},{"form":"enum","max_items":null,"name":"granularity","required":false,"values":["DAILY"]},{"form":"cursor","max_items":null,"name":"after","required":false,"values":[]}]}],"lists":"A list parameter is one comma-separated value (dimensions=COUNTRY,PHONE). Values match without regard to case and go to Meta in Meta's documented spelling. A parameter given twice, or with an empty value, is refused","lookback":"A start whose UTC calendar day is earlier than the day the family's lookback reaches back to is refused before Graph is called: 1 year for messaging, conversation and pricing, 90 days for template, template_group and group. Meta documents none for call, so none is enforced. The boundary day itself goes to Meta, which may still refuse it","observed_at":"The instant this server received Meta's answer (RFC 3339, UTC). It is not when Meta computed the values; Meta does not say that","pagination":"template, template_group and group are Meta edges with cursor pages. next_after is Meta's cursor when Meta says another page exists; pass it back as after. The four node-field families take no cursor; truncated is true only if Meta ever says more exists and gives no cursor this route can pass back","reference":"https://developers.facebook.com/docs/whatsapp/business-management-api/analytics/","retention":"None. Sambungapi stores nothing from this read: no row, no cache and no observation column. The response is the only copy","route":"GET /wabas/{waba_id}/analytics/{family}","source":"meta_graph","values":"Every number is Meta's. data_points holds Meta's data points copied verbatim in Meta's order, and raw holds Meta's whole answer. Nothing is counted from Sambungapi's delivery log, send log or stored messages, and nothing is filled in: a period Meta did not report is absent, never zero. data_points is [] when Meta's answer holds the family's container with no points, and null when it holds no container at all. Meta states that its analytics are approximate and may differ from invoices","window":"start and end are UNIX timestamps in seconds (UTC). The template and template_group families also take YYYY-MM-DD dates, and need them when use_waba_timezone=true. end must not be earlier than start"},"auth":{"deliveries":{"name":"warta_key","note":"issue #1028 (decision #933): the /deliveries subtree carries the SAME middleware and the SAME gate as /subscriptions and admits BOTH families. organization_id is implied by the key and may not name another organization (403 Messaging_ScopeDenied), and is still required of a control caller (400 Messaging_InvalidInput). The projection, the filters, the cursor, the meta keys, the replay state gate and the three delivery slugs are identical for both families; no new slug exists. A control caller keeps its audit row, a Warta caller leaves none — a replay is traffic-grade like a send, not configuration-grade like a registry write, which does record a Warta key's create and revoke (decision #890, issue #1249)","scope":"organization-wide ONLY — a phone-scoped key is refused (403 Messaging_ScopeDenied, naming the delivery log), because the log spans every subscription and every WABA in the organization","transport":"an API key with a channel part: Authorization: Bearer ww_* or ak_*, or the x-api-key header. A valid API key without a channel part is 403 Auth_KeyOutOfReach","validation":"hash-only via the messaging repository"},"gateway":{"name":"warta_key","note":"own middleware — one family per request; any API key, org key, or session cookie alongside is rejected (400 Auth_MultipleCredentials)","scope":"organization-wide or exactly one phone_number_id","transport":"an API key with a channel part: Authorization: Bearer ww_* or ak_*, or the x-api-key header. A valid API key without a channel part is 403 Auth_KeyOutOfReach","validation":"hash-only via the messaging repository"},"operator":{"authorizes":"every /v1/messaging route outside the gateway subtree; the /subscriptions, /deliveries and /sends subtrees admit it beside the Warta key","name":"control_personal_token","rbac":"org_admin authority or above at the organization the request names (?organization_id= or body.organization_id) or that owns the {waba_id} in the path, or an explicit messaging_waba grant — the two arms of authorize_messaging, whose bar is MESSAGING_MIN_AUTHORITY = org_admin (the issue #139 follow-up moved this branch off require_platform_operator). A platform_operator needs no special case: an installation-anchored binding matches every Organization scope inside its installation and platform_operator authority clears the bar. GET /embedded/diagnostics is the ONE route that asks for the installation binding itself, and it refuses an org_admin.","transport":"Authorization: Bearer cpt_*, or an HttpOnly session cookie — the shared v1 middleware resolves both into ONE control principal, and each route then applies its own gate"},"sends":{"name":"warta_key","note":"issue #1135 (decision #1027 item 5): the /sends subtree carries the delivery log's two-layer middleware and admits BOTH families. organization_id is implied by the key and may not name another organization (403 Messaging_ScopeDenied), and is still required of a control caller (400 Messaging_InvalidInput). A Warta key reads origin gateway only, because the key is the gateway's credential; a control caller reads every origin. The credential-family refusals are the gateway's verbatim (400 Auth_MultipleCredentials, 401 Messaging_KeyInvalid); no new slug exists","scope":"organization-wide reads the organization's gateway Sends; phone-scoped reads its own number's gateway Sends, and naming another phone_number_id is refused (403 Messaging_ScopeDenied, the gateway's own message) — a Send names its business phone number, so the narrower key is confined rather than refused","transport":"an API key with a channel part: Authorization: Bearer ww_* or ak_*, or the x-api-key header. A valid API key without a channel part is 403 Auth_KeyOutOfReach","validation":"hash-only via the messaging repository"},"subscriptions":{"name":"warta_key","note":"issue #871: the /subscriptions subtree carries its own middleware and admits BOTH families. organization_id is implied by the key and may not name another organization; a request with no ww_* bearer is resolved by the shared v1 middleware exactly as before, audit trail and CSRF gate included. A Warta key's create and revoke are configuration writes and record a security audit event carrying the key's identity (credential_class warta_key, principal null), appended in the write's own transaction so an unrecordable write is refused (issue #1249); its list records nothing","scope":"organization-wide, or NARROWED for a phone-scoped key (issue #1268): it registers, lists and revokes only subscriptions narrowed to its own number, which fan out that number's events only — a subscription is org-level configuration by default, and the narrower key never reaches that","transport":"an API key with a channel part: Authorization: Bearer ww_* or ak_*, or the x-api-key header. A valid API key without a channel part is 403 Auth_KeyOutOfReach","validation":"hash-only via the messaging repository"},"webhooks":"none — Meta authenticates by verify token (GET challenge) and X-Hub-Signature-256 HMAC over the raw body (POST): per-WABA blob credentials on /webhooks/meta/{waba_id} and on the Flow endpoint /webhooks/meta/{waba_id}/flows/{flow_id}, the platform SAMBUNGAPI_META_* credentials on /webhooks/meta/app. OUTBOUND subscriber deliveries carry X-Sambungapi-Signature-256: sha256=<hex> over the raw body under the endpoint's own whsec_* secret, plus Idempotency-Key: <event id>; at-least-once, byte-identical on every retry and replay"},"base_path":"/v1/messaging","business_profile":{"api_reference":"https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/whatsapp-business-profile-api","delete":"None. Meta states that a business profile is deleted only by deleting the phone number, so this build offers no profile delete","failure":"A Graph refusal keeps the stored-credential slugs: 190 is 400 Messaging_GraphTokenInvalid and moves the WABA to EXPIRED, 200 or 10 is 403 Messaging_GraphAccessDenied and moves it to ACCESS_LOST, anything else is 502 Messaging_GraphError, and an unreachable Graph is 502 Messaging_GraphUnavailable. Meta's code, subcode and details ride in the message. An update Meta answers without success: true is 502 Messaging_GraphError and stores nothing. A refusal never replaces the stored observation","fields":[{"kind":"string","max_chars":139,"max_items":null,"min_chars":1,"name":"about","read":true,"rule":"The About text, shown beneath the profile picture. Meta: it cannot be empty and must be between 1 and 139 characters; hyperlinks do not render as links and Markdown is not supported","schemes":[],"values":[],"write":true},{"kind":"string","max_chars":256,"max_items":null,"min_chars":0,"name":"address","read":true,"rule":"The address of the business. Meta: character limit 256","schemes":[],"values":[],"write":true},{"kind":"string","max_chars":512,"max_items":null,"min_chars":0,"name":"description","read":true,"rule":"The description of the business. Meta: character limit 512","schemes":[],"values":[],"write":true},{"kind":"string","max_chars":128,"max_items":null,"min_chars":0,"name":"email","read":true,"rule":"The contact email of the business. Meta: a valid email format, character limit 128. Meta checks the format; this build checks only the limit","schemes":[],"values":[],"write":true},{"kind":"string_list","max_chars":256,"max_items":2,"min_chars":0,"name":"websites","read":true,"rule":"The URLs of the business, such as a website, a Facebook Page or an Instagram account. Meta: at most 2 websites of at most 256 characters each, and each must include the http:// or https:// portion","schemes":["http://","https://"],"values":[],"write":true},{"kind":"choice","max_chars":null,"max_items":null,"min_chars":0,"name":"vertical","read":true,"rule":"The category of the business. Meta: an empty string or one of the accepted values","schemes":[],"values":["ALCOHOL","APPAREL","AUTO","BEAUTY","EDU","ENTERTAIN","EVENT_PLAN","FINANCE","GOVT","GROCERY","HEALTH","HOTEL","NONPROFIT","ONLINE_GAMBLING","OTC_DRUGS","OTHER","PHYSICAL_GAMBLING","PROF_SERVICES","RESTAURANT","RETAIL","TRAVEL"],"write":true},{"kind":"string","max_chars":null,"max_items":null,"min_chars":0,"name":"profile_picture_url","read":true,"rule":"The URL of the profile picture, as Meta answers it. Read only: an update sets the picture with profile_picture_handle","schemes":[],"values":[],"write":false},{"kind":"string","max_chars":null,"max_items":null,"min_chars":1,"name":"profile_picture_handle","read":false,"rule":"The handle Meta's Resumable Upload API returns for the uploaded picture file. Write only: a read answers profile_picture_url instead","schemes":[],"values":[],"write":true},{"kind":"string","max_chars":null,"max_items":null,"min_chars":0,"name":"messaging_product","read":false,"rule":"Always whatsapp. Meta requires it on an update and this build sets it; a caller does not send it","schemes":[],"values":[],"write":false}],"freshness":"observed_at is the instant this server received Meta's answer (RFC 3339, UTC, on the database clock). Meta and WhatsApp Manager can change the profile at any time, and this build does not learn of a change made elsewhere until the next refresh or update. The GET makes no Graph call and answers the stored observation with its age; before the first refresh or update, observed_at, profile and raw are null","picture":"profile_picture_handle is a handle from Meta's Resumable Upload API; this build forwards it and does not upload the picture. A read answers profile_picture_url instead. POST /wabas/{waba_id}/templates/media calls the same Resumable Upload API and answers its handle as header_handle, but no live call from this build has set a profile picture with such a handle","reference":"https://developers.facebook.com/docs/whatsapp/cloud-api/reference/business-profiles/","retention":"One observation per business phone number, replaced by the next. It is deleted with the WABA or the organization","routes":["GET /wabas/{waba_id}/phones/{phone_number_id}/business-profile","POST /wabas/{waba_id}/phones/{phone_number_id}/business-profile/refresh","PATCH /wabas/{waba_id}/phones/{phone_number_id}/business-profile"],"source":"meta_graph","update":"PATCH takes a JSON object of the writable fields; a field left out is left as it is at Meta. Characters are counted as Unicode scalar values. The body is checked against fields before any Graph call. messaging_product is set by this build and must not be sent. After Meta answers success: true, the route reads the profile back and answers that stored read, so the answer is what Meta reports holding, not the request","values":"Every profile value is Meta's. raw is Meta's whole answer to the last read, and profile is projected out of that stored answer: a field Meta did not send, or sent with an unexpected type, is null. A field Meta sends that this build does not project is named in unknown_fields and kept in raw; it never fails the read"},"channel_stop":{"held_account_message":"This WhatsApp Business Account cannot be connected now. To ask why, write to info@metatech.id.","message":"Sambungapi stopped this organization's use of WhatsApp because Meta asked it to. No message is sent and no WhatsApp data is read until a platform operator ends the stop.","routes_that_stay_open":["GET /wabas","GET /wabas/{waba_id}","DELETE /wabas/{waba_id}","POST /wabas/{waba_id}/keys","GET /keys","POST /keys/{key_id}/revoke","GET /subscriptions","POST /subscriptions","POST /subscriptions/{subscription_id}/revoke","GET /teams","POST /teams","GET /teams/{team_id}/members","POST /teams/{team_id}/members","DELETE /teams/{team_id}/members/{principal_id}","GET /embedded/config","GET /embedded/diagnostics"],"slug":"Messaging_ChannelStopped","status":409,"summary":"A Channel stop (issue #1944, decided in #1866): a platform operator stopped the organization's use of WhatsApp because Meta asked for it (POST /v1/control/organizations/{organization_id}/channel-stops), and sambungapi keeps the data. Until a platform operator ends the stop, every route of this surface answers this refusal for the stopped organization, AFTER authentication and authorization and before any call to Meta — the gateway send and every other /gateway route, the Send log, the Delivery log, the console sends, the Chats, Contacts, template, Flow, media, phone and quality routes, the account move, and every connect. The same slug with the message in held_account_message refuses a connect, in any other organization of the installation, of a Meta WABA id that the stopped organization held at its stop. GET /v1/control/organizations reports the stop to every member as channel_stops: [{channel, stopped_at}]"},"envelope":{"error":{"shape":{"error":{"code":"integer","message":"string","request_id":"string","slug":"string","status":"integer","suggested_fix":"string"},"errors":["string (400 validation only)"]}},"request_id":{"auth_use":"never used for authentication","echoed":"x-request-id response header on every v1 response","inbound":"X-Request-ID header (validated: alnum + ._- , len <= 128) or generated"},"success":{"shape":{"data":"any","meta":{"request_id":"string","total":"integer (lists only)"}}}},"envelope_note":"the gateway send, the gateway media read, the gateway media upload and the webhook endpoints are wire surfaces: the send passes Meta's HTTP status and body through verbatim, the media read answers Meta's raw bytes with the media type on Content-Type, the media upload answers Meta's own {id}, and the webhooks answer plain text (the Flow endpoint's answer is Meta's encrypted base64 string) — all deliberately outside the {data, meta} envelope. Every refusal on those routes IS enveloped. GET /gateway/me, POST /gateway/{phone_number_id}/messages/validate and GET /gateway/{phone_number_id}/contacts/{contact} are not wire routes at all: sambungapi authors every field of those answers, so they ride the envelope like the subtree's refusals already do","flow_endpoint":{"actions":["ping","INIT","BACK","data_exchange"],"answers":"The handler answers in Meta's plaintext protocol. A health check needs data.status active and Meta receives exactly {\"data\":{\"status\":\"active\"}}; an error notification needs data.acknowledged true and Meta receives exactly {\"data\":{\"acknowledged\":true}}; INIT, BACK and data_exchange need a non-empty screen string and a data object (the next screen, or SUCCESS with extension_message_response) and Meta receives the handler's bytes unchanged. A handler 427 with a JSON object (Meta's sample sends error_msg) on a request that carries a flow_token reaches Meta as 427: the Flow token is no longer valid","authorization":"Meta's X-Hub-Signature-256: sha256=<hex HMAC-SHA256> over the raw request bytes under the app secret in the account's custody blob, compared in constant time, BEFORE the body is parsed or any key is read. A missing, malformed or wrong signature, and an unknown {waba_id}, answer 432 with nothing read. The endpoint then serves only the Flow in its path, and only with the key of the account in its path: no request can reach another organization's key, endpoint or handler","decryption":"The body is {encrypted_flow_data, encrypted_aes_key, initial_vector}, each base64. encrypted_aes_key is unwrapped with RSA-OAEP (SHA-256, MGF1 with SHA-256) under the account's 2048-bit business private key, blinded, and must be 16 bytes; initial_vector must be 16 bytes; encrypted_flow_data is AES-128-GCM with its last 16 bytes the tag, and must open to one UTF-8 JSON object. No key in custody, an unreadable key, and every one of these failures answer 421, which Meta's client meets by downloading the public key again and retrying","encryption":"Every answer is encrypted with the request's own AES key under the initial vector with every byte XOR 0xFF, AES-128-GCM with the 16-byte tag appended, base64-encoded, sent as Content-Type text/plain","encryption_reference":"https://developers.facebook.com/documentation/business-messaging/whatsapp/flows/guides/whatsapp-business-encryption","endpoint_fields":["handler_url","rotate_signing_secret"],"error_notification":"A request whose data.error is set is an error notification: Meta telling the endpoint that its last answer could not be used, with data.error and data.error_message (Meta documents action INIT or data_exchange). It goes to the handler marked error_notification; only data.acknowledged true reaches Meta, as exactly {\"data\":{\"acknowledged\":true}}. Nothing about it is stored or logged beyond the one log line","handler":"The decrypted request goes to the endpoint's handler_url byte for byte as Meta encrypted it: POST, Content-Type application/json, X-Sambungapi-Signature-256: sha256=<hex HMAC-SHA256> over those bytes under the endpoint's whsec_ secret, X-Sambungapi-Flow-Request naming the request kind, X-Sambungapi-Waba-Id, X-Sambungapi-Flow-Id and X-Request-Id. Redirects are not followed, and at most 4194304 bytes of the answer are read. Sambungapi never answers for the handler: the health check and the error notification go to it as well","health":"Meta sends action ping to the endpoint of a published Flow and before it publishes one (Meta's health-monitoring guide). The health check goes to the handler like every other request, marked health_check; only a handler answer whose data.status is active reaches Meta, as exactly {\"data\":{\"status\":\"active\"}}, so Meta's health check measures the handler and not only Sambungapi. With no endpoint it answers 404, and with no key 421","health_reference":"https://developers.facebook.com/docs/whatsapp/flows/guides/healthmonitoring/","key_custody":"One 2048-bit RSA pair per WhatsApp Business Account, because Meta keeps one business public key per phone number and every Flow a number sends is encrypted to it. The private key is PKCS#8 PEM stored only as key-ring ciphertext (flow_endpoint_keys.private_key_enc), rewrapped by a key rotation, erased with the account and with the organization, and answered by no route. POST /wabas/{waba_id}/flow-encryption signs the public half onto every number","logging":"One line per request, event messaging.flow_endpoint: request_id, waba_id once known, flow_id once well-formed, kind, outcome, reason, status, handler_status and elapsed_ms. Never the request or the answer, a Flow token, a screen, the handler URL, a key, the app secret or the signing secret. Every refusal body Meta receives is a fixed sentence","method":"POST","path":"/webhooks/meta/{waba_id}/flows/{flow_id}","reference":"https://developers.facebook.com/docs/whatsapp/flows/guides/implementingyourflowendpoint/","request_kinds":["health_check","error_notification","init","back","data_exchange"],"retention":"Nothing about a Flow request is stored: not the request, not the answer, not the Flow token","retry":"None. One handler attempt per request: a person is waiting on the handset and a data_exchange may have side effects. Meta's client decides what follows a failure; after a 421 it retries with a fresh key","status_code_reference":"https://github.com/WhatsApp/WhatsApp-Flows-Tools/tree/main/examples/endpoint","statuses":{"200":"the handler's answer, encrypted","400":"the decrypted request breaks Meta's version, action or field rules","404":"no endpoint is configured for the Flow in the path","421":"the request could not be decrypted, or the account holds no usable key","427":"the handler said the Flow token is no longer valid; its JSON object, encrypted","432":"the signature did not verify, or the account is unknown","500":"storage failed, or custody could not be read","502":"the handler failed, redirected, answered too much, or answered a shape Meta does not document","504":"the handler did not answer in time"},"tenancy":"An endpoint row exists only for a Flow whose whatsapp_business_account Meta reported as the account in the path when the endpoint was configured. The public path resolves the account by its high-entropy wb_ id, then reads that account's endpoint and key under its own installation and organization","timeout":"The handler has 8000 ms, connect included; Meta's health-monitoring guide asks for an answer in under one second. A slower handler answers 504","validation":"version must be exactly 3.0 and action one of the four, spelled as Meta spells them; every action but ping needs a non-empty flow_token string; data, when present, is an object; screen, when present, is a string; data.error, when present, is a string and makes the request an error notification. Anything else answers 400 and never reaches the handler","versions":["3.0"]},"flows":{"categories":["SIGN_UP","SIGN_IN","APPOINTMENT_BOOKING","LEAD_GENERATION","CONTACT_US","CUSTOMER_SUPPORT","SURVEY","OTHER"],"create_fields":["name","categories","clone_flow_id","endpoint_uri","flow_json","publish"],"endpoint":"The endpoint a data-exchange Flow calls is hosted by Sambungapi (issue #1142): see flow_endpoint. endpoint_uri is still forwarded to Meta verbatim; set it to the endpoint_uri PUT /wabas/{waba_id}/flows/{flow_id}/endpoint answers","failure":"Refused before Graph as 400 Messaging_InvalidInput: a field the operation does not take, a missing required field, a category outside Meta's list, a malformed Flow id, flow_json that is not one JSON object or is over 10 MB. From Meta: throttling is 429 Messaging_GraphThrottled with Retry-After, 190 is 400 Messaging_GraphTokenInvalid, 200 or 10 is 403 Messaging_GraphAccessDenied, a Flow Meta does not hold is 404 Messaging_FlowNotFound, any other refusal Meta answers with a 4xx is 409 Messaging_FlowRefused naming the operation and Meta's call, a Meta 5xx is 502 Messaging_GraphError and an unreachable Graph is 502 Messaging_GraphUnavailable. Meta's code, subcode and details ride in the message; the token never does. No Flow route moves the WABA status, except the send, which answers a Meta refusal like a Chats reply","flow_json":{"asset_name":"flow.json","asset_type":"FLOW_JSON","max_bytes":10485760},"operations":[{"meta_call":"GET /{WABA-ID}/flows","operation":"list"},{"meta_call":"POST /{WABA-ID}/flows","operation":"create"},{"meta_call":"GET /{FLOW-ID}","operation":"read"},{"meta_call":"POST /{FLOW-ID}","operation":"update_metadata"},{"meta_call":"POST /{FLOW-ID}/assets","operation":"upload_flow_json"},{"meta_call":"GET /{FLOW-ID}/assets","operation":"list_assets"},{"meta_call":"POST /{FLOW-ID}/publish","operation":"publish"},{"meta_call":"POST /{FLOW-ID}/deprecate","operation":"deprecate"},{"meta_call":"DELETE /{FLOW-ID}","operation":"delete"},{"meta_call":"POST /{PHONE-NUMBER-ID}/messages","operation":"send"}],"ownership":"A Flow is addressed by Meta's Flow id, which Meta does not scope to an account. Every route on one Flow (and a create's clone_flow_id) first reads the Flow's whatsapp_business_account, which Meta documents as the account which owns the Flow, and acts only when it is the account in the path. Another account's Flow and a Flow Meta does not hold are the same 404 Messaging_FlowNotFound","reference":"https://developers.facebook.com/docs/whatsapp/flows/reference/flowsapi/","retention":"None for the Flow: Sambungapi stores no Flow, status or asset. The managed send stores the Send record a Chats reply stores","send":"The managed send reads the Flow's owner and status first. DEPRECATED and BLOCKED are refused before the send as 409 Messaging_FlowNotSendable, because Meta documents them as unable to send. Every other status goes to Meta: Meta's sending guide says a published Flow that was edited reads as a draft and still sends its last published version under mode published. mode is required, draft or published; flow_status in the answer is Meta's word from the read and is never inferred from mode","send_fields":["phone_number_id","to","mode","flow_cta","body","header","footer","flow_token","flow_action","flow_action_payload"],"sending_reference":"https://developers.facebook.com/docs/whatsapp/flows/guides/sendingaflow/","source":"meta_graph","statuses":["DRAFT","PUBLISHED","DEPRECATED","BLOCKED","THROTTLED"],"statuses_rule":"Meta decides what a Flow's status allows. Its Flows API reference says a DRAFT Flow can be updated, deleted and published, a PUBLISHED Flow cannot be updated or deleted and can only be deprecated, a DEPRECATED Flow cannot be sent or restored, a BLOCKED Flow cannot be sent or opened, and a THROTTLED Flow sends at most 10 messages an hour. Sambungapi does not predict these refusals: it forwards the operation and reports Meta's refusal as 409 Messaging_FlowRefused","update_fields":["name","categories","endpoint_uri"],"values":"Every field is Meta's. The projected fields are copied verbatim out of Meta's answer and are null when Meta left them out, and raw holds Meta's whole answer. A mutation answers Meta's success and validation_errors and claims no status afterwards; read the Flow for Meta's status now"},"path_rule":"absolute_path = base_path + path. Every route entry carries absolute_path precomputed; send requests to absolute_path, never to path alone and never to /v1 + path.","quality":{"account_changes":{"capture":"Each verified change of the four fields is STAGED as an Inbound event before the receiver answers Meta, and the inbound event lane in sambungapi worker captures it (issue #1485), as Meta's whole value object with entry[].id and entry[].time. The per-WABA receiver captures the two fields Meta may send to an override callback, and only when entry[].id is that account's Meta id; the app receiver captures all four under every WABA here whose Meta id is entry[].id. An account_update partner event (PARTNER_ADDED, PARTNER_APP_INSTALLED, PARTNER_APP_UNINSTALLED, PARTNER_CLIENT_CERTIFICATION_STATUS_UPDATE, PARTNER_REMOVED) is never captured: its entry[].id is the partner's portfolio. A change that names no account here is logged and acknowledged. Nothing is fanned out to a subscriber","deduplication":"Meta gives these changes no delivery id, so the capture keys on a digest of the field, entry[].id, entry[].time and the value object, per WABA. Meta's redelivery collapses onto the first capture and writes nothing","failure":"A change that cannot be staged answers 500 plain text, so Meta redelivers. A capture that does not persist after the stage is retried by the inbound event lane up to eight times and then marked failed for an operator to requeue; either way the change lands once. A change whose shape this build does not know is still captured and answers 200","fields":[{"field":"phone_number_quality_update","keys":[{"deprecated":null,"entity":"phone_number","meaning":"The business phone number, as Meta displays it. The only name of the number this change carries: it has no phone number id","name":"display_phone_number","path":["display_phone_number"],"shape":"text","values":null},{"deprecated":null,"entity":"phone_number","meaning":"What changed on the number's throughput level","name":"event","path":["event"],"shape":"token","values":["ONBOARDING","THROUGHPUT_UPGRADE"]},{"deprecated":"Meta: removed in February 2026; use max_daily_conversations_per_business","entity":"phone_number","meaning":"The messaging limit tier after the change","name":"current_limit","path":["current_limit"],"shape":"token","values":["TIER_50","TIER_250","TIER_2K","TIER_10K","TIER_100K","TIER_NOT_SET","TIER_UNLIMITED"]},{"deprecated":"Meta: removed in February 2026; use max_daily_conversations_per_business","entity":"phone_number","meaning":"The messaging limit tier before the change","name":"old_limit","path":["old_limit"],"shape":"token","values":["TIER_50","TIER_250","TIER_2K","TIER_10K","TIER_100K","TIER_NOT_SET","TIER_UNLIMITED"]},{"deprecated":null,"entity":"business_portfolio","meaning":"The business portfolio's messaging limit","name":"max_daily_conversations_per_business","path":["max_daily_conversations_per_business"],"shape":"token","values":["TIER_250","TIER_2K","TIER_10K","TIER_100K","TIER_UNLIMITED"]}],"receivers":["POST /webhooks/meta/{waba_id}","POST /webhooks/meta/app"],"reference":"https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/phone_number_quality_update","trigger":"A business phone number's throughput level changes. It carries no quality rating"},{"field":"business_capability_update","keys":[{"deprecated":null,"entity":"business_portfolio","meaning":"The business portfolio's messaging limit. Meta's table lists TIER_* tokens and Meta's example sends the number 2000, so both are projected as sent","name":"max_daily_conversations_per_business","path":["max_daily_conversations_per_business"],"shape":"token_or_count","values":["TIER_250","TIER_2K","TIER_10K","TIER_100K","TIER_UNLIMITED"]},{"deprecated":"Meta: removed in February 2026","entity":"business_portfolio","meaning":"The business portfolio's messaging limit as a number: 250, 2000, 10000, 100000, or -1 for no limit","name":"max_daily_conversation_per_phone","path":["max_daily_conversation_per_phone"],"shape":"count_in","values":[250,2000,10000,100000,-1]},{"deprecated":null,"entity":"business_portfolio","meaning":"The most business phone numbers the business portfolio can have","name":"max_phone_numbers_per_business","path":["max_phone_numbers_per_business"],"shape":"count","values":null},{"deprecated":null,"entity":"waba","meaning":"The most business phone numbers one WhatsApp Business Account can have","name":"max_phone_numbers_per_waba","path":["max_phone_numbers_per_waba"],"shape":"count","values":null}],"receivers":["POST /webhooks/meta/{waba_id}","POST /webhooks/meta/app"],"reference":"https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/business_capability_update","trigger":"A WhatsApp Business account is created, or a WhatsApp Business account or business portfolio business capability is increased or decreased"},{"field":"account_update","keys":[{"deprecated":null,"entity":"waba","meaning":"The account-level event","name":"event","path":["event"],"shape":"token","values":["ACCOUNT_DELETED","ACCOUNT_RESTRICTION","ACCOUNT_VIOLATION","AD_ACCOUNT_LINKED","AUTH_INTL_PRICE_ELIGIBILITY_UPDATE","BUSINESS_PRIMARY_LOCATION_COUNTRY_UPDATE","DISABLED_UPDATE","MM_LITE_TERMS_SIGNED","PARTNER_ADDED","PARTNER_APP_INSTALLED","PARTNER_APP_UNINSTALLED","PARTNER_CLIENT_CERTIFICATION_STATUS_UPDATE","PARTNER_REMOVED","VOLUME_BASED_PRICING_TIER_UPDATE","ACCOUNT_OFFBOARDED","ACCOUNT_RECONNECTED"]},{"deprecated":null,"entity":"waba","meaning":"ACCOUNT_RESTRICTION: each restriction's restriction_type and its expiration (Unix seconds)","name":"restriction_info","path":["restriction_info"],"shape":"restrictions","values":["RESTRICTED_ADD_PHONE_NUMBER_ACTION","RESTRICTED_BIZ_INITIATED_AND_USER_INITIATED_CALLING","RESTRICTED_BIZ_INITIATED_MESSAGING","RESTRICTED_BUSINESS_INITIATED_CALLING","RESTRICTED_CUSTOMER_INITIATED_MESSAGING","RESTRICTED_DIRECT_SEND_UTILITY_TEMPLATES","RESTRICTED_USER_INITIATED_CALLING","RESTRICTED_USER_INITIATED_CALLING_CALL_BUTTON_HIDDEN","RESTRICTED_UTILITY_TEMPLATES"]},{"deprecated":null,"entity":"waba","meaning":"ACCOUNT_VIOLATION: the kind of policy violation","name":"violation_type","path":["violation_info","violation_type"],"shape":"text","values":null},{"deprecated":null,"entity":"waba","meaning":"DISABLED_UPDATE: whether the account is disabled, scheduled for disabling, or reinstated","name":"waba_ban_state","path":["ban_info","waba_ban_state"],"shape":"token","values":["DISABLE","REINSTATE","SCHEDULE_FOR_DISABLE"]},{"deprecated":null,"entity":"waba","meaning":"DISABLED_UPDATE: the date of the ban state","name":"waba_ban_date","path":["ban_info","waba_ban_date"],"shape":"text","values":null}],"receivers":["POST /webhooks/meta/app"],"reference":"https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/account_update","trigger":"An account-level event: a restriction, a policy violation, a ban, a deletion, an offboarding or reconnection, or a partner event. Partner events are not captured"},{"field":"account_alerts","keys":[{"deprecated":null,"entity":"waba","meaning":"What the alert is about: a business portfolio, a business phone number, or a number's business profile","name":"entity_type","path":["entity_type"],"shape":"token","values":["BUSINESS","PHONE_NUMBER","CURRENT_STATUS_ID"]},{"deprecated":null,"entity":"waba","meaning":"Meta's id of that entity","name":"entity_id","path":["entity_id"],"shape":"text","values":null},{"deprecated":null,"entity":"waba","meaning":"How severe Meta says the alert is","name":"alert_severity","path":["alert_info","alert_severity"],"shape":"token","values":["CRITICAL","WARNING","INFORMATIONAL"]},{"deprecated":null,"entity":"waba","meaning":"Whether the alert is active","name":"alert_status","path":["alert_info","alert_status"],"shape":"token","values":["ACTIVE","NONE"]},{"deprecated":null,"entity":"waba","meaning":"What the alert reports","name":"alert_type","path":["alert_info","alert_type"],"shape":"token","values":["INCREASED_CAPABILITIES_ELIGIBILITY_DEFERRED","INCREASED_CAPABILITIES_ELIGIBILITY_FAILED","INCREASED_CAPABILITIES_ELIGIBILITY_NEED_MORE_INFO","OBA_APPROVED","OBA_REJECTED","PROFILE_PICTURE_LOST"]},{"deprecated":null,"entity":"waba","meaning":"Meta's own text for the alert, shown as text and never matched on","name":"alert_description","path":["alert_info","alert_description"],"shape":"text","values":null}],"receivers":["POST /webhooks/meta/app"],"reference":"https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/account_alerts","trigger":"A messaging limit increase request is denied, deferred or needs more information, an Official Business Account request is approved or rejected, or a profile photo is lost"}],"list":"GET /wabas/{waba_id}/account-changes answers newest first, at most 200 per call, 50 when limit is not given","not_a_rating":"phone_number_quality_update carries NO quality rating, whatever its name says: Meta's reference names its trigger as a change of the number's throughput level. No captured change moves quality_status, and no business_capability_update moves messaging_limit_recipients or proactive admission","overrides_reference":"https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/override","projection":"values holds every documented key of the field: the value when it has the documented shape, null when Meta did not send it or sent another shape. unknown_fields names each top-level key of raw no documented key starts at. event is values.event when the field has one. entity names what the change is about: a phone_number (phone_number_quality_update, named only by display_phone_number), the waba entry[].id names (business_capability_update, account_update), or the entity_type of an alert (business_portfolio, phone_number, business_profile; null for any other). phone_number_id is Meta's id of the business phone number of this account the change names — by entity_id for a PHONE_NUMBER alert, by display number digit for digit for phone_number_quality_update — and null otherwise","retention":"Kept as long as the WABA. Deleted with the WABA or the organization","source":"meta_webhook"},"api_reference":"https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/whatsapp-business-account-phone-number-api","failure":"A Graph refusal of the refresh keeps the stored-credential slugs: 190 is 400 Messaging_GraphTokenInvalid and moves the WABA to EXPIRED, 200 or 10 is 403 Messaging_GraphAccessDenied and moves it to ACCESS_LOST, anything else, including Meta's 100 for a field the node does not have, is 502 Messaging_GraphError, and an unreachable Graph is 502 Messaging_GraphUnavailable. A refusal never replaces the stored read","freshness":"checked_at is the instant this server received Meta's answer (RFC 3339, UTC, on the database clock). expires_at is checked_at plus max_age_seconds. A read older than max_age_seconds answers quality_status stale whatever it said, measured by the database clock when the row is read; quality_rating still shows the token Meta sent then. max_age_seconds is this build's bound, not a number Meta publishes. The GETs make no Graph call; before the first refresh a number answers not_observed with checked_at, expires_at, quality_rating and raw null","graph_read":"GET /{phone_number_id}?fields=quality_rating","list_reference":"https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/phone-number-management-api","max_age_seconds":86400,"ratings":[{"documented_by":"https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/whatsapp-business-account-phone-number-api","meaning":"High quality","quality_status":"high","token":"GREEN"},{"documented_by":"https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/whatsapp-business-account-phone-number-api","meaning":"Medium quality","quality_status":"medium","token":"YELLOW"},{"documented_by":"https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/whatsapp-business-account-phone-number-api","meaning":"Low quality","quality_status":"low","token":"RED"},{"documented_by":"https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-phone-number/whatsapp-business-account-phone-number-api","meaning":"Quality has not been determined","quality_status":"not_determined","token":"NA"},{"documented_by":"https://developers.facebook.com/documentation/business-messaging/whatsapp/reference/whatsapp-business-account/phone-number-management-api","meaning":"Quality has not been determined (listed by the phone number list reference)","quality_status":"not_determined","token":"UNKNOWN"}],"reference":"https://developers.facebook.com/docs/whatsapp/cloud-api/phone-numbers/","retention":"One read per business phone number, replaced by the next. It is deleted with the WABA or the organization","routes":["GET /wabas/{waba_id}/quality","GET /wabas/{waba_id}/phones/{phone_number_id}/quality","POST /wabas/{waba_id}/phones/{phone_number_id}/quality/refresh","GET /wabas/{waba_id}/account-changes"],"separate_facts":"Three facts, three places, none derived from another. The quality rating is Meta's quality_rating of ONE business phone number, on these routes. The account's send health is can_send_message (Meta's health_status for the WABA entity) on the WABA object. The Business Portfolio messaging limit is messaging_limit_recipients on the WABA object, which only POST /wabas/{waba_id}/messaging-limit-refresh writes and proactive admission spends. A quality answer carries no can_send_message and no messaging limit, and the WABA object carries no quality_rating","source":"meta_graph","statuses":[{"quality_status":"high","rule":"Meta answered GREEN (high quality) to a read younger than max_age_seconds"},{"quality_status":"medium","rule":"Meta answered YELLOW (medium quality) to a read younger than max_age_seconds"},{"quality_status":"low","rule":"Meta answered RED (low quality) to a read younger than max_age_seconds"},{"quality_status":"not_determined","rule":"Meta answered NA or UNKNOWN to a read younger than max_age_seconds: Meta has not rated the number"},{"quality_status":"unreadable","rule":"The last read, younger than max_age_seconds, carried no quality_rating, a value that is not a string, or a token outside Meta's five. quality_rating is null and raw holds Meta's answer"},{"quality_status":"stale","rule":"The last read is older than max_age_seconds, whatever it said. quality_rating still shows what Meta answered then, dated by checked_at"},{"quality_status":"not_observed","rule":"Nobody read the rating through this build"}],"values":"quality_rating is Meta's token exactly as read, when it is one of the five in ratings; any other value, a value that is not a string, and an absent key read as null, and the read answers quality_status unreadable. raw is Meta's whole answer to the last read, and unknown_fields names every key of it other than id and quality_rating. A rating is never taken from a webhook: Meta sends none","verdict":"quality_status is the field to branch on. It is high only when Meta answered GREEN to a read younger than max_age_seconds. A number nobody read is not_observed, an old read is stale, and an unreadable read is unreadable, so neither a stale nor an unreadable value is ever a high verdict"},"routes":[{"absolute_path":"/v1/messaging/wabas/verify","auth":"org_admin authority or above at body.organization_id, or an explicit messaging_waba.read grant (session cookie or cpt_*)","method":"POST","path":"/wabas/verify","summary":"Verify-before-custody: check a pasted token against Graph; nothing stored"},{"absolute_path":"/v1/messaging/wabas","auth":"org_admin authority or above at body.organization_id, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/wabas","summary":"Take custody: verify, create WABA + phones, store the encrypted credential blob, attempt app subscription"},{"absolute_path":"/v1/messaging/wabas","auth":"org_admin authority or above at ?organization_id=, or an explicit messaging_waba.list grant (session cookie or cpt_*)","method":"GET","path":"/wabas","summary":"List an organization's WABAs (?organization_id=) with webhook-verified state. Every WABA projection carries reonboarding_since: the start of Meta's Reonboarding of the account (an ACCOUNT_OFFBOARDED after the business registered the WhatsApp Business app again), RFC 3339 in UTC, or null. While it is set the proactive executor holds every run of the account and a Graph refusal 190/200/10 does not change status (#1844, decided in #1645). Every WABA projection also carries access_lost: null unless status is ACCESS_LOST, else {cause (graph_refusal | partner_removed | partner_app_uninstalled | unrecorded), graph_code (200 or 10 for graph_refusal), reason and initiated_by (Meta's disconnection_info of a PARTNER_REMOVED, as Meta sent them), at (RFC 3339 in UTC, null for unrecorded)}: why Meta refuses the account (#1837, decided in #1880)"},{"absolute_path":"/v1/messaging/wabas/{waba_id}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}","summary":"Get a WABA with phones, attached projects, webhook state, and sync_runs — every Coexistence Sync run of its numbers, one per phone_number_id and sync_type (smb_app_state_sync | history), with outcome (pending | complete | declined | window_closed | failed, window_closed computed on the database clock), request_status, Meta's refusal_message and refusal_code, phase, chunk_order, progress, items_received, window_closes_at, window_open and can_request_again. Empty for an account with no Coexistence number; never a subscriber event. A new Coexistence onboarding (an Embedded Signup exchange with FINISH_WHATSAPP_BUSINESS_APP_ONBOARDING) starts both runs of each number it activates again; request_count is then 1 again (#1837, decided in #1644). The waba object carries access_lost, as the list does. Each business phone number carries routing_seen_at (issue #1897): the time of its newest Conversation Routing evidence (a handover, a standby copy, a thread-control call), canonical RFC 3339, or null when it has none"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/reconnect","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/reconnect","summary":"Verify a fresh token, rebuild the credential blob (broken custody repairable with app_secret), re-sync phones, and return the webhook config"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/health-refresh","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/health-refresh","summary":"Ask Meta whether this account may send and STORE the answer with the instant it was read ({send_health, send_health_evidence, send_health_checked_at} on the WABA projection). A refresh and not a probe: the verdict changes with no custody event when the customer attaches its own payment method. It adds NO gate in front of a send — Meta decides at send time and the Warta gateway still passes its status and body through verbatim — so this route only lets an operator SEE a refusal coming. A dead token found here takes the shared stored-credential tail: Graph 190 flips the WABA to EXPIRED, 200/10 to ACCESS_LOST"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/messaging-limit-refresh","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/messaging-limit-refresh","summary":"Ask Meta how many unique recipients this account's Business Portfolio may reach outside a customer-service window in a moving 24-hour window, and STORE the answer with the instant it was read ({messaging_limit_recipients, messaging_limit_unlimited, messaging_limit_checked_at, messaging_limit_expires_at} on the WABA projection). Meta is asked where it documents the read: GET /{phone_number_id}?fields=whatsapp_business_manager_messaging_limit on ONE business phone number of the account (the oldest), because the limit is the portfolio's and every number shares it; an account with no business phone number is 404 Messaging_PhoneNotFound before any Graph call. Meta's documented tiers are read as TIER_250=250, TIER_2K=2000, TIER_10K=10000, TIER_100K=100000, and TIER_UNLIMITED as messaging_limit_unlimited=true with a null count. This is the observation the productized proactive layer's admission gate spends: without it POST /v1/control/proactive/runs can only answer pause / portfolio_limit_unknown, and an observation older than an hour answers portfolio_limit_stale. A refresh and NOT a caller-supplied setting -- a limit the caller could state is a permission the caller granted itself -- and it adds no gate in front of a raw gateway send. A read Meta answers with nothing this build can read (UNTIERED, an undocumented tier, no field) still stores the stamp and Meta's whole node, and still pauses. A dead token found here takes the shared stored-credential tail: Graph 190 flips the WABA to EXPIRED, 200/10 to ACCESS_LOST"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/token-info","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/token-info","summary":"Ask Meta what it thinks of the STORED credential without revealing it: {is_valid, waba_in_messaging_scope, app_id, expires_at, scopes}. It separates the two opposite conditions Meta reports with the same Graph code 200 on a send refusal — a grant that is gone (repair: reconnect or re-run Embedded Signup) from a Meta app without advanced access for a WABA another business owns (repair: App Review). Diagnosis only: nothing is stored and no WABA status moves, even when Meta refuses"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/analytics/{family}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/analytics/{family}","summary":"Read one family of Meta's WhatsApp analytics for this account with its stored token: {family} is messaging, conversation, pricing, template, template_group, call or group, and each takes only the query parameters Meta documents for it (see analytics.families). Answers {source: meta_graph, family, meta_field, waba_id, meta_waba_id, window, filters, observed_at, data_points, next_after, truncated, raw}: Meta's data points copied verbatim, never a local count or an estimate, and Meta's whole answer as raw evidence. An unknown family or parameter, a value outside the family's vocabulary, a missing required parameter, end before start, or a start outside Meta's lookback is 400 Messaging_InvalidInput before any Graph call. Stores nothing and moves no WABA status, even when Meta refuses"},{"absolute_path":"/v1/messaging/wabas/{waba_id}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.delete grant (session cookie or cpt_*)","method":"DELETE","path":"/wabas/{waba_id}","summary":"Destructive cascade delete (statuses, credential, attachments, phones). Before it, an Embedded Signup account gets one DELETE /{meta_waba_id}/subscribed_apps with its stored token, so Meta stops sending its webhooks; no call under Manual custody or when another mirror holds the account through the same Meta app. The call never stops the delete (always 204) and its outcome goes only to the Security audit event messaging.waba.meta_unsubscribe. Phone numbers are never deregistered"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/move","auth":"org_admin authority or above at the organization that owns {waba_id} AND at body.organization_id, or an explicit messaging_waba.delete grant at the first and an explicit messaging_waba.create grant at the second (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/move","summary":"Move the account to another Organization of the same Installation (body {organization_id, project_id?}; #1958, decided in #1874). 200 with the account as GET /wabas/{waba_id} answers it, now in the target. The account keeps its wb_ id, its credential and its Meta webhook override, and no Meta call is made. These move with it: its phone numbers, Conversations, messages and statuses, Send failures, message templates, Consent evidence, Campaign history, Coexistence history, Flow endpoint custody, the Warta keys scoped to one of its numbers, and the Subscriptions narrowed to one of its numbers and to no Project, with their queued Deliveries. These stay: Contacts (the target uses its own Contact for a number, or gets a copy with the Display name, Labels and Contact note), Projects (the account is detached from each; project_id attaches it to one Project of the target), every key and Subscription of the whole Organization or of a Project, and the Usage counted before the move. A moved Conversation keeps its state and loses its Assignment and its read cursors. One transaction in a task of its own: a 504 Request_Timeout does not stop it. Refusals, each with nothing moved, in this order: 404 Messaging_WabaNotFound, 404 Messaging_OrganizationNotFound, 403 Messaging_Forbidden, 400 Messaging_InvalidInput (the target is the account's Organization), 404 Messaging_ProjectNotFound, 409 Messaging_WabaMoveBusy (a Campaign or Event-triggered send is scheduled, running or paused), 409 Messaging_WabaMoveConflict (the target holds the Meta WABA id or one of the numbers, is scheduled for deletion, or a narrowed Subscription's endpoint is in use there). Each Organization gets one Security audit event messaging.waba.move; no Delivery is sent"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/projects","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/projects","summary":"Attach a control-plane project (same organization only)"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/projects/{project_id}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"DELETE","path":"/wabas/{waba_id}/projects/{project_id}","summary":"Detach a project (idempotent)"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/keys","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/keys","summary":"Mint a Warta key — the raw ww_* key is shown once, hashes never serialized"},{"absolute_path":"/v1/messaging/keys","auth":"org_admin authority or above at ?organization_id=, or an explicit messaging_waba.list grant (session cookie or cpt_*)","method":"GET","path":"/keys","summary":"List an organization's Warta keys (?organization_id=; revoked rows included)"},{"absolute_path":"/v1/messaging/keys/{key_id}/revoke","auth":"org_admin authority or above at body.organization_id, or an explicit messaging_waba.delete grant (session cookie or cpt_*)","method":"POST","path":"/keys/{key_id}/revoke","summary":"Revoke a Warta key (body {organization_id}; idempotent)"},{"absolute_path":"/v1/messaging/deliveries","auth":"org_admin authority or above at ?organization_id=, or an explicit messaging_waba.list grant (session cookie or cpt_*), OR an organization-scoped warta_key","method":"GET","path":"/deliveries","summary":"One page of an organization's delivery log (?organization_id=, required of a control caller and IMPLIED by a Warta key), newest first — metadata ONLY: no payload_json, no signing secret, and the endpoint reduced to a scheme://host[:port]/… locator. Filters: status (pending|in_flight|completed|failed), endpoint (exact) OR subscription_id (resolved server-side; naming both is 400), event_id, event_type (exact, from the vocabulary both channels share; a name outside it is 400), wamid (exact, opaque — an id nothing was fanned out for answers an empty page), created_after/created_before (inclusive RFC 3339), limit (default 50, clamped to 200), before (meta.next_cursor). meta also carries status_counts for the whole organization, retention_days, and subscriptions[] — the destination filter vocabulary ({id, name, endpoint-locator, phone_number, revoked_at}, revoked registrations included; phone_number names the business phone number a narrowed subscription is narrowed to — the display number of the organization's phone record, else its phone_number_id — and is null for one that is not narrowed; revoked_at is the RFC 3339 time of the revoke, or null while the subscription is live; decision 6 of #1276), served here so a browser never has to read the subscription registry's raw endpoint_url. Both credential families get the same projection, filters, cursor and meta keys (issue #1028)"},{"absolute_path":"/v1/messaging/deliveries/{delivery_id}","auth":"org_admin authority or above at ?organization_id=, or an explicit messaging_waba.read grant (session cookie or cpt_*), OR an organization-scoped warta_key","method":"GET","path":"/deliveries/{delivery_id}","summary":"One delivery's metadata (?organization_id=, required of a control caller and IMPLIED by a Warta key), same projection as the listing plus its capped attempt timeline. 404 Messaging_DeliveryNotFound covers no-such-id, a purged row past the 30-day terminal-row horizon, and another organization's id alike, so a dlv_* id is never an existence oracle — for a Warta key exactly as for a session"},{"absolute_path":"/v1/messaging/deliveries/{delivery_id}/replay","auth":"org_admin authority or above at body.organization_id, or an explicit messaging_waba.update grant (session cookie or cpt_*), OR an organization-scoped warta_key","method":"POST","path":"/deliveries/{delivery_id}/replay","summary":"Queue ONE failed delivery again (body {organization_id}, required of a control caller and IMPLIED by a Warta key). 202 with {delivery_id, source_delivery_id, event_id, status} — the new row is a FRESH delivery id at the queue tail carrying the same event id, endpoint, payload, signature and idempotency key, and the source row stays terminal. Only a failed row is accepted: pending, in_flight and completed answer 409 Messaging_DeliveryNotReplayable naming the state, a second replay while the first is still queued answers 409 Messaging_ReplayAlreadyQueued, and a missing, purged or other-organization id answers the same 404 Messaging_DeliveryNotFound the read route does. The organization is authorized BEFORE the id is looked at, so a dlv_* id stays un-probeable. Nothing is sent on the request path — the delivery worker performs every subscriber request — and there is no event-wide bulk replay here"},{"absolute_path":"/v1/messaging/deliveries/{delivery_id}/retry-now","auth":"org_admin authority or above at body.organization_id, or an explicit messaging_waba.update grant (session cookie or cpt_*), OR an organization-scoped warta_key","method":"POST","path":"/deliveries/{delivery_id}/retry-now","summary":"Make ONE delivery that waits for a retry due now (issue #1843, decided in #1643; body {organization_id}, required of a control caller and IMPLIED by a Warta key). 202 with {delivery_id, status, next_attempt_at} — the row keeps its delivery id and its attempt_count, and the delivery worker sends it on its next pass; that attempt counts toward the nine and its attempt record carries trigger retry_now. A wait after a Retry-After header ends too. Only a pending delivery that has made at least one attempt and waits for a later next_attempt_at is accepted (the waiting_for_retry flag of the log): any other answers 409 Messaging_DeliveryNotWaiting naming its state, and a missing, purged or other-organization id answers the same 404 Messaging_DeliveryNotFound the read route does. The organization is authorized BEFORE the id is looked at. Nothing is sent on the request path, and there is no bulk Retry now"},{"absolute_path":"/v1/messaging/sends","auth":"org_admin authority or above at ?organization_id=, or an explicit messaging_waba.list grant (session cookie or cpt_*), OR a warta_key: an organization-scoped key reads the organization's gateway Sends, a phone-scoped key its own number's","method":"GET","path":"/sends","summary":"One page of the organization's Send log (?organization_id=, required of a control caller and IMPLIED by a Warta key), newest first: every Send recorded after Meta answered, in ONE timeline across both arms — an accepted Send (id om_*, Meta's wamid, and status: the newest status Meta reported for it, or null before the first) and a refused attempt (id sf_*, wamid null, failure_reason: the slug, failure_code: Meta's number — never Meta's error message). Each entry carries id, wamid, contact, phone_number_id, origin, message_type, text, status, failure_reason, failure_code, sent_by (the principal for a console Send, else null) and at. A Warta key reads origin gateway only; a phone-scoped key reads its own number only and naming another phone_number_id is 403 Messaging_ScopeDenied; a control caller reads every origin. Filters: contact (a phone number; + dropped), phone_number_id (exact), wamid (exact; matches no refused attempt), created_after/created_before (inclusive RFC 3339), limit (default 50, clamped to 200), before (meta.next_cursor); a malformed one is 400 Messaging_InvalidInput, never dropped. A Send is not a Delivery: this log is what went to Meta, /deliveries is what reached a subscriber"},{"absolute_path":"/v1/messaging/sends/{send_id}","auth":"org_admin authority or above at ?organization_id=, or an explicit messaging_waba.read grant (session cookie or cpt_*), OR a warta_key: an organization-scoped key reads the organization's gateway Sends, a phone-scoped key its own number's","method":"GET","path":"/sends/{send_id}","summary":"One Send (?organization_id=, required of a control caller and IMPLIED by a Warta key) in the listing's projection, by its om_* or sf_* id. A refused attempt answers 200 with wamid null, never a 404. 404 Resource_NotFound covers no-such-id, another organization's id, and a Send outside the credential's reach (a console Send for a Warta key, another number's for a phone-scoped key) alike, so a send id is never an existence oracle"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/test-message","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/test-message","summary":"First working call via the stored credential (text or template payload)"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/statuses","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.list grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/statuses","summary":"List inbound message statuses (?wamid=), newest first"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/messages","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.list grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/messages","summary":"List inbound user messages (?wamid=), newest first — the stable projection subscribers receive, never Meta's raw object"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/chats","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.list grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/chats","summary":"The inbox: one entry per conversation — one (contact, business phone number) pair, issue #1244 — for every contact who wrote to this WABA or was written to from it, newest activity first, each with its own phone_number_id, last message, conversation assignment and unread count, and its 24-hour customer service window state, computed from that entry's own number's inbound messages only (issue #1245: Meta's window is per business number and customer). An inbound message Meta reported with no business number is a display-only entry: phone_number_id null, conversation null, window closed. The window and the unread count read live inbound messages only: a message the history sync backfilled sits on the entry of the business phone number it was exchanged on, opens no window and is never unread. Each entry carries responder (issue #1897): which responder holds the conversation under Meta's Conversation Routing — this_platform, another, none, or null when the number has no Conversation Routing evidence (and on the display-only entry) — by the one derivation every read calls. A standby copy opens the window and is never unread. Read-only, served from stored rows; Graph is never called"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/chats/{contact}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.list grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/chats/{contact}","summary":"One contact's thread, oldest first — inbound messages in the stable subscriber projection and replies carrying their newest Meta status and their origin (console, gateway, business_app, history, other_app; sent_by is null when no principal sent it). A Message echo edit or revoke is folded onto the reply whose wamid it names, whatever that reply's origin: edited_at with the replaced text or media, or revoked_at with the content nulled; an edit or revoke whose original is not on the thread is shown on its own, marked. A message the history sync backfilled carries origin history on either side and its additive history_status snapshot (lowercased history_context.status), never a Meta status. An additive failures[] array carries the attempts Meta refused — {id, origin, contact, phone_number_id, message_type, text, failure_reason, failure_code, sent_by, at} — for the console to interleave on at. {contact} takes the same key the gateway contact read takes, a leading + tolerated. The thread is ONE conversation (issue #1244): ?phone_number_id= names the business phone number and serves only that number's messages and failures. Absent, it resolves when the contact holds at most one conversation (none serves the display-only thread of its no-number messages, window closed) and answers 400 Messaging_ConversationAmbiguous when it holds more. The thread carries responder (issue #1897: this_platform, another, none or null, as on the list), summaries — Meta's conversation summaries, {text, received_at}, oldest first, [] when none — and a standby boolean on each message: true for a standby copy of a message from the WhatsApp user that Meta sent while another app held the conversation; an outbound message with origin other_app is a standby echo of that other app's message"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/chats/{contact}/messages","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/chats/{contact}/messages","summary":"Reply through the STORED credential — the path test-message takes — then record the message under the wamid Meta returned so the thread holds both sides. 201 with the new thread item. FOUR arms, exactly one per call: text, image, reaction and template; the template arm is the one surface that reaches a contact whose window has shut. The 24-hour window is NOT checked here: Meta decides at send time and its refusal comes back on this call. No message is stored when Meta refuses; the attempt is (issue #1134) — one send failure naming the calling principal, the reason slug and Meta's code, shown in the thread's failures[] — and the refusal the caller reads does not change"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/chats/{contact}/assignee","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit conversation.assign grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/chats/{contact}/assignee","summary":"Give one conversation to one principal, or clear the individual assignee (body {principal_id, version, phone_number_id?}). Optimistic concurrency: a stale version answers 409 Messaging_ConversationVersionConflict carrying the row that won. phone_number_id names the conversation (issue #1244): absent, it resolves when the contact holds exactly one conversation, answers 400 Messaging_ConversationAmbiguous when it holds more, and 404 Messaging_ConversationNotFound when it holds none or none on the named number; the same rule on the team, take and state writes"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/chats/{contact}/team","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit conversation.assign grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/chats/{contact}/team","summary":"Put one conversation in a team's queue, or clear the team (body {team_id, version, phone_number_id?}); same 409 on a stale version, same phone_number_id rule"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/chats/{contact}/take","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit conversation.assign grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/chats/{contact}/take","summary":"The calling principal takes the conversation — the write the assignee route makes, with the caller's own id. A route of its own because taking work is the one assignment an operator makes about ITSELF, and naming your own principal id in a body is a step every page would have to invent. Body {version, phone_number_id?}, the same phone_number_id rule"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/chats/{contact}/state","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit conversation.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/chats/{contact}/state","summary":"Move one conversation through open / pending / resolved (body {state, version, phone_number_id?}); same 409 on a stale version, same phone_number_id rule. conversation.update and NOT conversation.assign, separately grantable on purpose: a triager who may resolve conversations is not thereby allowed to hand them to other people"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/chats/{contact}/responder","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/chats/{contact}/responder","summary":"Take over or release the conversation at Meta (Conversation Routing thread control) through the STORED credential. 200 with the conversation's responder. Only the escalation partner can take: Meta's refusal answers 409 Messaging_NotEscalationPartner. Thread control that Meta has not turned on answers 409 Messaging_ThreadControlUnavailable. Not a Send, no Usage unit, no Delivery; the Assignment and the state do not change."},{"absolute_path":"/v1/messaging/teams","auth":"org_admin authority or above at ?organization_id=, or an explicit team.list grant (session cookie or cpt_*)","method":"GET","path":"/teams","summary":"One organization's teams by name (?organization_id= required). ORGANIZATION-owned, so the route hangs off the branch root rather than under a {waba_id}: a team's queue spans every WhatsApp Business Account the organization holds"},{"absolute_path":"/v1/messaging/teams","auth":"org_admin authority or above at body.organization_id, or an explicit team.create grant (session cookie or cpt_*)","method":"POST","path":"/teams","summary":"Create one organization-owned team (body {organization_id, name}); a duplicate name in the organization is refused"},{"absolute_path":"/v1/messaging/teams/{team_id}/members","auth":"org_admin authority or above at ?organization_id=, or an explicit team.list grant (session cookie or cpt_*)","method":"GET","path":"/teams/{team_id}/members","summary":"One team's members, oldest first (?organization_id= required)"},{"absolute_path":"/v1/messaging/teams/{team_id}/members","auth":"org_admin authority or above at body.organization_id, or an explicit team.update grant (session cookie or cpt_*)","method":"POST","path":"/teams/{team_id}/members","summary":"Put a principal on a team (body {organization_id, principal_id}). team.update and not team.create: the thing being changed is the team's membership, and team.create is the authority to DEFINE a queue rather than to staff one"},{"absolute_path":"/v1/messaging/teams/{team_id}/members/{principal_id}","auth":"org_admin authority or above at ?organization_id=, or an explicit team.update grant (session cookie or cpt_*)","method":"DELETE","path":"/teams/{team_id}/members/{principal_id}","summary":"Take a principal off a team (?organization_id= required). Idempotent: removing a non-member states a fact that is already true"},{"absolute_path":"/v1/messaging/contacts","auth":"org_admin authority or above at ?organization_id=, or an explicit contact.list grant (session cookie or cpt_*)","method":"GET","path":"/contacts","summary":"One organization's contacts (?organization_id= required, optional &labels=). ORGANIZATION-owned like teams and for a stronger reason: one customer identity spans every business phone number the organization holds. The label filter is array OVERLAP in the database, so naming several labels is an ANY and never an ALL. Since #2199 the list holds every Contact of the organization: one row for each phone identity, by its value, then one row for each other Contact (a Contact known only on Messenger, Instagram or Microsoft Teams), by id, with contact null. Each row carries id (the ct_ id the {contact} routes also take), channels and identities [{kind, scope, value, profile_name, superseded}]"},{"absolute_path":"/v1/messaging/contacts/{contact}","auth":"org_admin authority or above at ?organization_id=, or an explicit contact.read grant (session cookie or cpt_*)","method":"GET","path":"/contacts/{contact}","summary":"One full contact: {id, contact, name, profile_name, display_name, app_contact_name, labels, note, channels, identities} plus its stamps (?organization_id= required). {contact} is a phone number, a WhatsApp user id, or since #2199 the Contact's ct_ id, which reaches a Contact of any channel; contact is null for a Contact with no phone number and no WhatsApp user id, and a ct_ id of another organization is a 404. name falls back display_name, then app_contact_name (the name the business saved in its WhatsApp Business app, written only by the Coexistence contact sync), then profile_name, then the number or the value of a channel identity. 404 and NOT a lazily created empty row — a contact is created by TRAFFIC, so answering 200 for a number nobody ever messaged would make a typo indistinguishable from a customer"},{"absolute_path":"/v1/messaging/contacts/{contact}","auth":"org_admin authority or above at body.organization_id, or an explicit contact.update grant (session cookie or cpt_*)","method":"PATCH","path":"/contacts/{contact}","summary":"Edit the operator's own contact metadata: display_name, labels, note. A three-valued PATCH — a field the body omits keeps its stored value, null clears it, a value sets it — so two operators editing from two tabs cannot erase each other's work. profile_name is NOT addressable: Meta's evidence has exactly one writer, the webhook ingest, and a request naming it is a 400. app_contact_name is refused the same way: the Coexistence contact sync is its only writer. {contact} names the Contact as the read does, a ct_ id included (#2199)"},{"absolute_path":"/v1/messaging/contacts/{contact}","auth":"org_admin authority or above at ?organization_id=, or an explicit contact.delete grant (session cookie or cpt_*); a Warta key reaches no delete","method":"DELETE","path":"/contacts/{contact}","summary":"Erase one Contact with everything the Organization keeps about that person in the Service (?organization_id= required; #1947). {contact} is a phone number, a WhatsApp user id or the Contact's ct_ id (#2199), which reaches a Contact of any channel. In one transaction it deletes the messages to and from each channel identity of the Contact with their statuses and Send failures (on another channel, each Send failure whose recipient is a scoped identity of the Contact on the business address of that scope), each direct Conversation of those identities with its read cursors, their Consent evidence, their Proactive message run recipients, their Coexistence contact sync rows, the channel identities and the Contact. It keeps the usage counts, the Inbound events, Coexistence captures and Deliveries (each goes at the end of its own period), and the Security audit events of other actions. 204 with no body; 404 Messaging_ContactNotFound for a number or an id with no Contact, so a repeated delete is a 404. One Security audit event, messaging.contact.erase, carries the Contact id and the count of deleted messages and no phone number and no name. A later message from the same number creates a new Contact. Meta and WhatsApp keep their own copies"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/media","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/media","summary":"Upload bytes as a media object on this business phone number (?phone_number_id= required) and get the media id a reply names. 201 and ENVELOPED, unlike the gateway's leg, because this is the control plane. The body IS the media and Content-Type declares its type; the id belongs to Meta, expires 30 days after the upload, and nothing is stored. 413 Messaging_MediaTooLarge past SAMBUNGAPI_MAX_UPLOAD_BYTES"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/media/{media_id}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.list grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/media/{media_id}","summary":"The control plane's media read (?phone_number_id= required): the console holds a SESSION and the gateway subtree takes ww_* bearers only, so without this route the Chats thread can report that a photo arrived and not show it. Everything below the gate is the gateway read's own code — raw unenveloped bytes, the media type on Content-Type, X-Sambungapi-Media-Sha256, nosniff and attachment, the same 404 Messaging_MediaNotFound and 413 Messaging_MediaTooLarge. messaging_waba.list and not .read, because the thread that names a media object is itself list-gated"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/templates","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.list grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/templates","summary":"The WABA's message templates, newest first, filterable by status, language and name. Served from OUR rows and never from Meta: the message_templates edge shares a 200-request-per-hour-per-WABA budget with connect and phone-number management, so a console that polls this list must not spend it"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/templates","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/templates","summary":"Create one message template. 201. Authorize, validate at our edge, pre-check the (name, language) collision so it is a 409 before anything is spent, and only then call Meta. What Meta accepts is what gets stored — category in particular may NOT be the one submitted, because allow_category_change lets Meta reassign it from the content"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/templates/media","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/templates/media","summary":"Upload ONE header asset to Meta's Resumable Upload API and get its header_handle back. 201. Its own route rather than a field on the create body: a handle is reusable across the same template in several languages, an edit replaces every component, and the create body stays a 1 MiB control document while this leg takes RAW bytes under SAMBUNGAPI_MAX_UPLOAD_BYTES. The asset's media type is the request's own Content-Type"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/templates/{template_id}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/templates/{template_id}","summary":"One template plus every webhook transition recorded for it. The evidence list is what makes the projection auditable: an operator reading status paused can see whether Meta said PAUSED or LIMIT_EXCEEDED, two tokens the vocabulary deliberately collapses"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/templates/{template_id}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"PATCH","path":"/wabas/{waba_id}/templates/{template_id}","summary":"Edit the category, the components, the parameter format or the time-to-live. The stored status becomes pending on success, and that is Meta's rule rather than ours: any edit puts the template back into review, and reporting approved for a template Meta is re-reviewing is the dangerous direction — an operator would send it"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/templates/{template_id}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.delete grant (session cookie or cpt_*)","method":"DELETE","path":"/wabas/{waba_id}/templates/{template_id}","summary":"Delete ONE language of one template. 204. The provider call always sends hsm_id AND name together: Meta's delete-by-name alone removes every language variant, and the one-language promise depends entirely on the pair. The stored row goes with it and the recorded webhook evidence survives; Meta stays authoritative on what may be recreated"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/templates/{template_id}/refresh","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/templates/{template_id}/refresh","summary":"Read ONE template from Meta and reconcile the stored row. META WINS. The explicit reconciliation hop the hybrid needs: five state changes reach us only as webhook events, auto-archival at 12 months and auto-deletion 28 days later are silent server-side clocks, and a delete issued from WhatsApp Manager happens entirely behind our back. It writes, so it is messaging_waba.update and not .read"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/templates/sync","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/templates/sync","summary":"Read the ACCOUNT's whole template inventory from Meta and reconcile our rows against it. META WINS, as on the single-template refresh. The one route in the family that can DISCOVER a template we hold no row for — one created in WhatsApp Manager, or one Meta duplicated onto this WABA during a provider move. Explicit and never automatic: each page spends one request of the WABA's Business Management budget. A refused pull writes nothing"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/templates/copy","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.create grant (session cookie or cpt_*); a body.source_waba_id the caller cannot read answers as GET /wabas/{waba_id} answers for it","method":"POST","path":"/wabas/{waba_id}/templates/copy","summary":"Ask Meta to copy the approved templates of another WhatsApp Business Account into {waba_id} with its Template Migration API (issue #1957, decision #1872). Body: exactly one of source_waba_id (a wb_ id of another account of the same organization) or source_meta_waba_id (a Meta account id, digits only, for an account sambungapi does not hold); both, neither, or the destination itself is 400 Messaging_InvalidInput. Meta copies only between accounts that one Meta business owns, and only templates that are APPROVED with quality GREEN or UNKNOWN; each copy is a new template with its own id. 200 {copied: [meta template id], not_copied: [{meta_template_id, reason}] with Meta's reason byte for byte, pages, truncated, sync, sync_error}: sync is what POST /wabas/{waba_id}/templates/sync answers, run after Meta's last page so each copied template gets a row with Meta's status, and sync_error is set (sync null) when that pull fails after the copy. A refusal of page 0 is the template family's Graph refusal and writes nothing; a refusal of a later page stops the copy with truncated true. The copy runs in a task of its own: a 504 Request_Timeout does not stop it"},{"absolute_path":"/v1/messaging/subscriptions","auth":"org_admin authority or above at body.organization_id, or an explicit messaging_waba.create grant (session cookie or cpt_*), OR a warta_key: an organization-scoped key registers for the organization, a phone-scoped key a subscription narrowed to its own number","method":"POST","path":"/subscriptions","summary":"Register a subscriber endpoint for Warta fan-out; the whsec_* signing secret is returned ONCE as data.raw_secret and never again (409 Messaging_DuplicateSubscription while a LIVE subscription holds the endpoint_url; a revoked one holds none, and two subscriptions narrowed to DIFFERENT business phone numbers share one endpoint_url, issue #1553 / decision #1276). organization_id is required of a control caller and IMPLIED by a Warta key, which may not name another organization (403 Messaging_ScopeDenied) and may not set project_id. A phone-scoped key's subscription carries phone_number_id and is delivered only that number's events"},{"absolute_path":"/v1/messaging/subscriptions","auth":"org_admin authority or above at ?organization_id=, or an explicit messaging_waba.list grant (session cookie or cpt_*), OR a warta_key: an organization-scoped key lists the organization's, a phone-scoped key only those narrowed to its own number","method":"GET","path":"/subscriptions","summary":"List subscriber endpoints of an organization (?organization_id=, required of a control caller and implied by a Warta key), revoked rows included; no secret in any form. Each row carries phone_number_id: the one number a narrowed subscription is delivered for, else null"},{"absolute_path":"/v1/messaging/subscriptions/{subscription_id}/revoke","auth":"org_admin authority or above at body.organization_id, or an explicit messaging_waba.delete grant (session cookie or cpt_*), OR a warta_key: an organization-scoped key revokes the organization's, a phone-scoped key only those narrowed to its own number (any other id is 404 Messaging_SubscriptionNotFound)","method":"POST","path":"/subscriptions/{subscription_id}/revoke","summary":"Stop future fan-out (body {organization_id}, required of a control caller and implied by a Warta key; idempotent) and release the endpoint_url for a new registration. Deliveries already queued keep their retry/DLQ lifecycle"},{"absolute_path":"/v1/messaging/embedded/config","auth":"org_admin authority or above at ANY organization of the installation, or an explicit messaging_waba.read grant (session cookie or cpt_*) — the route reads deployment configuration rather than tenant data, so it scopes to no organization","method":"GET","path":"/embedded/config","summary":"Embedded Signup launch parameters ({configured, app_id?, config_id?, graph_version, es_version, feature_type?, feature_types?}); never the secret. `feature_types` is every flow this server can launch — null for Meta's standard onboarding, then each ES_SUPPORTED_FEATURE_TYPES member — and `feature_type` the flow for a request that names none, always one of them (issue #1091). Optional `feature_type` query parameter picks the flow for ONE onboarding (issue #982): `cloud_api` for Meta's standard onboarding, any ES_SUPPORTED_FEATURE_TYPES member, or absent for the server's configured flow; anything else is 400"},{"absolute_path":"/v1/messaging/embedded/diagnostics","auth":"control_personal_token or session cookie held by a platform_operator — an ACTIVE installation-anchored binding, checked by gate_platform_operator and by nothing else on this surface; org_admin authority does NOT reach it","method":"GET","path":"/embedded/diagnostics","summary":"Deployment-level Embedded Signup check: which Meta app this server is configured against ({app: {id, name, app_type, link}}) and whether the configured config_id is one that app holds. config_id_matches is THREE-valued — null means Meta's reply carried no config_ids at all, so no verdict is possible. PULL, never a readiness check: it spends one app-secret-authenticated Graph call per request, which is why the gate is the installation binding and not the org_admin bar its neighbours take. 503 Messaging_EmbeddedSignupUnconfigured without SAMBUNGAPI_META_*; the app secret appears in no field and in no error"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.list grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/flows","summary":"One page of the account's WhatsApp Flows as Meta lists them, through the stored token: {source: meta_graph, operation: list, waba_id, meta_waba_id, observed_at, flows: [{flow_id, name, status, categories, validation_errors}], next_after, truncated, raw}. ?after= passes back Meta's cursor from next_after and is the only parameter. Every field is Meta's, copied verbatim; Sambungapi stores no Flow"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/flows","summary":"Create a Flow on the account (201): name and categories (at least one of SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER) are required; clone_flow_id, endpoint_uri, flow_json (the Flow JSON object, or a string holding it, at most 10 MB) and publish are Meta's optional fields, and any other field is 400 Messaging_InvalidInput before Graph. A clone_flow_id must name a Flow of this account (404 Messaging_FlowNotFound otherwise). Answers Meta's Flow id with Meta's success and validation_errors and Meta's whole answer as raw"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/flows/{flow_id}","summary":"One Flow with every field of Meta's detail request: {flow: {flow_id, name, status, categories, validation_errors, json_version, data_api_version, endpoint_uri, preview, application, meta_waba_id}, raw, observed_at}. status is Meta's word, verbatim. A Flow whose whatsapp_business_account is another account, or that Meta does not hold, is the same 404 Messaging_FlowNotFound"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"PATCH","path":"/wabas/{waba_id}/flows/{flow_id}","summary":"Update the Flow's metadata through Meta's POST /{FLOW-ID}: at least one of name, categories, endpoint_uri, and nothing else. Meta decides whether the Flow's status allows it: a refusal Meta answers with a 4xx, such as an update to a PUBLISHED Flow, is 409 Messaging_FlowRefused naming the operation and Meta's code. Answers {success, validation_errors, raw} and claims no status"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.delete grant (session cookie or cpt_*)","method":"DELETE","path":"/wabas/{waba_id}/flows/{flow_id}","summary":"Delete the Flow through Meta's DELETE /{FLOW-ID}. Meta deletes only a DRAFT Flow, and its refusal of any other is 409 Messaging_FlowRefused; a published Flow is retired with /deprecate instead. Answers {success, raw}"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}/assets","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.list grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/flows/{flow_id}/assets","summary":"The Flow's assets as Meta lists them: {assets: [{name, asset_type, download_url}], next_after, truncated, raw}. download_url is Meta's signed link, verbatim"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}/assets","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/flows/{flow_id}/assets","summary":"Replace the Flow's Flow JSON through Meta's asset upload: the body is {flow_json} (the Flow JSON object, or a string holding it, at most 10 MB), sent to Meta as the file flow.json with asset_type FLOW_JSON. Answers Meta's {success, validation_errors, raw}. A refusal names the asset operation, and no error carries the token"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}/publish","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/flows/{flow_id}/publish","summary":"Publish the Flow through Meta's POST /{FLOW-ID}/publish. Meta publishes only a Flow whose validation errors are resolved, and cannot undo it; its refusal is 409 Messaging_FlowRefused. Answers {success, raw}"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}/deprecate","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/flows/{flow_id}/deprecate","summary":"Deprecate the Flow through Meta's POST /{FLOW-ID}/deprecate. Meta says a deprecated Flow cannot be sent, deleted or restored. Answers {success, raw}"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}/messages","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/flows/{flow_id}/messages","summary":"The managed Flow message (201): {phone_number_id, to, mode, flow_cta, body} required, {header, footer, flow_token, flow_action, flow_action_payload} optional. mode is REQUIRED, draft or published, although Meta defaults it to published. The route reads the Flow's owner and Meta's status first: DEPRECATED or BLOCKED is 409 Messaging_FlowNotSendable before any send, and every other status goes to Meta, which decides. Answers {wamid, message_id, mode, flow_status, raw}, where flow_status is Meta's word from that read and is never inferred from mode. Recorded like a Chats reply (origin console, type interactive). The raw gateway still forwards a Flow message a caller writes itself, unchanged"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flow-encryption","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/flow-encryption","summary":"The account's Flow endpoint key and what Meta holds for each business phone number: {source: sambungapi, waba_id, meta_waba_id, key: null | {public_key_pem, public_key_sha256, created_at}, phone_numbers: [{phone_number_id, business_public_key_signature_status, meta_public_key_sha256, matches_custody, raw}], observed_at}. One Graph read per number, GET /{PHONE_NUMBER_ID}/whatsapp_business_encryption; business_public_key_signature_status is Meta's word (VALID or MISMATCH) and matches_custody is true only when Meta's key is the key Sambungapi decrypts with. The private key has no field. Stores nothing"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flow-encryption","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"POST","path":"/wabas/{waba_id}/flow-encryption","summary":"Hold a 2048-bit RSA business key pair for the account and sign its public half onto EVERY business phone number of the account with POST /{PHONE_NUMBER_ID}/whatsapp_business_encryption (form field business_public_key). The pair is minted once (201, created: true); a later call signs the same pair again (200, created: false), which is what Meta asks for after a number is re-registered. Meta keeps one business public key per number, so the key is per account and every Flow a number sends is encrypted to it. The first Graph refusal stops the walk and is the answer, naming how many numbers were signed before it; the key stays in custody and a retry signs it again. The private key is stored only as key-ring ciphertext and is never answered"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flow-encryption","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.delete grant (session cookie or cpt_*)","method":"DELETE","path":"/wabas/{waba_id}/flow-encryption","summary":"Retire the account's key pair (204; 404 Messaging_FlowKeyNotFound when none is held). Every endpoint of the account answers Meta 421 until a new pair is minted and signed: DELETE then POST is the rotation"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}/endpoint","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/flows/{flow_id}/endpoint","summary":"One Flow's endpoint as configured: {source: sambungapi, waba_id, meta_waba_id, flow_id, endpoint_uri, handler_url, data_api_version, key: {present, public_key_sha256}, signing_secret: null, created_at, updated_at}. No Graph call. 404 Messaging_FlowEndpointNotFound when none is configured"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}/endpoint","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*)","method":"PUT","path":"/wabas/{waba_id}/flows/{flow_id}/endpoint","summary":"Configure one Flow's endpoint: {handler_url} required (absolute http or https), {rotate_signing_secret} optional, any other field 400 Messaging_InvalidInput. Reads the Flow's whatsapp_business_account from Meta first; another account's Flow is 404 Messaging_FlowNotFound and stores nothing. 201 on the first configuration with the whsec_ signing_secret shown ONCE; 200 on a change of handler_url with signing_secret null, unless rotate_signing_secret is true, which mints and shows a new one. endpoint_uri is the URL to set as the Flow's endpoint_uri (PATCH /wabas/{waba_id}/flows/{flow_id})"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/flows/{flow_id}/endpoint","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.delete grant (session cookie or cpt_*)","method":"DELETE","path":"/wabas/{waba_id}/flows/{flow_id}/endpoint","summary":"Remove one Flow's endpoint and its signing secret (204; 404 Messaging_FlowEndpointNotFound when none). Meta's next request for the Flow answers 404; the Flow's endpoint_uri at Meta is not changed"},{"absolute_path":"/v1/messaging/embedded/exchange","auth":"org_admin authority or above at body.organization_id, or an explicit messaging_waba.create grant (session cookie or cpt_*)","method":"POST","path":"/embedded/exchange","summary":"Embedded Signup onboarding: exchange the popup code, confirm EVERY browser-supplied waba_id/waba_ids entry with the exchanged token, connect each one with the per-WABA webhook override (connections[]), register every activated phone for the Cloud API with a PIN minted for each call and kept nowhere (registration[]), and name the ones it refuses (refused[]); 503 Messaging_EmbeddedSignupUnconfigured without SAMBUNGAPI_META_*, 409 Messaging_PhoneAlreadyConnected when another Organization holds one of the account's activated business phone numbers through Embedded Signup (that id alone is refused). Optional rerun_waba_id (wb_ id) makes it a re-run that repairs only that account: 404 Messaging_WabaNotFound when the Organization does not hold it, 409 Messaging_RerunWabaMismatch before the code is spent when the completion does not name the account's Meta WABA id, and every other Meta id it names lands in refused[] with that slug and is not read at Meta. After the router timeout the exchange continues; the same request sent again within 10 minutes waits for it and answers its outcome (504 Request_Timeout says so)."},{"absolute_path":"/v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/register","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*); {phone_number_id} must belong to THAT WABA, not merely to the same organization","method":"POST","path":"/wabas/{waba_id}/phones/{phone_number_id}/register","summary":"Register a phone for Cloud API use via the stored credential; pin is optional (without it a 6-digit PIN is minted for the call, sent and kept nowhere), 'already registered' Graph errors are a success, Graph 133005 answers 400 Messaging_PinRequired. reset_pin: true, for a number whose registration is pin_required, sets a new PIN at Meta, keeps it nowhere and registers the number with it; 400 Messaging_InvalidInput for reset_pin together with pin, and for a number that is not pin_required"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/deregister","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*); {phone_number_id} must belong to THAT WABA, not merely to the same organization","method":"POST","path":"/wabas/{waba_id}/phones/{phone_number_id}/deregister","summary":"Release a business phone number from Meta's Cloud API — the reverse of register. Under Meta's new account model a deregistered number stops sending through every provider on it, so a business that moves to another provider or leaves sambungapi removes sambungapi's app in Meta Business Suite instead, and deregisters only a number that no provider keeps. Sambungapi keeps no PIN, so a later register of a number that has a two-step verification PIN answers 400 Messaging_PinRequired: send reset_pin: true to set a new PIN and register, or send the PIN that the number has. NO request body: Meta's own edge takes none, so a caller may POST with no Content-Type at all. Idempotent by the same rule its reverse uses: a 'not registered' answer from Meta is 200 {deregistered: true, already: true}"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/request-code","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*); {phone_number_id} must belong to THAT WABA, not merely to the same organization","method":"POST","path":"/wabas/{waba_id}/phones/{phone_number_id}/request-code","summary":"Step 2 of Meta's four-step registration: ask Meta to send a verification code to the business phone number. Body {code_method: SMS|VOICE, language}, both required. code_method is trimmed and upper-cased before it is matched and anything outside those two values is 400 Messaging_InvalidInput before any Graph call; language is forwarded VERBATIM, deliberately neither defaulted nor whitelisted"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/verify-code","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*); {phone_number_id} must belong to THAT WABA, not merely to the same organization","method":"POST","path":"/wabas/{waba_id}/phones/{phone_number_id}/verify-code","summary":"Step 3 of the four-step registration, and the step that makes the number verified: hand Meta back the code it sent. Body {code}, trimmed, non-empty and all ASCII digits, else 400 before any Graph call; no length is pinned, because the code is generated AT META. 200 {verified: true}. A code Meta will not accept stays an ERROR and never already: true — reading it as success would turn a possession check into a no-op"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/sync-request","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*); {phone_number_id} must belong to THAT WABA, not merely to the same organization","method":"POST","path":"/wabas/{waba_id}/phones/{phone_number_id}/sync-request","summary":"Request again: re-send one Coexistence sync request Meta did not accept. Body {sync_type: smb_app_state_sync | history}, anything else 400 Messaging_InvalidInput before any Graph call. Allowed only while the run has not ended, Meta has not accepted it, the 24-hour window counted from sambungapi's first request for the number is open, and no earlier request awaits its answer — checked in one statement on the database clock, so two clicks cannot both send. Refusals: 404 Messaging_SyncRunNotFound (no run of that type), 409 Messaging_SyncWindowClosed, 409 Messaging_SyncNotRetryable. Calls POST /{phone_number_id}/smb_app_data with the stored credential and records Meta's answer on the run first; 200 answers the run, and a Graph refusal takes the stored-credential Graph error tail with the refusal already recorded"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/business-profile","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*); {phone_number_id} must belong to THAT WABA, not merely to the same organization","method":"GET","path":"/wabas/{waba_id}/phones/{phone_number_id}/business-profile","summary":"The business profile Meta last reported for this business phone number, with NO Graph call: {source: meta_graph, waba_id, phone_number_id, observed_at, profile, unknown_fields, raw}. profile holds about, address, description, email, websites, vertical and profile_picture_url projected from the stored answer; raw is Meta's whole answer; unknown_fields names every field Meta sent that is not projected. Before any refresh or update, observed_at, profile and raw are null. See business_profile for the rules"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/business-profile/refresh","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*); {phone_number_id} must belong to THAT WABA, not merely to the same organization","method":"POST","path":"/wabas/{waba_id}/phones/{phone_number_id}/business-profile/refresh","summary":"Read the business profile from Meta with the stored credential (GET /{phone_number_id}/whatsapp_business_profile) and STORE Meta's whole answer as the number's last observation, stamped with the instant it arrived. No body. Answers the same shape as the GET. A Graph refusal stores nothing and takes the stored-credential Graph error tail: 190 flips the WABA to EXPIRED, 200/10 to ACCESS_LOST"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/business-profile","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*); {phone_number_id} must belong to THAT WABA, not merely to the same organization","method":"PATCH","path":"/wabas/{waba_id}/phones/{phone_number_id}/business-profile","summary":"Update the business profile at Meta (POST /{phone_number_id}/whatsapp_business_profile), then read it back and store Meta's answer. Body: a JSON object of any of about, address, description, email, websites, vertical and profile_picture_handle; a field left out is left as it is. A field Meta does not take, a wrong type, a text past Meta's limit, a third website, a website without http:// or https://, or a vertical outside Meta's list is 400 Messaging_InvalidInput before any Graph call (see business_profile.fields). Answers what Meta reports AFTER the update, never an echo of the body. A Graph refusal takes the stored-credential Graph error tail; a read-back that fails after Meta accepted the update keeps that tail and says the update was accepted. There is no DELETE: Meta deletes a business profile only by deleting the phone number"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/quality","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/quality","summary":"The quality rating of EVERY business phone number of this account, as last read from Meta, with NO Graph call: {source: meta_graph, waba_id, max_age_seconds, phones[]}, one entry per number in phone number id order, each the object GET /wabas/{waba_id}/phones/{phone_number_id}/quality answers. A number nobody read is present with quality_status not_observed. See quality for the rules"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/quality","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*); {phone_number_id} must belong to THAT WABA, not merely to the same organization","method":"GET","path":"/wabas/{waba_id}/phones/{phone_number_id}/quality","summary":"The quality rating Meta last reported for this business phone number, with NO Graph call: {source: meta_graph, waba_id, phone_number_id, display_phone_number, quality_rating, quality_status, checked_at, expires_at, max_age_seconds, unknown_fields, raw}. quality_rating is Meta's token (GREEN, YELLOW, RED, NA or UNKNOWN) as read at checked_at, or null when the read carried none this build can read; quality_status is the verdict to branch on, and it is high only for a GREEN read younger than max_age_seconds. It is never derived from the account's can_send_message or from the messaging limit. See quality for the rules"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/quality/refresh","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.update grant (session cookie or cpt_*); {phone_number_id} must belong to THAT WABA, not merely to the same organization","method":"POST","path":"/wabas/{waba_id}/phones/{phone_number_id}/quality/refresh","summary":"Read the number's quality rating from Meta with the stored credential (GET /{phone_number_id}?fields=quality_rating) and STORE Meta's whole answer as the number's last read, stamped with the instant it arrived. No body. Answers the same shape as the GET. An answer with no readable rating is stored and answers quality_status unreadable, never a failure. A Graph refusal stores nothing and takes the stored-credential Graph error tail: 190 flips the WABA to EXPIRED, 200/10 to ACCESS_LOST"},{"absolute_path":"/v1/messaging/wabas/{waba_id}/account-changes","auth":"org_admin authority or above at the organization that owns {waba_id}, or an explicit messaging_waba.read grant (session cookie or cpt_*)","method":"GET","path":"/wabas/{waba_id}/account-changes","summary":"The Account changes Meta reported for this account by webhook, newest first: {source: meta_webhook, waba_id, changes[]}. Each change is one captured phone_number_quality_update, business_capability_update, account_update (not its partner events) or account_alerts change: {id, field, event, entity, entry_id, phone_number_id, values, unknown_fields, raw, occurred_at, received_at}. Query: field (one of the four), phone_number_id (only changes resolved to that number of this account), limit (1 to 200, default 50); any other parameter, a repeated one, or a value outside those is 400 Messaging_InvalidInput. No change moves the quality rating or the messaging limit. See quality.account_changes for the rules"},{"absolute_path":"/v1/messaging/gateway/me","auth":"warta_key","method":"GET","path":"/gateway/me","summary":"Non-destructive key probe: what this Warta key can send from — {organization_id, scope, scope_phone_number_id, phone_number_ids, waba_id, waba_ids} in the {data, meta} envelope. Nothing written, Graph never called; a phone-scoped key answers 200, never Messaging_ScopeDenied"},{"absolute_path":"/v1/messaging/gateway/{phone_number_id}/messages","auth":"warta_key","method":"POST","path":"/gateway/{phone_number_id}/messages","summary":"Wire gateway: forward a JSON message payload verbatim to Graph; Meta's status + body pass through verbatim. It never gates or alters the forward, and it records what it forwarded after Meta answers (issue #1134): an accepted send becomes an outbound message with origin gateway and no sent_by, shown in the Chats thread; a refused one (a Meta refusal, no answer, or a 2xx with no message id) becomes a send failure carrying a reason slug and Meta's numeric code, never Meta's error message. A payload naming no recipient (mark-as-read, typing) records nothing, and a record that fails to land never changes the answer"},{"absolute_path":"/v1/messaging/gateway/{phone_number_id}/messages/validate","auth":"warta_key","method":"POST","path":"/gateway/{phone_number_id}/messages/validate","summary":"Local check on one message payload: seven checks — payload shape, the 4,096-character text bound and the recipient's leading +, the customer service window of this contact on this number from stored live inbound rows (a message the history sync backfilled opens none), the stored message template's approval status, a date-bound cost line, and pacing against Meta's six-second pair window from the Send record (accepted Sends to this contact from this number decide it; the newest attempt, a refusal's slug and code included, is reported beside it; sambungapi's own Graph sends only), and the responder of the conversation under Meta's Conversation Routing (issue #1897: passes for null, this_platform or a template payload, fails for another or none) — answered as {checks, window, template?, pacing, responder, remote_checks} in the {data, meta} envelope. Graph is never called and nothing is written, so remote_checks is always not_run; a FAILING CHECK IS A 200, not a refusal. Refusals are the send's, unchanged. The send never consults it: it is a separate route precisely so that a missing one sends nothing"},{"absolute_path":"/v1/messaging/gateway/{phone_number_id}/contacts/{contact}","auth":"warta_key","method":"GET","path":"/gateway/{phone_number_id}/contacts/{contact}","summary":"One contact and its customer service window: {contact, name, profile_name, display_name, app_contact_name, labels, note, state, window_closes_at, last_inbound_at, responder} in the {data, meta} envelope, where state is open, closed or unknown. unknown means no live inbound message from this WhatsApp user to this business phone number is stored (a message the history sync backfilled opens no window) — a 200, never a 404, and never a claim that Meta has none; both timestamps are null there. Computed by the one chats::contact_window the Chats projections and the local check already call, so the three surfaces cannot disagree, and per (contact, business phone number) exactly as Chats computes each conversation's window: a message the contact sent to a sibling number of the account opens nothing here (issue #1245). The Contact fields are decision #942's extension to THIS route: name falls back display_name then app_contact_name then profile_name then the number and is never null, a contact no traffic has created yet answers every name null with labels empty, and a warta key is READ-ONLY for contact data — display_name, labels and note are edited on the control plane. responder (issue #1897) is this_platform, another, none or null: which responder holds this conversation under Meta's Conversation Routing, null when the path's number has no Conversation Routing evidence, by the one derivation the Chats reads and the local check call. Labels, notes and the app_contact_name never enter the subscriber fan-out envelope. Graph is never called and nothing is written. {contact} takes the same key the Chats routes take, a leading + tolerated; refusals are the send's, unchanged"},{"absolute_path":"/v1/messaging/gateway/{phone_number_id}/media/{media_id}","auth":"warta_key","method":"GET","path":"/gateway/{phone_number_id}/media/{media_id}","summary":"Resolve an inbound media handle to the BYTES: Meta's object is answered raw and unenveloped, with its media type on Content-Type, its digest on X-Sambungapi-Media-Sha256 (Meta's own claim, unverified), and Content-Disposition: attachment plus nosniff. 404 Messaging_MediaNotFound for a handle Meta no longer holds (a handle expires 30 days after its message) and 413 Messaging_MediaTooLarge past SAMBUNGAPI_MAX_UPLOAD_BYTES; refusals are enveloped"},{"absolute_path":"/v1/messaging/gateway/{phone_number_id}/media","auth":"warta_key","method":"POST","path":"/gateway/{phone_number_id}/media","summary":"Upload bytes as a media object on this business phone number and get the media id a send names under image: {id}. The body IS the media and Content-Type declares its type; the id expires 30 days after the upload and nothing is stored here. Meta's own {id} answers unenveloped like the send's body; refusals are enveloped, with 413 Messaging_MediaTooLarge past SAMBUNGAPI_MAX_UPLOAD_BYTES"}],"surface":"messaging","version":"v1","webhooks":[{"absolute_path":"/webhooks/meta/{waba_id}","auth":"per-WABA verify token","method":"GET","path":"/webhooks/meta/{waba_id}","summary":"Meta webhook verification challenge — echoes hub.challenge as plain text and stamps webhook-verified"},{"absolute_path":"/webhooks/meta/{waba_id}","auth":"X-Hub-Signature-256 (HMAC-SHA256 over raw bytes)","method":"POST","path":"/webhooks/meta/{waba_id}","summary":"Signed Meta event receiver — splits the verified body into Inbound events (one per status, per inbound message, and per template status, Coexistence or Account change) and STAGES them in ONE transaction, deduplicated on (channel, business address, dedupe key), before it answers 200; it answers 500 only when that stage does not commit (issue #1485). The inbound event lane in sambungapi worker then does the ingest per event: message statuses (dedup on wamid+status) and inbound messages (dedup on wamid) with the per-endpoint subscriber fan-out in one transaction; the three Coexistence fields (smb_message_echoes, smb_app_state_sync, history — routed on the outer changes[].field) captured on a delivery digest, checkpointing their Sync run, and never fanned out. Each smb_message_echoes item is stored in the capture's own transaction as an outbound message with origin business_app and no sent_by (first writer wins on wamid), creating the Contact and conversation when absent and never moving their state, assignment, unread count or customer service window. A history chunk becomes origin history evidence and resolved, unassigned conversations, and a live inbound message takes over a history row that holds its wamid. A failed event is retried with a backoff, then marked failed for an operator to requeue. The two Account change fields Meta may send here (phone_number_quality_update, business_capability_update) are captured the same way when entry[].id is this account's Meta id, and logged, not captured, when it names another account (see quality.account_changes). Conversation Routing (issue #1897): each messaging_handovers change is one handover event and each item of standby.messages, standby.message_echoes and standby.statuses one standby event; the lane writes the conversation's responder signal and stores a standby message as an inbound message with standby true (no Delivery, never unread, no state change), a standby echo as an outbound message with origin other_app and no sent_by, and a standby status as its status. Neither field queues a Delivery"},{"absolute_path":"/webhooks/meta/app","auth":"SAMBUNGAPI_META_VERIFY_TOKEN","method":"GET","path":"/webhooks/meta/app","summary":"App-level Meta webhook challenge for the platform app (403 when unconfigured; no storage side effect)"},{"absolute_path":"/webhooks/meta/app","auth":"X-Hub-Signature-256 (HMAC-SHA256 with SAMBUNGAPI_META_APP_SECRET)","method":"POST","path":"/webhooks/meta/app","summary":"App-level account_update receiver — PARTNER_ADDED acknowledged, PARTNER_REMOVED/PARTNER_APP_UNINSTALLED staged once per mirror of the WABA that value.waba_info.waba_id names, or that entry[].id names when the value has no waba_info (a Coexistence disconnect), and the inbound event lane flips each platform-custody mirror to ACCESS_LOST and records the cause with Meta's disconnection_info (#1837, decided in #1880). Every other account_update event, and every account_alerts, phone_number_quality_update and business_capability_update change, is staged under each mirror of the WABA entry[].id names before the answer and captured by the lane as an Account change, deduplicated on a delivery digest; the receiver answers 500 only when the stage does not commit (issue #1485; see quality.account_changes)"},{"absolute_path":"/webhooks/meta/{waba_id}/flows/{flow_id}","auth":"X-Hub-Signature-256 (HMAC-SHA256 over raw bytes with the account's app secret; 432 when it fails) plus the account's Flow endpoint key","method":"POST","path":"/webhooks/meta/{waba_id}/flows/{flow_id}","summary":"The WhatsApp Flow endpoint (issue #1142): verify Meta's signature, decrypt with the account's business key (421 when it cannot), check version 3.0 and the action (400), forward the decrypted request to the endpoint's handler_url signed with its whsec_ secret, and answer the handler's answer encrypted with the request's key and the flipped initial vector as base64 text/plain. See flow_endpoint"}],"webhooks_path_rule":"webhooks[] are mounted at the server root, outside base_path: absolute_path = path"},"meta":{"request_id":"req_XXV7nehFqID6Rn8d","vocabulary_version":"584af6dde90e"}}