# 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.

## Mint a key first

SambungAPI has one kind of key for your software, the [API key](/glossary#glossary-term-api-key). Your [organization](/glossary#glossary-term-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](/glossary#glossary-term-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.

1. Send `POST /v1/control/key_requests` with `{"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 carries `device_code`, `user_code`, `verification_uri`, `verification_uri_complete`, `expires_in` (600) and `interval` (5).
2. Give the person `verification_uri_complete`, and show them `user_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.
3. Every `interval` seconds, send `POST /v1/control/key_requests/token` with `{"device_code": "…"}`. Keep `device_code` secret: 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.

```js
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](/glossary#glossary-term-connected-account): the provider authorization of one application user for one [toolkit](/glossary#glossary-term-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.

```bash
# 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`:

```json
{
  "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.

```bash
# 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`.

```bash
# 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
```

```bash
# 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"
      }'
```

```ts
// 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
# 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`:

```json
{
  "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

```bash
# 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:

```json
{
  "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.

```bash
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"
      }'
```

```ts
// 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
# 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`:

```json
{
  "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:

```json
{
  "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:

```bash
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" }
      }'
```

```ts
// 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
# 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](/glossary#glossary-term-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](/glossary#glossary-term-waba) 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:

```bash
curl -sS \
  "https://sabi.metatech.id/v1/messaging/gateway/me" \
  -H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx"
```

```json
{
  "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.

```bash
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" }
      }'
```

```json
{
  "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](/glossary#glossary-term-message-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](/glossary#glossary-term-label) your team applied, and the shared note. Same bearer, nothing written, Meta never called.

```bash
curl -sS \
  "https://sabi.metatech.id/v1/messaging/gateway/1234567890/contacts/+6281234567890" \
  -H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx"
```

```json
{
  "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](/glossary#glossary-term-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](/glossary#glossary-term-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](/glossary#glossary-term-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.

```bash
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" }'
```

```json
{
  "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`:

```json
{
  "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:

```json
{
  "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}
```

```bash
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
```

```bash
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:

```json
{ "id": "1234567890123456" }
```

Spend that id on the ordinary send, which is unchanged:

```json
{
  "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:

```json
{
  "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](/glossary#glossary-term-subscription). Each event that a subscription receives becomes one [delivery](/glossary#glossary-term-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 its `phone_number_id` names the number. A Warta key cannot send `project_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 send `project_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.

```bash
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](/docs/try-whatsapp#step-8-read-a-signed-delivery-at-your-own-endpoint).

**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](/glossary#glossary-term-campaign) or an [Event-triggered send](/glossary#glossary-term-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:

```json
{
  "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 `POST`s 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](/glossary#glossary-term-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
```

```bash
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.

```bash
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}
```

```bash
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 `id` starts with `om_`, `wamid` is the id Meta returned, and `status` is the newest status Meta reported for it (`sent`, `delivered`, `read`, `failed`), or `null` before the first one arrives.
- **Meta refused it**, or no answer came back. The `id` starts with `sf_`, and the entry reads `wamid: null`, `failure_reason` (a stable slug such as `recipient_unreachable` or `rate_limited`) and `failure_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 its `id` answers `200`, 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 |

```bash
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.

- `start` and `end` are UNIX timestamps in seconds, UTC. The `template` and `template_group` families also take `YYYY-MM-DD` dates, and need dates on both ends when `use_waba_timezone=true`. `end` must not be earlier than `start`.
- A list is one comma-separated value: `dimensions=COUNTRY,PHONE`. Leave a parameter out to mean every value; an empty value is refused.
- `phone_numbers` holds 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

```json
{
  "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 |

```bash
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:

```json
{
  "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 |

```bash
curl -sS -X POST \
  "https://sabi.metatech.id/v1/messaging/wabas/wb_xxxxxxxx/phones/106540352242922/quality/refresh" \
  -H "Authorization: Bearer cpt_xxxxxxxxxxxxxxxx"
```

### The rating

```json
{
  "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.

```json
{
  "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

```bash
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` — `true` asks 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 the `endpoint_uri` that `PUT .../flows/{flow_id}/endpoint` answers (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`:

```json
{
  "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

```bash
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`:

```json
{
  "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, a `flow_json` that is not one JSON object or is larger than 10 MB, or a `mode` that is not `draft` or `published`.
- `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 as `DEPRECATED` or `BLOCKED`.
- `429 Messaging_GraphThrottled`: Meta is throttling the account. Wait for the time in `Retry-After`.
- `400 Messaging_GraphTokenInvalid`, `403 Messaging_GraphAccessDenied`, `502 Messaging_GraphError` and `502 Messaging_GraphUnavailable` mean what they mean on every route that uses the stored token. For `403`, Meta's Flows API reference says the token needs `whatsapp_business_management` and `whatsapp_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](https://developers.facebook.com/docs/whatsapp/flows/guides/implementingyourflowendpoint/).

| 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

1. **Sign the Flow key.** `POST .../flow-encryption` with no body. The first call makes the Flow key, a 2048-bit RSA key pair, and answers `201`; a later call answers `200` and signs the same key again. Each business phone number of the account gets the public key through Meta's `POST /{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 holds `key.public_key_pem` and `key.public_key_sha256`. The private key is never shown.
2. **Set the handler.** `PUT .../flows/{flow_id}/endpoint` with `{"handler_url": "https://your.example/flows"}`. SambungAPI first reads from Meta that the Flow belongs to this account. The first call answers `201` with `signing_secret` (`whsec_…`). **This is the only time the secret is shown.** Keep it. A later call changes `handler_url` and answers `200` with `signing_secret: null`. Add `"rotate_signing_secret": true` to get a new secret.
3. **Point the Flow at it.** The answer's `endpoint_uri` is `https://sabi.metatech.id/webhooks/meta/{waba_id}/flows/{flow_id}`. Set it on the Flow with `PATCH .../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.

```bash
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" }'
```

```json
{
  "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 your `whsec_` secret. Check it the same way as a delivery signature, before you read the body.
- `X-Sambungapi-Flow-Request` is `health_check`, `error_notification`, `init`, `back` or `data_exchange`.
- `X-Sambungapi-Flow-Id`, `X-Sambungapi-Waba-Id` and `X-Request-Id` identify 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](/glossary#glossary-term-meta-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_ms` is how long the provider call took, in milliseconds. A failed call's row also has `provider_outcome.error_kind`, the kind of failure (`auth`, `bad_args`, `transient`, `not_found` or `unknown`), and `provider_outcome.provider_status`, the provider's HTTP status (`null` when the provider gave none).
- Every call that SambungAPI refuses after it finds your Project, before it calls the provider: for example `404 ActionExecute_ConnectedAccountNotFound` when the user has no connected account for the toolkit, or `404 Tool_NotFound`. The row's `status` is the refusal's HTTP status, and `provider_outcome.refusal` holds its `slug` and `message`.

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

```json
{
  "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's `absolute_path`.
- `/v1/specs/catalog` — the tool catalog: each toolkit, and each tool with its input schema. It needs no credential.
- [`/docs/mcp`](/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 that `meta.vocabulary_version` names.
- `/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](/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](/docs/composio) as well.
