Skip to main content

Docs

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, before a production one exists.

On this page

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 with a 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 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 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:

FieldWhat to paste
WABA idThe WhatsApp Business Account id from step 1.
System-user tokenThe 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 secretYour Meta app's secret, from App settings → Basic. SambungAPI verifies the signature on every 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 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.

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:

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

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, 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:

curl -sS \
  "https://sabi.metatech.id/v1/messaging/gateway/1234567890/contacts/+6281234567890" \
  -H "Authorization: Bearer ww_xxxxxxxxxxxxxxxx"
{
  "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:

{
  "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 from step 4 — the organization is the key's own, so the body does not name one:

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:

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

Idempotency-Key is the event id. Deliveries 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:

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:

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.

LimitWhat 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. Every route this walk used, in full: the envelopes, the error taxonomy, the media legs and the execution log.
  • Read what SambungAPI owns. The machinery around the send you just made, and what a direct integration builds and keeps running instead.
  • Bring your real number. 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.