# What SambungAPI owns

The choice is not Meta's WhatsApp Cloud API against SambungAPI's API. Both call the same [Cloud API](/glossary#glossary-term-cloud-api), and for one send the request body is the same either way. The choice is whether you build and keep running the machinery around that send — or whether this service holds it for you.

That machinery is what this page lists. It is a count of mechanisms, not of days: each row names something that exists in the build serving this page, and where a capability is internal the page says so rather than implying a call you cannot make.

## The ledger

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. It is an API key with a channel part, and the same key can also hold a Tools part, so one key runs tools and sends messages.

| What SambungAPI owns | What you build and keep running yourself |
| --- | --- |
| **Webhook verification.** The `GET` challenge compares verify-token digests and echoes Meta's exact challenge. The `POST` verifies `X-Hub-Signature-256` as an HMAC-SHA256 over the raw request bytes *before* any JSON parsing, and answers `401` when it is absent or wrong. A verified body this build cannot parse is acknowledged `200`, so a parse bug cannot invite a redelivery storm; a storage failure answers `500`, so Meta does redeliver. | Keep the raw bytes before you parse them, find the [app secret](/glossary#glossary-term-meta-app-secret) that belongs to the callback, compare in constant time, implement the challenge, and decide which of your own failures Meta should retry and which it should not. |
| **Retry with backoff.** A subscriber delivery is written in the same database transaction as the change that caused it, then attempted outside it. The default profile is nine attempts — immediate, 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 24 hours — with up to 25% jitter and a `Retry-After` honoured to a 24-hour cap. A `2xx` from an endpoint sends at once, one time per wait, each delivery to it that waits after a `3xx`, a `5xx`, a timeout or a connection error; such an early attempt does not count when it fails. Heads are claimed per [subscription](/glossary#glossary-term-subscription) and queue under a lease, so a crashed worker's work is recovered and one slow endpoint cannot hold the queue. | A durable queue, a claiming strategy, failure classification, lease recovery, bounded concurrency and timeouts. Meta's contract ends at its own HTTP response; everything behind that response is yours. |
| **Dead-lettering, replay and retention.** An exhausted delivery lands in a terminal `failed` state rather than disappearing, and replay lives beside it in the same engine. Both are calls you make: `GET /v1/messaging/deliveries` reads the log with your own [Warta key](/glossary#glossary-term-warta-key), and `POST /v1/messaging/deliveries/{delivery_id}/replay` queues one failed delivery again — `202` means queued, not sent. Metadata is purged at 30 days; the Execution log keeps the provider's response to a tool call that changes data for 24 hours by default, to answer a repeated idempotency key, and keeps no response to a read; a longer window is an explicit per-project opt-in bounded to 30 days, and never applies to a Google [toolkit](/glossary#glossary-term-toolkit). Deleting an [organization](/glossary#glossary-term-organization) is a scoped, idempotent job that revokes reach first, hard-deletes the rows and objects that organization owns, keeps the append-only audit for its retention period, and leaves a minimal tombstone. | A terminal state for work that will never succeed, a copy of the event that is safe to replay from, a retention policy that is enforced rather than intended, and a deletion you can run twice without a different answer. |
| **Dedupe across at-least-once delivery.** Meta delivers each event at least once. Evidence and fan-out commit in one transaction; statuses are unique per account on `(wamid, status)` and inbound messages on `(wamid)`; each event carries a deterministic `evt_` id derived from the account, the event type, the message id and the status; and the queue's `(event_id, endpoint)` uniqueness absorbs a second enqueue. Subscribers receive a dated envelope version, an `X-Sambungapi-Signature-256` over the exact stored bytes, and the [event id](/glossary#glossary-term-event-id) as `Idempotency-Key` — byte-identical on every retry. | Choose a canonical identity, decide what counts as a duplicate transition rather than a new one, make the dedupe write atomic with the enqueue that follows it, and give your own subscribers an authenticity and idempotency contract of your own. |
| **Credential custody and repair.** The account's access token, app secret and verify token live in one row encrypted with AES-256-GCM behind a versioned key ring. The key version travels inside the ciphertext rather than in a column, so rotation needs no migration and a retired key stays decrypt-only. Reconnect verifies a fresh token against Meta before it replaces [custody](/glossary#glossary-term-custody), and the token-info route reads Meta's own verdict on the stored token without revealing it. Meta's error code `190` is classified as an invalid or expired token; `200` and `10` as lost access to the asset. | Pick a token type, keep the token and the app secret out of logs and ordinary rows, encrypt and rotate custody, watch validity and permissions, and build a repair path that never hands the token back out. |
| **Media, both legs.** An inbound read resolves Meta's handle to bytes and checks that the download URL Meta returned names a host Meta actually serves media from — *before* the bearer credential travels to it. Declared size and streamed size are both bounded, and the content type is preserved. The outbound upload is its own route, and a [message template](/glossary#glossary-term-message-template)'s header asset is a separate two-call resumable upload with a different auth and body shape. | Both legs of a read, safe authentication of a URL the provider handed you, a size bound that survives a chunked response, and the template asset flow as a second implementation beside the first. |
| **Message templates.** Create, list, read, edit, delete, refresh and header-asset upload are a regular REST family rather than a mirror of Meta's asymmetric verb shape. Meta's changing status vocabulary is projected into a small closed vocabulary on the wire while Meta's own value is kept as evidence, and a status event this build does not recognise is refused rather than guessed at. | Provider CRUD, a local projection with name-and-language identity, component validation, asynchronous review-status ingestion, and a reconciliation read for when your copy and Meta's disagree. |
| **Phone registration and PINs.** Requesting a code, verifying it, registering and deregistering are four routes over Meta's sequence. Registration refuses a PIN that is not exactly six digits before Meta is called, mints one for each call when none is sent and keeps it nowhere, and treats an already-registered answer as success. Deregistration is idempotent. No route returns a PIN. For a number that Meta refuses without its PIN, `reset_pin: true` sets a new PIN at Meta and registers the number; the console offers it as one button, Set a new PIN. | The provider sequence, a PIN for each register call and a rule for where it is kept that you can defend, the code flow with its method and language, already-registered against genuinely failed, and a recovery path for a PIN nobody has. |
| **The tenant boundary.** Installation, organization, [WhatsApp Business Account](/glossary#glossary-term-waba) and business phone number are an explicit hierarchy, and PostgreSQL row-level security is forced on the messaging tables — so an isolation slip is refused by the database rather than by a remembered `WHERE`. Two public paths cannot know the tenant in advance: the [webhook](/glossary#glossary-term-webhook) resolves the account from its own path, and the gateway resolves a Warta key by hash, because the raw key is never stored. A resource in another organization answers not-found rather than forbidden, so the surface is not an existence oracle. | The hierarchy and its ownership checks, authorization in front of every provider call, an isolation boundary the database itself enforces, a capability lookup for the requests that arrive with no tenant context, and cross-tenant tests around all of it. |
| **Audit.** Security audit events are append-only — the database raises an exception on an update — and their metadata is recursively redacted for any key containing `token`, `secret` or `password`, and for `authorization`. The worker deletes each event at the end of its retention period, 365 days by default, and first writes a signed purge manifest, so a verification of the chain still covers the events that it deleted. The worker signs the root of the chain each day with an Ed25519 key that the database does not hold, so a verification cannot answer verified over a changed event, even when the stored roots were changed with it. The worker also keeps a signed copy of the newest root in file storage, outside the database, so a verification finds a deletion of the newest events even when the newest stored roots were deleted with them. The events written after that copy, at most one day of them, are not covered by it. Execution logs separately carry the project, request id, method, path, status, provider outcome and idempotency key on a 30-day read window. The webhook ingest log records counts only: no subscriber URL, no envelope, no payload, no signature. | An append-only trail over credential changes, authorization decisions and destructive operations; correlation between provider ids and your own request ids; a redaction policy; an operator read path; and a decision about which records survive a customer's deletion. |

## What exists today

Every row above describes this build, at the commit that serves this page.

The send itself is the shortest way to see the shape of the trade. SambungAPI's gateway is a forwarder, not a wrapper: your request body is forwarded to Meta unchanged, and Meta's status and body come back unchanged. So the body is the same in both of these, and the difference is entirely in what stands behind the URL.

```http
# Your own integration, straight at Meta:
POST https://graph.facebook.com/v26.0/{phone_number_id}/messages
Authorization: Bearer <a token you mint, protect, rotate and repair>

# The same send through sambungapi:
POST https://sabi.metatech.id/v1/messaging/gateway/{phone_number_id}/messages
Authorization: Bearer ww_...
```

The Warta key behind the second call is hashed at rest and scoped to an organization or to one business phone number, and `GET /v1/messaging/gateway/me` tells you which numbers it reaches without calling Meta at all.

Around that send, the build holds: the signed Meta webhook receiver and its subscriber fan-out, with retries and dead-lettering; inbound media reads and outbound media uploads on the business phone number; the message template family, including the header-asset upload; the WhatsApp Flows family, from create to publish, with a managed Flow message and the encrypted endpoint that a data-exchange Flow calls; the phone registration lifecycle, with a PIN minted for each register call and kept nowhere; the quality rating of each business phone number, with the throughput, messaging limit, restriction and alert changes that Meta reports by webhook; credential custody in two shapes — the Meta-hosted [Embedded Signup](/glossary#glossary-term-embedded-signup) flow, where the business grants access and hands over no token, and [manual custody](/glossary#glossary-term-manual-custody), where a pasted token can later be replaced by a reconnect; the tool execute surface and its execution log; and a console with a Playground that sends a real request and hands you the same request as code.

The ledger and this section describe the build, not a roadmap: an item joins them when it merges. The table below is the only place this page names work that has not merged, and it labels every such row `planned`.

## Meta capability coverage

Meta's WhatsApp Cloud API covers more than messaging. This table states what this build does with each Meta surface that a developer asks about. Every row has one status:

- `landed` — in this build. The row names the route you call.
- `planned` — accepted scope with a numbered build issue. It is not in this build, and nothing on a `planned` row is a call you can make today.
- `transport-only` — the gateway forwards your payload to Meta unchanged, but this build does not manage the surface.
- `gap` — outside the target scope. You use Meta directly.

Each status is true at the commit that serves this page. A row changes status in the same commit as the build that changes it.

| Meta surface | Status | What this build does | Where to read more |
| --- | --- | --- | --- |
| Messaging limit | `landed` | Reads the Business Portfolio messaging limit from Meta through one of the account's business phone numbers, with the account's stored credential, and stores it with the time of the read. Meta's documented tiers read as 250, 2,000, 10,000 and 100,000 recipients, and `TIER_UNLIMITED` as no portfolio ceiling. [Campaigns](/glossary#glossary-term-campaign) and [event-triggered sends](/glossary#glossary-term-event-triggered-send) spend that observation: admission pauses when the limit is unknown, older than one hour, or spent. This build has not yet read the field from a live account, so an answer it cannot read is stored as evidence and pauses admission instead of guessing. | [`POST /v1/messaging/wabas/{waba_id}/messaging-limit-refresh`](/v1/specs/messaging) · [`POST /v1/control/proactive/policy-check`](/v1/specs/control) · [Meta: messaging limits](https://developers.facebook.com/docs/whatsapp/messaging-limits/) |
| Phone quality rating | `landed` | Reads the quality rating of a business phone number from Meta with the account's stored credential, and stores Meta's answer with the time of the read. The answer gives Meta's own rating and a verdict. The verdict is `high` only when Meta answered `GREEN` less than 24 hours before, so an old answer, or an answer this build cannot read, never shows as good quality. It also records the throughput, messaging limit, restriction and alert changes that Meta reports by webhook, each with Meta's whole payload. None of those changes sets a quality rating: Meta's webhooks do not send one. | [`GET /v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/quality`](/v1/specs/messaging) · [`POST /v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/quality/refresh`](/v1/specs/messaging) · [`GET /v1/messaging/wabas/{waba_id}/quality`](/v1/specs/messaging) · [`GET /v1/messaging/wabas/{waba_id}/account-changes`](/v1/specs/messaging) · [Meta: business phone numbers](https://developers.facebook.com/docs/whatsapp/cloud-api/phone-numbers/) |
| Business profile | `landed` | Reads the business profile of a business phone number from Meta with the account's stored credential — About, address, description, email, websites, category and profile picture URL — and stores Meta's answer with the time of the read. An update sends only the fields you give, holds each to Meta's limits before Meta is called, and then reads the profile back, so the answer is what Meta holds rather than what you sent. A field that Meta adds stays in Meta's stored answer and does not break the read. | [`GET /v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/business-profile`](/v1/specs/messaging) · [`PATCH /v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/business-profile`](/v1/specs/messaging) · [`POST /v1/messaging/wabas/{waba_id}/phones/{phone_number_id}/business-profile/refresh`](/v1/specs/messaging) · [Meta: business profiles](https://developers.facebook.com/docs/whatsapp/cloud-api/reference/business-profiles/) |
| Business profile delete | `gap` | Outside the target scope. Meta deletes a business profile only by deleting the business phone number itself, so this build offers no separate profile delete. | [Meta: business profiles](https://developers.facebook.com/docs/whatsapp/cloud-api/reference/business-profiles/) |
| Meta analytics | `landed` | Reads Meta's own messaging, conversation, pricing, template, template group, call and group analytics for a WhatsApp Business Account with the account's stored credential. The read is read-only: each family takes only the parameters Meta documents for it, a start outside Meta's lookback is refused before Meta is called, and the answer carries Meta's data points unchanged beside Meta's whole response. This build stores none of it. | [`GET /v1/messaging/wabas/{waba_id}/analytics/{family}`](/v1/specs/messaging) · [Meta: analytics](https://developers.facebook.com/docs/whatsapp/business-management-api/analytics/) |
| Flow messages | `landed` | Sends a Flow message that this build writes from the fields you name: the business phone number, the recipient, the button text, the body, and `mode`, which you must set to `draft` or `published`. Before the send, it reads from Meta which account owns the Flow and what status the Flow has. It refuses a Flow that Meta reports as deprecated or blocked, and the answer gives Meta's status unchanged. The gateway still forwards a Flow message that you write to Meta unchanged. | [`POST /v1/messaging/wabas/{waba_id}/flows/{flow_id}/messages`](/v1/specs/messaging) · [`POST /v1/messaging/gateway/{phone_number_id}/messages`](/docs/api) · [Meta: sending a Flow](https://developers.facebook.com/docs/whatsapp/flows/guides/sendingaflow/) |
| Flow lifecycle | `landed` | Creates, lists, reads, updates, publishes, deprecates and deletes the Flows of a WhatsApp Business Account through Meta's Flows API with the account's stored credential, and replaces a Flow's Flow JSON through Meta's asset upload. Before each operation on one Flow, it checks with Meta that the Flow belongs to the account in the path. Meta decides what each Flow status allows, and a refusal from Meta is reported as Meta's refusal. This build stores no Flow. | [`POST /v1/messaging/wabas/{waba_id}/flows`](/v1/specs/messaging) · [`POST /v1/messaging/wabas/{waba_id}/flows/{flow_id}/assets`](/v1/specs/messaging) · [`POST /v1/messaging/wabas/{waba_id}/flows/{flow_id}/publish`](/v1/specs/messaging) · [`POST /v1/messaging/wabas/{waba_id}/flows/{flow_id}/deprecate`](/v1/specs/messaging) · [Meta: Flows API](https://developers.facebook.com/docs/whatsapp/flows/reference/flowsapi/) |
| Flow endpoint | `landed` | Hosts the endpoint that a data-exchange Flow calls. This build holds the account's business key pair and signs the public key onto each business phone number at Meta. For each request, it verifies Meta's signature, decrypts the request, checks the data API version and the action, sends the request to your Flow handler, and encrypts your handler's answer for Meta. The INIT, BACK, data-exchange, health-check and error-notification requests all go to your handler. The private key is never shown, and the request is never stored. | [`POST /v1/messaging/wabas/{waba_id}/flow-encryption`](/v1/specs/messaging) · [`PUT /v1/messaging/wabas/{waba_id}/flows/{flow_id}/endpoint`](/v1/specs/messaging) · [Meta: implementing a Flow endpoint](https://developers.facebook.com/docs/whatsapp/flows/guides/implementingyourflowendpoint/) |

Three distinctions hold the table together:

- **Local message and delivery evidence is not Meta analytics.** The delivery log (`GET /v1/messaging/deliveries`) and the stored message statuses record what passed through this build. They are not Meta's analytics, and this build does not present them as Meta's figures. Meta's figures come only from the analytics route, whose answer names Meta as its source and holds no local count.
- **Flow payload transport is not Flow lifecycle management.** A Flow message that reaches Meta through the gateway is your payload, forwarded unchanged, and the gateway checks nothing about the Flow that it opens. The Flow lifecycle routes manage the Flow itself, and the managed Flow send reads the Flow's owner and status from Meta before it sends. The Flow endpoint is a third thing: it answers the encrypted requests that a Flow sends while a person uses it, and your handler decides each answer.
- **Send health is not phone quality rating.** The `can_send_message` field on a WhatsApp Business Account, which this build reads from Meta's `health_status`, states whether Meta lets the account send. It is not Meta's phone-level `quality_rating`, and this build does not derive a quality rating from it. This build reads `quality_rating` from each business phone number itself, and the messaging limit stays a third, separate value.

## The boundary Meta keeps

SambungAPI does not stand between you and Meta's decisions, and it would be dishonest to imply otherwise.

**Meta decides acceptance.** Meta approves a message template before it can be sent, gives it an approval status, and can change that status later. Meta decides whether a business phone number may send, and outside the customer service window only an approved message template reaches a user at all.

**Meta delivers the message.** The gateway returns Meta's own status and body verbatim, so a refusal reaches you exactly as Meta wrote it — there is no place in that path for SambungAPI to soften it.

**Meta bills.** Meta prices the conversation, the message template's category sets that price, and Meta can change the category.

**The Meta assets stay yours.** The WhatsApp Business Account and its business phone numbers are your own Meta assets. SambungAPI holds credentials for them and calls Meta on their behalf; it does not become their owner, and what it holds can be replaced or revoked.

## Where to go next

- **[Read the API guide](/docs/api).** It is public, it needs no key, and it states the first call end to end: where to mint a credential, the two request shapes, and the envelopes that come back.
- **[Try a call in the Playground](/app).** A project key is the credential your software uses to run tools for one project. The Playground sends one real request with a [project key](/glossary#glossary-term-api-key) or a Warta key, shows the response, and renders the same request as code. Its path carries your project id — `/app/projects/{project_id}/playground` — so the console is the entrance.
- **[Send a first WhatsApp message](/docs/try-whatsapp).** If you hold no WhatsApp Business Account yet, the try path walks you from your own Meta app to a reply arriving signed at your own endpoint, on Meta's test account, and states what that walk does not prove.
- **[Moving from another provider?](/docs/migrate-whatsapp)** If your business phone number already sends through Kirimdev, Qiscus, WATI or someone else, the migration guide says how you connect the same number here, what you build again, and how your source provider leaves the number. Meta no longer moves a number between providers: you share it, and nothing is imported.
