# Your first WhatsApp message

This walk takes you from your own Meta app to a WhatsApp reply arriving, signed, at an endpoint you control — on a test [WhatsApp Business Account](/glossary#glossary-term-waba), before a production one exists.

Nothing below is simulated. The test WhatsApp Business Account is a real account with relaxed limits, the send is the same send a production account makes, and the message reaches a real phone through Meta's WhatsApp Cloud API. What the walk cannot prove is listed at the end, before you build on it.

You need a Meta developer account, the phone you will message yourself on, and a SambungAPI [organization](/glossary#glossary-term-organization) with a [project](/glossary#glossary-term-project) in it. 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. Step 4 mints one.

## Step 1 — take the ids and the token from Meta

**Where:** Meta's App Dashboard, under **WhatsApp → API Setup** in your own Meta app.

Meta creates a test WhatsApp Business Account and a test business phone number for you there. Take four things from the page:

1. The **WhatsApp Business Account id**.
2. The **phone number id** of the test number. This is not the phone number itself — it is the long numeric id printed beside it, and it is what every send below puts in the path.
3. The **temporary access token**. Meta calls it short-lived and says it is not the token to develop against; step 9 replaces it.
4. Your own phone, added under **To** and verified with the code Meta sends you over WhatsApp.

The same page lists a [message template](/glossary#glossary-term-message-template) named `hello_world`. Meta pre-approves it on the test account, and it is what step 5 sends.

**At the end of this step** the page shows both ids, a token you have copied somewhere safe, and your own number listed as a verified recipient.

## Step 2 — connect the account by manual custody

**Where:** the developer console, **Business accounts** (`/app/business-accounts`) → **Connect**.

[Manual custody](/glossary#glossary-term-manual-custody) is the connection path for an organization that brings its own Meta app, which is exactly what you built in step 1. It takes three fields:

| Field | What to paste |
| --- | --- |
| [WABA id](/glossary#glossary-term-waba-id) | The WhatsApp Business Account id from step 1. |
| [System-user token](/glossary#glossary-term-system-user-token) | The **temporary** token from step 1. The label names the token you will finish on, not the one you start with — step 9 is where you swap it. |
| [Meta app secret](/glossary#glossary-term-meta-app-secret) | Your Meta app's secret, from **App settings → Basic**. SambungAPI verifies the signature on every [webhook](/glossary#glossary-term-webhook) Meta sends with it. It is encrypted at rest and never shown again. |

SambungAPI verifies the token against Meta before it stores anything, then connects every business phone number on the account.

**At the end of this step** the completion card shows a **Callback URL** and a **Verify token**, and the webhook line reads *Waiting for Meta's challenge*.

## Step 3 — point Meta's webhook at SambungAPI

**Where:** Meta's App Dashboard, **WhatsApp → Configuration**.

Paste the callback URL and the verify token from step 2 into **Webhook** and save. Then open the subscribed fields beside it and subscribe to **`messages`**.

Meta sends a `GET` challenge to the callback the moment you save. SambungAPI compares the verify-token digests, echoes Meta's exact challenge back, and marks the account verified.

**At the end of this step** the console's webhook line reads *Verified … Delivery statuses and inbound messages reach the platform.* The card re-checks every few seconds on its own, so you do not need to reload it.

Sends work before this step. Inbound messages do not, and steps 6, 7 and 8 all depend on them.

## Step 4 — mint a Warta key

**Where:** the developer console, **Business accounts** (`/app/business-accounts`), on the account you just connected.

Name a key and mint it at **whole organization** scope. The scope sets what the [subscription](/glossary#glossary-term-subscription) of step 8 receives: a key at whole-organization scope registers a subscription for every business phone number of the organization, and a key scoped to one business phone number registers a subscription that is narrowed to its own number.

Over the API the same mint is `POST /v1/messaging/wabas/{waba_id}/keys`. A Warta key is an API key with a channel part: `POST /v1/control/organizations/{organization_id}/keys` mints one key with a channel part and a Tools part, if the same software also runs tools.

**At the end of this step** the reveal panel shows the raw `ww_…` key once. The server keeps only a hash of it, so a key you did not copy is replaced, never recovered.

## Step 5 — send `hello_world`

**Where:** your own terminal.

The gateway is a forwarder: your body reaches Meta unchanged, and Meta's status and body come back unchanged. The `1234567890` in the path is the phone number id from step 1, and the `to` is your own verified number.

```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": "template",
        "template": {
          "name": "hello_world",
          "language": { "code": "en_US" }
        }
      }'
```

Meta answers with the message id it assigned:

```json
{
  "messaging_product": "whatsapp",
  "contacts": [{ "input": "+6281234567890", "wa_id": "6281234567890" }],
  "messages": [{ "id": "wamid.HBgL..." }]
}
```

**At the end of this step** a `wamid` has come back and the message is on your phone.

The Playground sends the same thing if you would rather click than curl: its WhatsApp door opens on a `ww_…` key, its payload selector has a **Template** preset pre-filled with `hello_world` and `en_US`, and the request it shows before it sends is the request it sends.

A `wamid` is **acceptance, not delivery**. Meta states this itself: the response says the request was accepted, and nothing more. Whether the message was delivered, read, or failed arrives later, as a status event on the webhook you subscribed in step 3. A message a recipient never sees can still have been accepted here.

A message template goes first because the conversation does not exist yet. Outside the customer service window, only an approved message template reaches a WhatsApp user at all.

## Step 6 — reply from your phone

**Where:** the phone the message arrived on, then the developer console's **Chats** (`/app/chats`).

Reply to the message in WhatsApp. Meta posts it to the callback from step 3, SambungAPI verifies the signature over the raw request bytes before parsing anything, and stores it.

**At the end of this step** your reply is in the thread in Chats, and the thread header reads *Replies are open until …* — the customer service window has opened. It stays open for 24 hours after your last inbound message, and the composer in Chats is open for exactly as long.

This is the checkpoint the rest of the walk stands on. If the reply does not appear, the webhook is not verified: go back to step 3 rather than forward.

## Step 7 — check a text, then send it

**Where:** your own terminal, then the Playground.

The window is open now, so a plain text message is accepted. Two reads tell you that before you spend a send.

The local check answers what SambungAPI can already see about one payload. Meta is never called, nothing is stored, and no message goes out:

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

It answers `200` with a `checks` array — `payload`, `text`, `window`, `template`, `cost`, `pacing` — and `remote_checks` set to `not_run`. A failed check is still a `200`: it is a result, not a refusal, so read `checks` rather than the status code. Nothing in the answer speaks for Meta.

`pacing` is the check you are most likely to see fail here. Meta accepts one message every six seconds from one business number to the same WhatsApp user and refuses a faster one with error `131056` — which is what pressing Send twice does. The check reads the sends SambungAPI has recorded for this number and this [contact](/glossary#glossary-term-contact), fails while the newest accepted one is inside those six seconds, and names the wait. A refused attempt does not restart the wait, but it is reported beside the result with its reason and code. The contact is the person, not one spelling of the number: when Meta delivers a send to a number it rewrote — Mexico's `+521…` comes back 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` Meta answered. **It sees SambungAPI's own sends only:** a message another integration or the WhatsApp Business app sent from the same number never passes through here, so a `pass` is not a promise that Meta will accept.

The window read answers the same question on its own, for one contact:

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

```json
{
  "data": {
    "contact": "6281234567890",
    "state": "open",
    "window_closes_at": "2026-09-14T09:12:00+00:00",
    "last_inbound_at": "2026-09-13T09:12:00+00:00"
  },
  "meta": { "request_id": "req_xxxxxxxx" }
}
```

`state` is `open`, `closed`, or `unknown`, for this contact on the business number in the path: a message the contact sent to another of your numbers opens nothing here. `unknown` means this server holds no inbound message from that contact to this number — never that Meta holds none.

The Playground (`/app/projects/{project_id}/playground`) runs both of those for you. Its **Check** button sits beside Send and calls the same local check, printing each result with its fix and repeating that Meta was not called; the customer service window sits beside the recipient field, from the same read. Switch the payload selector back to **Text**, write the message, and send.

Or send it with `curl`, changing only the body of the step 5 request:

```json
{
  "messaging_product": "whatsapp",
  "to": "+6281234567890",
  "type": "text",
  "text": { "body": "Hello from sambungapi" }
}
```

**At the end of this step** the text is on your phone, and you have seen SambungAPI answer the two questions that decide which payload to build without asking Meta either of them.

## Step 8 — read a signed delivery at your own endpoint

**Where:** your own terminal, then whatever is listening at your endpoint.

Everything so far has been one direction. A subscription is the other one: SambungAPI delivers each event to an HTTPS URL you control, signed, with retries behind it. Any HTTPS URL works for the walk, including a request-capture service.

Register it with the [Warta key](/glossary#glossary-term-warta-key) from step 4 — the organization is the key's own, so the body does not name one:

```bash
curl -sS -X POST \
  "https://sabi.metatech.id/v1/messaging/subscriptions" \
  -H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx" \
  -H "content-type: application/json" \
  -d '{
        "name": "try-path",
        "endpoint_url": "https://example.test/hook"
      }'
```

The `201` carries `data.raw_secret`, a `whsec_…` signing secret shown **exactly once** and retrievable from no route afterwards, and `data.signature_header`, which names the header to verify.

Now reply from your phone again. The event reaches your endpoint as a `POST` with these headers:

```http
content-type: application/json
Idempotency-Key: evt_9f1c2a4b...
X-Sambungapi-Signature-256: sha256=3b8f1d...
```

`Idempotency-Key` is the [event id](/glossary#glossary-term-event-id). [Deliveries](/glossary#glossary-term-delivery) are at-least-once, and a retry is byte-identical to the first attempt — signature included — so deduplicating on this key is the whole of the work.

The signature is an HMAC-SHA256 over the **raw body bytes**, keyed with the `whsec_…` secret in full, prefixed `sha256=`. That is the same shape as Meta's own `X-Hub-Signature-256`, so whatever already verifies Meta's webhook verifies this one. Compute it over the bytes you received, before you parse them:

```bash
printf '%s' "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$WHSEC" -r \
  | cut -d ' ' -f 1
```

**At the end of this step** the hex that command prints equals the hex after `sha256=` in the header, and the walk is done: your reply left a phone, crossed Meta, and arrived signed at code you wrote.

## Step 9 — before the temporary token dies, reconnect

**Where:** your own terminal, or the console's reconnect drawer on `/app/business-accounts`.

The token from step 1 expires quickly. When it does, the next send answers Meta's own error code `190`, verbatim, and SambungAPI flips the stored account to `EXPIRED` so the console stops reporting an account nothing can use.

Reconnect repairs it. Mint a system-user token in your Meta Business with the WhatsApp permissions, grant it access to the WhatsApp Business Account, and hand it over:

```bash
curl -sS -X POST \
  "https://sabi.metatech.id/v1/messaging/wabas/wb_xxxxxxxx/reconnect" \
  -H "Authorization: Bearer cpt_xxxxxxxxxxxxxxxx" \
  -H "content-type: application/json" \
  -d '{ "token": "EAA..." }'
```

The new token is verified against Meta **before** anything is replaced, so a wrong token leaves the working account alone. The app secret and the verify token survive the swap, which means the webhook you configured in step 3 keeps working and your Warta keys keep sending.

**At the end of this step** the account is `ACTIVE` again on a token that does not expire on you.

## What the test account cannot do

Every line here is a limit of Meta's test assets, not of SambungAPI.

| Limit | What it means for this walk |
| --- | --- |
| The test number reaches a short list of verified recipients, and an entry cannot be removed. | Add your own number and stop. A slot spent on a wrong number is spent for good. |
| A new Meta app is in Development mode. | Only people holding a role on the app can request permissions or features, and what the app produces is visible only to them. |
| The temporary token expires. | Step 9 is not optional if you keep the account past the first sitting. |
| A message template opens a conversation; a reply opens the window. | Step 5 cannot be a text message, and step 7 cannot come before step 6. |
| A `200` is acceptance, not delivery. | Delivery, reads and late failures arrive on the webhook, and a message can fail after it was accepted. |
| Meta charges service messages from 2026-10-01. | Whether a test account keeps its payment-method exemption after that date is not documented by Meta. Do not plan on it. |

The recipient-list size is deliberately written here as "a short list". The exact number is archived evidence from an older Meta page, and the current page does not repeat it — it will be printed here with the date it was seen once it has been read off a live dashboard.

## What this proved, and what it did not

**Proved.** The Warta key authenticates and carries a scope. The gateway forwards your body to Meta and hands Meta's answer back unchanged. The webhook path verifies Meta's signature and stores what arrives. The customer service window opens on a reply and is readable before you send. A subscription delivers the event to your own endpoint, signed and with an idempotency key.

**Not proved.** Production messaging limits — the test account's are relaxed and a production account's are not. The quality rating Meta gives a real business phone number, which only real traffic to real customers produces. App Review and the advanced access it grants, which a Development-mode app has not been through and does not need while it touches only your own data.

## Where to go next

- **[Read the API guide](/docs/api).** Every route this walk used, in full: the envelopes, the error taxonomy, the media legs and the execution log.
- **[Read what SambungAPI owns](/docs/why).** The machinery around the send you just made, and what a direct integration builds and keeps running instead.
- **[Bring your real number](/docs/migrate-whatsapp).** When your real number is with another provider, the migration guide says how you connect the same number here, what you build again, and the six-step runbook for the move.
