WhatsApp

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

GET/api/v1/connect/whatsapp
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.

POST/api/v1/whatsapp/connect
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

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

POST/api/v1/whatsapp/accounts/{id}/messages
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

POST/api/v1/whatsapp/accounts/{id}/templates
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

GET/api/v1/whatsapp/accounts/{id}/media/{waMediaId}?download=1
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
{
  "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
    }
  }
}
message.status
{
  "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"
  }
}
template.status
{
  "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.

GET/api/v1/whatsapp/numbers/available
curl https://postgale.com/api/v1/whatsapp/numbers/available?country=US&areaCode=415&limit=5 \
  -H 'Authorization: Bearer sk_…'
POST/api/v1/whatsapp/numbers
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",…}}

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.

StatusCodeMeaning
409outside_messaging_windowA 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
409recipient_unavailableThe number is not on WhatsApp, uses an old app version, or is the business number itself (131026, 131021)
409phone_number_not_registeredThe number is not registered for the Cloud API (133010): POST /whatsapp/accounts/{id}/register
409reconnect_requiredThe token is no longer valid (190): reconnect the number
403approval_requiredApproval applies to the key, organization or account: sends are refused
403recipient_not_allowedTest numbers only message the up to 5 recipients in their allowed list (131030; WhatsApp Manager → API Setup)
403account_restrictedThe WhatsApp Business Account is restricted or has a payment issue (131031, 368, 131042)
403missing_permissionThe token lacks whatsapp_business_messaging or whatsapp_business_management; reconnect
400template_errorTemplate name, language, parameters or status rejected (132000–132016)
400unsupported_message_typeWhatsApp does not support this message type (131051)
400invalid_tokenManual connect: Meta refused the access token
400not_supportedNot a WhatsApp account, or a Messenger-only option (tag) on WhatsApp
429rate_limitedToo many messages to this person or for the number’s throughput (131056, 130429, 131048, 131057)
429marketing_limitMeta limits how many marketing templates one person receives (131049); retry later
502platform_errorAny 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.