Postgale connects WhatsApp Business phone numbers through Meta's Cloud API and lets your backend or AI agent send and receive WhatsApp messages: text, media, templates, buttons and lists, locations and reactions, with delivery and read statuses as webhooks. Everything here is available over the REST API (paths relative to https://postgale.com/api/v1, Bearer API key) and the MCP server (tools whatsapp_*). Each connected number is one account with platform whatsapp; WhatsApp accounts cannot publish posts.
Connect a number
Embedded Signup (recommended): ask for a connect link and send the number's owner to it. The link opens a Postgale page that runs Meta's Embedded Signup, where they log in with Facebook, pick or create the WhatsApp Business Account (WABA) and verify the phone number. Postgale then exchanges the code, subscribes the app to the WABA's webhooks, registers the number for the Cloud API and emits account.connected. With several numbers and no choice made in the signup, the result is a pending connection (connection.pending, then POST /connect/pending/{id}/select).
curl https://postgale.com/api/v1/connect/whatsapp?externalId=cust-1 \
-H 'Authorization: Bearer sk_…'
# {"authUrl":"https://postgale.com/connect/whatsapp?state=…","expiresInSeconds":600}Manual connect, for your own numbers or testing: pass a token you already have (a system user token from Meta Business Settings, or the temporary token from WhatsApp Manager → API Setup) with the phone number id and WABA id. Postgale reads both with the token, subscribes and registers (register: false skips registration; pin sets the 6-digit two-step verification PIN, otherwise a random one is generated). The token is stored encrypted and never returned.
curl -X POST https://postgale.com/api/v1/whatsapp/connect \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"accessToken":"EAAG…","phoneNumberId":"106540352242922","wabaId":"102290129340398","externalId":"cust-1"}'The response holds the account and its whatsapp view: display number, verified name, quality rating, messaging limit tier and the onboarding state. When registration failed (onboarding.registerError), retry with POST /whatsapp/accounts/{id}/register and an optional {"pin":"123456"}. GET /whatsapp/accounts/{id}/phone-number?refresh=true reads the current values from Meta.
The 24-hour window
- WhatsApp only allows free-form messages (text, media, interactive, location, reactions) within 24 hours of the person's last message. Every inbound message reopens the window; each conversation reports it as
messagingWindow. - Outside the window, or when the person never wrote, only an approved template can be sent. Postgale answers
409 outside_messaging_windowbefore calling Meta, so nothing is sent or billed. - Templates (business-initiated messages) are charged by Meta per message; replies within the window are free. There is no
HUMAN_AGENTtag on WhatsApp. - While approval applies to the key, organization or account, sends answer
403 approval_required.
Send messages
POST /whatsapp/accounts/{id}/messages with the person as to (phone number in international format), recipient (business-scoped user id) or conversationId, plus type and its fields. replyTo (a wamid) quotes an earlier message. Inside a conversation, POST /inbox/conversations/{id}/messages takes the same fields without to (the type is inferred). The answer (201) is the stored message and conversation; the message is a live WhatsApp message to a real person.
Text
curl -X POST https://postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"text","text":"Yes, the blue one ships tomorrow: https://shop.example/blue","previewUrl":true}'Image, video, audio, document, sticker
One of mediaUrl (public https), whatsappMediaId (uploaded, see Media below) or mediaId (a Postgale upload with a public URL); caption for image, video and document, filename for documents.
curl -X POST https://postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"document","mediaUrl":"https://shop.example/invoices/1234.pdf","filename":"invoice-1234.pdf","caption":"Your invoice"}'Template
curl -X POST https://postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"template","template":{"name":"order_shipped","language":"en_US","components":[{"type":"body","parameters":[{"type":"text","text":"Sheena"},{"type":"text","text":"#1234"}]}]}}'Interactive: reply buttons, list, call-to-action URL
curl -X POST https://postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"interactive","interactive":{"type":"button","body":"Is this delivery time OK?","buttons":[{"id":"yes","title":"Yes"},{"id":"later","title":"Later please"}]}}'curl -X POST https://postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"interactive","interactive":{"type":"list","body":"Pick a size","buttonText":"Sizes","sections":[{"title":"In stock","rows":[{"id":"s","title":"Small"},{"id":"m","title":"Medium","description":"2 left"}]}]}}'curl -X POST https://postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"interactive","interactive":{"type":"cta_url","body":"Track your parcel","displayText":"Track","url":"https://shop.example/track/1234"}}'Limits: up to 3 buttons (titles ≤20 characters), up to 10 list rows, body ≤1024 characters. Button and list replies arrive as message.received with type interactive and the id you set.
Location and reaction
curl -X POST https://postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"location","location":{"latitude":52.3676,"longitude":4.9041,"name":"Postgale Store","address":"Damrak 1, Amsterdam"}}'curl -X POST https://postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/messages \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"to":"+15551234567","type":"reaction","reaction":{"messageId":"wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQzQTdCM0Y","emoji":"👍"}}'Mark an inbound message read (blue ticks), optionally with a typing indicator while you prepare the answer: POST /whatsapp/accounts/{id}/messages/{wamid}/read with {"typing":true}. POST /inbox/conversations/{id}/read marks the latest inbound message read.
Templates
GET /whatsapp/accounts/{id}/templates: the WABA's templates (?status=APPROVED&name&category&language&limit&after). OnlyAPPROVEDtemplates can be sent.POST /whatsapp/accounts/{id}/templates: submit a template for review; it starts asPENDINGand the decision arrives astemplate.status.GET /whatsapp/accounts/{id}/templates/{templateId}andDELETE(this language;?allLanguages=truedeletes every language of the name).
curl -X POST https://postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/templates \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"name":"order_shipped","language":"en_US","category":"UTILITY","components":[{"type":"BODY","text":"Hi {{1}}, your order {{2}} has shipped.","example":{"body_text":[["Sheena","#1234"]]}}]}'Media
POST /whatsapp/accounts/{id}/mediawith{"mediaId":"…"}(a Postgale upload fromPOST /media) or{"url":"https://…"}(fetched by Postgale, at most 100 MB) uploads the file to WhatsApp and returns its id; send it withwhatsappMediaId. Meta keeps uploads for 30 days.GET /whatsapp/accounts/{id}/media/{waMediaId}returns{id, mimeType, sha256, size}; add?download=1to stream the bytes (inbound images, voice notes and documents carry such ids in their attachments). Meta's own media URLs need the business token and expire after 5 minutes, so always download through Postgale.DELETEon the same path removes the media from WhatsApp.
curl -o photo.jpg 'https://postgale.com/api/v1/whatsapp/accounts/acc_whatsapp/media/1037543291543636?download=1' \
-H 'Authorization: Bearer sk_…'Webhooks
Subscribe an endpoint (POST /webhooks) to the events you need; deliveries are signed and retried like every Postgale webhook (see the REST reference).
message.received: a person wrote (text, media, interactive and button replies, location, contacts, orders);message.reactionfor reactions;message.sentfor messages sent through Postgale.message.status:sent,delivered,readorfailed(with Meta'serrors) for each message the account sent, withpricing.template.status(approved, rejected, paused, disabled…),template.quality(quality score or category changes) andaccount.quality(the number's quality rating or messaging limit changed).
{
"id": "cmdlw1a2b0001wa0000001",
"event": "message.received",
"createdAt": "2026-10-18T09:15:02.481Z",
"data": {
"eventId": "cmdlw19zz0000wa0000001",
"platform": "whatsapp",
"accountId": "acc_whatsapp",
"externalId": "cust-1",
"conversationId": "cmdlw0k700003wa0000001",
"messageId": "cmdlw1a0q0002wa0000001",
"platformMessageId": "wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQzQTdCM0Y",
"platformMessageIds": [
"wamid.HBgLMTU1NTEyMzQ1NjcVAgASGBQzQTdCM0Y"
],
"direction": "inbound",
"participant": {
"id": "15551234567",
"username": null,
"name": "Sheena"
},
"text": "Do you have the blue one in stock?",
"attachments": [],
"postback": null,
"reaction": null,
"watermark": null,
"sentAt": "2026-10-18T09:15:00.000Z",
"receivedAt": "2026-10-18T09:15:02.470Z",
"type": "text",
"content": {
"body": "Do you have the blue one in stock?"
},
"contact": {
"phone": "15551234567",
"userId": "US.13491208655302741918",
"parentUserId": null,
"name": "Sheena",
"username": null
}
}
}{
"id": "cmdlw2c3d0001wa0000002",
"event": "message.status",
"createdAt": "2026-10-18T09:16:04.112Z",
"data": {
"eventId": "cmdlw2c2z0000wa0000002",
"platform": "whatsapp",
"accountId": "acc_whatsapp",
"externalId": "cust-1",
"conversationId": "cmdlw0k700003wa0000001",
"messageId": "cmdlw1x9q0004wa0000001",
"platformMessageId": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI5QTNDQTVCM0Q",
"status": "read",
"errors": [],
"pricing": {
"billable": false,
"pricing_model": "PMP",
"category": "service",
"type": "free_customer_service"
},
"conversation": null,
"recipient": {
"phone": "15551234567",
"userId": null
},
"timestamp": "2026-10-18T09:16:03.000Z",
"receivedAt": "2026-10-18T09:16:04.100Z"
}
}{
"id": "cmdlw3e4f0001wa0000003",
"event": "template.status",
"createdAt": "2026-10-18T10:02:11.020Z",
"data": {
"eventId": "cmdlw3e3z0000wa0000003",
"platform": "whatsapp",
"accountId": "acc_whatsapp",
"externalId": "cust-1",
"wabaId": "102290129340398",
"kind": "status",
"templateId": "1689556908129832",
"name": "order_shipped",
"language": "en_US",
"event": "APPROVED",
"reason": "NONE",
"otherInfo": null,
"receivedAt": "2026-10-18T10:02:11.000Z"
}
}Buy a WhatsApp number
When the Postgale instance sells numbers, an organization that has already connected a WhatsApp Business Account can get a new number without owning a SIM: Postgale buys a US number from its carrier, adds it to that WABA with your display name, asks Meta for the verification code by voice call (SMS after 10 minutes), answers the call, transcribes the code, verifies and registers the number and connects it as a new WhatsApp account (account.connected and phone_number.provisioned). Otherwise these endpoints answer 403 numbers_disabled.
curl https://postgale.com/api/v1/whatsapp/numbers/available?country=US&areaCode=415&limit=5 \
-H 'Authorization: Bearer sk_…'curl -X POST https://postgale.com/api/v1/whatsapp/numbers \
-H 'Authorization: Bearer sk_…' \
-H 'Content-Type: application/json' \
-d '{"phoneNumber":"+14155550123","accountId":"acc_whatsapp","verifiedName":"Lucky Shrub"}'
# {"number":{"id":"…","status":"code_requested","phoneNumber":"+14155550123","codeMethod":"VOICE",…}}statusmoves throughpurchasing,purchased,adding_to_waba,code_requested,verifying,registeringtoactive(the newaccountId). PollGET /whatsapp/numbers/{id}or listen to the webhooks.- A failed step sets
status: failedwitherror(e.g.otp_timeout,add_failed: …) and emitsphone_number.failed;POST /whatsapp/numbers/{id}/retrycontinues from that step. - Each organization may hold a limited number of numbers (
403 number_limit_reached). The monthly price is shown by the search. - A number with no production message (sent or received) for 30 days is released:
phone_number.release_scheduled3 days before, thenphone_number.released, and its account becomesneeds_reconnect.DELETE /whatsapp/numbers/{id}releases it at once. - If Meta does not let Postgale add the number to your WABA, connect it yourself with Embedded Signup: type the number, choose voice call, and read the code from
lastCodeinGET /whatsapp/numbers/{id}(kept for 10 minutes).
MCP: whatsapp_search_numbers, whatsapp_buy_number, whatsapp_list_numbers, whatsapp_get_number, whatsapp_release_number.
Errors
Errors use the usual {"error":{"message":"…","code":"…"}} shape; Meta's Cloud API codes are mapped as follows.
| Status | Code | Meaning |
|---|---|---|
| 409 | outside_messaging_window | A free-form message more than 24 hours after the person’s last message (or before they ever wrote). Send a template. Checked before calling Meta, and Meta’s 131047 maps to it too |
| 409 | recipient_unavailable | The number is not on WhatsApp, uses an old app version, or is the business number itself (131026, 131021) |
| 409 | phone_number_not_registered | The number is not registered for the Cloud API (133010): POST /whatsapp/accounts/{id}/register |
| 409 | reconnect_required | The token is no longer valid (190): reconnect the number |
| 403 | approval_required | Approval applies to the key, organization or account: sends are refused |
| 403 | recipient_not_allowed | Test numbers only message the up to 5 recipients in their allowed list (131030; WhatsApp Manager → API Setup) |
| 403 | account_restricted | The WhatsApp Business Account is restricted or has a payment issue (131031, 368, 131042) |
| 403 | missing_permission | The token lacks whatsapp_business_messaging or whatsapp_business_management; reconnect |
| 400 | template_error | Template name, language, parameters or status rejected (132000–132016) |
| 400 | unsupported_message_type | WhatsApp does not support this message type (131051) |
| 400 | invalid_token | Manual connect: Meta refused the access token |
| 400 | not_supported | Not a WhatsApp account, or a Messenger-only option (tag) on WhatsApp |
| 429 | rate_limited | Too many messages to this person or for the number’s throughput (131056, 130429, 131048, 131057) |
| 429 | marketing_limit | Meta limits how many marketing templates one person receives (131049); retry later |
| 502 | platform_error | Any other Meta failure |
Mock mode
Without PROVIDER_LIVE_WHATSAPP=1 (and the Meta app credentials) WhatsApp runs in mock mode: the connect link comes back immediately with a mock number (+1 555-010-0000, quality GREEN), sends return wamid.mock… ids, templates live in memory (a hello_world template is approved) and media downloads return a small PNG. The 24-hour window, approval gate, storage and webhooks behave exactly as in live mode, so integrations can be built and tested without Meta.