# WABridges API Reference WhatsApp-as-a-service. One bridge per phone number. ## Conventions **Phone numbers:** E.164 digits only, no `+`. Example: `15550001234`. **JIDs:** WhatsApp identifiers. Individual: `15550001234@s.whatsapp.net`. Group: `120363XXXXXX@g.us`. The `chat` field in messaging endpoints accepts either a bare phone number or a full JID. **Timestamps:** All timestamp fields are Unix epoch seconds (integer). **Errors:** All error responses use `{"error": "", "code": ""}` - see the Errors section for the code vocabulary. Common statuses: 400 bad request, 401 unauthorized, 404 not found, 429 rate limited, 502 WhatsApp upstream failure, 503 bridge not connected, 500 server error. ## Authentication All requests require: `Authorization: Bearer $WA_API_KEY` ## Sandbox Every account has a built-in simulated bridge at `customer_ref = sandbox`. It needs no phone and is never billed. It answers `/status` and the messaging endpoints (`/send/text`, `/send/media`, `/send/reaction`, `/send/poll`, `/send/location`, `/send/typing`, `/messages/:id/read`) with production-shaped responses; anything else returns `501 not_available_in_sandbox`. Its single contact is `15550001234` ("Alice"), who replies a few seconds after every message. Events are visible on the dashboard, shaped exactly as a real bridge would deliver them; the sandbox itself never posts to a webhook. Use it for a first request before provisioning anything. **Deprecated:** the `?apikey=` query parameter still authenticates but is deprecated and will be removed; the `Authorization` header is the only supported method going forward. Requests using the query parameter receive a `Deprecation: true` response header. ## Rate Limits All limits per API key. Exceeded requests → `429` with `Retry-After` header. | Endpoint | Limit | Window | |----------|-------|--------| | `POST /api/instances` (provision) | 10 req | per minute | | `/api/instances/:customer_ref/proxy/*` | 120 req | per minute | | `/api/*` (all other endpoints) | 120 req | per minute | | All endpoints (per IP) | 300 req | per minute | ## Errors Errors are JSON: `{"error": "", "code": ""}`. Branch on `code`, not the message text. Codes: `unauthorized` (401) · `validation_failed`, `invalid_chat`, `invalid_sender`, `media_fetch_failed` (400) · `contact_not_found`, `media_not_found`, `avatar_not_found` (404) · `already_paired` (409) · `not_connected`, `bridge_starting` (503, carries a `Retry-After` header — wait and retry; `bridge_starting` means an idle bridge was just woken by this call) · `upstream_error` (502, WhatsApp-side failure, retryable) · `qr_timeout` (504) · `internal_error` (500). Every response carries an `X-Request-Id` header — include it when contacting support. ## Idempotency Message-producing endpoints (`/send/text`, `/send/media`, `/send/location`, `/send/poll`, `/send/event`, `/send/reaction`, `PATCH /messages/:id/edit`, `DELETE /messages/:id`) accept an optional `Idempotency-Key` header (unique per logical message, max 255 chars) that makes retries safe: - First use of a key: the send happens and the response is stored for 24h. - Retry with the same key: the stored response is replayed with `X-Idempotency-Replayed: true`; nothing is re-sent to WhatsApp. - Concurrent duplicate: `409` with code `idempotency_conflict` while the first request is in flight. - Failed sends do not consume the key, so retrying them works normally. Without the header, behavior is unchanged — fully opt-in. Recommended for all production sends. The hosted API relays `Idempotency-Key`, `X-Request-Id` and `Accept` to the bridge and returns `X-Idempotency-Replayed`, `Retry-After` and `X-Request-Id` from it. The sandbox bridge does not implement idempotency and ignores the header. ## Example: a Claude bot A complete WhatsApp bot answering with Claude, in one file, Node and Python: https://wabridges.com/examples/claude-bot/README.md (code at https://wabridges.com/examples/claude-bot/node/bot.js and https://wabridges.com/examples/claude-bot/python/bot.py). It verifies the webhook signature, acks before calling the model, keeps per-chat history, de-duplicates on `X-Webhook-Event-Id`, sends with `Idempotency-Key`, and retries once on `503 bridge_starting`. Send a few messages through `customer_ref = sandbox` first, then point a real bridge at it. ## How-to guides Task-shaped walkthroughs, the same text published at https://wabridges.com/docs. Each one is a page; the headings are its sections. ### Connect a bridge Create a bridge, link a WhatsApp number to it, and confirm it is connected - the three calls that come before everything else. #### What a bridge is A bridge is one WhatsApp number under API control. You create it, link a phone to it once, and from then on it is a base path you send HTTP requests to. It runs as its own container, so one noisy number never affects another. You address a bridge by the customer_ref you chose when you created it - your own identifier, not ours. If you only ever run one bridge, the alias default works everywhere a customer_ref does, so a single-number integration never carries a name in its URLs. Billing note: the subscription and its 3-day free trial start when you connect your first number in the dashboard, card on file. The API adds bridges to an existing subscription rather than opening one. #### Create the bridge One POST. Give it your customer_ref and the URL that should receive events. Omit webhook_url and events land in the hosted inbox on the dashboard instead, which is the easier way to start - you can point it at your own endpoint later with PATCH. The response carries the webhook_secret you will need to verify deliveries. Store it now; it is shown again on the bridge page but never in a webhook. curl: curl -X POST https://wabridges.com/api/instances \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customer_ref": "user-123", "webhook_url": "https://yourbackend.com/hook" }' ← 201: { "id": "7d09aa9c-2f4e-4b6a-9c1d-3e5f7a9b1c2d", "customer_ref": "user-123", "state": "running", "created_at": 1777180605, "webhook_secret": "whsec_...", "webhook_url": "https://yourbackend.com/hook" } Creating bridges is rate limited to 10 requests per minute, separately from the 120 per minute everything else gets. Provisioning is expensive on our side; sending is not. #### Link a phone A fresh bridge is running but not yet linked to a number. There are two ways to link one and they end in the same place. A pairing code is the one you can do without leaving the terminal. POST the phone number, then on the handset open Linked devices → Link with phone number instead and type the eight characters that come back. curl: curl -X POST https://wabridges.com/api/instances/user-123/proxy/pair \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"phone": "15550001234"}' ← 200: { "code": "ABCD-EFGH" } The link is a WhatsApp linked device, the same mechanism as WhatsApp Web. The phone stays the owner of the account and keeps receiving messages normally. #### Scan a QR code instead A QR code is the other way, and the one to use if the phone is in your hand. GET /qr returns the pairing QR as a PNG. Save it, open it, and scan it from Linked devices → Link a device on the phone. curl: curl https://wabridges.com/api/instances/user-123/proxy/qr \ -H "Authorization: Bearer $WA_API_KEY" \ --output qr.png The endpoint takes your API key, so it is a server-side call - the URL cannot be dropped into a browser or an tag as-is. Once a phone is linked it returns 409, which is a useful way to ask "is this one already paired?". #### Check it is connected Pairing is not instant - the phone has to scan or type, and the session has to come up. Poll this after showing a code until status is connected, and use the same call as a health check afterwards. status is one of three values. unpaired means no phone has ever linked, connected means it is live, disconnected means it was linked and the link dropped - re-pairing fixes the last one and keeps the same API key, endpoints and webhook config. curl: curl https://wabridges.com/api/instances/user-123/proxy/status \ -H "Authorization: Bearer $WA_API_KEY" ← 200: { "status": "connected", "jid": "15550001234@s.whatsapp.net", "phone": "15550001234", "name": "Acme Support" } A 503 from any other endpoint means the same thing as status disconnected, and carries a Retry-After header in seconds so a client can back off on its own. Your webhook also gets a disconnected event the moment a link drops, which beats polling - you find out before your users do. #### What to read next The bridge is up and linked, so the next two pages are the whole loop: send a message and receive a message. If you do not have a spare number yet, start on the sandbox instead - every account has one, and it answers the real API with no phone and no billing. Published at: https://wabridges.com/docs/connect-a-bridge ### Send a message POST a body and a chat id to a connected bridge - plus how to address a person or a group, and how to make a retry safe. #### The call One POST with two fields: who, and what. chat is the recipient, body is the text. The response is the message id WhatsApp assigned. Hold on to it: delivery and read receipts arrive later on your webhook keyed by that same id, and it is what you quote if you need us to trace a send. curl: curl -X POST https://wabridges.com/api/instances/user-123/proxy/send/text \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "chat": "15550001234", "body": "hello" }' ← 200: { "message_id": "ACE41E988162AB19A4A7BB7A9E663693", "timestamp": 1777180605 } #### Addressing a person or a group A plain international number without punctuation is enough for a one-to-one chat - 15550001234. It is expanded to the full WhatsApp address for you. Groups have no phone number, so they can only be addressed by their full id, which ends in @g.us. You get that id from the chat_id of any message the group sends you, which is the practical reason to store chat_id on every inbound event rather than just the sender's phone. a person: {"chat": "15550001234", "body": "hello"} the same person, written out: {"chat": "15550001234@s.whatsapp.net", "body": "hello"} a group: {"chat": "120363000000000001@g.us", "body": "hi team"} #### Making a retry safe A timed-out send is genuinely ambiguous - the request may have reached WhatsApp before the connection died. Send an Idempotency-Key and it stops being ambiguous. The first use performs the send and stores the response for 24 hours. A repeat of the same key replays that stored response, marked X-Idempotency-Replayed: true, without sending anything. A duplicate that arrives while the first is still in flight gets 409 idempotency_conflict. Only successful responses consume a key, so a failed send can be retried with the same one. Use one key per logical action - an order id, a UUID you generate before the first attempt - and reuse it for every retry of that action. curl: curl -X POST https://wabridges.com/api/instances/user-123/proxy/send/text \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Idempotency-Key: order-4417-confirmation" \ -H "Content-Type: application/json" \ -d '{"chat": "15550001234", "body": "Your order shipped"}' #### When a send fails Errors come back as {"error": "...", "code": "..."}. Branch on code - it is stable and machine-readable, where the message is prose that may be reworded. - 503 - the session is not connected. Re-pair, or wait out the Retry-After. See check it is connected. - 400 - a missing or malformed field, usually a chat that is not a number or a full id. - 502 - WhatsApp itself rejected or failed the request. - 429 - you are over 120 requests per minute. The Retry-After header says how long to wait. The full list, and how to make a retry safe, is on errors and retries. Every response carries an X-Request-Id. If a send goes wrong in a way this page does not explain, that id lets us find the exact request instead of guessing. #### Beyond text The same shape sends everything else: /send/media for images, audio, documents and voice notes, /send/reaction to react to a message id, /send/typing to show the typing indicator before a slow reply. Fields and payloads for each are in the API reference. Published at: https://wabridges.com/docs/send-a-message ### Receive a message Point a bridge at your URL and every inbound WhatsApp message arrives as JSON - the payload, the rules your endpoint must follow, and how to prove a delivery is really ours. #### Point the bridge at your endpoint Inbound messages are pushed to you, not polled. Set webhook_url when you create the bridge, or change it later - the bridge keeps running and the next event goes to the new URL. Until you set one, events land in the hosted inbox on the dashboard. That is the fastest way to see the shape of real traffic before you have written a handler. curl: curl -X PATCH https://wabridges.com/api/instances/user-123 \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"webhook_url": "https://yourbackend.com/hook"}' #### What arrives A POST of JSON, one event per request. Every inbound message the bridge sees fires this, including messages sent from the linked phone itself - those carry from_me: true, which is the flag that stops a bot answering itself. chat_id is where to reply. contact_id is who wrote it. In a one-to-one chat they are the same; in a group they are not, and confusing them is the most common way a first integration replies to the wrong place. POST https://yourbackend.com/hook: { "event": "message", "type": "text", "body": "hello", "chat_id": "15550001234@s.whatsapp.net", "contact_id": "15550001234@s.whatsapp.net", "from_me": false, "is_group": false, "media_type": "", "message_id": "ACE41E988162AB19A4A7BB7A9E663693", "name": "John Doe", "phone": "15550001234", "timestamp": 1777180605 } message is one of several events. connected and disconnected tell you the phone link came up or went down - see stay connected for which drops heal themselves and which need a person. #### Answer fast, work later Return 2xx immediately and do the real work asynchronously. If your handler calls a model or a slow API before responding, deliveries queue behind it and WhatsApp senders see nothing happen. Delivery is at-least-once and in order. A failed delivery - network error, 429, or any 5xx - is retried up to five times with backoff at 10s, 30s, 1m, 3m. Any other 4xx is never retried, so a stray 404 or 403 from your side drops that event for good. Because retries exist, the same event can arrive twice. Deduplicate on X-Webhook-Event-Id, which stays the same across every retry of one event; X-Webhook-Attempt tells you which try you are looking at, 1-based. The delivery log on the bridge page records every attempt with its status code, latency and error - the first place to look when an event did not arrive. #### Verify it is really us Your webhook URL is a public endpoint, so authenticate every delivery before trusting it. Each one is signed two ways with the bridge's webhook_secret. An Authorization: Bearer header carries the secret directly, which is what no-code tools like n8n's Webhook node verify natively. Alongside it, X-Webhook-Signature: t=,v1= is an HMAC-SHA256 over . keyed with the secret. Prefer the signature, compare it in constant time, and reject anything whose t is more than about five minutes old so a captured delivery cannot be replayed at you later. Sign against the raw request body, before any JSON parsing or re-serialisation. Re-encoding changes the bytes and the signature will never match. #### Replying Reply by posting to the same bridge with the event's chat_id as chat - see send a message. The two calls together are the entire loop; everything else in the API is detail on top of it. Published at: https://wabridges.com/docs/receive-a-message ### Send and receive media Images, video, audio, documents and stickers - sent by URL, received as a message id you download. #### Sending is a URL, not an upload There is no multipart upload. You give the bridge a public HTTP(S) URL and it fetches the file itself, which means the file has to be reachable from the internet - a signed S3 link, a public CDN path, an endpoint of your own. The kind of message is decided by the Content-Type the URL responds with, not by the file extension or by a field you set. A URL that serves image/jpeg becomes an image; one that serves application/pdf becomes a document. A server that answers application/octet-stream for everything is the usual reason media arrives as a nondescript file. curl: curl -X POST https://wabridges.com/api/instances/user-123/proxy/send/media \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "chat": "15550001234", "url": "https://example.com/photo.jpg", "caption": "check this out" }' ← 200: { "message_id": "ACE41E988162AB19A4A7BB7A9E663693", "timestamp": 1777180605 } #### Every kind, same call Only chat and url are required. caption is optional and ignored for kinds that cannot carry one. Stickers are the one special case: set as_sticker and serve the file as image/webp. Anything else is rejected, and a caption on a sticker is dropped. image: {"chat": "15550001234", "url": "https://example.com/photo.jpg", "caption": "check this out"} document: {"chat": "15550001234", "url": "https://example.com/report.pdf", "caption": "Q1 report"} voice note: {"chat": "15550001234", "url": "https://example.com/voice.ogg"} sticker: {"chat": "15550001234", "url": "https://example.com/sticker.webp", "as_sticker": true} #### Receiving: the event names the file, it does not carry it An inbound photo arrives on your webhook as an ordinary message event with type: "media" and media_type saying which kind. The bytes are not in the payload - webhooks stay small and fast. What you get is the message_id, and that is the handle you download with. A caption, when there is one, arrives in caption rather than body. An image sent with no caption has both empty, which is worth handling before you index on message text. POST https://yourbackend.com/hook: { "event": "message", "type": "media", "media_type": "image", "body": "", "caption": "look at this", "chat_id": "15550001234@s.whatsapp.net", "contact_id": "15550001234@s.whatsapp.net", "from_me": false, "is_group": false, "message_id": "ACE7253B69494A9CB9D671C943A7F4AB", "name": "John Doe", "phone": "15550001234", "timestamp": 1777180605 } #### Downloading what arrived Fetch the bytes with the message_id from the event. The response is the raw file with its real content type - image/jpeg, audio/ogg, video/mp4, or application/octet-stream when it is anything else. The bridge caches media for 3 hours. Inside that window this is cheap and repeatable; after it, the fetch can return 404. If you need to keep a file, copy it to your own storage when the webhook arrives rather than downloading it on demand later. curl: curl https://wabridges.com/api/instances/user-123/proxy/messages/ACE7253B69494A9CB9D671C943A7F4AB/media \ -H "Authorization: Bearer $WA_API_KEY" \ --output inbound.jpg Download in the background, not inside your webhook handler - the handler still has to answer 2xx quickly or deliveries queue up behind it. Published at: https://wabridges.com/docs/send-and-receive-media ### Build without a phone Every account has a free sandbox bridge that answers the real API and replies like a real contact - no number, no card, no container. #### What the sandbox is From the moment you sign up there is a bridge at customer_ref sandbox. It is not a container and not a WhatsApp connection - it is a simulation that answers the same REST surface with responses shaped like the real ones. It costs nothing, needs no card, and does not count against your bridge limit. Use it to get your request signatures, error handling and message shapes right before a real number is involved. curl: curl -X POST https://wabridges.com/api/instances/sandbox/proxy/send/text \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"chat": "15550001234", "body": "hello"}' #### A contact writes back There is one simulated contact, Alice, on 15550001234 - the same number used throughout these docs. Send her a message and she starts typing, then replies about 4 seconds later. The reply is a real event with a real payload shape, so you can build against inbound traffic without owning two phones. Messages to any other number come back as an error: Alice is the only chat that exists. The sandbox accepts 60 sends per minute and keeps 7 days of events. Both are lower than production on purpose - it is a scratchpad, not a staging environment. #### The one thing it does not do Sandbox events are never delivered to a URL. They land in the dashboard, which streams them live, and that is where you read them. So the sandbox exercises everything you send and every response you parse, but it does not exercise your webhook handler - not the endpoint, not the signature check, not your 2xx-fast discipline. That half is tested against a real bridge. Plan for it rather than being surprised by it on launch day. If your integration answers every message automatically, you and Alice will ping-pong forever - she answers everything too. After a short burst she says so once and goes quiet for a minute. It is a deliberate mirror of a loop you can build in production, so treat it as a warning about replying to every event rather than a sandbox quirk. #### When an endpoint says no The messaging endpoints are simulated. Anything needing a live WhatsApp session - pairing, profile, presence, contacts - answers with a clear error instead of pretending. Treat that response as a checklist: each one marks a code path that only a paired number will exercise. ← 400: { "error": "not_available_in_sandbox", "message": "This endpoint isn't simulated. Connect a real WhatsApp number to use it.", "docs_url": "https://wabridges.com/docs/api" } #### Moving to a real bridge Nothing to migrate. Create a bridge with your own customer_ref, pair a number, and point your code at the new ref - see connect a bridge. Same key, same paths, same payloads. Published at: https://wabridges.com/docs/the-sandbox ### Many bridges, one account One API key can run a WhatsApp number per customer - how to name them, address them, and what billing does as they come and go. #### One key, many numbers An account key is account-wide. Every bridge on the account is reachable with the same key, addressed by the customer_ref you gave it, so a platform putting a WhatsApp number behind each of its own customers needs one credential rather than a keyring. That also means the key is worth guarding accordingly: it can send from every number you run, so it belongs on your server and never in an app, a browser, or a customer's hands. #### Naming a bridge customer_ref is any string you control - a user id, a tenant slug, an order number - made of letters, digits, dashes and underscores. Pick something you already have in your database, because it becomes the address in every later call and you will be looking it up constantly. Creation is idempotent on that ref. Posting one that already exists returns the existing bridge with 200 instead of creating a duplicate, where a fresh one returns 201. That makes provisioning safe to retry, and safe to run on every tenant boot without a "does it exist" check first. curl: curl -X POST https://wabridges.com/api/instances \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "customer_ref": "tenant-4417", "webhook_url": "https://yourbackend.com/hook/tenant-4417" }' ← 200 (it already existed): { "id": "7d09aa9c-2f4e-4b6a-9c1d-3e5f7a9b1c2d", "customer_ref": "tenant-4417", "state": "running", "created_at": 1777180605, "webhook_secret": "whsec_...", "webhook_url": "https://yourbackend.com/hook/tenant-4417" } #### Routing events back to the right tenant Two ways to know which bridge an event came from, and they suit different shapes of system. A URL per bridge - /hook/tenant-4417 - puts the routing in the path, so your handler knows the tenant before it parses anything. One shared URL is less to manage, and you identify the bridge from the delivery instead. Either way each bridge has its own webhook_secret, so a shared endpoint must pick the right secret per bridge before it can verify the signature. A single-bridge integration can skip naming entirely: default is accepted anywhere a customer_ref is and means the account's first bridge, so URLs never carry a name. curl: curl https://wabridges.com/api/instances \ -H "Authorization: Bearer $WA_API_KEY" #### What each one costs A bridge is a $5/month seat on the account's subscription, added when you create it and removed when you cancel it, with the unused days refunded automatically. Bridges created during the 3-day trial are free until the trial ends. The subscription itself starts when you connect your first number in the dashboard, card on file. Until that has happened the API has nothing to add a seat to, and provisioning returns 402 with no_subscription - the body carries the dashboard URL to send an admin to. 402 payment_required is the other one: the subscription exists but payment is past due. Accounts start capped at 10 bridges and a further create returns 403. That cap is a guard against runaway loops, not a sales gate - ask us and we raise it. Provisioning is rate limited to 10 requests per minute. A platform onboarding many tenants at once should queue creates rather than firing them in parallel. Published at: https://wabridges.com/docs/many-bridges-one-account ### Errors and retries One error shape, a handful of status codes, and the two headers that make a failed call safe to send again. #### Every error has the same shape Whatever went wrong, the body is the same object. error is prose for a human reading a log; code is the stable, machine-readable one. Branch on code, never on the message text and never on the message alone - wording gets improved, codes do not. ← 4xx / 5xx: { "error": "bridge is not connected", "code": "not_connected" } #### What each status means here The codes are used narrowly, so each one points at a different fix. - 400 - a missing or malformed field. Your payload is wrong; retrying unchanged will fail again. - 401 - missing or invalid bearer token. See API keys. - 402 - no subscription on the account, or payment past due. A human has to act in the dashboard. - 403 - the account bridge limit is reached. - 404 - no such bridge, message or contact. - 409 - conflicting state, such as pairing a bridge that is already paired. - 429 - over the rate limit. Wait for Retry-After. - 502 - WhatsApp itself rejected or failed the request. - 503 - the WhatsApp session is not connected. See stay connected. The split worth internalising: 4xx other than 429 means fix the request, 429 and 503 mean wait and repeat, 502 and 500 mean try again and alert if it persists. #### Retrying without sending twice A connection that dies mid-request leaves you unable to tell whether the send happened. Idempotency-Key removes the doubt: the first use performs the call and stores the response for 24 hours, and a repeat with the same key replays that stored response instead of sending again. A replay is labelled, so you can tell the two apart in your own logs. A duplicate that arrives while the first is still in flight gets 409 idempotency_conflict - wait and retry rather than treating it as a failure. Only successful responses consume a key, so a call that failed can be retried with the same one. curl: curl -X POST https://wabridges.com/api/instances/user-123/proxy/send/text \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Idempotency-Key: order-4417-confirmation" \ -H "Content-Type: application/json" \ -d '{"chat": "15550001234", "body": "Your order shipped"}' ← 200 · X-Idempotency-Replayed: true: { "message_id": "ACE41E988162AB19A4A7BB7A9E663693", "timestamp": 1777180605 } Derive the key from the thing you are doing - an order id, a notification row id - not from a fresh UUID per attempt. A key generated inside the retry loop is a new key every time and protects nothing. #### Backing off when rate limited Sending and reading allow 120 requests per minute; creating bridges allows 10. Over either, the response is 429 with a Retry-After header giving the seconds to wait. Honour that header rather than guessing an interval. Retrying immediately keeps the window full and extends the block, and a fleet of workers all retrying on the same fixed timer will collide again on every cycle - spread them out. #### When you need us to look Every response carries an X-Request-Id header. Log it next to your own request records, especially for failures. If you send a well-formed one yourself - 1 to 64 characters of letters, digits, dashes or underscores - it is echoed back, so your id and ours are the same string and correlating the two sides is trivial. Anything else is replaced with a generated one. With that id, support can find the exact request instead of asking you three follow-up questions. Published at: https://wabridges.com/docs/errors-and-retries ### Stay connected Links drop - phones die, WhatsApp logs devices out. What the bridge tells you, which drops heal themselves, and which need a person. #### Two kinds of disconnect A disconnected event does not always mean trouble, and the difference is one field. No reason means a transient drop - a network blip, a WhatsApp server moving you. The bridge reconnects by itself and you will get connected shortly after. Log it, do not page anyone. A reason means the session is terminal and will not come back on its own. logged_out means the device was unpaired, usually from the phone's Linked devices screen, and someone has to pair it again. stream_replaced means another client took over the session. transient - it will heal: {"event": "disconnected"} terminal - needs a person: { "event": "disconnected", "reason": "logged_out", "detail": "401: logged out" } Alert on a disconnected event that carries a reason. Alerting on every disconnected event will page you for ordinary network weather. #### Coming back up connected fires when the link is live again and carries the number, which is worth asserting against the number you expect if you run many bridges. POST https://yourbackend.com/hook: { "event": "connected", "phone": "15550001234" } #### The catch-up flood After a reconnect, WhatsApp replays everything that happened while the bridge was away, and this is where naive integrations embarrass themselves - a bot that answers every message will answer two hours of backlog at once. You are warned first. offline_sync_preview arrives with a count of what is about to be replayed. Every event delivered during that window carries "offline": true. offline_sync_completed closes it, and events after that are live again. So the rule is simple: skip auto-replies for any event carrying offline: true. Store them, count them, summarise them - just do not answer each one as if it arrived now. POST https://yourbackend.com/hook: { "event": "offline_sync_preview", "total": 142, "messages": 120, "receipts": 14, "notifications": 8, "app_data_changes": 0 } then, when it is done: { "event": "offline_sync_completed", "count": 142 } #### Checking without waiting to be told Webhooks are the fast path, but a health check should not depend on having received one. Poll the bridge instead - status is connected, disconnected or unpaired. Once a minute per bridge is plenty and sits far inside the 120 per minute budget. Treat unpaired as a distinct alert from disconnected: it means nobody ever linked a phone, which is a setup problem rather than an outage. curl: curl https://wabridges.com/api/instances/user-123/proxy/status \ -H "Authorization: Bearer $WA_API_KEY" While a bridge is down, sends return 503 with Retry-After rather than being silently dropped, and messages people send still arrive on the phone as normal - the bridge is a linked device, not the account. Published at: https://wabridges.com/docs/stay-connected ### Groups and contacts Read who is in a group, look up a contact, and check whether a phone number is on WhatsApp before you message it. #### Check a number before messaging it Sending to a number that is not on WhatsApp wastes a call and, at volume, looks like the behaviour of a spammer. Check first - up to a batch at a time - and message only what comes back registered. is_on_whatsapp is still in the response as a deprecated alias. Read registered in new code. curl: curl -X POST https://wabridges.com/api/instances/user-123/proxy/contacts/check \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"phones": ["15550001234", "00000000000"]}' ← 200: [ { "phone": "15550001234", "jid": "15550001234@s.whatsapp.net", "registered": true, "is_on_whatsapp": true, "is_business": false }, { "phone": "00000000000", "registered": false, "is_on_whatsapp": false, "is_business": false } ] Run this when a number enters your system, not before every send. The answer changes rarely, and caching it keeps you well inside the rate limit. #### Looking up one contact Ask by JID and you get the display name, whether the account is a business, and the business fields when it is - name, email, address. Names come from WhatsApp and from the phone's own address book, so treat them as a display convenience rather than identity. The JID is the identity. curl: curl https://wabridges.com/api/instances/user-123/proxy/contacts/15550001234@s.whatsapp.net \ -H "Authorization: Bearer $WA_API_KEY" #### The whole address book GET /contacts returns every contact the bridge knows about. On a busy number that is a long list, so treat it as a sync job rather than something you call inside a request. Avatars are a separate call per contact and return image bytes, not a URL - fetch them lazily and cache what you fetch. curl: curl https://wabridges.com/api/instances/user-123/proxy/contacts \ -H "Authorization: Bearer $WA_API_KEY" #### What a group is Group metadata comes back by JID - the subject, the description, when it was created, and how many people are in it. You get the group JID from the chat_id of any message it sends you, which is the practical reason to store chat_id on every inbound event. In a group, chat_id is the group and contact_id is the person who spoke, and replying to the wrong one is the classic first-integration bug. curl: curl https://wabridges.com/api/instances/user-123/proxy/groups/120363000000000001@g.us \ -H "Authorization: Bearer $WA_API_KEY" ← 200: { "jid": "120363000000000001@g.us", "name": "Team standup", "topic": "daily at 9", "participants": 12, "created": 1777180605 } This is read-only. Creating groups, adding people and changing the subject are not part of the API - do those on the phone. Published at: https://wabridges.com/docs/groups-and-contacts ### Typing, presence and receipts The small signals that make a bot feel like a person: typing indicators, online state, read receipts, and knowing when the other side is typing. #### Show that you are typing If your reply takes a couple of seconds - a model call, a database lookup - send composing first. The recipient sees "typing…" and the wait stops feeling like nothing happened. Send paused to clear it, though sending the message clears it too. The indicator also expires on its own, so a crashed handler will not leave someone watching a bot type forever. curl: curl -X POST https://wabridges.com/api/instances/user-123/proxy/send/typing \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"chat": "15550001234", "state": "composing"}' and to stop: {"chat": "15550001234", "state": "paused"} #### Marking messages read Read receipts are sent per message, by id. In a one-to-one chat the chat is enough; in a group you also name the sender, because the receipt is about that person's message. Whether to send them at all is a product decision. Marking read the instant your webhook fires tells people a machine is reading, which is honest but sets an expectation of an instant answer. a one-to-one chat: {"chat": "15550001234@s.whatsapp.net"} in a group: { "chat": "120363000000000001@g.us", "sender": "15550001234@s.whatsapp.net" } #### Your own online state A bridge can appear online or offline. available shows the number as online while your service is up; unavailable keeps it dark. Staying permanently available tells everyone the account is always online, which is a strange look for a support number that only answers office hours. Set it deliberately. curl: curl -X POST https://wabridges.com/api/instances/user-123/proxy/presence \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"state": "available"}' #### Seeing when they type Typing and presence events for other people are opt-in per contact. Subscribe once, and from then on that contact's typing and presence events arrive on your webhook. A typing event carries mode: text when they are typing, voice when they are recording a voice note. Useful if you want to hold a reply while someone is clearly still composing. curl: curl -X POST https://wabridges.com/api/instances/user-123/proxy/presence/subscribe \ -H "Authorization: Bearer $WA_API_KEY" \ -H "Content-Type: application/json" \ -d '{"chat": "15550001234"}' Without a subscription these events are simply never delivered - it is not a bug in your handler. Subscribe only to the contacts you actually act on, or a busy number will fill your webhook with keystrokes. Published at: https://wabridges.com/docs/typing-and-presence ### API keys How keys are issued, what we can and cannot recover for you, and how to rotate one without downtime. #### One header, every call Every request carries the key as a bearer token. There is nothing else to sign and no session to establish. A key is account-wide: it reaches every bridge on the account, so it belongs on your server, in an environment variable or a secret store - never in a mobile app, a browser bundle, or a repository. curl: curl https://wabridges.com/api/instances \ -H "Authorization: Bearer $WA_API_KEY" #### We cannot read it back to you The key is shown once, when you create it in Dashboard → API keys. After that we store only a hash of it and the last four characters, so nobody here can recover it - not support, not by asking nicely. If a key is lost, create a new one and revoke the old one. The last four are shown in the dashboard, which is how you work out which key a running service is using without seeing either in full. Keys are created and revoked in the dashboard, not through the API. A leaked key cannot be used to mint more keys. #### Rotating without downtime An account can hold several keys at once, each with a label, and that is what makes rotation boring rather than an outage. Create the new key, deploy it, confirm traffic is flowing under it, then revoke the old one. Because both work in the meantime there is no moment where a request has nowhere to go. Label keys after where they run - production, worker, staging - so revoking the right one later is not guesswork. Revoking is immediate and cannot be undone. The next request using that key gets 401. #### If a key leaks Revoke it first and ask questions afterwards - a new key takes seconds to issue, and anyone holding the old one can send from every number on your account until it is gone. Then check what was sent. The dashboard shows recent activity per bridge, and we can help you read it if something looks wrong. Published at: https://wabridges.com/docs/api-keys ### What a bridge costs and when Running, paused, suspended - what each state means for your traffic and your bill, and what happens to an idle bridge. #### The three states Every bridge is in one of three states, and state comes back on any bridge you fetch or list. - running - the container is up. This is the only state that sends and receives. - paused - the container is stopped and the session is kept. Nothing is delivered while paused, and it is not billed. - suspended - stopped by us, effectively always because payment failed. Settle the bill in the dashboard and it comes back. curl: curl https://wabridges.com/api/instances \ -H "Authorization: Bearer $WA_API_KEY" #### Idle bridges pause themselves A bridge that is provisioned but never paired, and sees no activity for about 6 hours, has its container stopped automatically. It is not deleted and nothing is lost - the record, the customer_ref, the webhook config all stay. Using it wakes it. The next call touches the bridge, the container is recreated, and it carries on. So a provisioning script that creates bridges ahead of demand costs nothing while they wait, and the first real request pays a short wake-up delay. A bridge you paused yourself is never auto-woken - that pause was a decision, not idleness, and we do not undo it behind your back. #### What you are billed for A bridge is a $5/month seat, added to the account subscription when you create it. Bridges created during the 3-day trial are free until the trial ends, and the subscription itself only begins when the first number is connected in the dashboard, card on file. Cancelling a bridge refunds the unused part of the month automatically - no ticket, no waiting period. Unlimited messages either way; there is no per-message fee to model. Full terms, including what happens to a number that gets banned, are on the pricing page. #### Cancelling Bridges are cancelled in the dashboard, not through the API. Creating is automatable because platforms provision constantly; removing a number is deliberate, rare, and takes money off a subscription, so it stays a decision someone makes on purpose. If your platform genuinely needs to tear bridges down programmatically, tell us what you are building - it is a reasonable thing to want and we would rather hear the use case than guess at it. Published at: https://wabridges.com/docs/bridge-states-and-billing ## Bridges Manage bridge instances. All routes require `Authorization: Bearer $WA_API_KEY`. Proxy calls use: `https://wabridges.com/api/instances/{customer_ref}/proxy` ### POST /api/instances The first bridge is created from the dashboard (one click, starts the free trial with a card on file); address it as `default` (`POST /api/instances/default/proxy/send/text`): `default` is accepted wherever a `customer_ref` is and always means the first bridge on the account, so a single-number integration never needs a name in the URL. Provision a new bridge on your subscription. The subscription itself, and the 3-day free trial (card on file, nothing charged until it ends), starts when you connect your first number in the dashboard; the API adds bridges to it. Bridges added during the trial are free until it ends. **Body:** `{"customer_ref": "user-123", "webhook_url": "https://yourbackend.com/hook"}` `customer_ref`: your internal identifier (e.g. user ID). Letters, digits, dashes and underscores only. `sandbox` and `default` are reserved. `webhook_url`: optional. Omit it (or send `""`) to use the hosted inbox - events are then visible on the dashboard and the response `webhook_url` is the inbox URL. Change it any time with `PATCH /api/instances/:customer_ref`. **Response 201:** `{"id": "7d09aa9c-…", "customer_ref": "user-123", "state": "running", "created_at": 1777180605, "webhook_secret": "whs_…", "webhook_url": "https://yourbackend.com/hook"}` Outbound webhooks carry `Authorization: Bearer ` and an HMAC `X-Webhook-Signature` header - store the secret to verify them. If customer_ref already exists, returns the existing record unchanged with status 200. **Response 400:** `{"error": "invalid_customer_ref" | "invalid_webhook_url" | "reserved_customer_ref", "message": "…"}`. **Response 402:** `{"error": "no_subscription", "message": "…", "dashboard_url": "…", "billing_url": "…"}` - the account has no subscription yet; connect a number in the dashboard first. `error` is `payment_required` when the account is past due. **Response 403:** `{"error": "bridge_limit_reached", "limit": 10, "message": "…"}` - accounts are capped at 10 bridges by default; email hello@wabridges.com to raise it. The bridge is ready to pair right away. Pairing needs the phone in hand (about a minute); the sandbox needs no phone. ### GET /api/instances List all bridges. Response: array of `{id, customer_ref, state, created_at}`. ### GET /api/instances/:customer_ref Get single bridge with live manager state. Includes `webhook_url`, `webhook_secret`, and `manager.health`, `manager.started_at`, `manager.fail_count`. `:customer_ref` may be `default` (the first bridge on the account) on this and every other bridge route. `state` is `running`, `paused` or `suspended`. A bridge with no WhatsApp number linked that sees no activity for a few hours is paused automatically (`auto_paused_at` set); reading it or calling any proxy endpoint starts it again. Outbound webhooks carry `Authorization: Bearer ` and an HMAC `X-Webhook-Signature` header - verify before trusting payloads. ### PATCH /api/instances/:customer_ref Change where the bridge delivers webhooks. **Body:** `{"webhook_url": "https://yourbackend.com/hook"}`; send `""` or `null` to switch to the hosted inbox. The bridge restarts to apply the change (a few seconds; the WhatsApp session is kept). The sandbox answers `501 not_available_in_sandbox`: its events stay in the dashboard. **Response 200:** the bridge record plus `"inbox": true|false`. **409** `bridge_paused` / `bridge_suspended` when the bridge is user-paused or suspended (an idle auto-paused bridge is woken instead). **502** when the orchestrator could not apply the change. ### GET /api/instances/:customer_ref/logs Stream bridge logs as server-sent events (SSE). Query param `tail` sets trailing lines to start from (default 100, max 10000). Response content type: `text/event-stream`. ## SESSION ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/status Connection status **Response:** `{"jid":"","lid":"","name":"","phone":"","status":"unpaired"}` ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/qr QR code PNG for pairing **Response:** `(image/png)` ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/pair Pair via link code sent to phone **Request body:** `{"phone":"15550001234"}` **Response:** `{"code":"ABCD-EFGH"}` ### DELETE https://wabridges.com/api/instances/{customer_ref}/proxy/logout Log out of WhatsApp session **Response:** `{"ok":true}` ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/webhook-logs Returns up to 200 most recent outbound webhook delivery attempts, newest first. Ring buffer — oldest entries are evicted once full. **Response:** `[{"attempt":0,"error":"","event":"","event_id":"","latency_ms":0,"status_code":0,"success":false,"timestamp":""}]` ## MESSAGING ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/send/text Send a text message **Request body:** `{"body":"hello","chat":"15550001234@s.whatsapp.net"}` **Response:** `{"message_id":"ACE41E988162AB19A4A7BB7A9E663693","timestamp":1777180605}` ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/send/media Media type is inferred from the Content-Type of the URL response. Set as_sticker to send image/webp media as a WhatsApp sticker. **Request body:** `{"chat":"15550001234","url":"https://example.com/voice.ogg"}` **Response:** `{"message_id":"ACE41E988162AB19A4A7BB7A9E663693","timestamp":1777180605}` ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/send/reaction React to a message with an emoji **Request body:** `{"chat":"15550001234","emoji":"👍","message_id":"ACE41E988162AB19A4A7BB7A9E663693"}` **Response:** `{"message_id":"ACE41E988162AB19A4A7BB7A9E663693","timestamp":1777180605}` ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/send/poll Send a poll **Request body:** `{"chat":"15550001234","max_answers":3,"options":["Mon","Tue","Wed","Thu","Fri"],"question":"Which days work for you?"}` **Response:** `{"message_id":"ACE41E988162AB19A4A7BB7A9E663693","timestamp":1777180605}` ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/send/location Send a location message **Request body:** `{"address":"Plaza de la Constitución, Mexico City","chat":"15550001234","lat":19.432608,"lng":-99.133209,"name":"Zócalo"}` **Response:** `{"message_id":"ACE41E988162AB19A4A7BB7A9E663693","timestamp":1777180605}` ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/send/event Sends a WA EventMessage. The bridge embeds a fresh MessageSecret in MessageContextInfo so future EncEventResponseMessage RSVPs can be decrypted and delivered as event_response webhooks. **Request body:** `{"chat":"120363000000000001@g.us","description":"weekly status","end_time":1777338000,"has_reminder":true,"is_schedule_call":true,"join_link":"https://meet.example.com/team-sync","name":"Team sync","reminder_offset_sec":900,"start_time":1777334400}` **Response:** `{"message_id":"ACE41E988162AB19A4A7BB7A9E663693","timestamp":1777180605}` ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/send/typing Send a typing indicator **Request body:** `{"chat":"15550001234","state":"composing"}` **Response:** `{"ok":true}` ### DELETE https://wabridges.com/api/instances/{customer_ref}/proxy/messages/:id Revoke a sent message for everyone **Response:** `{"message_id":"ACE41E988162AB19A4A7BB7A9E663693","timestamp":1777180605}` ### PATCH https://wabridges.com/api/instances/{customer_ref}/proxy/messages/:id/edit Edit a sent text message **Request body:** `{"body":"corrected text","chat":"15550001234@s.whatsapp.net"}` **Response:** `{"message_id":"ACE41E988162AB19A4A7BB7A9E663693","timestamp":1777180605}` ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/messages/:id/read Send a read receipt **Request body:** `{"chat":"15550001234@s.whatsapp.net"}` **Response:** `{"ok":true}` ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/messages/:id/media Download received media by message_id (cached 3h) **Response:** `(image/jpeg)` ## CALLS ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/calls/:id/reject Declines the call identified by a call_incoming webhook while it is still ringing. The caller sees the call declined immediately instead of ringing out. Rejecting a call that already ended is not an error WhatsApp reports; the request still returns ok. **Request body:** `{"contact_id":"15550001234@s.whatsapp.net"}` **Response:** `{"ok":true}` ## CONTACTS ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/contacts Returns a plain JSON array of contacts sorted by JID ascending. Without parameters the full list is returned (back-compat). To page, pass `limit` and use the last JID of the previous page as `after` on the next request; a page shorter than `limit` means you have reached the end. **Response:** `[{"about":"","business_address":"","business_email":"","business_name":"","first_name":"","full_name":"","is_business":false,"jid":"","name":"","phone":"","redacted_phone":"","registered":false,"verified_name":""}]` ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/contacts/check Check which phone numbers are on WhatsApp **Request body:** `{"phones":["15550001234","00000000000"]}` **Response:** `[{"is_business":false,"is_on_whatsapp":false,"jid":"","phone":"","registered":false,"verified_name":""}]` ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/contacts/:jid Get info for a single contact **Response:** `{"about":"","business_address":"","business_email":"","business_name":"","first_name":"","full_name":"","is_business":false,"jid":"","name":"","phone":"","redacted_phone":"","registered":false,"verified_name":""}` ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/contacts/:jid/avatar Download a contact's avatar **Response:** `(image/jpeg)` ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/groups/:jid Get metadata for a group chat (subject, topic, size) **Response:** `{"created":0,"jid":"","name":"","participants":0,"topic":""}` ## PROFILE ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/profile Your own profile **Response:** `{"about":"","jid":"","name":"","phone":""}` ### PATCH https://wabridges.com/api/instances/{customer_ref}/proxy/profile/status Set your About status text **Request body:** `{"status":"Hey there!"}` **Response:** `{"ok":true}` ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/profile/avatar Download your own avatar **Response:** `(image/jpeg)` ### PUT https://wabridges.com/api/instances/{customer_ref}/proxy/profile/avatar Server fetches the URL (must return JPEG) and uploads it as the account profile picture. **Request body:** `{"url":"https://example.com/me.jpg"}` **Response:** `{"picture_id":""}` ### DELETE https://wabridges.com/api/instances/{customer_ref}/proxy/profile/avatar Remove your profile picture **Response:** `{"ok":true}` ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/profile/qr Personal QR link to share your contact **Response:** `{"link":"https://wa.me/qr/ABCDEFGHIJKL"}` ## PRIVACY ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/status/privacy Get who can see your status updates **Response:** `[{"is_default":false,"list":[""],"type":"contacts"}]` ## PRESENCE ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/presence Set your online/offline presence **Request body:** `{"state":"unavailable"}` **Response:** `{"ok":true}` ### POST https://wabridges.com/api/instances/{customer_ref}/proxy/presence/subscribe WhatsApp only pushes ChatPresence (typing/recording) and Presence (online/offline) for contacts the client has subscribed to. Subscriptions decay after a few minutes; refresh periodically. Requires global presence to be `available` first. **Request body:** `{"chat":"15550001234"}` **Response:** `{"ok":true}` ## DEBUG ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/healthz Used by the container HEALTHCHECK. Always returns 200 while the process is up. Does NOT check WhatsApp connection — use /status for that. **Response:** `{"ok":true}` ### GET https://wabridges.com/api/instances/{customer_ref}/proxy/debug/stats Runtime stats (goroutines, heap, cache) **Response:** `{"gc_cycles":0,"goroutines":0,"heap_alloc_mb":0,"heap_objects":0,"heap_sys_mb":0,"media_cache_size":0,"next_gc_mb":0,"total_alloc_mb":0}` ## WEBHOOKS Every inbound event fires a POST to your `webhook_url`. **Delivery:** at-least-once, in order. Retries up to 5 attempts with backoff (10s/30s/1m/3m) on network errors, 429, and 5xx; other 4xx are not retried. Method: POST. Content-Type: application/json. **Timeout:** 10 seconds per attempt. Return 200 immediately — process async. Body is ignored. **Dedupe:** `X-Webhook-Event-Id` header is stable across retries — use it as an idempotency key. `X-Webhook-Attempt` is the 1-based attempt number. **Verification:** each delivery carries `Authorization: Bearer ` and `X-Webhook-Signature: t=,v1=` where v1 = HMAC-SHA256 over `.` keyed with webhook_secret. Verify v1 against the raw body and reject stale t (older than ~5 min). All events share a common shape: `{"event": "", ...event-specific fields}`. Switch on the `event` field to route to the correct handler. ### webhook: message **Fields:** event (message), message_id, contact_id, phone, chat_id, name, body, type (text|media|reaction|poll|location|event), media_type (|image|video|audio|voice|document|sticker), is_group, from_me, timestamp, caption?, edit (edit|revoke|pin)?, event_details?, forwarded?, is_broadcast?, location?, max_answers?, mentions?, offline?, options?, quoted?, reaction?, target_id?, verified_business? **Example payload:** ```json {"body":"Team sync","chat_id":"15550009999@g.us","contact_id":"15550001234@s.whatsapp.net","event":"message","event_details":{"description":"weekly status","end_time":1777338000,"join_link":"https://meet.example.com/team-sync","name":"Team sync","start_time":1777334400},"from_me":false,"is_group":true,"media_type":"","message_id":"ACEVENT0001","name":"John Doe","phone":"15550001234","timestamp":1777330600,"type":"event"} ``` ### webhook: connected **Fields:** event (connected), phone **Example payload:** ```json {"event":"connected","phone":"15550001234"} ``` ### webhook: disconnected **Fields:** event (disconnected), detail?, reason (logged_out|stream_replaced)? **Example payload:** ```json {"detail":"401: logged out","event":"disconnected","reason":"logged_out"} ``` ### webhook: typing **Fields:** event (typing), contact_id, chat_id, state (composing|paused), mode (text|voice) **Example payload:** ```json {"chat_id":"15550001234@s.whatsapp.net","contact_id":"15550001234@s.whatsapp.net","event":"typing","mode":"text","state":"composing"} ``` ### webhook: presence **Fields:** event (presence), contact_id, unavailable, last_seen? **Example payload:** ```json {"contact_id":"15550001234@s.whatsapp.net","event":"presence","last_seen":1777180605,"unavailable":true} ``` ### webhook: poll_vote **Fields:** event (poll_vote), contact_id, phone, chat_id, poll_id, message_id, name, from_me, timestamp, selected_options, offline? **Example payload:** ```json {"chat_id":"15550001234@s.whatsapp.net","contact_id":"15550001234@s.whatsapp.net","event":"poll_vote","from_me":false,"message_id":"ACVOTE0099","name":"John Doe","offline":true,"phone":"15550001234","poll_id":"ACPOLL0001","selected_options":["red"],"timestamp":1777180800} ``` ### webhook: event_response **Fields:** event (event_response), contact_id, phone, chat_id, event_id, message_id, name, from_me, timestamp, response (going|not_going|maybe|unknown), response_time_ms, extra_guest_count, offline? **Example payload:** ```json {"chat_id":"15550009999@g.us","contact_id":"15550001234@s.whatsapp.net","event":"event_response","event_id":"ACEVENT0001","extra_guest_count":1,"from_me":false,"message_id":"ACEVRESP0001","name":"John Doe","phone":"15550001234","response":"going","response_time_ms":1777330700123,"timestamp":1777330700} ``` ### webhook: call_incoming **Fields:** event (call_incoming), call_id, contact_id, platform, timestamp **Example payload:** ```json {"call_id":"ABCDEF123456","contact_id":"15550001234@s.whatsapp.net","event":"call_incoming","platform":"android","timestamp":1777180605} ``` ### webhook: call_terminated **Fields:** event (call_terminated), call_id, contact_id, reason (timeout|hangup|decline|busy|accept_elsewhere), timestamp **Example payload:** ```json {"call_id":"ABCDEF123456","contact_id":"15550001234@s.whatsapp.net","event":"call_terminated","reason":"hangup","timestamp":1777180720} ``` ### webhook: offline_sync_preview **Fields:** event (offline_sync_preview), total, messages, notifications, receipts, app_data_changes **Example payload:** ```json {"app_data_changes":0,"event":"offline_sync_preview","messages":120,"notifications":8,"receipts":14,"total":142} ``` ### webhook: offline_sync_completed **Fields:** event (offline_sync_completed), count **Example payload:** ```json {"count":142,"event":"offline_sync_completed"} ``` ### webhook: profile_picture_updated **Fields:** event (profile_picture_updated), remove, timestamp, picture_id? **Example payload:** ```json {"event":"profile_picture_updated","remove":true,"timestamp":1777180800} ```