Docs
The SambungAPI API
Two calls do the work: execute a tool, and send a WhatsApp message. Both are plain HTTP requests, so any language can make them. Make them from your server, not from a web page.
On this page
Mint a key first
SambungAPI has one kind of key for your software, the API key. Your organization owns it, and it has a Tools part, a channel part, or both. A route accepts a key that has the part the route needs. A key without that part gets 403 Auth_KeyOutOfReach, which names the missing part. This page names a key by its part. A project key is the credential your software uses to run tools for one project. A Warta key is the credential your software uses to send WhatsApp messages through SambungAPI. It belongs to your organization, it can be limited to one business phone number, and SambungAPI shows it only once. Its channel part reaches the whole organization, one channel, or one business address.
| Part | What it opens | Where to mint a key with it |
|---|---|---|
| Tools | The tools surface under /v1/tools, /mcp and the file routes of its project. | The console, at /app/projects/{project_id}/keys. An agent asks a person for one with a key request (below). Over the API: POST /v1/control/projects/{project_id}/keys with a personal control-plane token (cpt_). POST /v1/control/organizations/{organization_id}/keys with tools mints one too. |
| Channel | The WhatsApp wire gateway under /v1/messaging/gateway, the subscription, delivery and Send logs, and the channel API under /v1/channels. | The console, at /app/business-accounts. Over the API: POST /v1/control/organizations/{organization_id}/keys with channel, or POST /v1/messaging/wabas/{waba_id}/keys. |
One key can hold both parts: mint it with POST /v1/control/organizations/{organization_id}/keys and a body that names tools and channel. Add or remove a part later with PATCH /v1/control/organizations/{organization_id}/keys/{key_id}. A key that this route mints starts with ak_. A key that the WhatsApp and channel key routes mint starts with ww_, and it is the same kind of key. Send a key as Authorization: Bearer <key> or in the x-api-key header, never both. GET /v1/key answers the calling key's id, name, masked value, organization and parts, and never the secret. No key reaches a route under /v1/control.
A Control-plane token (cpt_) is your own credential for the control routes, and for the routes on this page that name it. Create one in the console: open the account menu and select Control-plane tokens. SambungAPI shows it only once, and it expires 30 days after you create it.
To mint, list or revoke a key, the caller needs organization admin authority or above in the organization, or a custom role: project_key.create, project_key.list or project_key.delete on the project of a Tools part, and messaging_waba.create, messaging_waba.list or messaging_waba.delete for a channel part. Adding a part needs the permission of that part, and removing a part needs the permission over that part only. Only an organization owner can give a project_key permission to another member.
The raw value of each key is shown one time, when it is minted. The server keeps only a hash of it, so a lost key is replaced, never recovered.
Keep a key on a server. The hosted service allows no other origin, so a browser refuses a call from a page on another origin. Anyone who loads a web page can also read a key that is in it.
An agent with no key: the key request
The console is for persons. Do not create an account with a script: the Terms forbid it. An agent asks a person for a project key: it starts a key request, gives the person a link, and polls until the person answers.
- Send
POST /v1/control/key_requestswith{"client_name": "Release bot"}. No credential is needed. To name the toolkits that your agent needs, add "toolkits": ["gmail", "googlecalendar"]. After Allow, the page lets the person connect their own account of each of them. One Google Workspace consent covers Google Drive, Docs, Sheets, Slides and Meet. The answer carriesdevice_code,user_code,verification_uri,verification_uri_complete,expires_in(600) andinterval(5). - Give the person
verification_uri_complete, and show themuser_code. The person signs in, checks that the page shows the same code, chooses a project and selects Allow. By default the page makes a new project with the name of your agent. - Every
intervalseconds, sendPOST /v1/control/key_requests/tokenwith{"device_code": "…"}. Keepdevice_codesecret: it collects the key.
| Answer | Meaning |
|---|---|
202 | The person has not answered. Poll again after interval seconds. |
429 KeyRequest_SlowDown | You polled too soon. Wait Retry-After seconds. |
200 | data.api_key is the new project key, shown this one time. data.key is its metadata, and data.project_id its project. |
403 KeyRequest_Denied | The person selected Deny, or the person who allowed the request can no longer create keys in its project. |
400 KeyRequest_Expired | The request expired, its key was already collected, or it does not exist. Start a new one. |
500 or 503 | The server could not create the key. The request stays allowed: poll again after interval seconds. |
When the person connected a toolkit on the key request page, run its tools with the user id data.key.created_by of the poll answer: the account is that person's own. That account is deleted when the person leaves the organization or deletes their account. Over MCP, put the same value in the user_id of the server URL. When the project shares an account of a toolkit and the person did not connect their own, send the user id org:<organization_id>, where <organization_id> is data.key.organization_id.
const post = (path, body) => fetch(`https://sabi.metatech.id/v1/control/key_requests${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
const request = (await (await post("", { client_name: "Release bot" })).json()).data;
console.log(`Open ${request.verification_uri_complete} and check the code ${request.user_code}.`);
let answer;
do {
await new Promise((wait) => setTimeout(wait, (Number(answer?.headers.get("retry-after")) || request.interval) * 1000));
answer = await post("/token", { device_code: request.device_code });
} while (answer.status === 202 || answer.status === 429 || answer.status >= 500);
const apiKey = await answer.json().then(({ data, error }) => { if (error) throw new Error(error.slug); return data.api_key; }); // shown this one time
The key belongs to the project that the person chose. It does not expire, and a member revokes it on the project's API keys page like any other key.
An agent with no token: machine sign-in
An agent with no project key can ask a person for one with a Key request, or start machine sign-in and ask the person to approve one link. A Key request returns one project key. Machine sign-in returns a Control-plane token for the approving person; the agent can use it to mint project keys. Each Control-plane token expires after 30 days, but the agent can mint another one. Project keys do not expire by default. A Control-plane token cannot call Tools or /mcp; the agent still needs a project key and a Connected account.
Machine sign-in has the steps of the key request, on its own routes. Send POST /v1/control/machine_sign_ins with {"client_name": "Release bot"}, give the person verification_uri_complete and show them user_code. The person signs in, checks the code and selects Allow machine sign-in; the page names no project, because a Control-plane token is not scoped to one. Every interval seconds, send POST /v1/control/machine_sign_ins/token with {"device_code": "…"}. The answers are those of the table above, with the slugs MachineSignIn_SlowDown, MachineSignIn_Denied and MachineSignIn_Expired. The 200 carries the token in data.personal_token, shown this one time, with its metadata beside it.
The token is the approving person's own: it can use the control routes that the person can use, except the routes that need a browser session or a fresh sign-in, and it can mint more Control-plane tokens and project keys. The agent names no principal, and POST /v1/control/tokens still mints only for its caller. Machine sign-in connects no toolkit.
Connect an application user's account
A tool executes against a connected account: the provider authorization of one application user for one toolkit. An application user with no connected account for the toolkit cannot execute its tools, and the execute call answers 404 ActionExecute_ConnectedAccountNotFound. The steps below take an application user from no connected account to an ACTIVE one. Every call uses the project key, the same credential as the execute call.
The requests in this section are shapes read from the server code, and route tests pin them. They were not run against sabi.metatech.id, so each curl block says "shape verified; not executed". The first line of each TypeScript and Python block says how far that block was checked.
Step 1: create an auth config
POST /v1/tools/auth_configs
An auth config names a toolkit and the OAuth application that its connections use. It belongs to the organization of the project whose key creates it, so each project of that organization can use it. Create one for each toolkit, not one for each application user. GET /v1/tools/auth_configs lists the organization's auth configs.
# shape verified; not executed
curl -sS -X POST \
"https://sabi.metatech.id/v1/tools/auth_configs" \
-H "Authorization: Bearer ak_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{
"toolkit": { "slug": "gmail" },
"type": "use_composio_managed_auth"
}'
The body fields:
| Field | Required | Meaning |
|---|---|---|
toolkit | yes | An object, {"slug": "gmail"}. A bare string answers 400 BadRequest. |
type | yes | use_composio_managed_auth uses the OAuth application that SambungAPI holds for the toolkit's provider. use_custom_auth uses your own OAuth application. |
auth_scheme | no | For use_custom_auth only. The default is OAUTH2. |
credentials | for use_custom_auth | {client_id, client_secret, scopes?}. SambungAPI encrypts the secret and never returns it. |
The answer is 201. Keep data.auth_config.id:
{
"data": {
"toolkit": { "slug": "gmail" },
"auth_config": {
"id": "ac_xxxxxxxx",
"auth_scheme": "OAUTH2",
"is_composio_managed": true,
"restrict_to_following_tools": []
}
},
"meta": { "request_id": "req_xxxxxxxx", "vocabulary_version": "..." }
}
use_composio_managed_auth for a toolkit that has no OAuth application on this deployment answers 400 Toolkit_NotConnectable. GET /v1/tools/toolkits shows which toolkits you can connect: an item with available: true does not get this refusal. use_custom_auth with your own OAuth application applies only to a toolkit whose managed_provider is google or microsoft: its connect link sends the application user to that provider's consent page. A toolkit whose managed_provider is null has no connect flow on this deployment.
Replace the client secret of an auth config
PATCH /v1/tools/auth_configs/{id}
Send type with the kind of the auth config: custom for your own OAuth application, default for a managed one. A custom auth config takes name and credentials with client_id, client_secret and scopes. A field that you do not send stays as it is.
# shape verified; not executed
curl -sS -X PATCH \
"https://sabi.metatech.id/v1/tools/auth_configs/ac_xxxxxxxx" \
-H "Authorization: Bearer ak_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{"type": "custom", "credentials": {"client_secret": "NEW_CLIENT_SECRET"}}'
The answer is 200, with data.success set to true and data.message set to "Auth config ac_xxxxxxxx updated.". Each Connected account uses the new secret at its next refresh, and nobody connects again.
The client ID changes only while no connected account uses the auth config: otherwise the answer is 409 AuthConfig_InUse. A field that this server does not store, such as sealed_credentials, answers 400 AuthConfig_FieldNotSupported and changes nothing. Only a project key can update an auth config: a browser session answers 403 AuthConfig_SessionRefused.
When the provider refuses the client secret at a refresh (invalid_client), the connected account keeps its status, and the error names the auth config to update.
Delete an auth config
DELETE /v1/tools/auth_configs/{id}
The delete removes an auth config of your organization, with the connected accounts of the key's project that use it. Its encrypted client secret goes with it. Only a project key can delete an auth config: a browser session answers 403 AuthConfig_SessionRefused. An unknown id, or an id of another organization, answers 404 AuthConfig_NotFound.
# shape verified; not executed
curl -sS -X DELETE \
"https://sabi.metatech.id/v1/tools/auth_configs/ac_xxxxxxxx" \
-H "Authorization: Bearer ak_xxxxxxxxxxxxxxxx"
The answer is 200, with data.success set to true and data.message set to "Auth config ac_xxxxxxxx deleted, with 2 connected accounts.". Add ?revoke_on_delete=true to ask the provider to end the grant of each deleted connected account first, as on DELETE /v1/tools/connected_accounts/{id}. The outcome of that call does not stop the delete and is not in the answer.
When a connected account of another project of your organization uses the auth config, the delete removes nothing and answers 409 AuthConfig_InUse. Delete those connected accounts with a key of their project, then delete the auth config again.
Step 2: create a connect link
POST /v1/tools/connected_accounts/link
# shape verified; not executed
curl -sS -X POST \
"https://sabi.metatech.id/v1/tools/connected_accounts/link" \
-H "Authorization: Bearer ak_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{
"user_id": "user-123",
"auth_config_id": "ac_xxxxxxxx"
}'
// TypeScript — fetch; run against a local build of this server
const response = await fetch("https://sabi.metatech.id/v1/tools/connected_accounts/link", {
method: "POST",
headers: {
"Authorization": "Bearer ak_xxxxxxxxxxxxxxxx",
"content-type": "application/json",
},
body: JSON.stringify({
"user_id": "user-123",
"auth_config_id": "ac_xxxxxxxx"
}),
});
const envelope = await response.json();
if (response.ok) {
console.log(envelope.data.redirect_url, envelope.data.connected_account_id);
} else {
console.log(response.status, envelope.error.slug); // branch on slug, never on message
}
# Python — requests; run against a local build of this server
import requests
response = requests.post(
"https://sabi.metatech.id/v1/tools/connected_accounts/link",
headers={"Authorization": "Bearer ak_xxxxxxxxxxxxxxxx"},
json={
"user_id": "user-123",
"auth_config_id": "ac_xxxxxxxx"
},
)
envelope = response.json()
if response.ok:
print(envelope["data"]["redirect_url"], envelope["data"]["connected_account_id"])
else:
print(response.status_code, envelope["error"]["slug"]) # branch on slug, never on message
| Field | Required | Meaning |
|---|---|---|
user_id | yes | The application user. Use the same value in the execute body. |
auth_config_id | yes | The id from step 1. An auth config of another organization, or an unknown id, answers 404 AuthConfig_NotFound. |
The answer is 201:
{
"data": {
"link_token": "lk_xxxxxxxx",
"redirect_url": "https://sabi.metatech.id/connect/lk_xxxxxxxx",
"expires_at": "2026-09-24T10:10:00+00:00",
"connected_account_id": "ca_xxxxxxxx",
"experimental": { "account_type": "PRIVATE" }
},
"meta": { "request_id": "req_xxxxxxxx", "vocabulary_version": "..." }
}
The call creates the connected account immediately, with the status INITIALIZING. Keep connected_account_id. The link expires ten minutes after it is created, at expires_at. When a link expires, create a new one: each link creates a new connected account.
Step 3: send the application user to redirect_url
Open redirect_url in the application user's browser. It answers 303 to the provider's consent page.
The request carries no return URL, so the application user does not come back to your application after consent. The provider sends the browser to the OAuth callback of SambungAPI, and the callback answers 303 to the return page of the deployment. On sabi.metatech.id the return page is:
https://sabi.metatech.id/callback?status=success&connected_account_id=ca_xxxxxxxx
https://sabi.metatech.id/callback?status=error&error=...
This page is part of the SambungAPI console, not of your application. It shows the result, then offers the console's connected accounts page, which needs a console sign-in. After a success it goes to that page by itself. Tell the application user to go back to your application after consent, and read the status there.
Step 4: read the status
# shape verified; not executed
curl -sS \
"https://sabi.metatech.id/v1/tools/connected_accounts?user_ids=user-123" \
-H "Authorization: Bearer ak_xxxxxxxxxxxxxxxx"
data is a list of the project's connected accounts for those application users. user_ids takes a comma-separated list. Each item has id, user_id, toolkit.slug and status. The status is INITIALIZING until the consent completes, and ACTIVE after it. Execute only when the status is ACTIVE.
Step 5: share one account in a project
Every toolkit connects one account for each application user. To share one account in a project, such as a team mailbox or a shared drive, create the connect link in step 2 with user_id org:<organization_id>, and use the same value in the execute body. <organization_id> is the id of the organization that owns the project of your key. Every caller of the project that sends that user id runs tools on that account, and no other project reaches it. Over MCP, name org:<organization_id> as the user id in the server URL.
Every user_id other than org:<organization_id> can get one of these refusals, all 400:
| Slug | When |
|---|---|
ScopePolicy_OrganizationMismatch | The user_id starts with org: but is not org:<organization_id>. This refusal applies to every toolkit. |
ScopePolicy_OrgShared | The deployment's operator made the toolkit shared in each project, and the user_id is not org:<organization_id>. |
ScopePolicy_UnsupportedPersonal | The toolkit is not in the tool catalog. |
A user_id that is the id of a person in your organization, such as the id that the Developer console files an account under, is that person's own. Only that person uses it: from their own console session, or with a project key that they created. A key that another person created gets 403 Auth_UserIdUnauthorized for a link under that id, and the execute answers 404 ActionExecute_ConnectedAccountNotFound. Use your own application user ids.
Step 6: execute
Put the user_id from step 2 in the execute body, and optionally the connected_account_id from the link answer:
{
"arguments": { "recipient_email": "someone@example.com", "subject": "Hi", "body": "Hello" },
"user_id": "user-123",
"connected_account_id": "ca_xxxxxxxx"
}
Without connected_account_id, the call uses the application user's ACTIVE account for the toolkit. With it, user_id must be the application user that the link named: for another user_id the call answers 404 ActionExecute_ConnectedAccountNotFound. The next section gives the whole execute call.
Find a tool and its arguments
GET /v1/specs/catalog needs no credential. It lists each toolkit and each tool with its input schema, so you can choose a tool and write its arguments before you hold a project key:
GET /v1/specs/catalog?toolkit_slug=gmail
data.toolkits has the same items as GET /v1/tools/toolkits, and data.tools has the same items as GET /v1/tools. It takes the same three filters as GET /v1/tools: search, toolkit_slug and limit. A platform that this deployment treats as a channel and not as a toolkit is not in the catalog, and its tools are not in it either. data.execute names the next step: POST /v1/tools/{slug}/execute with a project key in Authorization: Bearer.
With a project key, GET /v1/tools?search= searches the same catalog, and GET /v1/tools/{slug} reads one tool.
The arguments of an execute follow the input_parameters of the tool: a JSON Schema that names each field, its type, and which fields are required.
available: true on a toolkit means that this deployment offers a managed connection for that toolkit. The catalog has no Organization, so an auth config that an Organization made does not change available.
Execute a tool
The path
The execute route is:
POST /v1/tools/{slug}/execute
/v1/tools is the surface. /{slug}/execute is the route inside it: the tool slug, then the word execute.
GET /openapi.json lists this path as written above, with the request body, the success schema and the error responses, so a client generated from it has a typed execute call. GET /v1/specs/tools names the same route: every route entry there carries a precomputed absolute_path, and the rule is absolute_path = base_path + path. Send requests to absolute_path, never to path alone and never to /v1 joined to path.
POST /v1/tools/execute/{slug}, with the slug after execute, is not a route. It answers 404 with the slug Route_NotFound, and its suggested_fix names the correct absolute path.
The old paths
Three routes had other paths before. The old paths were removed: each one answers 404 with the slug Route_NotFound, and its suggested_fix names the path to call.
| Call | Removed old path | Path |
|---|---|---|
| List the tools | GET /v1/tools/tools | GET /v1/tools |
| Read one tool | GET /v1/tools/tools/{slug} | GET /v1/tools/{slug} |
| Execute a tool | POST /v1/tools/tools/execute/{slug} | POST /v1/tools/{slug}/execute |
A call that ran on the old execute path keeps that path in its Execution log row. A repeat of its idempotency key on the new path replays it.
The request
The credential is an API key with a Tools part, sent as Authorization: Bearer ak_.... The x-api-key header is also accepted.
curl -sS \
"https://sabi.metatech.id/v1/tools/GMAIL_SEND_EMAIL/execute" \
-H "Authorization: Bearer ak_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{
"arguments": {
"recipient_email": "someone@example.com",
"subject": "Hi",
"body": "Hello"
},
"user_id": "user-123",
"connected_account_id": "ca_xxxxxxxx"
}'
// TypeScript — fetch; run against a local build of this server
const response = await fetch("https://sabi.metatech.id/v1/tools/GMAIL_SEND_EMAIL/execute", {
method: "POST",
headers: {
"Authorization": "Bearer ak_xxxxxxxxxxxxxxxx",
"content-type": "application/json",
},
body: JSON.stringify({
"arguments": {
"recipient_email": "someone@example.com",
"subject": "Hi",
"body": "Hello"
},
"user_id": "user-123",
"connected_account_id": "ca_xxxxxxxx"
}),
});
const envelope = await response.json();
if (response.ok) {
console.log(envelope.data.successful, envelope.data.data); // data.data is the tool's own result
} else {
console.log(response.status, envelope.error.slug); // branch on slug, never on message
}
# Python — requests; run against a local build of this server
import requests
response = requests.post(
"https://sabi.metatech.id/v1/tools/GMAIL_SEND_EMAIL/execute",
headers={"Authorization": "Bearer ak_xxxxxxxxxxxxxxxx"},
json={
"arguments": {
"recipient_email": "someone@example.com",
"subject": "Hi",
"body": "Hello"
},
"user_id": "user-123",
"connected_account_id": "ca_xxxxxxxx"
},
)
envelope = response.json()
if response.ok:
print(envelope["data"]["successful"], envelope["data"]["data"]) # data.data is the tool's own result
else:
print(response.status_code, envelope["error"]["slug"]) # branch on slug, never on message
The body fields:
| Field | Required | Meaning |
|---|---|---|
arguments | yes | An object. The tool's own input, as the input_parameters of the tool describe it. GET /v1/specs/catalog has them for every tool. |
user_id | yes | The application user the call runs for. It selects the connected account when connected_account_id is absent. |
version | no | Accepted and ignored. It is kept for callers that send it. |
connected_account_id | no | A specific connected account, when the user has more than one for the toolkit. |
Unknown fields are ignored.
The response
A tool call that reaches the provider answers 200 in the {data, meta} envelope. The tool's own result is nested inside data:
{
"data": {
"successful": true,
"error": null,
"data": { "message_id": "..." },
"log_id": "log_xxxxxxxx"
},
"meta": {
"request_id": "req_xxxxxxxx",
"vocabulary_version": "..."
}
}
data.successful is the provider's verdict. data.data is the provider's payload. data.log_id names the execution-log row for this call. meta.request_id is the identifier to quote in a support request.
The error envelope
A refusal never uses the envelope above. It answers a non-2xx status with one error object:
{
"error": {
"message": "...",
"code": 404,
"slug": "ActionExecute_ConnectedAccountNotFound",
"status": 404,
"request_id": "req_xxxxxxxx",
"suggested_fix": "..."
}
}
slug is the stable, machine-readable name of the refusal. Branch on slug, never on message. A 400 validation refusal adds an errors array of field-level entries; the key is absent when there is nothing to list.
Two refusals are worth knowing before the first call. A user with no connected account for the toolkit gets 404 ActionExecute_ConnectedAccountNotFound, not a 200 with successful: false; its suggested_fix names POST /v1/tools/connected_accounts/link, and "Connect an application user's account" above gives the steps. A project that has spent its daily execution budget gets 429 Quota_Exceeded, with a Retry-After header. A call that repeats an idempotency key while the first call with that key is still running gets 409 ActionExecute_IdempotencyKeyInUse and does not run the tool; repeat it after the first call ends to get its result. A call that takes longer than 30 seconds gets 504 Request_Timeout with the header x-should-retry: false, but the tool call continues to its end and writes its row in the execution log. Send an X-Idempotency-Key with each call that changes data: a repeat with the same key after the call ends gets its result, and a repeat with no key runs the tool again.
Send a WhatsApp message
The wire gateway is a forwarder, not a wrapper:
POST /v1/messaging/gateway/{phone_number_id}/messages
The credential is an API key with a channel part (a Warta key), as a bearer token. The x-api-key header is also accepted:
curl -sS -X POST \
"https://sabi.metatech.id/v1/messaging/gateway/1234567890/messages" \
-H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "6281234567890",
"type": "text",
"text": { "body": "Hello" }
}'
// TypeScript — fetch; run against a local build of this server
const response = await fetch("https://sabi.metatech.id/v1/messaging/gateway/1234567890/messages", {
method: "POST",
headers: {
"Authorization": "Bearer ww_xxxxxxxxxxxxxxxx",
"content-type": "application/json",
},
body: JSON.stringify({
"messaging_product": "whatsapp",
"to": "6281234567890",
"type": "text",
"text": { "body": "Hello" }
}),
});
const body = await response.json();
if (response.ok) {
console.log(body.messages[0].id); // Meta's own answer, unchanged
} else if (body.error.slug) {
console.log(response.status, body.error.slug); // the gateway's own refusal
} else {
console.log(response.status, body.error.code); // Meta's refusal, unchanged
}
# Python — requests; run against a local build of this server
import requests
response = requests.post(
"https://sabi.metatech.id/v1/messaging/gateway/1234567890/messages",
headers={"Authorization": "Bearer ww_xxxxxxxxxxxxxxxx"},
json={
"messaging_product": "whatsapp",
"to": "6281234567890",
"type": "text",
"text": { "body": "Hello" }
},
)
body = response.json()
if response.ok:
print(body["messages"][0]["id"]) # Meta's own answer, unchanged
elif "slug" in body["error"]:
print(response.status_code, body["error"]["slug"]) # the gateway's own refusal
else:
print(response.status_code, body["error"]["code"]) # Meta's refusal, unchanged
The request body is forwarded to the Meta Graph API verbatim, and Meta's HTTP status and body come back verbatim — success and Graph errors alike. This is a deliberate, documented exception to the {data, meta} envelope: the body you send is the body Meta reads, and the body you read is the body Meta wrote. Write the request against Meta's own message documentation, and parse the response the same way.
Two refusals are the gateway's own, and they do use the error envelope: Messaging_KeyInvalid (401) for a key that is absent, wrong, or revoked, and Messaging_ScopeDenied (403) for a key whose scope does not reach the phone number in the path.
One more refusal can arrive on every gateway route, the key probe included: 409 Messaging_ChannelStopped. It means that SambungAPI stopped your organization's use of WhatsApp because Meta asked it to. No message is sent and no WhatsApp data is read until the stop ends, and a retry does not help. Your data is kept. To ask about the stop, write to info@metatech.id.
After Meta answers, SambungAPI records the send. It never changes what Meta receives or what you read back. A message Meta accepts is kept like a reply typed in the console, with its WhatsApp message id, the recipient, the business phone number, the message type, the text of a text message and the payload you sent, so it shows in the Chats thread for that contact as "Sent via the API". A message Meta refuses is kept as a Send failure, with a reason from a fixed list and Meta's numeric error code. Meta's error message is never stored. A mark-as-read or a typing indicator names no recipient and records nothing. The records follow the conversation's lifetime: they are not purged, and they go when the WhatsApp Business Account or the organization is deleted.
A send can outlast the 30-second budget. When its 504 Request_Timeout carries the header x-should-retry: false, the send to Meta has started and continues to its end. Sending the message again can deliver it twice, so wait one minute, read the Send log with the recipient's contact, and send it again only if the message is not there. A 504 of a send without that header sent nothing to Meta.
Check a Warta key before you use it
GET /v1/messaging/gateway/me
Same bearer, no side effect, and Meta is never called — so you can run it every time a key is saved:
curl -sS \
"https://sabi.metatech.id/v1/messaging/gateway/me" \
-H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx"
{
"data": {
"organization_id": "org_xxxxxxxx",
"scope": "phone_number",
"scope_phone_number_id": "1234567890",
"phone_number_ids": ["1234567890"],
"waba_id": "111111111111111",
"waba_ids": ["111111111111111"]
},
"meta": { "request_id": "req_xxxxxxxx" }
}
This one does use the {data, meta} envelope: the send passes Meta's bytes through because Meta wrote them, and nothing here comes from Meta.
phone_number_ids are the numbers this key can actually send from — its scope allows them and its organization holds them — so checking your phone_number_id against that list is the same check the send makes. An empty list with a scope_phone_number_id set means the key names a number this organization does not hold, and every send would be refused. waba_id is filled in only when the key reaches exactly one account; waba_ids always carries the full set. A key scoped to one phone number gets a 200 here, not Messaging_ScopeDenied — the route asks for no number.
Check a message before you send it
POST /v1/messaging/gateway/{phone_number_id}/messages/validate
Same bearer, same body you would send, and nothing happens: Meta is never called, nothing is stored, and no message goes out. It answers what this server can already see about your payload — so you can run it on every draft.
curl -sS -X POST \
"https://sabi.metatech.id/v1/messaging/gateway/1234567890/messages/validate" \
-H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "+6281234567890",
"type": "text",
"text": { "body": "Hello" }
}'
{
"data": {
"checks": [
{ "name": "payload", "result": "pass", "fix": null },
{ "name": "text", "result": "pass", "fix": null },
{ "name": "window", "result": "pass", "fix": null },
{ "name": "template", "result": "unknown", "fix": "This payload is not a template send, so there is no message template to check." },
{ "name": "cost", "result": "unknown", "fix": "Read Meta's pricing page for this market before you send at volume. …" },
{ "name": "pacing", "result": "pass", "fix": null },
{ "name": "responder", "result": "pass", "fix": null }
],
"window": {
"state": "open",
"window_closes_at": "2026-09-14T09:12:00+00:00",
"last_inbound_at": "2026-09-13T09:12:00+00:00"
},
"pacing": {
"contact": "6281234567890",
"pair_window_seconds": 6,
"pair_window_remaining_ms": 0,
"last_accepted_age_ms": 42000,
"accepted_last_second": 0,
"last_attempt": {
"result": "refused",
"age_ms": 3000,
"failure_reason": "rate_limited",
"failure_code": 131056,
"summary": "last attempt refused: rate_limited (131056), 3s ago"
}
},
"responder": null,
"remote_checks": "not_run"
},
"meta": { "request_id": "req_xxxxxxxx" }
}
A failed check is a 200. It is a result, not a refusal: read checks, not the status code. The status code only carries the refusals the send itself has — 401 Messaging_KeyInvalid, 403 Messaging_ScopeDenied, 404 Messaging_PhoneNotFound, and 400 Messaging_InvalidInput for a body that is not a JSON object.
Each entry's result is pass, fail, or unknown, and fix says what to do about anything that is not a pass:
| Check | What it reads | What unknown means |
|---|---|---|
payload | messaging_product is whatsapp, to and type are present, and type is one Meta's send reference lists: audio, contacts, document, image, interactive, location, reaction, sticker, template, text, video. | — |
text | For type: text, text.body is present and at most 4,096 characters. For any type, to starts with + — without it Meta prepends the business number's own country code, and the message goes to the wrong country. | Neither rule had anything to look at. |
window | The 24-hour customer service window, from the inbound messages this server has stored from that contact to the business phone number in the path. A template is accepted outside the window, so a template payload passes whatever the state. | No inbound message from that contact to this number is stored here — which is not a claim that Meta has none. |
template | For type: template, the stored template's approval status. Only approved passes. | This server holds no template under that name and language; Meta may hold one it has never read. |
cost | A date-bound pricing line: what Meta charged before 2026-10-01, and what Meta charges from that date, in-window utility templates included. | Always: only Meta applies the effective date, the category, the delivery state, and the market rate. |
pacing | Meta's pair limit: one message every six seconds from one business phone number to the same WhatsApp user, and error 131056 inside that window. It fails while the newest send this server recorded as accepted to this contact from this number is inside those six seconds, and names the wait. A refused attempt does not restart the wait; the newest attempt is reported under pacing.last_attempt, a refusal with its reason and Meta's code. | to is missing or is not a phone number, so there is no contact to pace. |
responder | Which app Meta gives the conversation to under Conversation Routing (see Conversation Routing below). It passes when responder is null or this_platform, and for a template. It fails when responder is another or none and the payload is not a template: Meta can refuse that message, unless this platform is the escalation partner. | — |
pacing sees this server's own sends only. It reads the sends this server recorded — through this gateway, the console, and proactive runs. A message another integration or the WhatsApp Business app sent from the same number reaches Meta without passing through here, so a pass is not a promise that Meta will accept. The contact is read under every spelling a recorded send has linked to it: when Meta delivered a send to a number it rewrote — +521… answered as 52… in contacts[0].wa_id — the check finds that send whether you ask with the to you wrote or with the wa_id. The pacing object reports what the check read on every answer: the contact as you asked for it, canonical, what is left of the six-second window in milliseconds, the newest accepted send's age, the accepted sends in the last second, and the newest attempt.
remote_checks is always not_run, and that is the point. Nothing here asks Meta anything, so nothing here can tell you whether Meta will accept the message, whether it will be delivered, or what it will cost. There is no field named will_send, will_deliver, or free, and there will not be one. A 200 from the send is acceptance, not delivery; the delivery state arrives on your webhook.
There is no validate flag on the send, on purpose. A header that is missing, misspelled, or dropped by a proxy would send the message for real. A wrong path cannot: it is a 404.
Read one contact and its reply window
GET /v1/messaging/gateway/{phone_number_id}/contacts/{contact}
WhatsApp lets you reply in free text for 24 hours after the contact writes to you. Outside that window Meta accepts only an approved message template. This route tells you which side of it you are on, from the inbound messages this server has stored — and it answers the contact itself beside the window: the name to show, the name Meta supplied, the name your team chose, the labels your team applied, and the shared note. Same bearer, nothing written, Meta never called.
curl -sS \
"https://sabi.metatech.id/v1/messaging/gateway/1234567890/contacts/+6281234567890" \
-H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx"
{
"data": {
"contact": "6281234567890",
"name": "Bu Rina",
"profile_name": "Rina",
"display_name": "Bu Rina",
"app_contact_name": null,
"labels": ["vip", "reseller"],
"note": "Prefers a call before 10.00.",
"state": "open",
"window_closes_at": "2026-09-14T09:12:00+00:00",
"last_inbound_at": "2026-09-13T09:12:00+00:00",
"responder": null
},
"meta": { "request_id": "req_xxxxxxxx" }
}
{contact} is the contact's number as Meta reports it — digits, country code first — and a leading + is fine. It is the same key the Chats API takes, so +6281234567890 and 6281234567890 name one conversation. contact echoes the value this server keyed on, after the + is dropped.
| Field | What it means |
|---|---|
name | The name to show. It falls back from display_name to app_contact_name to profile_name to the number, so it is never null — render this one rather than choosing yourself. |
profile_name | The name Meta supplies for the contact. It changes when the contact changes their WhatsApp profile, and a webhook that omits it never erases the stored one. |
display_name | The name an operator chose. Meta never overwrites it. null when nobody has set one. |
app_contact_name | The name the business saved for the contact in its WhatsApp Business app, for a number that also uses the app (Coexistence). null when the app never named the contact. |
labels | The markers your team applied, as a set of strings. Local to your organization; they are never sent to Meta. |
note | One shared note your team wrote about the contact. It is not a message and is never sent to Meta. |
responder | Which app Meta gives this conversation to: this_platform, another, none, or null when this server has seen no Conversation Routing on the number. See Conversation Routing below. |
This key reads the contact; it cannot change it. A ww_* key has no write path to display_name, labels or note — those are edited from the console, on the control-plane credential — and nothing but the WhatsApp Business app writes app_contact_name. A contact nobody has ever messaged has no stored record here, and the answer is still a 200: every name null, labels empty, note null, and name falling through to the number.
Labels, notes and the app contact name never appear in your webhook events. They describe the contact, not the message, so the message.received envelope is exactly what it was.
state | What it means | What to send |
|---|---|---|
open | A message from this contact to this number is stored, and it is less than 24 hours old. | Anything. window_closes_at is your deadline. |
closed | A message from this contact to this number is stored, and the 24 hours have passed. | An approved message template. The window reopens for 24 hours when the contact writes to this number again. |
unknown | No message from this contact to this number is stored here. Both timestamps are null. | An approved message template — it is the only thing that can ask for the reply that opens a window. |
unknown is a 200, and it is not a claim about Meta. It says this server holds no inbound message from that contact to this number — which is also what you get for a contact who has genuinely never written, and for one whose messages arrived before your webhook was subscribed. Meta may hold a window this server has never seen.
Messages from the WhatsApp Business app's history never open the window. On a number that also stays in the WhatsApp Business app, the one-time history sync brings the chats the business had before it connected. Those messages show in Chats, but they never reached the Cloud API, so they do not count here: a contact whose only messages came from that history reads unknown. This read, the local check and the Chats page all count only messages delivered live.
The refusals are the send's: 401 Messaging_KeyInvalid, 403 Messaging_ScopeDenied, 404 Messaging_PhoneNotFound, and 400 Messaging_InvalidInput for a {contact} that is not a phone number. A contact with no stored messages is never a 404.
The window is computed per contact and business phone number — the number in the path. Meta keeps one window for each pair of business number and customer, so a contact who wrote to one of your numbers has opened no window on another: that number answers from its own messages, and unknown when there are none. That matches what the Chats page shows for the same conversation, deliberately: the two read the same rows through the same function, so they cannot tell you different things.
Conversation Routing
When Meta's Conversation Routing gives a WhatsApp conversation to another app on the same number, Meta can refuse your reply unless it is a template. Meta accepts it when this platform is the escalation partner, and the reply then takes the conversation over. Once the business has configured Conversation Routing, a message.received webhook means that Meta gave the conversation to this platform, so a bot that replies only to message.received meets no refusal. Before the business configures it, Meta sends every message to every app on the number: turn off the other app's automatic replies, or two bots answer one person. To see which app holds a conversation, read responder on GET /v1/messaging/gateway/{phone_number_id}/contacts/{contact}.
If you connected your WhatsApp Business Account by manual custody, subscribe your own Meta app to the messaging_handovers and standby webhook fields in the App Dashboard (WhatsApp → Configuration). Without them, SambungAPI cannot see Conversation Routing on your number.
The responder field
responder says which app Meta gives the conversation to. It is always present, on three reads:
GET /v1/messaging/gateway/{phone_number_id}/contacts/{contact}
GET /v1/messaging/wabas/{waba_id}/chats
GET /v1/messaging/wabas/{waba_id}/chats/{contact}
responder | What it means | What to send |
|---|---|---|
this_platform | Meta gave the conversation to this platform. | Anything that the reply window allows. |
another | Another app on the number holds the conversation. | A template. Meta can refuse any other message, unless this platform is the escalation partner. |
none | No app holds the conversation: 24 hours passed with no message from the contact, or an app released it. Meta gives the contact's next message to the app set for it in Conversation Routing. | A template. Meta can refuse any other message, unless this platform is the escalation partner. |
null | SambungAPI has seen no Conversation Routing on this number. | Use the reply window only. |
SambungAPI never refuses a send for responder: Meta decides at send time. The gateway still forwards your bytes to Meta unchanged. A refusal by Meta does not change responder.
responder comes from what Meta tells SambungAPI: a message.received means this_platform, and the messaging_handovers and standby webhook fields report the other changes. Meta has no call that reads which app holds a conversation, so the value is as new as the last webhook.
The other fields
| Field | Where | What it means |
|---|---|---|
standby | Each message of GET /v1/messaging/wabas/{waba_id}/chats/{contact} | true on a message that the contact sent while another app held the conversation. Meta sends such a copy only when the business gives this platform standby visibility. It is context: it is never a message.received event, it does not reopen a resolved conversation, and it does not count as unread. It opens the 24-hour reply window, because Meta counts that window for each number and contact. Always present; false on every other message. |
origin: "other_app" | Each outbound message of the same read, last_message of the list, and the Send log for a control-plane caller | A message that another app sent to the contact on the same number. Nobody sent it through SambungAPI, so sent_by is null. A Warta key's Send log does not show it, and the pacing check does not count it. |
summaries | GET /v1/messaging/wabas/{waba_id}/chats/{contact} | The summaries that Meta wrote for the conversation, oldest first: {text, received_at}. Meta sends a summary only in some cases, so the list can be []. Always present. |
routing_seen_at | Each business phone number of GET /v1/messaging/wabas/{waba_id} | The time of the newest Conversation Routing signal for the number, or null when SambungAPI has seen none. |
responder check | POST /v1/messaging/gateway/{phone_number_id}/messages/validate | The seventh local check. It fails for a message that is not a template when responder is another or none. The answer also carries responder at the top level. |
Your webhook events do not change. message.received and message.status keep their data, and no event type is added for a change of responder.
Take over or release a conversation
POST /v1/messaging/wabas/{waba_id}/chats/{contact}/responder
This route calls Meta's thread control with the account's stored token. take takes the conversation over from another app, and release gives it up: Meta then gives the contact's next message to the app set for it in Conversation Routing. The credential is a personal control-plane token (cpt_) or a console session, and the caller needs the messaging_waba.create grant, as for a Chats reply. A Warta key does not open this route.
curl -sS -X POST \
"https://sabi.metatech.id/v1/messaging/wabas/wb_xxxxxxxx/chats/6281234567890/responder" \
-H "Authorization: Bearer cpt_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "action": "take" }'
{
"data": {
"contact": "6281234567890",
"phone_number_id": "1234567890",
"responder": "this_platform"
},
"meta": { "request_id": "req_xxxxxxxx" }
}
action is take or release. Add phone_number_id when the contact has a conversation on more than one of the account's numbers; without it, such a contact answers 400 Messaging_ConversationAmbiguous. The answer carries responder as every read then reports it: a release answers none, and so does a take when the contact's newest message is older than 24 hours.
A take over is not a send. It counts no usage, and it sends no event to your endpoint. It does not assign the conversation to a person and does not change its state.
| Refusal | What it means | What to do |
|---|---|---|
409 Messaging_NotEscalationPartner | Meta allows only the escalation partner to take over a conversation. | In Meta Business Suite, open Conversation Routing and make this platform the escalation partner. |
409 Messaging_ThreadControlUnavailable | Meta has not turned on thread control for this business phone number. | Make sure that the business has accepted the Meta Business Agents and Platform Terms in Meta Business Suite. If it has, ask Meta support to enroll the number for thread control. |
Every other refusal from Meta keeps the slugs of every route that uses the stored token: 400 Messaging_GraphTokenInvalid, 403 Messaging_GraphAccessDenied, 502 Messaging_GraphError and 502 Messaging_GraphUnavailable. A refusal changes nothing that SambungAPI stores.
A contact with a WhatsApp username
WhatsApp lets a person hide their phone number behind a username. Meta then names them by a user id that belongs to your business, such as US.13491208655302741918, and leaves the phone number out unless you have talked with that number in the last 30 days.
A message.received webhook from such a person has from: null. It carries the user id in from_user_id, and username holds their WhatsApp username. To reply, send recipient in place of to:
{
"messaging_product": "whatsapp",
"recipient": "US.13491208655302741918",
"type": "text",
"text": { "body": "Thanks, we got it." }
}
The channel API sends to the same person when you name the user id as the target of POST /v1/channels/addresses/{ba}/messages. SambungAPI then sends recipient to Meta for you:
{
"identity": { "kind": "whatsapp_user_id", "value": "US.13491208655302741918" },
"text": "Thanks, we got it."
}
A conversation target works too, for a person whom you know only by their user id.
Wherever this API names a contact by phone number — the {contact} in a path, or contact in an answer — a contact with no known phone number is named by their user id. Once Meta tells us the phone number of the same person, the phone number names them.
When the person changes their phone number, Meta gives them a new user id. SambungAPI keeps the contact and its conversation: the new user id names the same contact, and their next message lands in the same conversation. If you connected your WhatsApp Business Account by manual custody, subscribe your own Meta app to the user_id_update webhook field in the App Dashboard (WhatsApp → Configuration). Without it, SambungAPI sees the change only when Meta also sends it as a system message.
Read an inbound photo, receipt or document
A message.received webhook for an image, video, audio or document carries media_id — Meta's handle for the file. The handle opens nothing on its own; this route resolves it to the bytes.
A document also carries filename, the name the sender gave the file, so you can store it under that name rather than as document. filename is null on every other message, including a photo — Meta names a file on a document only.
GET /v1/messaging/gateway/{phone_number_id}/media/{media_id}
curl -sS \
"https://sabi.metatech.id/v1/messaging/gateway/1234567890/media/1234567890123456" \
-H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx" \
-o receipt.jpg
The response body is the file itself, not an envelope — the same reasoning as the send: these are Meta's bytes, so wrapping them in base64 inside JSON would make them a third larger for nothing. What you need alongside them arrives in the headers:
| Header | What it is |
|---|---|
Content-Type | The media type, from Meta. |
Content-Length | The size in bytes. |
X-Sambungapi-Media-Sha256 | Meta's own digest of the file. It is carried through as Meta sent it and is never verified here, so treat it as Meta's claim — useful for deduplicating an archive, and worth checking yourself if you need integrity. |
Refusals do use the error envelope. Two are worth knowing before the first call. A WhatsApp media handle expires 30 days after the message that carried it, and an expired one is 404 Messaging_MediaNotFound — that is permanent, so stop retrying it; anything else Meta says is a 502 you may retry. And a file past the deployment's upload limit (25 MiB by default) is 413 Messaging_MediaTooLarge, refused from Meta's declared size before the bytes travel.
The phone_number_id in the path is the number the message arrived on, and it is enforced: a key whose scope does not reach it gets 403 Messaging_ScopeDenied, and a number your organization does not hold is 404 Messaging_PhoneNotFound.
Send a photo you hold as bytes
Meta takes an image either by a public link it fetches itself, or by a media_id you upload first. Use the link when the file is already published; upload when it is not, so a private image never needs public hosting to be sent privately.
POST /v1/messaging/gateway/{phone_number_id}/media
curl -sS \
"https://sabi.metatech.id/v1/messaging/gateway/1234567890/media" \
-H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx" \
-H "Content-Type: image/jpeg" \
--data-binary @chart.jpg
The body is the file itself and Content-Type declares its type — the same shape as the read, in the other direction. The answer is Meta's own, unenveloped:
{ "id": "1234567890123456" }
Spend that id on the ordinary send, which is unchanged:
{
"messaging_product": "whatsapp",
"to": "+628111222333",
"type": "image",
"image": { "id": "1234567890123456", "caption": "Yesterday's numbers" }
}
An uploaded media id expires 30 days after the upload, and it belongs to the business phone number it was uploaded on — a send from a different number will not accept it. Nothing is stored here: the object is Meta's, and this route hands back its id rather than keeping a weaker copy of it. A body past the deployment's upload limit (25 MiB by default) is 413 Messaging_MediaTooLarge, and the scope and organization checks are the read's.
Read and send a reaction
A contact reacting to a message produces an ordinary message.received webhook with message_type of reaction, and two fields that are null on everything else:
| Field | What it is |
|---|---|
reaction_emoji | The emoji, as Meta sent it. An empty string means the reaction was removed — that is Meta's own encoding, carried through rather than dropped, so a running set of reactions can learn when one goes away. |
reaction_message_id | The wamid the reaction applies to. This is not context_id: a reaction is not a quote-reply, it carries no body of its own, and it can be taken back. |
Sending one is the ordinary send with Meta's reaction shape:
{
"messaging_product": "whatsapp",
"to": "+628111222333",
"type": "reaction",
"reaction": { "message_id": "wamid.HBgN...", "emoji": "👍" }
}
Send "emoji": "" to take your own reaction back.
Receive events at your endpoint
SambungAPI passes events to a web address that you register: the messages that contacts send to your business phone numbers, and the statuses that Meta reports for the messages that you send. That registration is a subscription. Each event that a subscription receives becomes one delivery to its endpoint, signed with the subscription's own secret. The next section reads those deliveries.
POST /v1/messaging/subscriptions
GET /v1/messaging/subscriptions
POST /v1/messaging/subscriptions/{subscription_id}/revoke
The three routes take an API key with a channel part (a Warta key, ww_ or ak_) or a control-plane token (cpt_):
- A Warta key implies the organization, so you do not send
organization_id. A key at whole-organization scope registers a subscription for every business phone number of the organization. A key scoped to one business phone number registers a subscription that is narrowed to its own number: the subscription receives only that number's events, and itsphone_number_idnames the number. A Warta key cannot sendproject_id. - A control-plane token names the organization in
organization_id: in the body of a registration or a revoke, and in the query of the list. A registration can also sendproject_id, which narrows the subscription to one Project. The subscription then receives the events of the WhatsApp Business Accounts attached to that Project.
An Organization owner or admin can also add and revoke a subscription for the whole Organization on the Delivery log in the console. The console adds that subscription on /v1/channels/subscriptions, for every channel, in the channel format. /v1/messaging/subscriptions registers a subscription in the older WhatsApp format.
curl -sS -X POST \
"https://sabi.metatech.id/v1/messaging/subscriptions" \
-H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{
"name": "orders-bot",
"endpoint_url": "https://example.com/sambungapi/events",
"event_types": ["message.received", "message.status"]
}'
name and endpoint_url are required. event_types is optional. It takes message.received and message.status, and a subscription that omits it receives both. An event type that is not in that list is refused by name with 400 Messaging_InvalidInput.
The endpoint URL must be an absolute https:// or http:// URL, and its host must be a public address. A host that is, or resolves to, a loopback, private or other internal address is refused with 400 Messaging_InvalidInput, and nothing is stored. SambungAPI checks the address again each time it connects to it.
The 201 carries data.raw_secret, a whsec_… signing secret. It is shown once and no route returns it again, so store it when you receive it. data.signature_header names the header that signs each delivery, X-Sambungapi-Signature-256. To check a signature, follow step 8 of Your first WhatsApp message.
One URL, more than one subscription. A live subscription of your organization on the same URL can refuse a registration with 409 Messaging_DuplicateSubscription. Two live subscriptions share a URL when each is narrowed to a business phone number and the two numbers are different, so two keys scoped to two different numbers can register one endpoint. Each of those subscriptions signs with its own secret. Every other pair on one URL is refused: two live subscriptions of one number, and any pair in which one subscription is not narrowed to a number. A revoked subscription frees its URL, so revoke it and register again to get a new secret.
GET /v1/messaging/subscriptions lists the subscriptions, revoked ones included, with no secret in any form. A key scoped to one business phone number lists only the subscriptions narrowed to its own number.
POST /v1/messaging/subscriptions/{subscription_id}/revoke stops new deliveries to the subscription and answers 204. A second revoke answers 204 too. The deliveries already queued for the subscription continue, with their retries, and stay in the delivery log. A key scoped to one business phone number can revoke only a subscription narrowed to its own number.
The result of a Campaign or an Event-triggered send. A subscription that you register on POST /v1/channels/subscriptions, in the channel format, can receive proactive_run.completed. It receives the event only when its event_types names it: a subscription that names no event types does not receive it, and /v1/messaging/subscriptions refuses it with 400 Messaging_InvalidInput. SambungAPI sends one event each time a run moves to completed. A run that you send again and that completes again sends one more event, with another id. A paused or a cancelled run sends none. business_address_id is the run's business phone number. conversation_id is the Conversation of the contact of an Event-triggered send on that number, and null for a Campaign. data.counts is the counts object of GET /v1/control/proactive/runs/{run_id} at completion. data.recipient is null for a Campaign. For an Event-triggered send it holds the one recipient, so you can see that a refused send needs Send again:
{
"id": "evt_5b0c3f6e2d41a9c87e10f2aa",
"type": "proactive_run.completed",
"api_version": "2026-10-06",
"created_at": "2026-10-07T04:12:09Z",
"organization_id": "org_xxxxxxxxxxxxxxxxxxxx",
"channel": "whatsapp",
"business_address_id": "ba_xxxxxxxxxxxxxxxxxxxx",
"conversation_id": "conv_xxxxxxxxxxxxxxxxxxxx",
"data": {
"run_id": "pxr_xxxxxxxxxxxxxxxxxxxx",
"mode": "event_triggered",
"template": { "name": "order_shipped", "language": "en_US", "category": "UTILITY" },
"counts": { "queued": 0, "suppressed": 0, "sent": 0, "delivered": 0, "read": 0, "failed": 1, "total": 1 },
"completed_at": "2026-10-07T04:12:09Z",
"recipient": {
"contact_id": "ct_xxxxxxxxxxxxxxxxxxxx",
"state": "failed",
"failure_reason": "rate_limited",
"failure_code": 131056,
"platform_message_id": null
}
}
}
Read the delivery log and replay a failed delivery
When SambungAPI has an event for you, it POSTs it to the endpoint you registered. That delivery is at-least-once: every attempt and every replay carries the same Idempotency-Key, which is the event id, so dedupe on it and a repeat costs you nothing. A failing endpoint is tried nine times over about 31 hours — once, then again immediately, then after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours — and then the delivery is terminal failed rather than gone.
A retry can come sooner. When your endpoint answers a delivery with a 2xx, SambungAPI sends at once each delivery to the same endpoint URL that waits after a 3xx, a 5xx, a timeout or a connection error. This happens once for each wait. When that early attempt fails, it does not count toward the nine, and the delivery waits for its scheduled time again. A delivery that waits after a 408, a 429 or a Retry-After header in seconds keeps its wait. Each attempt in the delivery log names what started it in trigger: scheduled, recovery or retry_now. To send a delivery that waits for a retry now, call POST /v1/messaging/deliveries/{delivery_id}/retry-now, or select Retry now on the delivery in the Delivery log. Retry now also ends a wait after a Retry-After header, and its attempt counts toward the nine. It answers 202, and the worker sends the delivery on its next pass.
SambungAPI sends the deliveries of one subscription in two queues: one for message.status events, and one for every other event. Each queue sends its deliveries one at a time, in the order in which SambungAPI queued them. So your endpoint can get two requests of one subscription at the same time, one from each queue, and the delivery of a message from a contact does not wait behind the status events of a large campaign. A delivery that waits for a retry holds back the later deliveries of its own queue, and of no other queue. Two subscriptions that share one endpoint URL are independent, so that URL can get their requests at the same time. The queue order is not always the order of the events on WhatsApp, and a status event can arrive before a message that SambungAPI queued before it. When the order matters to you, sort by data.occurred_at.
The delivery log is how you see that, and replay is how you ask for one again. Both take your Warta key:
GET /v1/messaging/deliveries
GET /v1/messaging/deliveries/{delivery_id}
POST /v1/messaging/deliveries/{delivery_id}/replay
POST /v1/messaging/deliveries/{delivery_id}/retry-now
curl -sS \
"https://sabi.metatech.id/v1/messaging/deliveries?status=failed" \
-H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx"
The key implies the organization, so you never send organization_id. The key must be organization-scoped: a key scoped to one phone number is refused with 403 Messaging_ScopeDenied, because the log spans every business account in the organization.
Filter with status (pending, in_flight, completed, failed), endpoint or subscription_id, event_id, event_type, wamid, and the inclusive pair created_after / created_before. A filter this surface does not understand is refused by name rather than silently dropped, so an empty page always means "nothing matched" and never "your filter was ignored". Page with limit and the before cursor from meta.next_cursor.
Each entry is metadata only — no payload, no signing secret, and the endpoint reduced to a scheme://host[:port]/… locator. replayable is the flag to read before you offer a replay; it is true only for a terminal failed delivery, and it is the same check the replay route makes.
curl -sS -X POST \
"https://sabi.metatech.id/v1/messaging/deliveries/dlv_xxxx/replay" \
-H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx" \
-H "content-type: application/json" \
-d '{}'
202 means queued, not sent. The reply names a new delivery_id at the tail of its queue in its subscription (the message.status queue for a status event) carrying the same event, endpoint, payload, signature and Idempotency-Key; the delivery worker makes the actual request on its own schedule, and the row you replayed stays terminal. Only a failed delivery is accepted: a pending, in_flight or completed one answers 409 Messaging_DeliveryNotReplayable naming the state it is in, and a second replay while the first is still queued answers 409 Messaging_ReplayAlreadyQueued.
Terminal deliveries are kept for 30 days and then purged. After that the id answers 404 Messaging_DeliveryNotFound, the same answer an id that never existed gets — queued and in-flight deliveries are never purged by that boundary, so they stay visible however old they are.
The event classes a subscription receives are chosen per subscription in event_types, and that list is additive: a class added later does not change what an existing subscription receives, and a class your subscription did not name is validated against the vocabulary rather than ignored.
Read the Send log
A Send is a message you asked Meta to deliver: a call to the gateway's /messages, or a reply your team typed in the console. A Delivery is an event SambungAPI hands to your endpoint. They are two logs: the Send log says what went to Meta and what Meta did with it, the delivery log above says what reached your server.
GET /v1/messaging/sends
GET /v1/messaging/sends/{send_id}
curl -sS \
"https://sabi.metatech.id/v1/messaging/sends?contact=628111222333" \
-H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx"
The record is written after Meta answers, and nothing about your send waits for it: the gateway still forwards your bytes to Meta unchanged and still hands you Meta's answer unchanged. Each entry carries id, wamid, contact, phone_number_id, origin, message_type, text, status, failure_reason, failure_code, sent_by and at, newest first.
- Meta accepted it. The
idstarts withom_,wamidis the id Meta returned, andstatusis the newest status Meta reported for it (sent,delivered,read,failed), ornullbefore the first one arrives. - Meta refused it, or no answer came back. The
idstarts withsf_, and the entry readswamid: null,failure_reason(a stable slug such asrecipient_unreachableorrate_limited) andfailure_code(Meta's numeric error code). It keeps the slug and the code and never Meta's error message, which can carry the recipient's phone number. A refused attempt is a real entry: reading itsidanswers200, never a 404.
The key implies the organization, so you never send organization_id. An organization-scoped key reads the organization's gateway Sends. A key scoped to one phone number reads that number's Sends only; naming another phone_number_id answers 403 Messaging_ScopeDenied. Replies typed in the console are not in a key's log.
Filter with contact, phone_number_id, wamid and the inclusive pair created_after / created_before, and page with limit and the before cursor from meta.next_cursor. A wamid matches no refused attempt, because a refused attempt has none. A filter this surface does not understand is refused by name, as on the delivery log.
A Send lives as long as the conversation it belongs to: it is not purged, and it is deleted with its WhatsApp Business Account or its organization. An id you cannot see — another organization's, or one outside your key's scope — answers 404 Resource_NotFound, the same answer an id that never existed gets.
Read Meta's WhatsApp analytics
Meta counts what your WhatsApp Business Account did — messages, conversations, pricing, templates, calls and groups — and serves the counts as analytics. This route reads them from Meta with the account's stored token and gives them back unchanged. It stores nothing.
GET /v1/messaging/wabas/{waba_id}/analytics/{family}
The credential is a personal control-plane token (cpt_) or a console session. The caller needs organization admin authority or above in the organization that owns the account, or a custom role with messaging_waba.read. A Warta key does not open this route. {waba_id} is the account's wb_ id.
{family} is one of Meta's seven analytics families:
{family} | Meta's name | What Meta counts | Lookback |
|---|---|---|---|
messaging | analytics | Messages sent and delivered by the account's numbers | 1 year |
conversation | conversation_analytics | Conversations and their cost | 1 year |
pricing | pricing_analytics | Delivered messages by price, category and tier | 1 year |
template | template_analytics | Sends, deliveries, reads, clicks and cost per template | 90 days |
template_group | template_group_analytics | The same metrics per template group | 90 days |
call | call_analytics | Calls made and received | Meta states none |
group | group_analytics | Messages and participants in one WhatsApp group | 90 days |
curl -sS -G \
"https://sabi.metatech.id/v1/messaging/wabas/wb_xxxxxxxx/analytics/pricing" \
-H "Authorization: Bearer cpt_xxxxxxxxxxxxxxxx" \
--data-urlencode "start=1748761200" \
--data-urlencode "end=1749687703" \
--data-urlencode "granularity=DAILY" \
--data-urlencode "dimensions=PRICING_CATEGORY,PRICING_TYPE,TIER,COUNTRY"
The query
Each family takes only the parameters Meta documents for it, and each value must be one Meta documents for that family:
{family} | Required | Optional |
|---|---|---|
messaging | start, end, granularity (HALF_HOUR, DAY, MONTH) | phone_numbers, product_types (0 template, 2 non-template, 100 incoming), country_codes |
conversation | start, end, granularity (HALF_HOUR, DAILY, MONTHLY) | phone_numbers, metric_types, conversation_categories, conversation_types, conversation_directions, dimensions |
pricing | start, end, granularity (HALF_HOUR, DAILY, MONTHLY) | phone_numbers, country_codes, metric_types, pricing_types, pricing_categories, dimensions |
template | start, end, granularity (DAILY), template_ids (at most 10) | metric_types, product_type, use_waba_timezone, after |
template_group | start, end, granularity (DAILY), template_group_ids (at most 10) | metric_types, use_waba_timezone, after |
call | start, end, granularity (HALF_HOUR, DAILY, MONTHLY) | phone_numbers, country_codes, directions, dimensions, metric_types |
group | start, end, group_ids (exactly one), metric_types | granularity (DAILY), after |
The allowed values of every parameter are in /v1/specs/messaging, under analytics.families. Meta spells the same idea differently in different families — DAY for messaging and DAILY for the others, and lower-case dimensions for calls — and each family accepts its own spelling only.
startandendare UNIX timestamps in seconds, UTC. Thetemplateandtemplate_groupfamilies also takeYYYY-MM-DDdates, and need dates on both ends whenuse_waba_timezone=true.endmust not be earlier thanstart.- A list is one comma-separated value:
dimensions=COUNTRY,PHONE. Leave a parameter out to mean every value; an empty value is refused. phone_numbersholds business phone numbers, country code first (6281100001111), not phone number ids.- Values match without regard to case, and Meta receives them in its own spelling.
A query that breaks any of these rules is refused with 400 Messaging_InvalidInput before Meta is called. The message names the parameter: an unknown or repeated parameter, a value outside the family's vocabulary, a missing required parameter, a window that ends before it starts, or a start outside the lookback.
The response
{
"data": {
"source": "meta_graph",
"family": "pricing",
"meta_field": "pricing_analytics",
"waba_id": "wb_xxxxxxxx",
"meta_waba_id": "161311403722088",
"window": { "start": 1748761200, "end": 1749687703 },
"filters": {
"granularity": "DAILY",
"dimensions": ["PRICING_CATEGORY", "PRICING_TYPE", "TIER", "COUNTRY"]
},
"observed_at": "2026-09-23T12:00:05.123456Z",
"data_points": [
{
"start": 1749106800,
"end": 1749193200,
"country": "IN",
"tier": "0:750000",
"pricing_type": "REGULAR",
"pricing_category": "AUTHENTICATION_INTERNATIONAL",
"volume": 2,
"cost": 4.6
}
],
"next_after": null,
"truncated": false,
"raw": { "pricing_analytics": { "data": [ { "data_points": [ "…" ] } ] } }
},
"meta": { "request_id": "req_xxxxxxxx" }
}
| Field | What it is |
|---|---|
source | Always meta_graph. Every number in this answer is Meta's. |
family, meta_field | The family you asked for, and Meta's own name for it. |
waba_id, meta_waba_id | The account the numbers are for: the wb_ id and Meta's id. |
window | start and end as Meta received them. |
filters | Every other parameter as Meta received it, in Meta's spelling. |
observed_at | When this server received Meta's answer. |
data_points | Meta's data points, copied unchanged and in Meta's order. |
next_after | Meta's cursor for the next page, or null. |
truncated | true only when Meta said more exists and gave no cursor this route can pass back. |
raw | Meta's whole answer, as Meta wrote it. |
observed_at is when this server read Meta, not when Meta computed the numbers. Meta does not say when it computed them. Meta also states that its analytics are approximate and may differ from its invoices.
Nothing here is SambungAPI's own count. The delivery log, the send log and the stored messages record what passed through this server; they are separate, and they never appear in this answer. Nothing is estimated or filled in: a period Meta did not report is absent, never a 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 — read raw to see what Meta sent.
The lookback is Meta's. Meta serves messaging, conversation and pricing analytics for 1 year back, and template, template group and group analytics for 90 days back. A start whose UTC calendar day is earlier than that is refused before Meta is called. The boundary day itself goes to Meta, which may still refuse it. Meta states no lookback for call analytics, so none is enforced here.
Pages. The template, template_group and group families are paged by Meta. When next_after is a string, send it back as after for the next page. The other four families take no cursor.
Conditions Meta sets:
- Template and template group analytics work only after someone confirms template analytics on the account in Meta. Meta says the confirmation cannot be undone, so this server does not do it for you.
- Meta keeps read and click counts for 7 days after a template message is sent, and then resets them to zero. A zero there is Meta's reset, not a fact about the send.
- Meta does not show cost for an account a Solution Partner bills.
Refusals from Meta keep the slugs of every other route that uses the stored token: 400 Messaging_GraphTokenInvalid (Meta code 190), 403 Messaging_GraphAccessDenied (codes 200 and 10), 502 Messaging_GraphError for any other Meta refusal, and 502 Messaging_GraphUnavailable when Meta cannot be reached. Meta's code and details are in the message. This read never changes the account's status, even when Meta refuses it. An account in another organization, or one that does not exist, is 404 Messaging_WabaNotFound.
Retention: none. This server stores nothing from this read — no row, no cache. The response is the only copy, so keep what you need. Within the lookback, you can read the same window from Meta again.
Read and update a business profile
A business profile is the card WhatsApp users see when they open a business phone number: the About text, the address, the description, the email, up to two websites, the category and the picture. Meta holds it. These routes read it from Meta and update it at Meta with the account's stored token, and this server keeps the last answer Meta gave, with the time it arrived.
GET /v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/business-profile
POST /v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/business-profile/refresh
PATCH /v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/business-profile
The credential is a personal control-plane token (cpt_) or a console session. The read needs organization admin authority or above in the organization that owns the account, or a custom role with messaging_waba.read; the refresh and the update need messaging_waba.update. A Warta key does not open these routes. {waba_id} is the account's wb_ id, and {phone_number_id} is Meta's phone number id of a business phone number of that account.
| Route | What it does | Calls Meta |
|---|---|---|
GET …/business-profile | Answers the profile Meta last reported, and when. | No |
POST …/business-profile/refresh | Reads the profile from Meta and stores the answer. No body. | One read |
PATCH …/business-profile | Updates the fields you send at Meta, then reads the profile back and stores that answer. | One update, then one read |
curl -sS -X PATCH \
"https://sabi.metatech.id/v1/messaging/wabas/wb_xxxxxxxx/phones/106540352242922/business-profile" \
-H "Authorization: Bearer cpt_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"about": "Open every day, 08:00-21:00", "websites": ["https://example.id"]}'
The update
The body is a JSON object of the fields to change. A field you leave out stays as it is at Meta. The limits are Meta's:
| Field | Value | Meta's limit |
|---|---|---|
about | Text | 1 to 139 characters. It cannot be empty. Links do not render as links, and Markdown is not supported. |
address | Text | At most 256 characters. |
description | Text | At most 512 characters. |
email | Text | At most 128 characters, in a valid email format. Meta checks the format. |
websites | Array of text | At most 2, each at most 256 characters, each starting with http:// or https://. |
vertical | Text | The category: an empty string, or one of 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. |
profile_picture_handle | Text | A handle from Meta's Resumable Upload API for the picture file. |
A body that breaks any of these rules is refused with 400 Messaging_InvalidInput before Meta is called, and the message names the field: a field an update does not take, a value of the wrong type, a text past its limit, a third website, a website without http:// or https://, or a category outside the list. Characters are counted as Unicode characters, not bytes. Do not send messaging_product: this server sets it. profile_picture_url is read only.
The picture. Meta sets the picture from a file uploaded through its Resumable Upload API, and the update carries that upload's handle. This server does not upload the picture for you. POST /v1/messaging/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 yet. A read answers profile_picture_url, not the handle.
The response
All three routes answer the same shape:
{
"data": {
"source": "meta_graph",
"waba_id": "wb_xxxxxxxx",
"phone_number_id": "106540352242922",
"observed_at": "2026-09-23T12:00:05.123456Z",
"profile": {
"about": "Open every day, 08:00-21:00",
"address": "Jl. Contoh No. 1, Jakarta",
"description": "We sell butterflies.",
"email": "hello@example.id",
"websites": ["https://example.id"],
"vertical": "RETAIL",
"profile_picture_url": "https://pps.whatsapp.net/…"
},
"unknown_fields": [],
"raw": { "data": [ { "messaging_product": "whatsapp", "about": "Open every day, 08:00-21:00", "…": "…" } ] }
},
"meta": { "request_id": "req_xxxxxxxx" }
}
| Field | What it is |
|---|---|
source | Always meta_graph. Every profile value is Meta's. |
observed_at | When this server received Meta's answer. null before the first refresh or update. |
profile | The fields, read out of raw. A field Meta did not send, or sent in a type this server does not expect, is null. |
unknown_fields | The names of fields Meta sent that profile does not show. Their values are in raw. |
raw | Meta's whole answer to the last read. |
The answer to an update is Meta's, not yours. Meta answers an update with only {"success": true}, so the route then reads the profile from Meta and answers that. What you get back is what Meta reports holding, including anything changed elsewhere since the last read.
Freshness. The GET does not call Meta: it answers the stored answer with its observed_at. Meta and WhatsApp Manager can change the profile at any time, and this server does not learn of such a change until the next refresh or update. Refresh when you need the current profile.
A field Meta adds does not break the read. It appears by name in unknown_fields, and its value stays in raw.
Refusals from Meta keep the slugs of every other route that uses the stored token: 400 Messaging_GraphTokenInvalid (Meta code 190), 403 Messaging_GraphAccessDenied (codes 200 and 10), 502 Messaging_GraphError for any other Meta refusal, and 502 Messaging_GraphUnavailable when Meta cannot be reached. Meta's code and details are in the message. Like registering a number, these routes change the account's status on a dead token: code 190 marks it EXPIRED, and codes 200 and 10 mark it ACCESS_LOST. A refusal stores nothing, so the previous answer stays readable. An update Meta answers without success: true is 502 Messaging_GraphError and stores nothing. If Meta accepts the update and the read after it fails, the message says the update was accepted; sending the same update again is safe.
An account in another organization, or one that does not exist, is 404 Messaging_WabaNotFound. A number that is not a business phone number of that account is 404 Messaging_PhoneNotFound.
No delete. Meta deletes a business profile only by deleting the phone number, so there is no profile delete here.
Read the quality rating of a business phone number
Meta rates each business phone number from how its messages were received: GREEN is high quality, YELLOW medium, RED low, and NA or UNKNOWN means Meta has not rated the number. These routes read that rating from Meta with the account's stored token, keep the last answer with the time it arrived, and tell you what it means now.
GET /v1/messaging/wabas/{waba_id}/quality
GET /v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/quality
POST /v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/quality/refresh
GET /v1/messaging/wabas/{waba_id}/account-changes
The credential is a personal control-plane token (cpt_) or a console session. The three reads need organization admin authority or above in the organization that owns the account, or a custom role with messaging_waba.read; the refresh needs messaging_waba.update. A Warta key does not open these routes.
| Route | What it does | Calls Meta |
|---|---|---|
GET …/quality | Answers the rating of every business phone number of the account. | No |
GET …/phones/{phone_number_id}/quality | Answers the rating of one number. | No |
POST …/phones/{phone_number_id}/quality/refresh | Reads GET /{phone_number_id}?fields=quality_rating from Meta and stores the answer. No body. | One read |
GET …/account-changes | Lists the quality, limit and account changes Meta reported by webhook. | No |
curl -sS -X POST \
"https://sabi.metatech.id/v1/messaging/wabas/wb_xxxxxxxx/phones/106540352242922/quality/refresh" \
-H "Authorization: Bearer cpt_xxxxxxxxxxxxxxxx"
The rating
{
"data": {
"source": "meta_graph",
"waba_id": "wb_xxxxxxxx",
"phone_number_id": "106540352242922",
"display_phone_number": "+1 555-078-3881",
"quality_rating": "GREEN",
"quality_status": "high",
"checked_at": "2026-09-23T12:00:05.123456Z",
"expires_at": "2026-09-24T12:00:05.123456Z",
"max_age_seconds": 86400,
"unknown_fields": [],
"raw": { "quality_rating": "GREEN", "id": "106540352242922" }
},
"meta": { "request_id": "req_xxxxxxxx" }
}
Branch on quality_status, not on quality_rating. quality_rating is Meta's token as Meta sent it at checked_at. quality_status is what this server concludes from it now:
quality_status | When |
|---|---|
high | Meta answered GREEN less than 24 hours ago. |
medium | Meta answered YELLOW less than 24 hours ago. |
low | Meta answered RED less than 24 hours ago. |
not_determined | Meta answered NA or UNKNOWN less than 24 hours ago. |
unreadable | The last read, less than 24 hours old, had no rating this server can read: the field was missing, it was not a string, or it was a token Meta has not documented. quality_rating is null, and Meta's answer is in raw. |
stale | The last read is more than 24 hours old, whatever it said. quality_rating still shows what Meta said then. |
not_observed | Nobody has read the rating through this server. Refresh it. |
A stale or unreadable rating is never high. Meta can change a rating at any time and sends no webhook that reports one, so a stored rating only gets older. The 24-hour bound is this server's, not a number Meta publishes, and the database's clock decides it. expires_at is when the read stops being current.
Three facts, three places. The quality rating belongs to one business phone number. It is not the account's send health (can_send_message on the account, from POST /v1/messaging/wabas/{waba_id}/health-refresh), and it is not the Business Portfolio messaging limit (messaging_limit_recipients on the account, from POST /v1/messaging/wabas/{waba_id}/messaging-limit-refresh). This server derives none of them from another, and the rating answer carries neither of the other two.
A field Meta adds does not break the read. Its name is in unknown_fields, and its value stays in raw.
Refusals from Meta keep the slugs of every route that uses the stored token: 400 Messaging_GraphTokenInvalid (Meta code 190, and the account is marked EXPIRED), 403 Messaging_GraphAccessDenied (codes 200 and 10, and the account is marked ACCESS_LOST), 502 Messaging_GraphError for any other Meta refusal, including Meta's code 100 for a field the node does not have, and 502 Messaging_GraphUnavailable when Meta cannot be reached. A refusal stores nothing, so the last read stays readable with its own age.
An account in another organization, or one that does not exist, is 404 Messaging_WabaNotFound. A number that is not a business phone number of that account is 404 Messaging_PhoneNotFound.
Account changes
Meta reports some changes by webhook. This server captures four of Meta's webhook fields before it answers Meta, and keeps each change with Meta's whole payload:
| Field | What Meta reports | What it names |
|---|---|---|
phone_number_quality_update | A business phone number's throughput level changed, with messaging limit tiers. It carries no quality rating. | The number, by its display number only |
business_capability_update | The business portfolio's messaging limit, or its number caps, changed. | The account |
account_update | An account event: a restriction, a violation, a ban, a deletion. Partner events are not captured. | The account |
account_alerts | A messaging limit increase was denied or deferred, an Official Business Account decision, a lost profile picture. | The entity in entity_type |
Subscribe these fields in your Meta App Dashboard. Meta sends account_update and account_alerts only to the app's callback, POST /webhooks/meta/app; the other two can also arrive at the account's own callback.
{
"data": {
"source": "meta_webhook",
"waba_id": "wb_xxxxxxxx",
"changes": [
{
"id": "ach_xxxxxxxx",
"source": "meta_webhook",
"waba_id": "wb_xxxxxxxx",
"field": "phone_number_quality_update",
"event": "THROUGHPUT_UPGRADE",
"entity": { "type": "phone_number", "id": null, "display_phone_number": "15550783881" },
"entry_id": "102290129340398",
"phone_number_id": "106540352242922",
"values": { "display_phone_number": "15550783881", "event": "THROUGHPUT_UPGRADE", "current_limit": "TIER_UNLIMITED", "old_limit": null, "max_daily_conversations_per_business": null },
"unknown_fields": [],
"raw": { "display_phone_number": "15550783881", "event": "THROUGHPUT_UPGRADE", "current_limit": "TIER_UNLIMITED" },
"occurred_at": "2025-05-28T17:46:34.000000Z",
"received_at": "2026-09-23T12:00:07.654321Z"
}
]
},
"meta": { "request_id": "req_xxxxxxxx" }
}
values holds every key Meta documents for the field: the value when it has the documented shape, null otherwise. A key Meta adds is named in unknown_fields and kept in raw. phone_number_id is the business phone number of this account the change names, when it names one: by entity_id for a phone number alert, and by the display number, digit for digit, for phone_number_quality_update.
A change is evidence, not a verdict. No captured change moves quality_status, and no business_capability_update moves messaging_limit_recipients or the admission of campaigns. Meta's reference lists the portfolio limit as TIER_* tokens and its example sends the number 2000, so both shapes are shown as sent.
The list is newest first. Query parameters: field (one of the four), phone_number_id, and limit (1 to 200, default 50). Any other parameter, a repeated one, or a value outside these is 400 Messaging_InvalidInput. Meta's redelivery of a change is recorded once.
Manage WhatsApp Flows
A WhatsApp Flow is a form or a short series of screens that opens inside WhatsApp. Meta holds the Flow. These routes create and change the Flows of one WhatsApp Business Account through Meta's Flows API, with the account's stored token, and send a Flow message. SambungAPI stores no Flow: every answer is Meta's, read at the time of the request.
The credential is a personal control-plane token (cpt_) or a console session. The caller needs organization admin authority or above in the organization that owns the account, or a custom role with the grant in the last column. A Warta key does not open these routes. {waba_id} is the account's wb_ id, and {flow_id} is Meta's Flow id.
| Route | What it does | Grant |
|---|---|---|
GET /v1/messaging/wabas/{waba_id}/flows | List the account's Flows, one page at a time | messaging_waba.list |
POST /v1/messaging/wabas/{waba_id}/flows | Create a Flow | messaging_waba.create |
GET /v1/messaging/wabas/{waba_id}/flows/{flow_id} | Read one Flow | messaging_waba.read |
PATCH /v1/messaging/wabas/{waba_id}/flows/{flow_id} | Change the Flow's name, categories or endpoint_uri | messaging_waba.update |
DELETE /v1/messaging/wabas/{waba_id}/flows/{flow_id} | Delete a draft Flow | messaging_waba.delete |
GET /v1/messaging/wabas/{waba_id}/flows/{flow_id}/assets | List the Flow's assets | messaging_waba.list |
POST /v1/messaging/wabas/{waba_id}/flows/{flow_id}/assets | Replace the Flow's Flow JSON | messaging_waba.update |
POST /v1/messaging/wabas/{waba_id}/flows/{flow_id}/publish | Publish the Flow | messaging_waba.update |
POST /v1/messaging/wabas/{waba_id}/flows/{flow_id}/deprecate | Retire a published Flow | messaging_waba.update |
POST /v1/messaging/wabas/{waba_id}/flows/{flow_id}/messages | Send a message that opens the Flow | messaging_waba.create |
Create a Flow
curl -sS -X POST "https://sabi.metatech.id/v1/messaging/wabas/wb_xxxxxxxx/flows" \
-H "Authorization: Bearer cpt_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Appointment booking",
"categories": ["APPOINTMENT_BOOKING"],
"flow_json": { "version": "5.0", "screens": [ "…" ] }
}'
name and categories are required. categories holds one or more of Meta's values: SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER. The optional fields are Meta's too:
flow_json— the Flow JSON, as a JSON object or as a string that holds one. At most 10 MB.publish—trueasks Meta to publish the Flow when it creates it.clone_flow_id— copy another Flow. It must be a Flow of the same account.endpoint_uri— the URL of the endpoint that a data-exchange Flow calls. SambungAPI sends it to Meta unchanged. To use the endpoint that SambungAPI hosts, set it to theendpoint_urithatPUT .../flows/{flow_id}/endpointanswers (see Host a WhatsApp Flow endpoint below).
A field that is not in this list is refused with 400 Messaging_InvalidInput before Meta is called, and so is a category Meta does not document. The answer is 201:
{
"data": {
"source": "meta_graph",
"operation": "create",
"waba_id": "wb_xxxxxxxx",
"meta_waba_id": "161311403722088",
"observed_at": "2026-09-23T12:00:05.123456Z",
"flow_id": "1181114100000001",
"success": true,
"validation_errors": [
{
"error": "INVALID_PROPERTY_VALUE",
"error_type": "FLOW_JSON_ERROR",
"message": "Invalid value found for property 'type'.",
"line_start": 10,
"line_end": 10,
"column_start": 21,
"column_end": 34,
"pointers": [ "…" ]
}
],
"raw": { "id": "1181114100000001", "success": true, "validation_errors": [ "…" ] }
},
"meta": { "request_id": "req_xxxxxxxx" }
}
Meta can create a Flow that has errors. validation_errors is Meta's list, unchanged. Meta does not publish a Flow until every error is resolved.
Change a Flow
PATCH .../flows/{flow_id} takes one or more of name, categories and endpoint_uri. POST .../flows/{flow_id}/assets takes {"flow_json": …} and sends it to Meta as the file flow.json with asset_type FLOW_JSON, which are the two values Meta requires. publish, deprecate and DELETE take no body.
Each of these answers 200 with Meta's success, Meta's validation_errors when Meta gives them, and Meta's whole answer as raw. The answer does not state the Flow's status after the change. Read the Flow to get the status Meta reports now.
Meta decides what each status allows. Meta's Flows API reference gives these rules:
| Status | What Meta allows |
|---|---|
DRAFT | Update, delete and publish. Send with mode: draft. |
PUBLISHED | Send, and deprecate. Meta does not allow an update or a delete. |
DEPRECATED | Nothing. A deprecated Flow cannot be sent, deleted or restored. |
BLOCKED | Nothing. Meta blocks a Flow whose endpoint is unhealthy. It cannot be sent or opened. |
THROTTLED | Send, at most 10 messages an hour. Meta throttles a Flow whose endpoint is unhealthy. |
SambungAPI does not predict these refusals. It sends the operation to Meta, and when Meta refuses it, the answer is 409 Messaging_FlowRefused. The message names the operation, Meta's call and Meta's error code, for example Meta refused to update the Flow's metadata (POST /{FLOW-ID}). A Flow JSON upload that Meta refuses names the asset operation in the same way. No error message contains the account's token.
Read Flows
GET .../flows answers flows, a list of {flow_id, name, status, categories, validation_errors}. When Meta says there is another page, next_after is Meta's cursor: send it back as ?after=. GET .../flows/{flow_id} answers flow, which also holds json_version, data_api_version, endpoint_uri, preview, application and meta_waba_id. A field that Meta did not return is null. status is Meta's word, unchanged. GET .../assets answers assets, a list of {name, asset_type, download_url}.
Whose Flow it is
Meta finds a Flow by its id alone, not by the account. So before every operation on one Flow, and before a create with clone_flow_id, SambungAPI reads the Flow's whatsapp_business_account from Meta. The operation continues only when that is the account in the path. A Flow of another account and a Flow that Meta does not hold get the same answer, 404 Messaging_FlowNotFound, so a Flow id tells you nothing about other accounts. An account in another organization, or one that does not exist, is 404 Messaging_WabaNotFound, and Meta is not called.
Send a Flow message
curl -sS -X POST \
"https://sabi.metatech.id/v1/messaging/wabas/wb_xxxxxxxx/flows/1181114100000001/messages" \
-H "Authorization: Bearer cpt_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"phone_number_id": "106540352242922",
"to": "+6281234567890",
"mode": "published",
"flow_cta": "Book now",
"body": "Pick a time for your visit.",
"flow_action_payload": { "screen": "WELCOME_SCREEN" }
}'
SambungAPI writes Meta's interactive Flow message from these fields and names the Flow by the id in the path. phone_number_id must be a business phone number of the same account. The fields:
| Field | Required | What it is |
|---|---|---|
phone_number_id | yes | The business phone number that sends the message. |
to | yes | The recipient, country code first. |
mode | yes | draft opens the Flow's current draft. published opens its last published version. |
flow_cta | yes | The text of the button that opens the Flow. Meta advises 30 characters or fewer. |
body | yes | The message text. |
header, footer | no | Text above and below the body. |
flow_token | no | An identifier that you generate for this Flow message. |
flow_action | no | navigate (Meta's default) or data_exchange. |
flow_action_payload | no | {screen, data}: the first screen, and optional data for it. data must be a non-empty object. |
mode is required. Meta uses published when a message does not state a mode. SambungAPI asks you to state it, so that a message you meant as a test of a draft does not reach a customer as the published Flow.
The send reads the Flow from Meta first. It checks the Flow's account and reads Meta's status. A Flow that Meta reports as DEPRECATED or BLOCKED is refused with 409 Messaging_FlowNotSendable, and nothing is sent. Any other status goes to Meta, and Meta decides. For example, Meta's guide says that a published Flow that was edited later reads as a draft, and that mode: published still sends its last published version.
A send can outlast the 30-second budget. When its 504 Request_Timeout carries the header x-should-retry: false, the send to Meta has started and continues to its end. Sending the message again can deliver it twice, so wait one minute, read the Send log (GET /v1/messaging/sends with organization_id and the recipient's contact), and send it again only if the message is not there. A 504 without that header sent nothing to Meta.
The answer is 201:
{
"data": {
"source": "meta_graph",
"operation": "send",
"waba_id": "wb_xxxxxxxx",
"meta_waba_id": "161311403722088",
"observed_at": "2026-09-23T12:00:05.123456Z",
"flow_id": "1181114100000001",
"mode": "published",
"flow_status": "PUBLISHED",
"phone_number_id": "106540352242922",
"contact": "6281234567890",
"wamid": "wamid.HBgNNjI4MTIzNDU2Nzg5MBUCABEYEjQ",
"message_id": "om_xxxxxxxxxxxxxxxxxxxx",
"raw": { "messaging_product": "whatsapp", "messages": [ { "id": "wamid.HBgNNjI4MTIzNDU2Nzg5MBUCABEYEjQ" } ] }
},
"meta": { "request_id": "req_xxxxxxxx" }
}
flow_status is the status Meta reported just before the send. It never comes from mode: a draft Flow sent with mode: published still reads DRAFT. The message is recorded like a reply sent from the console, with type interactive, and it appears in the contact's thread. When Meta refuses the send, the refusal is recorded and answered like a refused console reply.
The gateway send is unchanged. POST /v1/messaging/gateway/{phone_number_id}/messages still forwards a Flow message that you write yourself to Meta, byte for byte, and checks nothing about the Flow.
Refusals
400 Messaging_InvalidInput, before Meta is called: a field the route does not take, a missing required field, a category Meta does not document, a malformed Flow id, aflow_jsonthat is not one JSON object or is larger than 10 MB, or amodethat is notdraftorpublished.404 Messaging_FlowNotFound: Meta does not hold the Flow, or another account owns it.409 Messaging_FlowRefused: Meta refused the operation. The message names the operation and Meta's code.409 Messaging_FlowNotSendable: Meta reports the Flow asDEPRECATEDorBLOCKED.429 Messaging_GraphThrottled: Meta is throttling the account. Wait for the time inRetry-After.400 Messaging_GraphTokenInvalid,403 Messaging_GraphAccessDenied,502 Messaging_GraphErrorand502 Messaging_GraphUnavailablemean what they mean on every route that uses the stored token. For403, Meta's Flows API reference says the token needswhatsapp_business_managementandwhatsapp_business_messaging.
A Flow operation never changes the account's status. A send refused by Meta is the one exception, because it is a send, and it follows the same rules as a console reply.
Retention. SambungAPI stores no Flow, no status and no asset. The send stores the message record that a console reply stores.
The endpoint that a data-exchange Flow calls is the next section.
Host a WhatsApp Flow endpoint
A Flow that exchanges data asks an endpoint for each screen. WhatsApp sends that request encrypted, and the endpoint must answer encrypted. SambungAPI hosts the endpoint for you: it holds the key, checks Meta's signature, decrypts the request, checks it, and sends it to your Flow handler as plain JSON. Your handler decides the answer. SambungAPI encrypts the answer and returns it to Meta. Meta's guide is Implementing endpoints for Flows.
| Route | What it does | Grant |
|---|---|---|
POST /v1/messaging/wabas/{waba_id}/flow-encryption | Make the account's Flow key once, and sign its public key onto every business phone number at Meta | messaging_waba.update |
GET /v1/messaging/wabas/{waba_id}/flow-encryption | Read the public key, and the key Meta holds for each number | messaging_waba.read |
DELETE /v1/messaging/wabas/{waba_id}/flow-encryption | Remove the Flow key | messaging_waba.delete |
PUT /v1/messaging/wabas/{waba_id}/flows/{flow_id}/endpoint | Set your handler URL for one Flow | messaging_waba.update |
GET /v1/messaging/wabas/{waba_id}/flows/{flow_id}/endpoint | Read the Flow's endpoint | messaging_waba.read |
DELETE /v1/messaging/wabas/{waba_id}/flows/{flow_id}/endpoint | Remove the Flow's endpoint | messaging_waba.delete |
The credential and the authority are the same as for the Flow routes above.
Set it up
- Sign the Flow key.
POST .../flow-encryptionwith no body. The first call makes the Flow key, a 2048-bit RSA key pair, and answers201; a later call answers200and signs the same key again. Each business phone number of the account gets the public key through Meta'sPOST /{PHONE_NUMBER_ID}/whatsapp_business_encryption. Meta keeps one key for each number, so every Flow that a number sends uses this key. The answer holdskey.public_key_pemandkey.public_key_sha256. The private key is never shown. - Set the handler.
PUT .../flows/{flow_id}/endpointwith{"handler_url": "https://your.example/flows"}. SambungAPI first reads from Meta that the Flow belongs to this account. The first call answers201withsigning_secret(whsec_…). This is the only time the secret is shown. Keep it. A later call changeshandler_urland answers200withsigning_secret: null. Add"rotate_signing_secret": trueto get a new secret. - Point the Flow at it. The answer's
endpoint_uriishttps://sabi.metatech.id/webhooks/meta/{waba_id}/flows/{flow_id}. Set it on the Flow withPATCH .../flows/{flow_id}(or when you create the Flow), then publish. Meta sends a health check before it publishes, so your handler must answer it.
curl -sS -X PUT "https://sabi.metatech.id/v1/messaging/wabas/wb_xxxxxxxx/flows/1181114100000001/endpoint" \
-H "Authorization: Bearer cpt_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "handler_url": "https://your.example/flows" }'
{
"data": {
"source": "sambungapi",
"waba_id": "wb_xxxxxxxx",
"meta_waba_id": "161311403722088",
"flow_id": "1181114100000001",
"endpoint_uri": "https://sabi.metatech.id/webhooks/meta/wb_xxxxxxxx/flows/1181114100000001",
"handler_url": "https://your.example/flows",
"data_api_version": "3.0",
"key": { "present": true, "public_key_sha256": "5f2c…" },
"signing_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"created_at": "2026-09-23T12:00:00.000000Z",
"updated_at": "2026-09-23T12:00:00.000000Z"
},
"meta": { "request_id": "req_xxxxxxxx" }
}
GET .../flow-encryption reads Meta's key for each number. business_public_key_signature_status is Meta's word (VALID or MISMATCH), and matches_custody is true only when Meta holds the key that SambungAPI decrypts with. After you register a number again, Meta asks for the key again: call POST .../flow-encryption again.
What your handler receives
Each request that Meta sends to the Flow reaches your handler_url as one POST:
- The body is the decrypted request, byte for byte as Meta wrote it:
{"version": "3.0", "action": …, "screen": …, "data": …, "flow_token": …}. X-Sambungapi-Signature-256: sha256=<hex>is the HMAC-SHA256 of the body with yourwhsec_secret. Check it the same way as a delivery signature, before you read the body.X-Sambungapi-Flow-Requestishealth_check,error_notification,init,backordata_exchange.X-Sambungapi-Flow-Id,X-Sambungapi-Waba-IdandX-Request-Ididentify the Flow, the account and the request.
Answer in Meta's plain protocol, as Meta's own example getNextScreen does:
| Request | Your answer | What Meta receives |
|---|---|---|
health_check (action: ping) | {"data": {"status": "active"}} | exactly {"data":{"status":"active"}} |
error_notification (data.error is set) | {"data": {"acknowledged": true}} | exactly {"data":{"acknowledged":true}} |
init, back, data_exchange | {"screen": "NEXT", "data": {…}}, or {"screen": "SUCCESS", "data": {"extension_message_response": {"params": {"flow_token": …}}}} | your bytes, unchanged |
| a request with an expired Flow token | HTTP 427 with {"error_msg": "…"} | 427 with your object, which WhatsApp shows |
SambungAPI does not answer for your handler. Any other status, a redirect, an answer larger than 4 MiB, or an answer of another shape is not sent to Meta.
What Meta receives
| Status | When |
|---|---|
200 | Your answer, encrypted with the request's key and the flipped initial vector, as base64 text/plain |
400 | The decrypted request has a version other than 3.0, an action Meta does not document, or no flow_token. It does not reach your handler |
404 | The Flow has no endpoint |
421 | The request cannot be decrypted, or the account has no key. WhatsApp downloads the public key again and retries |
427 | Your handler said the Flow token is no longer valid |
432 | Meta's X-Hub-Signature-256 does not verify with the account's app secret. Nothing is decrypted |
502 | Your handler failed, redirected, answered too much, or answered another shape |
504 | Your handler did not answer in 8 seconds |
One attempt. A person waits on the phone while your handler runs, and a data_exchange can change data in your system. SambungAPI calls your handler once and does not retry. Meta's health-monitoring guide asks for an answer in less than one second.
Refusals on the routes: 400 Messaging_InvalidInput for a missing or non-http(s) handler_url, an unknown field, or a malformed Flow id; 404 Messaging_FlowNotFound when Meta does not hold the Flow or another account owns it; 404 Messaging_FlowEndpointNotFound and 404 Messaging_FlowKeyNotFound for a read or a delete of something that does not exist. The Meta errors of the key calls use the same slugs as the Flow routes. When Meta refuses the key for one number, the message says how many numbers got it before; the key stays, and the next call signs it again.
Rotation. DELETE .../flow-encryption, then POST .../flow-encryption. Between the two calls every endpoint of the account answers 421.
Retention and logs. SambungAPI stores the key pair (the private key encrypted with the server key), the handler URL, and the signing secret (encrypted). It never stores a Flow request or an answer. The log has one line for each request, with the ids, the request kind, the result and the status codes, and never the request, the answer, a Flow token, a key or a secret.
Preview a campaign before you create it
A campaign sends one approved message template to many Contacts of one organization. Check what it will do first, then create it:
POST /v1/control/proactive/policy-check
POST /v1/control/proactive/runs
Both take the same audience, and both accept at most 10,000 entries in
contacts. Every campaign you can create, you can preview. The preview
checks every Contact in the list and gives one outcome for each: eligible,
or suppressed with the reason. It never checks a sample.
The limit counts the entries you send, before a repeated number is removed.
A longer list gets 400 Proactive_InvalidInput, and the message gives the
limit. Divide a larger audience into more than one campaign. The console at
/app/proactive has the same limit: it shows a warning and sends nothing
when the selection is larger.
Send again after an outage
Meta refuses a send during an outage or a throttle, and a refused send is final. An event-triggered send is one run for each Contact, so one outage can leave many runs with one failed recipient each. Send their failed recipients again with one call:
POST /v1/control/proactive/runs/send-again
{"organization_id":"org_x","since":"2026-10-06T08:00:00Z","until":"2026-10-06T12:00:00Z","mode":"event_triggered","preview":true}
since is required: the call takes only the recipients that failed at or
after it. until is optional: the call then takes only the recipients that
failed at or before it, and an until before since gets
400 Proactive_InvalidInput. mode (campaign or event_triggered) and
waba_id narrow the runs. With preview: true nothing changes, and the
answer tells you what the call would do. Send the same body without preview
to do it.
The answer is {runs, recipients, remaining, remaining_recipients, skipped}.
One call takes at most 100 runs, the oldest failures first; remaining
counts the runs it did not take, and remaining_recipients their recipients.
Set until once, then send the same body again until remaining is 0. With
until fixed, a recipient that Meta refuses again during the loop is not
taken again by the same loop. A run whose WhatsApp Business Account cannot
send now is not changed, is not counted in remaining, and is listed in
skipped with the reason. A recipient that Send again of one run leaves out is
left out here too. When no run matches, the call answers
409 Proactive_RunTransitionRefused with reason=nothing_to_send_again. A
504 Request_Timeout leaves each run whole: send the same body again for the
rest.
Read the execution log
These calls to POST /v1/tools/{slug}/execute write a row:
- Every tool execution, successful or failed.
provider_outcome.duration_msis how long the provider call took, in milliseconds. A failed call's row also hasprovider_outcome.error_kind, the kind of failure (auth,bad_args,transient,not_foundorunknown), andprovider_outcome.provider_status, the provider's HTTP status (nullwhen the provider gave none). - Every call that SambungAPI refuses after it finds your Project, before it calls the provider: for example
404 ActionExecute_ConnectedAccountNotFoundwhen the user has no connected account for the toolkit, or404 Tool_NotFound. The row'sstatusis the refusal's HTTP status, andprovider_outcome.refusalholds itsslugandmessage.
These calls write no row: a call refused with 429 Quota_Exceeded (the usage summary shows the quota), a call refused before SambungAPI finds your Project (for example 401 for a missing or revoked key), a repeated idempotency key that gets the recorded answer again, and a call refused with 409 ActionExecute_IdempotencyKeyInUse because the first call with its key is still running.
Read the rows with a personal control-plane token or a console session:
GET /v1/control/execution_logs?project_id={project_id}
GET /v1/control/execution_logs/{log_id}?project_id={project_id}
{log_id} is the data.log_id the execute call returned. In the console the same rows are at /app/projects/{project_id}/logs.
Who reads the rows. Each member of the Project reads them: the Organization owner and the Organization admin of its Organization, and the project_admin and the project_member of the Project. A custom role reads them with an explicit execution_log.list grant (the list) or execution_log.read grant (one row) at the Project. A platform operator reads them too. An unknown project_id is 404, and a Project that you cannot reach is 403 Control_Forbidden.
What a row holds. Every reader sees the same fields: id, request_id, method, path, status (the HTTP status of the call), created_at, toolkit, user_id, connected_account_id, api_key_id, api_key, intent_id, implementation_id, resolution, ranked_alternatives, model_side_validity, idempotency_key and provider_outcome. api_key is the label of the project key that made the call — api_key_id, name, last4 and revoked — or null for a call from the console. provider_outcome holds only successful, refusal, error_kind, provider_status and duration_ms. No reader sees the provider's response or its error text. A row written before SambungAPI recorded the toolkit, the user and the connected account has null in those three fields.
The status of a row. A row is Refused when provider_outcome.refusal exists, Failed when provider_outcome.successful is false, and Succeeded in all other cases. A provider failure answers HTTP 200, so status does not tell a failure from a success.
The list. Rows come newest first. These filters narrow it: status (succeeded, failed or refused), toolkit, tool (the slug in path), api_key_id, user_id and since (an RFC 3339 instant; without it, the last 30 days). limit sets the page size: 100 by default, 500 at most. When more rows exist, meta.next_cursor is set: send it back as cursor to read the next page. meta.api_keys gives the label of each project key that a row in the window names. A malformed parameter is 400 Control_InvalidInput.
Rows are kept for 30 days. A row of a tool that changes data keeps the provider's response for 24 hours, to answer a repeated idempotency key, and then keeps only its metadata. A row of a tool that only reads data keeps no response, so a repeated idempotency key runs the read again as a new Usage unit. No row keeps the arguments of the call. A project can opt in to a bounded window of 1 to 30 days for the responses of tools that change data, except for the Google toolkits.
Read the usage summary
Every accepted tool execution is one Usage unit, and every outbound WhatsApp message the provider accepted is counted as well. Read what has been counted for one organization with a personal control-plane token:
GET /v1/control/usage?organization_id={org}&from=2026-09-01&to=2026-09-30
It carries counts only — never a payload, an argument, or a credential.
Who may read it. An organization administrator or above, or a custom role
holding usage.read. With project_id the bar is lower: any binding at
project_member or above that reaches that project reads that project.
The query
| Parameter | Meaning |
|---|---|
organization_id | Required. |
from, to | Inclusive UTC dates, YYYY-MM-DD. from alone means that one day; neither means today. |
granularity | day (default) or month. |
project_id | One project instead of the whole organization. |
A daily range is at most 92 days and a monthly range at most 13
months; a longer one answers 400 Control_InvalidInput, as does a
granularity outside the two values. A counter that cannot be read answers
503 Storage_Unavailable rather than a report of zeroes — zeroes would read
as "this tenant used nothing".
The response
{
"organization_id": "org_…",
"period": {"from": "2026-09-01", "to": "2026-09-30", "granularity": "day", "timezone": "UTC"},
"totals": {"accepted": 120, "succeeded": 118, "failed": 2, "unsettled": 0},
"quota": {"scope": "organization", "limit": 100000, "used": 6, "remaining": 99994, "…": "…"},
"periods": [{"period": "2026-09-01", "totals": {"accepted": 12, "…": "…"}}],
"projects": [
{
"project_id": "pr_…",
"totals": {"…": "…"},
"quota": {"scope": "project", "…": "…"},
"toolkits": [{"toolkit": "gmail", "totals": {"…": "…"}}],
"api_keys": [
{"api_key_id": "aki_…", "name": "production", "last4": "9f2c", "revoked": false,
"totals": {"…": "…"}}
],
"api_keys_counting_since": "2026-09-18",
"first_succeeded_on": "2026-08-12"
}
],
"messages": {
"counting_since": "2026-09-18",
"sends": {"accepted": 340},
"conversations": {"billable": 18, "by_category": {"marketing": 11, "utility": 7}},
"phone_numbers": [
{"waba_id": "wb_…", "phone_number_id": "…", "display": "+62 811 1111 1111",
"channel": "graph", "sends": {"accepted": 340},
"conversations": {"billable": 18, "by_category": {"marketing": 11, "utility": 7}}}
],
"periods": [
{"period": "2026-09-01", "sends": {"accepted": 12}, "conversations": {"billable": 1}}
]
}
}
periods[] holds one row per period that has usage — a day labelled
YYYY-MM-DD, or a calendar month labelled YYYY-MM. A month is a sum over
its UTC days, computed on read; a period with no usage is absent rather than
reported as zero.
api_keys[] names every project key that spent a unit in the range, and only
those: a key that spent nothing is absent, and a revoked key that spent
something is present with revoked: true. name and last4 are null for
a key id no metadata row names.
first_succeeded_on is the first UTC day on which the project completed a
tool execution, over all time. from, to and granularity do not change
it. It is null when the project has never completed one: a refused
execution and a provider failure do not set it.
messages.phone_numbers[] holds one entry per WABA, phone number and
channel. channel is graph. The value linked appears only on history
rows of the retired linked-device channel, which reports sends and never
conversations. display is the human-readable number when one of
your phone numbers matches, and null otherwise.
messages.periods[] is the same two counts by period, at the grain
periods[] uses and under the same rule: a period appears when either count
is non-zero, and the rows sum to messages.sends and
messages.conversations.billable.
messages.conversations counts messages, not conversations. billable is
the number of messages that Meta reported as charged (pricing.billable: true on a status webhook), counted once for each WhatsApp message id, and
by_category splits it by Meta's pricing category. Since 2026-10-01 Meta
charges replies (service) and utility templates inside an open customer
service window, so they appear here. The key keeps its name.
counting_since is the first UTC day the counter could have counted
anything. Nothing was backfilled, so a range before that day is empty by
construction rather than by accident.
project_id changes the shape. totals, quota, periods and
projects[] all describe that one project, quota.scope is project, and
there is no messages object: a message is scoped to a WABA and a phone
number, projects attach to WABAs many-to-many, so no project owns a send.
The machine-readable descriptions
/openapi.json— the public discovery surface, the tools surface with its request and response schemas, the project key mint, the delivery log, the Send log, the Meta analytics read and the business profile routes, as an OpenAPI document./v1/specs/control— the control-plane contract./v1/specs/tools— the tools contract, including the execute route'sabsolute_path./v1/specs/catalog— the tool catalog: each toolkit, and each tool with its input schema. It needs no credential./docs/mcp— how an MCP client reaches the same tools at/mcp, with a project key./v1/specs/messaging— the messaging contract./v1/specs/vocabulary— the vocabulary contract thatmeta.vocabulary_versionnames./llms.txt— the short product description for agents.
This page is the public rendering of the wire contract. It is not a second source of truth: where this page and /v1/specs/* disagree, the specs are right.
If you have no WhatsApp Business Account yet, /docs/try-whatsapp walks the same gateway routes on Meta's test account, from the Meta app to a signed delivery.
If you are moving here from the Composio SDK, read /docs/composio as well.