# WhatsApp Messages Partner API — Full Documentation > Send and receive WhatsApp messages through Linq. This file is the complete documentation for WhatsApp Messages Partner API, the WhatsApp channel of Linq API Docs, concatenated for LLM ingestion. Each section below corresponds to one page of the site and is preceded by its canonical URL. The companion file [llms.txt](https://docs.linqapp.com/channel/whatsapp/llms.txt) is a shorter index pointing at individual pages. The machine-readable OpenAPI specification at https://whatsapp.messages.api.linqapp.com/v1/openapi.yaml is the canonical definition of every endpoint, request body, response shape, and webhook event. **Pages included:** 55 --- # WhatsApp URL: https://docs.linqapp.com/channel/whatsapp/ Send and receive WhatsApp messages through a single API. Linq handles delivery, retries, templates, the customer window, per-chat event history, and webhooks. Building with an AI agent? Feed these docs straight to your LLM or coding agent: [`llms-full.txt`](https://docs.linqapp.com/channel/whatsapp/llms-full.txt) is the complete WhatsApp documentation in one file, and [`llms.txt`](https://docs.linqapp.com/channel/whatsapp/llms.txt) is a concise index. Both cover this channel only — iMessage and Apple Messages publish their own. [Connect WhatsApp ](/channel/whatsapp/getting-started/connect-whatsapp/index.md)Complete Embedded Signup, number verification, and registration. [Quickstart ](/channel/whatsapp/getting-started/quickstart/index.md)Go from an API key to an accepted message and its final outcome. [API reference ](/channel/whatsapp/api/index.md)All operations and wire shapes from the published OpenAPI contract. [Sending messages ](/channel/whatsapp/guides/messaging/sending-messages/index.md)Destination-first and chat-first sends, parts, templates, and idempotency. [Events and recovery ](/channel/whatsapp/guides/events/index.md)Consume live signals without losing the durable source of truth. --- # Authentication URL: https://docs.linqapp.com/channel/whatsapp/getting-started/authentication/ Send the API key issued at onboarding as a bearer token: ``` Authorization: Bearer sk_live_… ``` A key is bound to exactly one brand and one environment. An identifier owned by another brand returns the same `404` as an identifier that does not exist, so the API never confirms cross-tenant resource existence. Keep keys server-side, load them from a secret store, and never put them in browser code, logs, tickets, or webhook payloads. Do not edit the key prefix: `sk_test_` selects the simulator and `sk_live_` selects the live channel. --- # Best practices URL: https://docs.linqapp.com/channel/whatsapp/getting-started/best-practices/ - Use a stable idempotency key for every logical send, and reuse it only when retrying that same request. - Interpret `202` as durable acceptance, then wait for a terminal receipt. - Persist account and per-chat cursors separately; they are different cursor domains. - Reconcile from the event log and chat journal after disconnects instead of assuming a live stream delivered everything. - Ignore unknown event kinds and error codes unless you explicitly handle them. - Read `customer_window` before choosing free-form content or a template. - Fetch template state at send time; an approved template can later be paused. - Keep API keys and webhook signing secrets in a secret store. ## Review your setup with an agent Give your coding agent the current OpenAPI document and the message-part schemas your integration uses. Ask it to verify authentication, idempotency, cursor persistence, unknown-event handling, customer-window behavior, and terminal receipt handling against those files. The served contract is more current than copied examples. Copy this prompt into your own AI coding agent to check your integration against the guidelines above. It is the same prompt the **Copy agent prompt** button in the header carries. Audit your WhatsApp integration Copy prompt ``` You are auditing a codebase that integrates with Linq's WhatsApp Messages Partner API. This is read-only: do not change code unless I ask. First fetch the current OpenAPI contract from `https://whatsapp.messages.api.linqapp.com/v1/openapi.yaml` and the part schemas the integration uses (`GET /v1/parts/{type}`). If you cannot obtain them, stop instead of auditing from memory. Locate every API call, send path, SSE consumer, webhook handler, and cursor store. Verify that: - authentication uses the issued bearer key server-side and never changes its `sk_test_` or `sk_live_` prefix; - every logical send has a stable `Idempotency-Key`, transport retries reuse the same key and body, and unrelated sends never share a key; - HTTP 202 is treated as durable acceptance rather than delivery; - free-form content is sent only in the open customer window, with an approved template used outside it; - unknown event kinds, error types, and error codes are handled generically rather than crashing a consumer; - SSE IDs, opaque account-event cursors, and numeric per-chat sequence cursors are persisted in separate domains; - reconnect recovery uses `GET /v1/event_log` and `GET /v1/chats/{chat}/events`, and never assumes the live stream contained every event; - one inbound message cannot trigger duplicate replies from its received, window-opened, delivered, or read events; - webhook signatures are verified over the exact raw body, event IDs are deduplicated, and signing-secret rotation accepts both active secrets before the old one is retired; - 429 responses honor `Retry-After`, terminal failures are not retried, and an unresolved outcome is never blindly resent; - logs include `request_id` and stable resource IDs without credentials or message content. Report a table with Check, Status (pass/gap/n-a/unknown), Where (file and line), and Fix. Follow it with a short action list ordered by data-loss, duplicate-send, and security risk. Ground every finding in code you actually read. ``` --- # Connect WhatsApp URL: https://docs.linqapp.com/channel/whatsapp/getting-started/connect-whatsapp/ Connecting WhatsApp usually takes about five minutes. Start from the [WhatsApp page in your Linq account](https://zero.linqapp.com/whatsapp/). Request beta access first Ask your Linq contact to enable WhatsApp beta access for your account before starting. The WhatsApp page is not available until access is enabled. Use your personal Meta login Log in to Meta with your normal personal Facebook or Meta account—the one that has admin access to your business. The phone number you connect is not your Meta login. Use the number shown by Linq The number entered in Meta must exactly match the Linq number shown during setup. A different number will not connect. ## Complete Embedded Signup 1. Confirm that the line shown is the one you want to use, then select **Connect**. 2. Select **Copy number**, then **Get ready** and **Open Meta**. 3. In Meta, create a Business Portfolio and WhatsApp Business Account, or select existing ones. 4. At **Add your WhatsApp phone number**, select **Add a new number** and paste the Linq number you copied. Do not choose **Use a display name only**. You can edit the display name later. 5. Choose **Text message** as the verification method. 6. When Meta sends the code, return to the Linq tab without closing the Meta window. Copy the verification code that appears in Linq, return to Meta, and paste it there. 7. If Meta asks for a payment method, you can skip it during initial setup. 8. Select **Finish** in Meta, then wait on the Linq tab while setup completes. Do not refresh or close it. ## Troubleshoot verification ### Phone verification reports a rate limit If phone verification reports too many attempts or a rate limit, stop requesting codes and wait at least 24 hours from that response before trying again. Follow a longer wait shown on screen. The 24-hour cooldown is recommended guidance and does not guarantee that verification will be available then. Avoid repeated code requests in both Embedded Signup and WhatsApp Manager during the cooldown. When it has passed, return to the WhatsApp page in your Linq account and continue through the normal setup flow. Starting with a new setup link does not clear the rate limit. If the previous setup has expired, use the **Restart setup** option when it is available. If it is not available or you remain blocked after the cooldown, contact Linq support with the exact error and the time it appeared. Never share the verification code. ### The code was accepted, but setup did not finish An accepted SMS code is only one step. Signup is not complete until you return to the Meta window, select **Finish**, and keep the Linq tab open until it says **Your WhatsApp account is ready**. ## Save your credential When the page says **Your WhatsApp account is ready**, immediately select **Copy key** or **Download your credential**. The API key is shown only once and will not be available after you leave or refresh the page. The downloaded credential also includes the API URL. The OpenAPI contract is available from the setup page and from [the public contract endpoint](https://whatsapp.messages.api.linqapp.com/v1/openapi.yaml). Confirm that the credential can read your connected number: Terminal window ``` curl -sS https://whatsapp.messages.api.linqapp.com/v1/phone_numbers \ -H "Authorization: Bearer $API_KEY" ``` Then follow the [Quickstart](/channel/whatsapp/getting-started/quickstart/index.md) to test sending and receiving end to end. ## Verify your business Business verification is not required to complete initial setup, but doing it early can help avoid limits later. In Meta Business Settings, open **Security Center → Business verification** and make sure the legal business name, address, phone number, and website exactly match your official documents. See Meta’s [business verification guide](https://www.facebook.com/business/help/2058515294227817) for the full process. After onboarding, add a payment method to your WhatsApp Business Account in Meta. It is optional during initial setup, but adding it now prepares the account for paid message categories. --- # Key concepts URL: https://docs.linqapp.com/channel/whatsapp/getting-started/key-concepts/ ## Chats are 1:1 Each chat represents one customer conversation. You can send by destination and let Linq resolve the chat, or send to a known chat ID. ## The customer window controls free-form sends An inbound customer message opens or extends the customer-service window. While it is open, you can send free-form content. Outside it, send an approved template. The API refuses an invalid choice; it never silently substitutes a template or coerces content. ## Acceptance is not delivery Successful send requests return `202` after durable acceptance. Delivery, read, and failure receipts arrive later. Treat an unresolved outcome as unknown; do not blindly resend it. ## Events address durable state SSE and webhooks tell you which chat sequence range moved. The per-chat journal contains the message content and forms the durable recovery source. Account events also have a pageable log for bounded catch-up. ## Parts are the content contract Each send currently carries one member of the sealed `parts` union. The JSON Schemas served by `GET /v1/parts/{type}` are authoritative and can be used directly as tool schemas for your agent. --- # Quickstart URL: https://docs.linqapp.com/channel/whatsapp/getting-started/quickstart/ Your Linq contact gives you an API key during onboarding. Terminal window ``` export BASE_URL="https://whatsapp.messages.api.linqapp.com" export API_KEY="sk_live_…" # use the exact key issued to you ``` Beta, with no SLA Published wire shapes change additively, but the service has no uptime, latency, support-response, or blanket retention commitment during beta. ## 1. Fetch the contract The OpenAPI document and individual message-part schemas are public: Terminal window ``` curl -s "$BASE_URL/v1/openapi.yaml" -o openapi.yaml curl -s "$BASE_URL/v1/parts/text" | jq . ``` ## 2. Start the event stream Open the stream before sending so you see each transition: Terminal window ``` curl -N \ -H "Authorization: Bearer $API_KEY" \ "$BASE_URL/v1/streams/events" ``` Persist each SSE `id` as a resume cursor. The event envelope contains the chat and the range that moved, not message content; read content from the chat journal. Unknown event kinds must be ignored so additive events do not break your consumer. ## 3. Send a message `POST /v1/messages` lets Linq select the eligible line and chat for a destination. Supply an idempotency key for every logical send. Terminal window ``` curl -sS -X POST "$BASE_URL/v1/messages" \ -H "Authorization: Bearer $API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: first-message-001" \ -d '{ "to": "+15551234567", "parts": [{"type": "text", "body": "Hello from Linq"}] }' | jq . ``` HTTP `202` means the send is durably accepted. It does not mean delivered. Watch for `message.delivered`, `message.read`, or `message.failed`, then read the addressed range from `GET /v1/chats/{chat}/events`. ## 4. Recover after a disconnect Resume SSE with `Last-Event-ID`, or page the same durable account sequence: Terminal window ``` curl -sS \ -H "Authorization: Bearer $API_KEY" \ "$BASE_URL/v1/event_log?limit=100" | jq . ``` Use the response’s opaque `next_cursor` on the next request. Keep account-event cursors separate from per-chat sequence cursors. Next, read [Sending messages](/channel/whatsapp/guides/messaging/sending-messages/index.md) and [Events and recovery](/channel/whatsapp/guides/events/index.md). --- # Events and recovery URL: https://docs.linqapp.com/channel/whatsapp/guides/events/ The API separates notification from durable content: 1. `GET /v1/streams/events` delivers live account events over SSE. 2. `GET /v1/event_log` pages the same account sequence for bounded recovery. 3. `GET /v1/chats/{chat}/events` returns the detailed, gapless journal for one chat. An account event identifies a chat and a `seq_from`–`seq_to` range. Fetch that range from the chat journal to read the content. Do not treat every event as a reply trigger: one customer message may also produce a window-opened event, and an outbound message may later produce delivered and read events. ## Cursor rules - Persist the SSE `id` and send it back as `Last-Event-ID` after reconnecting. - The event-log cursor is opaque; pass `next_cursor` back unchanged. - A chat cursor is the last gapless numeric `seq` you processed. - Keep all three cursor domains separate. - A cursor older than retained account history returns `410 cursor_expired`. - Ignore unknown event kinds so newly added kinds remain backward compatible. Webhooks carry the same stable event identity as the stream. Deduplicate the two sinks by event ID if you consume both. --- # Sending messages URL: https://docs.linqapp.com/channel/whatsapp/guides/messaging/sending-messages/ There are two ways to send. `POST /v1/messages` takes a destination and lets Linq select the eligible line and existing chat. `POST /v1/chats/{chat}/messages` sends within a chat you already know. Both return the same accepted-message shape and support idempotency. WhatsApp separates service messages, sent during the customer window, from template messages in marketing, utility, and authentication categories. See [service messages](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages/) and [marketing, utility, and authentication templates](https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/) for the channel rules. The Linq part schemas remain the authority for shapes accepted by this API. ## Always make sends idempotent Set `Idempotency-Key` to a stable value for the logical send. If a timeout leaves the response unknown, retry the same route with the same key and body. Do not generate a new key for a transport retry, and do not reuse a key for different content. ## Choose content from the customer window The chat read exposes `customer_window`. While it is open, you may send free-form parts such as text or media. Outside it, send one approved `template` part. An out-of-window free-form request returns `422 outside_customer_window`; Linq does not replace your content. ``` { "to": "+15551234567", "parts": [{ "type": "text", "body": "How can we help?" }] } ``` ``` { "to": "+15551234567", "parts": [ { "type": "template", "name": "order_update", "language": "en_US", "parameters": { "order_number": "A-1042" } } ] } ``` The values above are illustrative. Read `GET /v1/templates` and validate each send against the selected template’s `send_schema`. The part schema served by `GET /v1/parts/{type}` defines the common template fields. Validation rejects invalid content and returns JSON Pointers to the most specific identifiable field. ## Follow the outcome HTTP `202` means accepted, not delivered. Consume event signals, then read `GET /v1/chats/{chat}/events` for the message’s current state. Terminal outcomes are delivered, read, or failed. If no terminal evidence arrives, keep the outcome unresolved rather than blindly resending. ## Download incoming attachments A fresh `message.received` [webhook](/channel/whatsapp/guides/webhooks/index.md) includes `data.message`, so you can read its `parts` without fetching the message again. Look for a `media` part with a `url`. For example, an incoming document: ``` { "type": "media", "kind": "document", "filename": "invoice.pdf", "media_id": "example-inbound-media-id", "url": "https://whatsapp.messages.api.linqapp.com/v1/attachments/media_example/content" } ``` Use the returned `url` with `GET /v1/attachments/{attachment}/content`. It requires your account’s Bearer API key on every download; it is not a public file link. Download from your server and keep the key out of browser code and logs. Only attach credentials to the trusted WhatsApp API origin shown below, and do not follow redirects. Terminal window ``` # Use the document part's returned URL, not its media_id or upload_ref. ATTACHMENT_URL='https://whatsapp.messages.api.linqapp.com/v1/attachments/media_example/content' file=$(mktemp) || exit 1 trap 'rm -f "$file"' EXIT status=$(curl --silent --show-error \ --proto '=https' --max-time 30 \ --header "Authorization: Bearer ${LINQ_WHATSAPP_API_KEY}" \ --output "$file" --write-out '%{http_code}' \ "$ATTACHMENT_URL") || exit 1 if [ "$status" = 200 ]; then mv "$file" invoice.pdf else printf 'Attachment download returned HTTP %s\n' "$status" >&2 exit 1 fi ``` This example makes one request and saves the original bytes on HTTP `200`. It intentionally omits `--location`, so credentials are not forwarded through a redirect. In your application, accept only `200` as a successful download and choose the local output filename yourself. See the [attachment reference](/channel/whatsapp/api/resources/attachments/index.md) for the response contract and [error handling](/channel/whatsapp/guides/platform/errors/index.md). | Response | Action | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `409 attachment_not_ready` | Capture is still pending. Wait at least `Retry-After` before retrying. Use a bounded policy, such as at most three attempts within 30 seconds; if the requested wait exceeds the remaining budget, stop and schedule a later attempt. | | `410 attachment_unavailable` | The attachment failed or expired. Stop retrying this download. | | `404 attachment_not_found` | No downloadable attachment is visible to this key, including missing or pruned files. Check the URL and account credentials; do not retry blindly. | Older events may contain only a chat and sequence range. Read `GET /v1/chats/{chat}/events` when `data.message` is absent, and download only parts that actually include a `url`. The URL is response-only; do not include it when sending a message. --- # Phone numbers URL: https://docs.linqapp.com/channel/whatsapp/guides/phone-numbers/ `GET /v1/phone_numbers` returns the Linq lines attached to your account and their current channel state. Sends select among eligible lines. The `quality` field preserves the token reported by the channel. Empty means no rating has been reported. An unfamiliar non-empty token remains visible without a `quality_band`; a recognized token carries both. Treat `quality_band` as Linq’s provisional placement, not a replacement for the original token. The messaging-tier token is also reported verbatim. This API version does not publish a numeric ceiling, so do not infer one from a token or hard-code a vendor table. --- # Errors URL: https://docs.linqapp.com/channel/whatsapp/guides/platform/errors/ Every failure uses the same envelope: ``` { "error": { "type": "validation_error", "code": "invalid_request", "message": "The request could not be accepted.", "details": [{ "pointer": "/parts/0/body", "message": "is required" }] }, "request_id": "req_…" } ``` `error.type` is the broad class and `error.code` is the specific failure. Both sets grow additively: branch on values you handle and treat unknown values as a generic failure. Do not model either as a closed enum. `error.details` identifies field failures with JSON Pointers. `error.context` may carry typed facts needed to decide a remedy. Neither field is guaranteed on every error. Log the HTTP status, `request_id`, type, and code. Never log credentials or message content. A `429` may include `Retry-After`; honor it when present. --- # Limits URL: https://docs.linqapp.com/channel/whatsapp/guides/platform/limits/ JSON request bodies are capped at 64 KiB. Larger bodies are rejected rather than truncated. Media uploads have kind-specific ceilings documented on the upload operation. Paging inputs are refused rather than clamped. Send a supported `limit` and preserve returned cursors exactly. Per-key rate limits are not currently enforced, but that is not a promise of unlimited traffic. Keys remain subject to fair use and limits may be introduced without notice. Handle `429 rate_limited` and `Retry-After` from day one. The channel’s portfolio-level messaging tier is separate from this API’s request rate. The phone-number read exposes the reported tier token but does not publish a numeric ceiling in this version. --- # Templates URL: https://docs.linqapp.com/channel/whatsapp/guides/templates/ Use `POST /v1/templates` to register a template and submit it for review. Review is asynchronous and may take an hour or longer for a first submission. Use `GET /v1/templates` to read the current status and accepted parameter shape. Only an `approved` template can be sent. Approval is not permanent: a template may later be paused for quality and become approved again. Read the registry rather than caching approval forever. For WhatsApp’s template categories, review rules, and lifecycle, see the [WhatsApp Business Platform message template documentation](https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/). Linq’s template endpoints are the authority for the request and response shapes you use with this API. A template send is validated against the registry before acceptance. A missing or non-approved template, wrong language, or mismatched parameter set returns `422 template_not_sendable`. Linq never substitutes a different template. --- # Webhooks URL: https://docs.linqapp.com/channel/whatsapp/guides/webhooks/ Register a destination with `POST /v1/webhook_endpoints`. The created response returns signing-secret material once; store it immediately in a secret manager. Endpoint reads expose configuration and health, never the secret value. Verify every request against the exact raw body before parsing JSON. Respond quickly after durable acceptance in your system, and process asynchronously. Use the event ID as the deduplication key. `GET /v1/webhook_deliveries` exposes delivery attempts and outcomes. If a bounded historical range needs redelivery, create a replay under the endpoint and poll that replay’s status. Replay creation has its own idempotency key; use a stable key when retrying the same replay request. Adding a signing secret starts a rotation window. Deploy verification for both active secrets before retiring the old one. Disabling an endpoint is a cutoff, not a pause: events produced while disabled are not silently queued for later. --- # API Reference URL: https://docs.linqapp.com/channel/whatsapp/api/ ## [Attachments](/channel/whatsapp/api/resources/attachments/index.md) - [Download](/channel/whatsapp/api/resources/attachments/methods/download/index.md) ## [Chats](/channel/whatsapp/api/resources/chats/index.md) - [List](/channel/whatsapp/api/resources/chats/methods/list/index.md) - [Mark Read](/channel/whatsapp/api/resources/chats/methods/mark_read/index.md) - [Retrieve](/channel/whatsapp/api/resources/chats/methods/retrieve/index.md) - [Events](/channel/whatsapp/api/resources/chats/subresources/events/index.md) - [List](/channel/whatsapp/api/resources/chats/subresources/events/methods/list/index.md) - [Messages](/channel/whatsapp/api/resources/chats/subresources/messages/index.md) - [Send](/channel/whatsapp/api/resources/chats/subresources/messages/methods/send/index.md) ## [Contract](/channel/whatsapp/api/resources/contract/index.md) - [OpenAPI](/channel/whatsapp/api/resources/contract/methods/openapi/index.md) - [Part Schema](/channel/whatsapp/api/resources/contract/methods/part_schema/index.md) ## [Destination Messages](/channel/whatsapp/api/resources/destination_messages/index.md) - [Send](/channel/whatsapp/api/resources/destination_messages/methods/send/index.md) ## [Event Log](/channel/whatsapp/api/resources/event_log/index.md) - [List](/channel/whatsapp/api/resources/event_log/methods/list/index.md) ## [Media](/channel/whatsapp/api/resources/media/index.md) - [Upload](/channel/whatsapp/api/resources/media/methods/upload/index.md) ## [Phone Numbers](/channel/whatsapp/api/resources/phone_numbers/index.md) - [List](/channel/whatsapp/api/resources/phone_numbers/methods/list/index.md) ## [Streams](/channel/whatsapp/api/resources/streams/index.md) - [Events](/channel/whatsapp/api/resources/streams/methods/events/index.md) ## [Templates](/channel/whatsapp/api/resources/templates/index.md) - [Create](/channel/whatsapp/api/resources/templates/methods/create/index.md) - [List](/channel/whatsapp/api/resources/templates/methods/list/index.md) ## [Webhook Deliveries](/channel/whatsapp/api/resources/webhook_deliveries/index.md) - [List](/channel/whatsapp/api/resources/webhook_deliveries/methods/list/index.md) ## [Webhook Endpoints](/channel/whatsapp/api/resources/webhook_endpoints/index.md) - [Create](/channel/whatsapp/api/resources/webhook_endpoints/methods/create/index.md) - [Delete](/channel/whatsapp/api/resources/webhook_endpoints/methods/delete/index.md) - [List](/channel/whatsapp/api/resources/webhook_endpoints/methods/list/index.md) - [Retrieve](/channel/whatsapp/api/resources/webhook_endpoints/methods/retrieve/index.md) - [Update](/channel/whatsapp/api/resources/webhook_endpoints/methods/update/index.md) - [Replays](/channel/whatsapp/api/resources/webhook_endpoints/subresources/replays/index.md) - [Create](/channel/whatsapp/api/resources/webhook_endpoints/subresources/replays/methods/create/index.md) - [Retrieve](/channel/whatsapp/api/resources/webhook_endpoints/subresources/replays/methods/retrieve/index.md) - [Secrets](/channel/whatsapp/api/resources/webhook_endpoints/subresources/secrets/index.md) - [Create](/channel/whatsapp/api/resources/webhook_endpoints/subresources/secrets/methods/create/index.md) - [Delete](/channel/whatsapp/api/resources/webhook_endpoints/subresources/secrets/methods/delete/index.md) - [List](/channel/whatsapp/api/resources/webhook_endpoints/subresources/secrets/methods/list/index.md) --- # Attachments URL: https://docs.linqapp.com/channel/whatsapp/api/resources/attachments/ ## Download an attachment **get** `/v1/attachments/{attachment}/content` Downloads exact retained inbound bytes using this account's Bearer key. URLs are stable and require Authorization on every request. Pending capture returns 409 attachment_not_ready with Retry-After; failed or expired retained assets return 410 attachment_unavailable; missing, pruned, outbound or foreign assets return 404 attachment_not_found. Retention is 30 days from server receipt and downloads do not extend it. GET admitted before expiry may finish from acquired bytes. Range is ignored (full 200 response); HEAD is refused. ### Path Parameters - `attachment: string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/attachments/$ATTACHMENT/content \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` --- # Download an attachment URL: https://docs.linqapp.com/channel/whatsapp/api/resources/attachments/methods/download/ **get** `/v1/attachments/{attachment}/content` Downloads exact retained inbound bytes using this account's Bearer key. URLs are stable and require Authorization on every request. Pending capture returns 409 attachment_not_ready with Retry-After; failed or expired retained assets return 410 attachment_unavailable; missing, pruned, outbound or foreign assets return 404 attachment_not_found. Retention is 30 days from server receipt and downloads do not extend it. GET admitted before expiry may finish from acquired bytes. Range is ignored (full 200 response); HEAD is refused. ### Path Parameters - `attachment: string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/attachments/$ATTACHMENT/content \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` --- # Chats URL: https://docs.linqapp.com/channel/whatsapp/api/resources/chats/ ## List chats **get** `/v1/chats` Lists the brand's chats, most recently active first. A chat moves to the front when its latest event is applied. The page is a keyset, not an offset: pass the previous page's `next_cursor` back verbatim. A cursor this API did not issue — including an empty one — is refused rather than treated as the beginning, so a paging loop cannot silently restart. **A walk of this list is not a snapshot, and the gap is one-directional.** The order is activity and activity only ever moves a chat toward the front, so a chat that receives a message while you are paging jumps ABOVE your cursor and is not returned again: on a busy account, a full walk silently omits the conversations that were active during it. It never returns one twice. That is a property of ordering by activity, so plan around it rather than against it: the chats you missed are exactly the chats that just had traffic, which `GET /v1/streams/events` announces as it happens and `GET /v1/chats/{chat}/events` reads back in full. Reconcile with those two, not by walking the list again. Within one chat there is no such gap — the transcript's order is the journal's own sequence and never moves. ### Query Parameters - `cursor: optional string` Cursor from a previous page's `next_cursor`. - `limit: optional number` Page size. The bounds are in this parameter's schema, not only in this sentence — a value outside them is refused, never clamped. ### Returns - `ChatPage object { data, has_more, next_cursor }` A page of chats. - `data: array of Chat` - `id: string` The chat's id. - `created_at: string` - `customer: string` Customer address as an opaque identifier. - `customer_window: object { expires_at, state, source }` The customer service window — first-class state on every chat read. Only `template` parts are sendable while it is closed. - `expires_at: string` When the open window expires. `null` when no window has ever opened. - `state: string` `open` or `closed`. - `source: optional string` What opened the current window: `customer_message`, or `free_entry_point` for ad-sourced windows. Absent when no window has ever opened. - `phone_number_id: string` The id of the sending number this chat is pinned to. - `updated_at: string` When the chat last changed — a new event moves it. - `from: optional string` The number this conversation sends from, in E.164 (leading `+`). This is the value to pass back as a send's `from` to address this exact conversation. Absent only if the number is no longer one of yours. - `has_more: boolean` True when another page exists — pass `next_cursor` to fetch it. - `next_cursor: optional string` Opaque cursor for the next page; present when `has_more` is true. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "customer": "customer", "customer_window": { "expires_at": "2019-12-27T18:11:19.117Z", "state": "state", "source": "source" }, "phone_number_id": "phone_number_id", "updated_at": "2019-12-27T18:11:19.117Z", "from": "from" } ], "has_more": true, "next_cursor": "next_cursor" } ``` ## Mark a chat read **post** `/v1/chats/{chat}/read` Marks the latest unread inbound message as read, optionally showing the typing indicator. The upstream command targets that one message; this API does not promise that the channel also marks earlier inbound messages. Omit the body, or send `{}`, for read-only; the two forms are identical. `typing: false` is identical too. If the chat is not available to this account, the request is refused with `404 chat_not_found`. If it has no unread inbound message, it is refused with `409 nothing_unread`; this route does not act as a standalone typing refresh. `202` means the command and its `chat.read` journal event are durable; the upstream action is asynchronous. Read receipts and typing are outbound-only: this API never reports customer typing. ### Path Parameters - `chat: string` ### Body Parameters - `typing: optional boolean` Also show the customer the typing indicator, which auto-dismisses after a short time or when your next message arrives. Read receipts and typing are outbound-only: the API never reports customer typing. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats/$CHAT/read \ -X POST \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` ## Get a chat **get** `/v1/chats/{chat}` Reads one chat, including its live customer-window state. A chat id that is not this key's answers `404` — the same answer an id that does not exist gets, because a response must never confirm one across accounts. ### Path Parameters - `chat: string` ### Returns - `Chat object { id, created_at, customer, 4 more }` One customer conversation. - `id: string` The chat's id. - `created_at: string` - `customer: string` Customer address as an opaque identifier. - `customer_window: object { expires_at, state, source }` The customer service window — first-class state on every chat read. Only `template` parts are sendable while it is closed. - `expires_at: string` When the open window expires. `null` when no window has ever opened. - `state: string` `open` or `closed`. - `source: optional string` What opened the current window: `customer_message`, or `free_entry_point` for ad-sourced windows. Absent when no window has ever opened. - `phone_number_id: string` The id of the sending number this chat is pinned to. - `updated_at: string` When the chat last changed — a new event moves it. - `from: optional string` The number this conversation sends from, in E.164 (leading `+`). This is the value to pass back as a send's `from` to address this exact conversation. Absent only if the number is no longer one of yours. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats/$CHAT \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "customer": "customer", "customer_window": { "expires_at": "2019-12-27T18:11:19.117Z", "state": "state", "source": "source" }, "phone_number_id": "phone_number_id", "updated_at": "2019-12-27T18:11:19.117Z", "from": "from" } ``` ## Domain Types ### Chat - `Chat object { id, created_at, customer, 4 more }` One customer conversation. - `id: string` The chat's id. - `created_at: string` - `customer: string` Customer address as an opaque identifier. - `customer_window: object { expires_at, state, source }` The customer service window — first-class state on every chat read. Only `template` parts are sendable while it is closed. - `expires_at: string` When the open window expires. `null` when no window has ever opened. - `state: string` `open` or `closed`. - `source: optional string` What opened the current window: `customer_message`, or `free_entry_point` for ad-sourced windows. Absent when no window has ever opened. - `phone_number_id: string` The id of the sending number this chat is pinned to. - `updated_at: string` When the chat last changed — a new event moves it. - `from: optional string` The number this conversation sends from, in E.164 (leading `+`). This is the value to pass back as a send's `from` to address this exact conversation. Absent only if the number is no longer one of yours. ### Chat Event - `ChatEvent object { occurred_at, seq, type, message }` One entry in a chat's gapless event sequence — the transcript is a read of these. - `occurred_at: string` - `seq: number` The event's position in this chat's sequence — contiguous from 1, no gaps. Use it as this chat's events-read cursor. It is scoped to one chat, so it is NOT the `/v1/streams/events` stream cursor: that stream spans every chat and carries its own cursor in each frame's `id`. - `type: string` The event's kind, e.g. `message.received`, `message.sent`. Kinds grow additively — skip what you do not handle. `chat.event.unknown` is the one kind that never carries meaning: it marks a journal entry whose partner-visible kind is not published yet. It exists so the sequence stays gapless — an entry you cannot interpret is still an entry, and omitting it would put a hole in `seq`. Skip it. - `message: optional Message` The message this event is about, on message-kind events. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. ### Chat Event Page - `ChatEventPage object { data, has_more, next_cursor }` A page of one chat's events, in sequence order. - `data: array of ChatEvent` - `occurred_at: string` - `seq: number` The event's position in this chat's sequence — contiguous from 1, no gaps. Use it as this chat's events-read cursor. It is scoped to one chat, so it is NOT the `/v1/streams/events` stream cursor: that stream spans every chat and carries its own cursor in each frame's `id`. - `type: string` The event's kind, e.g. `message.received`, `message.sent`. Kinds grow additively — skip what you do not handle. `chat.event.unknown` is the one kind that never carries meaning: it marks a journal entry whose partner-visible kind is not published yet. It exists so the sequence stays gapless — an entry you cannot interpret is still an entry, and omitting it would put a hole in `seq`. Skip it. - `message: optional Message` The message this event is about, on message-kind events. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `has_more: boolean` True when another page exists — pass `next_cursor` to fetch it. - `next_cursor: optional string` Opaque cursor for the next page; present when `has_more` is true. ### Chat Page - `ChatPage object { data, has_more, next_cursor }` A page of chats. - `data: array of Chat` - `id: string` The chat's id. - `created_at: string` - `customer: string` Customer address as an opaque identifier. - `customer_window: object { expires_at, state, source }` The customer service window — first-class state on every chat read. Only `template` parts are sendable while it is closed. - `expires_at: string` When the open window expires. `null` when no window has ever opened. - `state: string` `open` or `closed`. - `source: optional string` What opened the current window: `customer_message`, or `free_entry_point` for ad-sourced windows. Absent when no window has ever opened. - `phone_number_id: string` The id of the sending number this chat is pinned to. - `updated_at: string` When the chat last changed — a new event moves it. - `from: optional string` The number this conversation sends from, in E.164 (leading `+`). This is the value to pass back as a send's `from` to address this exact conversation. Absent only if the number is no longer one of yours. - `has_more: boolean` True when another page exists — pass `next_cursor` to fetch it. - `next_cursor: optional string` Opaque cursor for the next page; present when `has_more` is true. # Events ## List a chat's events **get** `/v1/chats/{chat}/events` Reads a chat's event sequence in order — the transcript API. The sequence is gapless per chat: page forward from `seq` 1 (or any cursor) and nothing is ever missing between two entries. This is the BACKFILL for `GET /v1/streams/events`, which is a live stream and replays nothing: an event that passed while you were not connected is re-read here, addressed by the `chat_id` and `seq` range every stream frame carries. The cursor is the `seq` of the last entry you processed, and it is exclusive — pass `next_cursor` back verbatim, or the `seq` you stopped at, and the next page starts after it. Paging never skips: the next page resumes from the last position actually handed to you, so an event journaled between two of your requests is returned rather than stepped over. **Being caught up and being lost are different answers.** A cursor at the end of the sequence returns an empty page — that is the ordinary polling result. A cursor BEYOND the end is refused with `422` (`invalid_paging`), because no such position has ever existed in this chat and answering it with silence would be indistinguishable from having caught up. The refusal names where the sequence actually ends, so a client that has drifted can resume. An empty cursor is refused the same way: omit the parameter to read from the beginning. **Retention.** This journal has no scheduled pruning during beta, so ordinary operation does not remove an old prefix or renumber its immutable sequence. That is not a permanent-retention promise or a fixed minimum availability period: authenticated account deletion, offboarding, a lawful deletion requirement, or a beta-environment reset may remove the account-scoped transcript. This operation therefore has no `expires_at`, retained-floor field, or scheduled `cursor_expired` response. If prefix pruning is introduced later, its additive retained floor and typed expiry behavior will be published before any history is removed. ### Path Parameters - `chat: string` ### Query Parameters - `cursor: optional string` Cursor from a previous page's `next_cursor`; pages run in ascending `seq` order. - `limit: optional number` Page size. The bounds are in this parameter's schema, not only in this sentence — a value outside them is refused, never clamped. ### Returns - `ChatEventPage object { data, has_more, next_cursor }` A page of one chat's events, in sequence order. - `data: array of ChatEvent` - `occurred_at: string` - `seq: number` The event's position in this chat's sequence — contiguous from 1, no gaps. Use it as this chat's events-read cursor. It is scoped to one chat, so it is NOT the `/v1/streams/events` stream cursor: that stream spans every chat and carries its own cursor in each frame's `id`. - `type: string` The event's kind, e.g. `message.received`, `message.sent`. Kinds grow additively — skip what you do not handle. `chat.event.unknown` is the one kind that never carries meaning: it marks a journal entry whose partner-visible kind is not published yet. It exists so the sequence stays gapless — an entry you cannot interpret is still an entry, and omitting it would put a hole in `seq`. Skip it. - `message: optional Message` The message this event is about, on message-kind events. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `has_more: boolean` True when another page exists — pass `next_cursor` to fetch it. - `next_cursor: optional string` Opaque cursor for the next page; present when `has_more` is true. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats/$CHAT/events \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "occurred_at": "2019-12-27T18:11:19.117Z", "seq": 0, "type": "type", "message": { "id": "id", "chat_id": "chat_id", "created_at": "2019-12-27T18:11:19.117Z", "direction": "inbound", "parts": [ { "body": "body", "type": "text", "preview_url": true } ], "status": "status", "channel_message_id": "channel_message_id", "failure": { "code": "code", "message": "message" }, "from": "from" } } ], "has_more": true, "next_cursor": "next_cursor" } ``` # Messages ## Send a message in a chat **post** `/v1/chats/{chat}/messages` Sends a reply on an existing chat's unchanged sending number. The chat supplies the customer and sender; this operation never creates, moves, repins or fails over a conversation. Acceptance and refusal semantics are otherwise identical to `POST /v1/messages`. ### Path Parameters - `chat: string` ### Header Parameters - `"Idempotency-Key": optional string` ### Body Parameters - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 8 more` The message content — sendable parts only, at least one. **One outbound part per request.** Each request has its own acceptance result and optional idempotency key. Each accepted request maps to one native channel message with its own identifier, delivery outcome and charge evidence. A request carrying more than one part is refused with HTTP 422 (`multiple_parts_not_available`); the API neither fans it out nor offers an atomic batch. Separate requests are independent, and no cross-request delivery or receipt order is promised. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `reply_to_message_id: optional string` Our opaque `Message.id` for an earlier message in this same chat. - `send_at: optional string` RESERVED — scheduled sends are not yet available. Any value is refused. ### Returns - `AcceptedMessage object { id, chat_id, created_at, 7 more }` An accepted send: the message, plus how its sending number was chosen. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `from_selection: optional object { reason }` Present only when this service chose the sending number for you — that is, when you omitted `from`. A send that named `from` omits this rather than reporting a choice nobody made. - `reason: string` Why this number was used. Exactly one of: - `reused_active_chat` — this customer already had a conversation and it continues on the same number. - `new_best_number` — there was no existing conversation, so the best available number was chosen. Like every other vocabulary here it is a plain string that may grow additively; treat a value you do not recognise as "chosen for you". ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats/$CHAT/messages \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{ "parts": [ { "body": "body", "type": "text" } ] }' ``` #### Response ```json { "id": "id", "chat_id": "chat_id", "created_at": "2019-12-27T18:11:19.117Z", "direction": "inbound", "parts": [ { "body": "body", "type": "text", "preview_url": true } ], "status": "status", "channel_message_id": "channel_message_id", "failure": { "code": "code", "message": "message" }, "from": "from", "from_selection": { "reason": "reason" } } ``` --- # List chats URL: https://docs.linqapp.com/channel/whatsapp/api/resources/chats/methods/list/ **get** `/v1/chats` Lists the brand's chats, most recently active first. A chat moves to the front when its latest event is applied. The page is a keyset, not an offset: pass the previous page's `next_cursor` back verbatim. A cursor this API did not issue — including an empty one — is refused rather than treated as the beginning, so a paging loop cannot silently restart. **A walk of this list is not a snapshot, and the gap is one-directional.** The order is activity and activity only ever moves a chat toward the front, so a chat that receives a message while you are paging jumps ABOVE your cursor and is not returned again: on a busy account, a full walk silently omits the conversations that were active during it. It never returns one twice. That is a property of ordering by activity, so plan around it rather than against it: the chats you missed are exactly the chats that just had traffic, which `GET /v1/streams/events` announces as it happens and `GET /v1/chats/{chat}/events` reads back in full. Reconcile with those two, not by walking the list again. Within one chat there is no such gap — the transcript's order is the journal's own sequence and never moves. ### Query Parameters - `cursor: optional string` Cursor from a previous page's `next_cursor`. - `limit: optional number` Page size. The bounds are in this parameter's schema, not only in this sentence — a value outside them is refused, never clamped. ### Returns - `ChatPage object { data, has_more, next_cursor }` A page of chats. - `data: array of Chat` - `id: string` The chat's id. - `created_at: string` - `customer: string` Customer address as an opaque identifier. - `customer_window: object { expires_at, state, source }` The customer service window — first-class state on every chat read. Only `template` parts are sendable while it is closed. - `expires_at: string` When the open window expires. `null` when no window has ever opened. - `state: string` `open` or `closed`. - `source: optional string` What opened the current window: `customer_message`, or `free_entry_point` for ad-sourced windows. Absent when no window has ever opened. - `phone_number_id: string` The id of the sending number this chat is pinned to. - `updated_at: string` When the chat last changed — a new event moves it. - `from: optional string` The number this conversation sends from, in E.164 (leading `+`). This is the value to pass back as a send's `from` to address this exact conversation. Absent only if the number is no longer one of yours. - `has_more: boolean` True when another page exists — pass `next_cursor` to fetch it. - `next_cursor: optional string` Opaque cursor for the next page; present when `has_more` is true. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "customer": "customer", "customer_window": { "expires_at": "2019-12-27T18:11:19.117Z", "state": "state", "source": "source" }, "phone_number_id": "phone_number_id", "updated_at": "2019-12-27T18:11:19.117Z", "from": "from" } ], "has_more": true, "next_cursor": "next_cursor" } ``` --- # Mark a chat read URL: https://docs.linqapp.com/channel/whatsapp/api/resources/chats/methods/mark_read/ **post** `/v1/chats/{chat}/read` Marks the latest unread inbound message as read, optionally showing the typing indicator. The upstream command targets that one message; this API does not promise that the channel also marks earlier inbound messages. Omit the body, or send `{}`, for read-only; the two forms are identical. `typing: false` is identical too. If the chat is not available to this account, the request is refused with `404 chat_not_found`. If it has no unread inbound message, it is refused with `409 nothing_unread`; this route does not act as a standalone typing refresh. `202` means the command and its `chat.read` journal event are durable; the upstream action is asynchronous. Read receipts and typing are outbound-only: this API never reports customer typing. ### Path Parameters - `chat: string` ### Body Parameters - `typing: optional boolean` Also show the customer the typing indicator, which auto-dismisses after a short time or when your next message arrives. Read receipts and typing are outbound-only: the API never reports customer typing. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats/$CHAT/read \ -X POST \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` --- # Get a chat URL: https://docs.linqapp.com/channel/whatsapp/api/resources/chats/methods/retrieve/ **get** `/v1/chats/{chat}` Reads one chat, including its live customer-window state. A chat id that is not this key's answers `404` — the same answer an id that does not exist gets, because a response must never confirm one across accounts. ### Path Parameters - `chat: string` ### Returns - `Chat object { id, created_at, customer, 4 more }` One customer conversation. - `id: string` The chat's id. - `created_at: string` - `customer: string` Customer address as an opaque identifier. - `customer_window: object { expires_at, state, source }` The customer service window — first-class state on every chat read. Only `template` parts are sendable while it is closed. - `expires_at: string` When the open window expires. `null` when no window has ever opened. - `state: string` `open` or `closed`. - `source: optional string` What opened the current window: `customer_message`, or `free_entry_point` for ad-sourced windows. Absent when no window has ever opened. - `phone_number_id: string` The id of the sending number this chat is pinned to. - `updated_at: string` When the chat last changed — a new event moves it. - `from: optional string` The number this conversation sends from, in E.164 (leading `+`). This is the value to pass back as a send's `from` to address this exact conversation. Absent only if the number is no longer one of yours. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats/$CHAT \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "customer": "customer", "customer_window": { "expires_at": "2019-12-27T18:11:19.117Z", "state": "state", "source": "source" }, "phone_number_id": "phone_number_id", "updated_at": "2019-12-27T18:11:19.117Z", "from": "from" } ``` --- # Events URL: https://docs.linqapp.com/channel/whatsapp/api/resources/chats/subresources/events/ ## List a chat's events **get** `/v1/chats/{chat}/events` Reads a chat's event sequence in order — the transcript API. The sequence is gapless per chat: page forward from `seq` 1 (or any cursor) and nothing is ever missing between two entries. This is the BACKFILL for `GET /v1/streams/events`, which is a live stream and replays nothing: an event that passed while you were not connected is re-read here, addressed by the `chat_id` and `seq` range every stream frame carries. The cursor is the `seq` of the last entry you processed, and it is exclusive — pass `next_cursor` back verbatim, or the `seq` you stopped at, and the next page starts after it. Paging never skips: the next page resumes from the last position actually handed to you, so an event journaled between two of your requests is returned rather than stepped over. **Being caught up and being lost are different answers.** A cursor at the end of the sequence returns an empty page — that is the ordinary polling result. A cursor BEYOND the end is refused with `422` (`invalid_paging`), because no such position has ever existed in this chat and answering it with silence would be indistinguishable from having caught up. The refusal names where the sequence actually ends, so a client that has drifted can resume. An empty cursor is refused the same way: omit the parameter to read from the beginning. **Retention.** This journal has no scheduled pruning during beta, so ordinary operation does not remove an old prefix or renumber its immutable sequence. That is not a permanent-retention promise or a fixed minimum availability period: authenticated account deletion, offboarding, a lawful deletion requirement, or a beta-environment reset may remove the account-scoped transcript. This operation therefore has no `expires_at`, retained-floor field, or scheduled `cursor_expired` response. If prefix pruning is introduced later, its additive retained floor and typed expiry behavior will be published before any history is removed. ### Path Parameters - `chat: string` ### Query Parameters - `cursor: optional string` Cursor from a previous page's `next_cursor`; pages run in ascending `seq` order. - `limit: optional number` Page size. The bounds are in this parameter's schema, not only in this sentence — a value outside them is refused, never clamped. ### Returns - `ChatEventPage object { data, has_more, next_cursor }` A page of one chat's events, in sequence order. - `data: array of ChatEvent` - `occurred_at: string` - `seq: number` The event's position in this chat's sequence — contiguous from 1, no gaps. Use it as this chat's events-read cursor. It is scoped to one chat, so it is NOT the `/v1/streams/events` stream cursor: that stream spans every chat and carries its own cursor in each frame's `id`. - `type: string` The event's kind, e.g. `message.received`, `message.sent`. Kinds grow additively — skip what you do not handle. `chat.event.unknown` is the one kind that never carries meaning: it marks a journal entry whose partner-visible kind is not published yet. It exists so the sequence stays gapless — an entry you cannot interpret is still an entry, and omitting it would put a hole in `seq`. Skip it. - `message: optional Message` The message this event is about, on message-kind events. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `has_more: boolean` True when another page exists — pass `next_cursor` to fetch it. - `next_cursor: optional string` Opaque cursor for the next page; present when `has_more` is true. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats/$CHAT/events \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "occurred_at": "2019-12-27T18:11:19.117Z", "seq": 0, "type": "type", "message": { "id": "id", "chat_id": "chat_id", "created_at": "2019-12-27T18:11:19.117Z", "direction": "inbound", "parts": [ { "body": "body", "type": "text", "preview_url": true } ], "status": "status", "channel_message_id": "channel_message_id", "failure": { "code": "code", "message": "message" }, "from": "from" } } ], "has_more": true, "next_cursor": "next_cursor" } ``` --- # List a chat's events URL: https://docs.linqapp.com/channel/whatsapp/api/resources/chats/subresources/events/methods/list/ **get** `/v1/chats/{chat}/events` Reads a chat's event sequence in order — the transcript API. The sequence is gapless per chat: page forward from `seq` 1 (or any cursor) and nothing is ever missing between two entries. This is the BACKFILL for `GET /v1/streams/events`, which is a live stream and replays nothing: an event that passed while you were not connected is re-read here, addressed by the `chat_id` and `seq` range every stream frame carries. The cursor is the `seq` of the last entry you processed, and it is exclusive — pass `next_cursor` back verbatim, or the `seq` you stopped at, and the next page starts after it. Paging never skips: the next page resumes from the last position actually handed to you, so an event journaled between two of your requests is returned rather than stepped over. **Being caught up and being lost are different answers.** A cursor at the end of the sequence returns an empty page — that is the ordinary polling result. A cursor BEYOND the end is refused with `422` (`invalid_paging`), because no such position has ever existed in this chat and answering it with silence would be indistinguishable from having caught up. The refusal names where the sequence actually ends, so a client that has drifted can resume. An empty cursor is refused the same way: omit the parameter to read from the beginning. **Retention.** This journal has no scheduled pruning during beta, so ordinary operation does not remove an old prefix or renumber its immutable sequence. That is not a permanent-retention promise or a fixed minimum availability period: authenticated account deletion, offboarding, a lawful deletion requirement, or a beta-environment reset may remove the account-scoped transcript. This operation therefore has no `expires_at`, retained-floor field, or scheduled `cursor_expired` response. If prefix pruning is introduced later, its additive retained floor and typed expiry behavior will be published before any history is removed. ### Path Parameters - `chat: string` ### Query Parameters - `cursor: optional string` Cursor from a previous page's `next_cursor`; pages run in ascending `seq` order. - `limit: optional number` Page size. The bounds are in this parameter's schema, not only in this sentence — a value outside them is refused, never clamped. ### Returns - `ChatEventPage object { data, has_more, next_cursor }` A page of one chat's events, in sequence order. - `data: array of ChatEvent` - `occurred_at: string` - `seq: number` The event's position in this chat's sequence — contiguous from 1, no gaps. Use it as this chat's events-read cursor. It is scoped to one chat, so it is NOT the `/v1/streams/events` stream cursor: that stream spans every chat and carries its own cursor in each frame's `id`. - `type: string` The event's kind, e.g. `message.received`, `message.sent`. Kinds grow additively — skip what you do not handle. `chat.event.unknown` is the one kind that never carries meaning: it marks a journal entry whose partner-visible kind is not published yet. It exists so the sequence stays gapless — an entry you cannot interpret is still an entry, and omitting it would put a hole in `seq`. Skip it. - `message: optional Message` The message this event is about, on message-kind events. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `has_more: boolean` True when another page exists — pass `next_cursor` to fetch it. - `next_cursor: optional string` Opaque cursor for the next page; present when `has_more` is true. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats/$CHAT/events \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "occurred_at": "2019-12-27T18:11:19.117Z", "seq": 0, "type": "type", "message": { "id": "id", "chat_id": "chat_id", "created_at": "2019-12-27T18:11:19.117Z", "direction": "inbound", "parts": [ { "body": "body", "type": "text", "preview_url": true } ], "status": "status", "channel_message_id": "channel_message_id", "failure": { "code": "code", "message": "message" }, "from": "from" } } ], "has_more": true, "next_cursor": "next_cursor" } ``` --- # Messages URL: https://docs.linqapp.com/channel/whatsapp/api/resources/chats/subresources/messages/ ## Send a message in a chat **post** `/v1/chats/{chat}/messages` Sends a reply on an existing chat's unchanged sending number. The chat supplies the customer and sender; this operation never creates, moves, repins or fails over a conversation. Acceptance and refusal semantics are otherwise identical to `POST /v1/messages`. ### Path Parameters - `chat: string` ### Header Parameters - `"Idempotency-Key": optional string` ### Body Parameters - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 8 more` The message content — sendable parts only, at least one. **One outbound part per request.** Each request has its own acceptance result and optional idempotency key. Each accepted request maps to one native channel message with its own identifier, delivery outcome and charge evidence. A request carrying more than one part is refused with HTTP 422 (`multiple_parts_not_available`); the API neither fans it out nor offers an atomic batch. Separate requests are independent, and no cross-request delivery or receipt order is promised. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `reply_to_message_id: optional string` Our opaque `Message.id` for an earlier message in this same chat. - `send_at: optional string` RESERVED — scheduled sends are not yet available. Any value is refused. ### Returns - `AcceptedMessage object { id, chat_id, created_at, 7 more }` An accepted send: the message, plus how its sending number was chosen. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `from_selection: optional object { reason }` Present only when this service chose the sending number for you — that is, when you omitted `from`. A send that named `from` omits this rather than reporting a choice nobody made. - `reason: string` Why this number was used. Exactly one of: - `reused_active_chat` — this customer already had a conversation and it continues on the same number. - `new_best_number` — there was no existing conversation, so the best available number was chosen. Like every other vocabulary here it is a plain string that may grow additively; treat a value you do not recognise as "chosen for you". ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats/$CHAT/messages \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{ "parts": [ { "body": "body", "type": "text" } ] }' ``` #### Response ```json { "id": "id", "chat_id": "chat_id", "created_at": "2019-12-27T18:11:19.117Z", "direction": "inbound", "parts": [ { "body": "body", "type": "text", "preview_url": true } ], "status": "status", "channel_message_id": "channel_message_id", "failure": { "code": "code", "message": "message" }, "from": "from", "from_selection": { "reason": "reason" } } ``` --- # Send a message in a chat URL: https://docs.linqapp.com/channel/whatsapp/api/resources/chats/subresources/messages/methods/send/ **post** `/v1/chats/{chat}/messages` Sends a reply on an existing chat's unchanged sending number. The chat supplies the customer and sender; this operation never creates, moves, repins or fails over a conversation. Acceptance and refusal semantics are otherwise identical to `POST /v1/messages`. ### Path Parameters - `chat: string` ### Header Parameters - `"Idempotency-Key": optional string` ### Body Parameters - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 8 more` The message content — sendable parts only, at least one. **One outbound part per request.** Each request has its own acceptance result and optional idempotency key. Each accepted request maps to one native channel message with its own identifier, delivery outcome and charge evidence. A request carrying more than one part is refused with HTTP 422 (`multiple_parts_not_available`); the API neither fans it out nor offers an atomic batch. Separate requests are independent, and no cross-request delivery or receipt order is promised. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `reply_to_message_id: optional string` Our opaque `Message.id` for an earlier message in this same chat. - `send_at: optional string` RESERVED — scheduled sends are not yet available. Any value is refused. ### Returns - `AcceptedMessage object { id, chat_id, created_at, 7 more }` An accepted send: the message, plus how its sending number was chosen. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `from_selection: optional object { reason }` Present only when this service chose the sending number for you — that is, when you omitted `from`. A send that named `from` omits this rather than reporting a choice nobody made. - `reason: string` Why this number was used. Exactly one of: - `reused_active_chat` — this customer already had a conversation and it continues on the same number. - `new_best_number` — there was no existing conversation, so the best available number was chosen. Like every other vocabulary here it is a plain string that may grow additively; treat a value you do not recognise as "chosen for you". ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/chats/$CHAT/messages \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{ "parts": [ { "body": "body", "type": "text" } ] }' ``` #### Response ```json { "id": "id", "chat_id": "chat_id", "created_at": "2019-12-27T18:11:19.117Z", "direction": "inbound", "parts": [ { "body": "body", "type": "text", "preview_url": true } ], "status": "status", "channel_message_id": "channel_message_id", "failure": { "code": "code", "message": "message" }, "from": "from", "from_selection": { "reason": "reason" } } ``` --- # Contract URL: https://docs.linqapp.com/channel/whatsapp/api/resources/contract/ ## Download the OpenAPI definition **get** `/v1/openapi.yaml` Downloads this API's OpenAPI 3.1 definition, served as `application/yaml`. It is the authority for request and response shapes and the error envelope. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/openapi.yaml ``` ## Get a part type's JSON Schema **get** `/v1/parts/{type}` Reads one part type's JSON Schema (2020-12) byte-verbatim, served as `application/schema+json` — ready to hand to a structured-output model as a tool schema. Codified length and item bounds are machine-readable in the schemas; cross-field rules they cannot express are declared in `x-rule`. A specific template's generated parameter schema comes from `GET /v1/templates`. A violation returns HTTP 422 naming the exact field. Unknown part types answer HTTP 404 `part_type_not_found`. ### Path Parameters - `type: string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/parts/$TYPE ``` ## Domain Types ### Contract OpenAPI Response - `ContractOpenAPIResponse = string` ### Contract Part Schema Response - `ContractPartSchemaResponse = unknown` --- # Download the OpenAPI definition URL: https://docs.linqapp.com/channel/whatsapp/api/resources/contract/methods/openapi/ **get** `/v1/openapi.yaml` Downloads this API's OpenAPI 3.1 definition, served as `application/yaml`. It is the authority for request and response shapes and the error envelope. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/openapi.yaml ``` --- # Get a part type's JSON Schema URL: https://docs.linqapp.com/channel/whatsapp/api/resources/contract/methods/part_schema/ **get** `/v1/parts/{type}` Reads one part type's JSON Schema (2020-12) byte-verbatim, served as `application/schema+json` — ready to hand to a structured-output model as a tool schema. Codified length and item bounds are machine-readable in the schemas; cross-field rules they cannot express are declared in `x-rule`. A specific template's generated parameter schema comes from `GET /v1/templates`. A violation returns HTTP 422 naming the exact field. Unknown part types answer HTTP 404 `part_type_not_found`. ### Path Parameters - `type: string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/parts/$TYPE ``` --- # Destination Messages URL: https://docs.linqapp.com/channel/whatsapp/api/resources/destination_messages/ ## Send a message **post** `/v1/messages` Sends a message. Acceptance is durable and asynchronous: a `202` means the message is journaled and will be delivered in order; delivery states arrive on the chat's event sequence (and your webhooks/streams), never on this call. The chat is created on first send to a new customer. The `202` body's `status` is `accepted` — the message exists and is ours to deliver, and nothing more is claimed by it. Watch `GET /v1/streams/events` for `message.sent`, `message.delivered`, `message.read` and `message.failed` to learn what became of it. The stream does not replay, so connect it first; anything you miss is read back from `GET /v1/chats/{chat}/events`, where the same message also carries its latest `status`. Every gate runs before anything is written, so a refusal means nothing was stored and nothing will be sent — always safe to correct and retry. A request that TIMES OUT is the one case that is not safe to retry without `Idempotency-Key`. Supply one unique key per logical message to make a timed-out acceptance safe to repeat. An operational safety stop or the absence of an active sending number refuses with retryable HTTP 503 (`sending_paused` or `no_sending_number`) and `error.type` `api_error`; nothing is journaled. A stop on the number a conversation already uses answers the same way, with `sending_paused` — and so does a stop on a number you named with `from`. It is ours to lift, and you are never told to move a customer to another number, or that your request was malformed, over one. `from_ambiguous` joins that class in the rare case where a `from` you supplied matches more than one of your numbers: it names no single conversation, so nothing is chosen for you and an operator resolves it. If the service cannot verify the safety state, it likewise refuses with HTTP 503 `sending_guard_unavailable` instead of assuming sending is allowed. These operator-dependent states carry no `Retry-After` because there is no honest recovery time to publish. **Which number the message leaves by.** Omit `from` and this service picks: a customer you have messaged before keeps the number they already see, and a new customer is placed on the best available one. The accepted message reports the number as `from`, and reports how it was chosen in `from_selection`. A conversation NEVER moves to another number. If the number a conversation uses is no longer a registered sending number of yours, the request is refused with HTTP 422 `pinned_line_unsendable` and NOTHING changes — no message is sent, no conversation is created, and the next send to that customer resolves the same way. `error.context.pinned_from` names the stuck number and `error.context.available_from` names the one that could serve instead; that number is a separate conversation with its own 24-hour window, so you open it yourself by sending an approved template with `from` set to it. This is deliberate: nothing is ever sent on your behalf that you did not compose, and nothing is billed that you did not request. Spend limits apply to every send, including templates. A projected reservation beyond a daily or monthly cap is refused with HTTP 429 `spend_limit_exceeded`; `Retry-After` names the exact UTC day/month reset and nothing is journaled. If the rate catalog has no applicable rate, the service fails closed with HTTP 503 `rate_not_configured`; only operator action can clear it, so contact support rather than retrying. If the catalog lookup, cap, or counter is temporarily unavailable, the retryable refusal is HTTP 503 `entitlement_unavailable`. Crossing 80% does not change acceptance: the response remains 202 and carries `X-Spend-Warning` once for each cap window crossed by that request. Values are comma-separated `brand_day`, `brand_month`, or `number_day`; the warning is emitted once per window, not on every later send. A daily ceiling stops template traffic while service-window replies remain available; the monthly account ceiling is hard and stops every send at the ceiling. Separately from spend, the channel limits how many DISTINCT customers a new conversation may be started with in a moving 24-hour period. A template send that would exceed it is refused with HTTP 429 `tier_quota_exceeded` and nothing is journaled; replies inside an open customer conversation are unaffected and never count against it, and a second template to a customer already counted in the period costs nothing. `Retry-After` on this refusal is a MINIMUM REQUESTED WAIT computed from the period as it stood when the request was refused: retry no earlier than that, and a retry then may still be refused. It is not an expiry — the earliest free place can move later as you send to customers already counted, and can open earlier when messages fail to deliver. **You may not be able to produce this refusal in an integration test**, in sandbox or in live: it is raised only where the channel has reported a limit for the account concerned, so an environment where no limit has been reported will never return it. Handle it from the response code rather than from having seen it. ### Header Parameters - `"Idempotency-Key": optional string` ### Body Parameters - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 8 more` The message content — sendable parts only, at least one. **One outbound part per request.** Each request has its own acceptance result and optional idempotency key. Each accepted request maps to one native channel message with its own identifier, delivery outcome and charge evidence. A request carrying more than one part is refused with HTTP 422 (`multiple_parts_not_available`); the API neither fans it out nor offers an atomic batch. Separate requests are independent, and no cross-request delivery or receipt order is promised. **`reaction` is declared but not sendable yet.** It is refused at accept with HTTP 422 (`part_not_supported`) naming the part. Media is sendable after its bytes are uploaded with `POST /v1/media`; outbound parts use that operation's `upload_ref`, never inbound `media_id`. Outside an open customer window the array must be exactly one `template` part; a freeform part there is refused with HTTP 422 `outside_customer_window` and the remedy in the message. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `to: string` The customer to message, in the channel's own address form: the same digits-only value the channel reports on inbound messages (country code first, no `+` and no punctuation). On a conversation that already exists, this is exactly the value that chat's `customer` field carries — copy it rather than reconstructing one. **The value is matched literally and never normalized**, because the address is the channel's to define, not ours to rewrite. It is the conversation's identity, so two spellings of the same number are two different conversations, each with its own window and its own event sequence. - `exclude: optional array of string` Numbers NOT to send from, in E.164 — a filter on a choice this service is making for you. It applies only when `from` is omitted; supplying both is refused. It affects this request alone: nothing is stored, and no operator setting changes. It cannot move a conversation. If it names the number an existing conversation already uses, or removes every number that could have served, the send is refused with `exclude_leaves_no_sender` rather than quietly sent from a number you asked to avoid. - `from: optional string` The number to send FROM, in E.164 (e.g. `+15550001111`). Together with `to` it names the exact conversation: one customer, one of your numbers. **Omit it and this service chooses**, exactly as it always has — a conversation you have already had continues on its own number, and a new customer is placed on the best available one. Omitting the field and sending an empty string are DIFFERENT requests: an empty string is refused rather than treated as "choose for me", because a client that computed nothing did not mean to hand the choice back. Supply it to reply on a specific number — in particular the second conversation a customer starts by messaging another of your numbers, which is its own chat with its own window. The number must be one of your registered sending numbers: `from_not_available` (422) if it is not one of your numbers at all, and `from_not_sendable` (422) if it is yours but is no longer registered to send. A number that is only PAUSED is neither of those — it answers retryable HTTP 503 `sending_paused`, the same as every other paused send, because that one is ours to lift and worth retrying shortly. It is matched on its digits, so `+15550001111` and `1 (555) 000-1111` are the same number — unlike `to`, which is the channel's own address form and is matched literally. Naming a number never moves an existing conversation onto it. It does decide where a BRAND-NEW one lives: if you have never exchanged a message with this customer, the number you name becomes the one they keep, and a later omitted-`from` send to them continues on it. That is the same rule as ever — a customer's number is fixed by the first contact, whoever chose it — and it is why naming a number for a customer you have already messaged changes nothing about their existing conversation. - `reply_to_message_id: optional string` Our opaque `Message.id` for an earlier message in this same chat. When present, this send is displayed as a visual reply to that message. Unknown ids return `reply_target_not_found`; an id from another chat returns `reply_target_wrong_chat`; and an outbound message whose channel acknowledgement has not arrived returns `reply_target_not_ready`. Nothing is accepted when any of those refusals is returned. - `send_at: optional string` RESERVED — scheduled sends are not yet available. The field is declared so its later arrival is purely additive; until then ANY value here, including a past timestamp, is rejected loudly with HTTP 422 `send_at_not_available`. Omit the field entirely. ### Returns - `AcceptedMessage object { id, chat_id, created_at, 7 more }` An accepted send: the message, plus how its sending number was chosen. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `from_selection: optional object { reason }` Present only when this service chose the sending number for you — that is, when you omitted `from`. A send that named `from` omits this rather than reporting a choice nobody made. - `reason: string` Why this number was used. Exactly one of: - `reused_active_chat` — this customer already had a conversation and it continues on the same number. - `new_best_number` — there was no existing conversation, so the best available number was chosen. Like every other vocabulary here it is a plain string that may grow additively; treat a value you do not recognise as "chosen for you". ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/messages \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{ "parts": [ { "body": "body", "type": "text" } ], "to": "to" }' ``` #### Response ```json { "id": "id", "chat_id": "chat_id", "created_at": "2019-12-27T18:11:19.117Z", "direction": "inbound", "parts": [ { "body": "body", "type": "text", "preview_url": true } ], "status": "status", "channel_message_id": "channel_message_id", "failure": { "code": "code", "message": "message" }, "from": "from", "from_selection": { "reason": "reason" } } ``` ## Domain Types ### Accepted Message - `AcceptedMessage object { id, chat_id, created_at, 7 more }` An accepted send: the message, plus how its sending number was chosen. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `from_selection: optional object { reason }` Present only when this service chose the sending number for you — that is, when you omitted `from`. A send that named `from` omits this rather than reporting a choice nobody made. - `reason: string` Why this number was used. Exactly one of: - `reused_active_chat` — this customer already had a conversation and it continues on the same number. - `new_best_number` — there was no existing conversation, so the best available number was chosen. Like every other vocabulary here it is a plain string that may grow additively; treat a value you do not recognise as "chosen for you". ### Message - `Message object { id, chat_id, created_at, 6 more }` One message, in either direction. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. --- # Send a message URL: https://docs.linqapp.com/channel/whatsapp/api/resources/destination_messages/methods/send/ **post** `/v1/messages` Sends a message. Acceptance is durable and asynchronous: a `202` means the message is journaled and will be delivered in order; delivery states arrive on the chat's event sequence (and your webhooks/streams), never on this call. The chat is created on first send to a new customer. The `202` body's `status` is `accepted` — the message exists and is ours to deliver, and nothing more is claimed by it. Watch `GET /v1/streams/events` for `message.sent`, `message.delivered`, `message.read` and `message.failed` to learn what became of it. The stream does not replay, so connect it first; anything you miss is read back from `GET /v1/chats/{chat}/events`, where the same message also carries its latest `status`. Every gate runs before anything is written, so a refusal means nothing was stored and nothing will be sent — always safe to correct and retry. A request that TIMES OUT is the one case that is not safe to retry without `Idempotency-Key`. Supply one unique key per logical message to make a timed-out acceptance safe to repeat. An operational safety stop or the absence of an active sending number refuses with retryable HTTP 503 (`sending_paused` or `no_sending_number`) and `error.type` `api_error`; nothing is journaled. A stop on the number a conversation already uses answers the same way, with `sending_paused` — and so does a stop on a number you named with `from`. It is ours to lift, and you are never told to move a customer to another number, or that your request was malformed, over one. `from_ambiguous` joins that class in the rare case where a `from` you supplied matches more than one of your numbers: it names no single conversation, so nothing is chosen for you and an operator resolves it. If the service cannot verify the safety state, it likewise refuses with HTTP 503 `sending_guard_unavailable` instead of assuming sending is allowed. These operator-dependent states carry no `Retry-After` because there is no honest recovery time to publish. **Which number the message leaves by.** Omit `from` and this service picks: a customer you have messaged before keeps the number they already see, and a new customer is placed on the best available one. The accepted message reports the number as `from`, and reports how it was chosen in `from_selection`. A conversation NEVER moves to another number. If the number a conversation uses is no longer a registered sending number of yours, the request is refused with HTTP 422 `pinned_line_unsendable` and NOTHING changes — no message is sent, no conversation is created, and the next send to that customer resolves the same way. `error.context.pinned_from` names the stuck number and `error.context.available_from` names the one that could serve instead; that number is a separate conversation with its own 24-hour window, so you open it yourself by sending an approved template with `from` set to it. This is deliberate: nothing is ever sent on your behalf that you did not compose, and nothing is billed that you did not request. Spend limits apply to every send, including templates. A projected reservation beyond a daily or monthly cap is refused with HTTP 429 `spend_limit_exceeded`; `Retry-After` names the exact UTC day/month reset and nothing is journaled. If the rate catalog has no applicable rate, the service fails closed with HTTP 503 `rate_not_configured`; only operator action can clear it, so contact support rather than retrying. If the catalog lookup, cap, or counter is temporarily unavailable, the retryable refusal is HTTP 503 `entitlement_unavailable`. Crossing 80% does not change acceptance: the response remains 202 and carries `X-Spend-Warning` once for each cap window crossed by that request. Values are comma-separated `brand_day`, `brand_month`, or `number_day`; the warning is emitted once per window, not on every later send. A daily ceiling stops template traffic while service-window replies remain available; the monthly account ceiling is hard and stops every send at the ceiling. Separately from spend, the channel limits how many DISTINCT customers a new conversation may be started with in a moving 24-hour period. A template send that would exceed it is refused with HTTP 429 `tier_quota_exceeded` and nothing is journaled; replies inside an open customer conversation are unaffected and never count against it, and a second template to a customer already counted in the period costs nothing. `Retry-After` on this refusal is a MINIMUM REQUESTED WAIT computed from the period as it stood when the request was refused: retry no earlier than that, and a retry then may still be refused. It is not an expiry — the earliest free place can move later as you send to customers already counted, and can open earlier when messages fail to deliver. **You may not be able to produce this refusal in an integration test**, in sandbox or in live: it is raised only where the channel has reported a limit for the account concerned, so an environment where no limit has been reported will never return it. Handle it from the response code rather than from having seen it. ### Header Parameters - `"Idempotency-Key": optional string` ### Body Parameters - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 8 more` The message content — sendable parts only, at least one. **One outbound part per request.** Each request has its own acceptance result and optional idempotency key. Each accepted request maps to one native channel message with its own identifier, delivery outcome and charge evidence. A request carrying more than one part is refused with HTTP 422 (`multiple_parts_not_available`); the API neither fans it out nor offers an atomic batch. Separate requests are independent, and no cross-request delivery or receipt order is promised. **`reaction` is declared but not sendable yet.** It is refused at accept with HTTP 422 (`part_not_supported`) naming the part. Media is sendable after its bytes are uploaded with `POST /v1/media`; outbound parts use that operation's `upload_ref`, never inbound `media_id`. Outside an open customer window the array must be exactly one `template` part; a freeform part there is refused with HTTP 422 `outside_customer_window` and the remedy in the message. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `to: string` The customer to message, in the channel's own address form: the same digits-only value the channel reports on inbound messages (country code first, no `+` and no punctuation). On a conversation that already exists, this is exactly the value that chat's `customer` field carries — copy it rather than reconstructing one. **The value is matched literally and never normalized**, because the address is the channel's to define, not ours to rewrite. It is the conversation's identity, so two spellings of the same number are two different conversations, each with its own window and its own event sequence. - `exclude: optional array of string` Numbers NOT to send from, in E.164 — a filter on a choice this service is making for you. It applies only when `from` is omitted; supplying both is refused. It affects this request alone: nothing is stored, and no operator setting changes. It cannot move a conversation. If it names the number an existing conversation already uses, or removes every number that could have served, the send is refused with `exclude_leaves_no_sender` rather than quietly sent from a number you asked to avoid. - `from: optional string` The number to send FROM, in E.164 (e.g. `+15550001111`). Together with `to` it names the exact conversation: one customer, one of your numbers. **Omit it and this service chooses**, exactly as it always has — a conversation you have already had continues on its own number, and a new customer is placed on the best available one. Omitting the field and sending an empty string are DIFFERENT requests: an empty string is refused rather than treated as "choose for me", because a client that computed nothing did not mean to hand the choice back. Supply it to reply on a specific number — in particular the second conversation a customer starts by messaging another of your numbers, which is its own chat with its own window. The number must be one of your registered sending numbers: `from_not_available` (422) if it is not one of your numbers at all, and `from_not_sendable` (422) if it is yours but is no longer registered to send. A number that is only PAUSED is neither of those — it answers retryable HTTP 503 `sending_paused`, the same as every other paused send, because that one is ours to lift and worth retrying shortly. It is matched on its digits, so `+15550001111` and `1 (555) 000-1111` are the same number — unlike `to`, which is the channel's own address form and is matched literally. Naming a number never moves an existing conversation onto it. It does decide where a BRAND-NEW one lives: if you have never exchanged a message with this customer, the number you name becomes the one they keep, and a later omitted-`from` send to them continues on it. That is the same rule as ever — a customer's number is fixed by the first contact, whoever chose it — and it is why naming a number for a customer you have already messaged changes nothing about their existing conversation. - `reply_to_message_id: optional string` Our opaque `Message.id` for an earlier message in this same chat. When present, this send is displayed as a visual reply to that message. Unknown ids return `reply_target_not_found`; an id from another chat returns `reply_target_wrong_chat`; and an outbound message whose channel acknowledgement has not arrived returns `reply_target_not_ready`. Nothing is accepted when any of those refusals is returned. - `send_at: optional string` RESERVED — scheduled sends are not yet available. The field is declared so its later arrival is purely additive; until then ANY value here, including a past timestamp, is rejected loudly with HTTP 422 `send_at_not_available`. Omit the field entirely. ### Returns - `AcceptedMessage object { id, chat_id, created_at, 7 more }` An accepted send: the message, plus how its sending number was chosen. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `from_selection: optional object { reason }` Present only when this service chose the sending number for you — that is, when you omitted `from`. A send that named `from` omits this rather than reporting a choice nobody made. - `reason: string` Why this number was used. Exactly one of: - `reused_active_chat` — this customer already had a conversation and it continues on the same number. - `new_best_number` — there was no existing conversation, so the best available number was chosen. Like every other vocabulary here it is a plain string that may grow additively; treat a value you do not recognise as "chosen for you". ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/messages \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{ "parts": [ { "body": "body", "type": "text" } ], "to": "to" }' ``` #### Response ```json { "id": "id", "chat_id": "chat_id", "created_at": "2019-12-27T18:11:19.117Z", "direction": "inbound", "parts": [ { "body": "body", "type": "text", "preview_url": true } ], "status": "status", "channel_message_id": "channel_message_id", "failure": { "code": "code", "message": "message" }, "from": "from", "from_selection": { "reason": "reason" } } ``` --- # Event Log URL: https://docs.linqapp.com/channel/whatsapp/api/resources/event_log/ ## List account events **get** `/v1/event_log` Reads the durable account-wide event sequence in committed order. This bounded JSON collection is the recovery companion to the SSE stream. Its opaque cursor is scoped to this account, key mode and collection; it is not interchangeable with an SSE decimal id or chat cursor. ### Query Parameters - `cursor: optional string` - `limit: optional number` ### Returns - `AccountEventPage object { data, has_more, next_cursor }` - `data: array of AccountEvent` - `id: string` - `data: object { message }` Extensible event-specific data. - `message: optional Message` Frozen message snapshot on newly materialized incoming-message events, including canonical parts and available authenticated attachment URLs. Older retained events omit it; use the chat transcript for history. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `timestamp: string` - `type: string` - `has_more: boolean` - `next_cursor: string` Exclusive opaque cursor after the last event returned, or null on an empty page. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/event_log \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "data": { "message": { "id": "id", "chat_id": "chat_id", "created_at": "2019-12-27T18:11:19.117Z", "direction": "inbound", "parts": [ { "body": "body", "type": "text", "preview_url": true } ], "status": "status", "channel_message_id": "channel_message_id", "failure": { "code": "code", "message": "message" }, "from": "from" } }, "timestamp": "2019-12-27T18:11:19.117Z", "type": "type" } ], "has_more": true, "next_cursor": "next_cursor" } ``` ## Domain Types ### Account Event - `AccountEvent object { id, data, timestamp, type }` One canonical account event envelope. Account sequence is transport metadata and is not a member. - `id: string` - `data: object { message }` Extensible event-specific data. - `message: optional Message` Frozen message snapshot on newly materialized incoming-message events, including canonical parts and available authenticated attachment URLs. Older retained events omit it; use the chat transcript for history. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `timestamp: string` - `type: string` ### Account Event Page - `AccountEventPage object { data, has_more, next_cursor }` - `data: array of AccountEvent` - `id: string` - `data: object { message }` Extensible event-specific data. - `message: optional Message` Frozen message snapshot on newly materialized incoming-message events, including canonical parts and available authenticated attachment URLs. Older retained events omit it; use the chat transcript for history. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `timestamp: string` - `type: string` - `has_more: boolean` - `next_cursor: string` Exclusive opaque cursor after the last event returned, or null on an empty page. --- # List account events URL: https://docs.linqapp.com/channel/whatsapp/api/resources/event_log/methods/list/ **get** `/v1/event_log` Reads the durable account-wide event sequence in committed order. This bounded JSON collection is the recovery companion to the SSE stream. Its opaque cursor is scoped to this account, key mode and collection; it is not interchangeable with an SSE decimal id or chat cursor. ### Query Parameters - `cursor: optional string` - `limit: optional number` ### Returns - `AccountEventPage object { data, has_more, next_cursor }` - `data: array of AccountEvent` - `id: string` - `data: object { message }` Extensible event-specific data. - `message: optional Message` Frozen message snapshot on newly materialized incoming-message events, including canonical parts and available authenticated attachment URLs. Older retained events omit it; use the chat transcript for history. - `id: string` This API's message id. Once outbound reactions are available, put this value in their `message_id`. Mark-as-read targets the latest unread inbound message in a chat, so it takes the chat id. - `chat_id: string` The chat this message belongs to. - `created_at: string` - `direction: "inbound" or "outbound"` `inbound` (from the customer) or `outbound` (sent by you). - `"inbound"` - `"outbound"` - `parts: array of object { body, type, preview_url } or object { kind, type, caption, 4 more } or object { language, name, type, 3 more } or 14 more` The message content — sendable parts, plus the inbound-only types on inbound messages. Every message carries at least one part. - `Text object { body, type, preview_url }` A plain text message. - `body: string` The message text. URLs render as tappable links. - `type: "text"` - `"text"` - `preview_url: optional boolean` Render a preview card for the first URL in body. Defaults to false. - `Media object { kind, type, caption, 4 more }` A media message — image, video, audio, document, or sticker. The two identifiers are distinct namespaces: inbound media carries the channel's transient media_id; outbound media carries our durable upload_ref from POST /v1/media. - `kind: "image" or "video" or "audio" or 2 more` Which kind of media this is. Captions apply to image, video and document only; filename applies to document only. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `type: "media"` - `"media"` - `caption: optional string` Caption rendered with the media. Image, video and document only — a caption on an audio or sticker part is rejected, never dropped. - `filename: optional string` Display filename. Documents only — a filename on any other kind is rejected, never dropped. - `media_id: optional string` Inbound only: the channel's transient media identifier. It expires after seven days and is never an outbound address; an outbound value is rejected with a pointer to media_id and upload_ref as the remedy. - `upload_ref: optional string` Outbound only: our durable handle returned by POST /v1/media. Handles visibly begin upload. and are reusable within the account and test/live partition that created them. - `url: optional string` Response only: stable attachment download URL requiring the account Bearer key on every request. Pending capture returns 409 with Retry-After; retained bytes expire 30 days after server receipt. Historical unbound messages omit this field. Never accepted on sends. - `Template object { language, name, type, 3 more }` An approved message template — the one part type sendable outside an open customer service window. name and language identify the approved template; parameters fills its named placeholders. Authentication templates use this same generic part unchanged: the channel supplies the OTP substitution, so do not invent or pass a code parameter unless the template's published parameter schema explicitly names one. Every sendable template is registered and approved ahead of the send and publishes its own parameter schema — a send referencing an unknown template, a missing or unknown parameter, or a value that breaks the template's rules is rejected with a 422 naming the exact field. - `language: string` The template's language-and-locale code, e.g. en_US. - `name: string` The approved template's name. Lowercase letters, digits and underscores only. - `type: "template"` - `"template"` - `cards: optional array of object { card_index, kind, upload_ref, 2 more }` Per-card values for an approved media-card carousel. The approved template's send_schema provides structural bounds and an x-rule listing the exact card order, media kind, named body values, and dynamic button slots enforced by runtime L2 validation. Omit for non-carousel templates. - `card_index: number` - `kind: "image" or "video"` - `"image"` - `"video"` - `upload_ref: string` A durable handle returned by POST /v1/media. It is resolved within this key's account and partition, then consumed into a transient channel media identifier only at delivery. - `buttons: optional array of object { index, payload, type }` - `index: number` - `payload: string` - `type: "quick_reply" or "url"` - `"quick_reply"` - `"url"` - `parameters: optional unknown` Named values for this card's body placeholders. - `header: optional object { kind, upload_ref }` The uploaded media used for this approved template's media header. Omit unless this template's send_schema requires it. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `parameters: optional unknown` Named values for the template's placeholders. The shape is per-template: each registered template publishes its own parameter schema — hand THAT schema to your model when composing a specific template. Omit for templates with no placeholders. - `InteractiveList object { body, button, sections, 3 more }` An interactive list message: body text plus a button that opens a sectioned list of selectable rows. The customer's selection arrives as an inbound interactive_reply part with kind list_reply, carrying the chosen row's id. - `body: string` The message body. - `button: string` Label of the button that opens the list. - `sections: array of object { rows, title }` The list's sections, each holding selectable rows. At most 10 rows in total across ALL sections combined — not 10 per section. - `rows: array of object { id, title, description }` This section's rows. The 10-row limit is on the TOTAL across all sections, so a second section reduces what this one may hold. - `id: string` Your identifier for the row — echoed back as the interactive_reply's id. - `title: string` The row's visible title. - `description: optional string` Secondary text under the title. - `title: optional string` Section title. Required when the list has more than one section. - `type: "interactive_list"` - `"interactive_list"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `InteractiveButtons object { body, buttons, type, 3 more }` An interactive reply-buttons message: body text plus up to three tappable buttons. The customer's tap arrives as an inbound interactive_reply part with kind button_reply, carrying the tapped button's id. - `body: string` The message body. - `buttons: array of object { id, title }` The tappable buttons — at most three. - `id: string` Your identifier for the button — echoed back as the interactive_reply's id. - `title: string` The button's visible label. - `type: "interactive_buttons"` - `"interactive_buttons"` - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `media_header: optional object { kind, upload_ref }` Uploaded image, video, or document above the body. Use either header or media_header, never both. - `kind: "image" or "video" or "document"` - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by POST /v1/media. - `InteractiveCtaURL object { body, display_text, type, 3 more }` An interactive call-to-action message: body text plus one button that opens a URL. - `body: string` The message body. - `display_text: string` The button's visible label. - `type: "interactive_cta_url"` - `"interactive_cta_url"` - `url: string` The URL the button opens. Must be absolute, with an http or https scheme. - `footer: optional string` Footer text below the body. - `header: optional string` Header text above the body. - `LocationRequest object { body, type }` Asks the customer to share a location. The response arrives as a location part. - `body: string` Prompt shown above the send-location button. - `type: "location_request"` - `"location_request"` - `AddressRequest object { body, country, type }` Reserved for asking an eligible customer in India to submit a structured address. Sending this part is not available until business and customer eligibility can be verified at acceptance. Address responses already arrive as address_reply parts. - `body: string` Prompt shown above the address form. - `country: "IN"` ISO country code. Structured address requests are currently available only in India. - `"IN"` - `type: "address_request"` - `"address_request"` - `Contacts object { contacts, type }` One or more contact cards. - `contacts: array of object { name, addresses, birthday, 4 more }` The contact cards to send. The channel permits far more; this API caps a message at five, because a message carrying hundreds of cards is a mistake rather than a use case. - `name: object { formatted_name, first_name, last_name, 3 more }` - `formatted_name: string` The contact's full display name. - `first_name: optional string` - `last_name: optional string` - `middle_name: optional string` - `prefix: optional string` - `suffix: optional string` - `addresses: optional array of object { city, country, country_code, 4 more }` - `city: optional string` - `country: optional string` - `country_code: optional string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `state: optional string` - `street: optional string` - `zip: optional string` - `birthday: optional string` The contact's birthday as YYYY-MM-DD. - `emails: optional array of object { email, kind }` - `email: string` - `kind: optional string` A label for the address, e.g. WORK, HOME. - `org: optional object { company, department, title }` - `company: optional string` - `department: optional string` - `title: optional string` - `phones: optional array of object { phone, kind, messaging_id }` - `phone: string` The phone number, ideally in +E.164 form. - `kind: optional string` A label for the number, e.g. CELL, MAIN, WORK, HOME. - `messaging_id: optional string` The contact's messaging-account identifier on this channel, when known — makes the card openable in the messaging app. - `urls: optional array of object { url, kind }` - `url: string` - `kind: optional string` A label for the URL, e.g. WORK, HOME. - `type: "contacts"` - `"contacts"` - `Location object { latitude, longitude, type, 2 more }` A location pin. - `latitude: number` Latitude in decimal degrees. - `longitude: number` Longitude in decimal degrees. - `type: "location"` - `"location"` - `address: optional string` The place's address, shown under the name. - `name: optional string` The place's name, shown on the pin. - `Reaction object { emoji, type, channel_message_id, message_id }` An emoji reaction to an earlier message in the chat. The two identifiers are distinct namespaces: inbound reactions carry the channel's opaque channel_message_id; outbound reactions will carry this API's message_id once sending them becomes available. - `emoji: string` A single emoji. Send an empty string to remove a previous reaction to the same message. - `type: "reaction"` - `"reaction"` - `channel_message_id: optional string` Inbound only: the channel's opaque identifier for the message being reacted to. Match it literally to Message.channel_message_id in the chat transcript; never parse it or compare it with Message.id. - `message_id: optional string` Outbound only: this API's Message.id for the message being reacted to. Outbound reactions are not available yet; when they land, the API resolves this identifier into the channel namespace before sending. - `InteractiveReply object { id, kind, title, 2 more }` Inbound only — never sendable. The customer's selection from an interactive_list (kind list_reply) or interactive_buttons (kind button_reply) message, carrying the id you assigned to the chosen row or button. - `id: string` The id you assigned to the chosen row or button. - `kind: "list_reply" or "button_reply"` Which interactive message kind was answered. - `"list_reply"` - `"button_reply"` - `title: string` The chosen row's or button's visible title. - `type: "interactive_reply"` - `"interactive_reply"` - `description: optional string` The chosen list row's secondary text, when it had one. - `AddressReply object { type, values, saved_address_id }` Inbound only: the structured values submitted in response to an address request. - `type: "address_reply"` - `"address_reply"` - `values: object { address, building_name, city, 8 more }` - `address: optional string` - `building_name: optional string` - `city: optional string` - `floor_number: optional string` - `house_number: optional string` - `landmark_area: optional string` - `name: optional string` - `phone_number: optional string` - `pin_code: optional string` - `state: optional string` - `tower_number: optional string` - `saved_address_id: optional string` Identifier of the selected saved address, when one was selected. - `Order object { catalog_id, items, type, text }` Inbound only — never sendable. An order the customer placed from a product catalog. - `catalog_id: string` The catalog the ordered items belong to. - `items: array of object { currency, price, product_retailer_id, quantity }` The ordered items. - `currency: string` ISO 4217 currency code for price. - `price: number` The per-item price at order time. - `product_retailer_id: string` Your identifier for the product, as registered in the catalog. - `quantity: number` - `type: "order"` - `"order"` - `text: optional string` Free text the customer attached to the order, when any. - `Referral object { type, body, click_id, 7 more }` Inbound only — never sendable. The ad or post context a customer's first message arrived from (for example an ad whose call to action opens a chat). A referral also opens a free-entry-point customer window — see the chat's customer_window. - `type: "referral"` - `"referral"` - `body: optional string` The ad's body text at click time. - `click_id: optional string` The click identifier assigned by the ad platform, for attribution. - `headline: optional string` The ad's headline at click time. - `image_url: optional string` URL of the ad's image creative, when media_kind is image. - `media_kind: optional string` The ad creative's media kind, e.g. image or video. - `source_id: optional string` The ad or post id. - `source_kind: optional string` What the source was, e.g. ad or post. - `source_url: optional string` The URL of the ad or post the customer came from. - `video_url: optional string` URL of the ad's video creative, when media_kind is video. - `System object { body, type, event }` Inbound only — never sendable. A system event in the chat, such as the customer changing their number. New event kinds appear over time; body is always present and human-readable. - `body: string` Human-readable description of the system event. - `type: "system"` - `"system"` - `event: optional string` The system event's kind, when the channel identifies one. New kinds appear over time — treat unknown values as informational. - `Unsupported object { raw, type, kind }` Inbound only — never sendable. A message kind this API does not yet type natively, carried as a typed passthrough: raw holds the channel payload, so a new message kind is never a black box or a silent drop. Native part types for popular kinds are added over time; this part is the compatibility guarantee in the meantime. - `raw: unknown` The channel payload — any JSON value: object, array, string, number, boolean, or null. Deliberately unconstrained, because the whole point of this part is to carry a shape this API does not yet know. For rows written by API version 1.3.0 or later, object member order, number formatting and duplicated members survive storage and read-back. The canonical encoder may compact insignificant whitespace before storage. Rows written before API version 1.3.0 retain normalized JSON only: member order and number formatting may differ, and only the last duplicated member survives. - `type: "unsupported"` - `"unsupported"` - `kind: optional string` The channel's name for the message kind, when it declares one. - `status: string` The message's delivery state as last reported. Inbound messages read `received`: they arrived, and no delivery of ours ran. A message you sent starts `accepted` and moves through `sent`, `delivered` and `read` as the channel reports them, with `failed` as the definitive negative outcome. `unknown` means exactly that: the send is INDETERMINATE and we decline to guess. It is NOT terminal — an indeterminate send is never re-sent, and it resolves to `sent`, `delivered`, `read` or `failed` when the channel's own report arrives — so keep observing rather than treating it as an outcome. States are reported asynchronously on the chat's event sequence, and the set grows additively: treat a value you do not recognise as "no information" rather than failing on it. Retention of this published status follows the chat transcript policy: there is no scheduled pruning during beta, but there is no fixed minimum availability guarantee. The present absence of a scheduled age-based sweep is not a promise of indefinite availability. UNDER A `sk_test_` KEY THESE ARE SIMULATED. A test-key send is reported `sent` and then `delivered` within milliseconds, every time; `delivered` there means a simulator accepted it, not that a device received it. Live delivery can lag by hours, can never report `delivered` at all, and can fail after the channel accepted the message — so do not calibrate timeouts or "delivered means it arrived" logic against a test key. - `channel_message_id: optional string` The channel's opaque identifier for this message, when the channel has assigned one. Match an inbound reaction's `channel_message_id` to this field literally; never parse it or compare it with `id`. It is absent while an accepted outbound message has not yet received a channel identifier. - `failure: optional object { code, message }` Why a failed message failed, when we have a reason worth publishing. Present only on `status` `failed`, and not on every one of those: not every channel refusal has a reason in the published vocabulary, so a message that failed on the wire may carry no `failure` at all. Its absence means "no published reason", never "no reason". `status` stays the thing to branch on. This is additive detail beside it, so an integration written before this field existed still sees a terminal `failed` and behaves exactly as it did. - `code: string` The specific reason. `sender_deregistered` — the number this message was going out from is no longer a registered sending number, and the message never left. This is terminal: it is not a pause and it does not clear, so retrying the same message cannot succeed and that conversation is over. Reach this customer by starting a new one on another of your numbers with a template, exactly as you would any customer whose 24-hour window has closed; `GET /v1/chats` shows which number each conversation uses. You are not billed for a message that failed this way. `quality_hold_expired` — this marketing message remained held by an automatic safety control on its sending line for ten minutes and was not sent. This is terminal: retrying the same message cannot succeed on that conversation. Send marketing from another of your numbers with a template, or contact support about the sending line. You are not billed for a message that failed this way. - `message: string` Human-readable, written for a person reading it. Do not branch on it. - `from: optional string` The number this message left by, or arrived on, in E.164 (leading `+`). Every message in one conversation carries the same value — a conversation never moves to another number. Absent only if the number is no longer one of yours. - `timestamp: string` - `type: string` - `has_more: boolean` - `next_cursor: string` Exclusive opaque cursor after the last event returned, or null on an empty page. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/event_log \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "data": { "message": { "id": "id", "chat_id": "chat_id", "created_at": "2019-12-27T18:11:19.117Z", "direction": "inbound", "parts": [ { "body": "body", "type": "text", "preview_url": true } ], "status": "status", "channel_message_id": "channel_message_id", "failure": { "code": "code", "message": "message" }, "from": "from" } }, "timestamp": "2019-12-27T18:11:19.117Z", "type": "type" } ], "has_more": true, "next_cursor": "next_cursor" } ``` --- # Media URL: https://docs.linqapp.com/channel/whatsapp/api/resources/media/ ## Upload media **post** `/v1/media` Uploads media bytes and returns the durable `upload_ref` used by an outbound `media` part. Send the raw bytes as the request body and their exact media type in `Content-Type`; the body is never JSON-normalized. `media_id` is a different namespace: it is the channel's transient, inbound-only identifier and expires after seven days. It is never an outbound address. Handles returned here visibly begin `upload.` and are reusable within this key's account and test/live partition. Outbound uploads have no fixed beta expiry and are not routinely pruned, but this is not a permanence promise: account deletion or offboarding may remove them. Reusing a handle does not create or refresh an expiry clock. Inbound media bytes are not readable through this route and are deleted 30 days after server receipt. Size ceilings are applied by kind: image 5,000,000 bytes; audio and video 16,000,000; document 100,000,000; static sticker 100,000 and animated sticker 500,000. A larger body is refused, never truncated. Images accept JPEG or PNG; video accepts MP4 or 3GPP; audio accepts AAC, AMR, MPEG, MP4 or Ogg; stickers accept WebP; documents accept `text/plain`, `application/pdf`, the legacy Word/Excel/PowerPoint media types, and the three Open XML Word/Excel/PowerPoint media types. The media type must match the kind; an invented vendor suffix is not accepted. ### Query Parameters - `kind: "image" or "video" or "audio" or 2 more` The kind this upload will be sent as. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` ### Returns - `Upload object { byte_size, kind, mime_type, 2 more }` A durable upload that can be reused by outbound media parts. No fixed beta expiry is promised. - `byte_size: number` Exact byte count stored. - `kind: "image" or "video" or "audio" or 2 more` The media kind chosen at upload. A send using this handle must use the same kind. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `mime_type: string` The normalized media type stored with the bytes. - `sha256: string` Lowercase hexadecimal SHA-256 over the exact stored bytes. - `upload_ref: string` Our reusable handle. It always begins `upload.` and is supplied as a media part's `upload_ref`. It has no fixed beta expiry or scheduled pruning, but is not permanent and may be removed by account deletion or offboarding. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/media \ -H 'Content-Type: */*' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{}' ``` #### Response ```json { "byte_size": 0, "kind": "image", "mime_type": "mime_type", "sha256": "sha256", "upload_ref": "upload_ref" } ``` ## Domain Types ### Upload - `Upload object { byte_size, kind, mime_type, 2 more }` A durable upload that can be reused by outbound media parts. No fixed beta expiry is promised. - `byte_size: number` Exact byte count stored. - `kind: "image" or "video" or "audio" or 2 more` The media kind chosen at upload. A send using this handle must use the same kind. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `mime_type: string` The normalized media type stored with the bytes. - `sha256: string` Lowercase hexadecimal SHA-256 over the exact stored bytes. - `upload_ref: string` Our reusable handle. It always begins `upload.` and is supplied as a media part's `upload_ref`. It has no fixed beta expiry or scheduled pruning, but is not permanent and may be removed by account deletion or offboarding. --- # Upload media URL: https://docs.linqapp.com/channel/whatsapp/api/resources/media/methods/upload/ **post** `/v1/media` Uploads media bytes and returns the durable `upload_ref` used by an outbound `media` part. Send the raw bytes as the request body and their exact media type in `Content-Type`; the body is never JSON-normalized. `media_id` is a different namespace: it is the channel's transient, inbound-only identifier and expires after seven days. It is never an outbound address. Handles returned here visibly begin `upload.` and are reusable within this key's account and test/live partition. Outbound uploads have no fixed beta expiry and are not routinely pruned, but this is not a permanence promise: account deletion or offboarding may remove them. Reusing a handle does not create or refresh an expiry clock. Inbound media bytes are not readable through this route and are deleted 30 days after server receipt. Size ceilings are applied by kind: image 5,000,000 bytes; audio and video 16,000,000; document 100,000,000; static sticker 100,000 and animated sticker 500,000. A larger body is refused, never truncated. Images accept JPEG or PNG; video accepts MP4 or 3GPP; audio accepts AAC, AMR, MPEG, MP4 or Ogg; stickers accept WebP; documents accept `text/plain`, `application/pdf`, the legacy Word/Excel/PowerPoint media types, and the three Open XML Word/Excel/PowerPoint media types. The media type must match the kind; an invented vendor suffix is not accepted. ### Query Parameters - `kind: "image" or "video" or "audio" or 2 more` The kind this upload will be sent as. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` ### Returns - `Upload object { byte_size, kind, mime_type, 2 more }` A durable upload that can be reused by outbound media parts. No fixed beta expiry is promised. - `byte_size: number` Exact byte count stored. - `kind: "image" or "video" or "audio" or 2 more` The media kind chosen at upload. A send using this handle must use the same kind. - `"image"` - `"video"` - `"audio"` - `"document"` - `"sticker"` - `mime_type: string` The normalized media type stored with the bytes. - `sha256: string` Lowercase hexadecimal SHA-256 over the exact stored bytes. - `upload_ref: string` Our reusable handle. It always begins `upload.` and is supplied as a media part's `upload_ref`. It has no fixed beta expiry or scheduled pruning, but is not permanent and may be removed by account deletion or offboarding. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/media \ -H 'Content-Type: */*' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{}' ``` #### Response ```json { "byte_size": 0, "kind": "image", "mime_type": "mime_type", "sha256": "sha256", "upload_ref": "upload_ref" } ``` --- # Phone Numbers URL: https://docs.linqapp.com/channel/whatsapp/api/resources/phone_numbers/ ## List phone numbers **get** `/v1/phone_numbers` **The placed quality band is provisional.** It is this service's current reading of the channel's `quality` token published beside it, and may change when the channel vocabulary is settled under V-19. Lists the currently registered sending numbers behind this brand's chats with their live channel health — quality rating, messaging tier, and display-name status. Numbers still moving through registration and numbers already released are not part of this sending inventory. ### Returns - `PhoneNumberList object { data }` The sending numbers visible to this key's brand. - `data: array of PhoneNumber` - `id: string` The number's id — chats pin to it via `phone_number_id`. - `channel_account_id: string` The channel account this number is registered under. Opaque; compare literally and never parse it. - `messaging_tier: string` The channel's messaging-tier token for this number, e.g. `TIER_1K`. Values are relayed as reported and may grow. - `name_status: string` The display-name review state for this number, as reported by the channel. - `phone_number: string` The number itself, in display form. - `quality: string` The channel's quality rating for this number, e.g. `green`, `yellow`, `red`. Values are relayed as reported and may grow. - `quality_band: optional "green" or "yellow" or "red"` This service's placed band for the current `quality` token. Absent when the token is empty or this build cannot place it. - `"green"` - `"yellow"` - `"red"` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/phone_numbers \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "channel_account_id": "channel_account_id", "messaging_tier": "messaging_tier", "name_status": "name_status", "phone_number": "phone_number", "quality": "quality", "quality_band": "green" } ] } ``` ## Domain Types ### Phone Number - `PhoneNumber object { id, channel_account_id, messaging_tier, 4 more }` One sending number and its live channel health. - `id: string` The number's id — chats pin to it via `phone_number_id`. - `channel_account_id: string` The channel account this number is registered under. Opaque; compare literally and never parse it. - `messaging_tier: string` The channel's messaging-tier token for this number, e.g. `TIER_1K`. Values are relayed as reported and may grow. - `name_status: string` The display-name review state for this number, as reported by the channel. - `phone_number: string` The number itself, in display form. - `quality: string` The channel's quality rating for this number, e.g. `green`, `yellow`, `red`. Values are relayed as reported and may grow. - `quality_band: optional "green" or "yellow" or "red"` This service's placed band for the current `quality` token. Absent when the token is empty or this build cannot place it. - `"green"` - `"yellow"` - `"red"` ### Phone Number List - `PhoneNumberList object { data }` The sending numbers visible to this key's brand. - `data: array of PhoneNumber` - `id: string` The number's id — chats pin to it via `phone_number_id`. - `channel_account_id: string` The channel account this number is registered under. Opaque; compare literally and never parse it. - `messaging_tier: string` The channel's messaging-tier token for this number, e.g. `TIER_1K`. Values are relayed as reported and may grow. - `name_status: string` The display-name review state for this number, as reported by the channel. - `phone_number: string` The number itself, in display form. - `quality: string` The channel's quality rating for this number, e.g. `green`, `yellow`, `red`. Values are relayed as reported and may grow. - `quality_band: optional "green" or "yellow" or "red"` This service's placed band for the current `quality` token. Absent when the token is empty or this build cannot place it. - `"green"` - `"yellow"` - `"red"` --- # List phone numbers URL: https://docs.linqapp.com/channel/whatsapp/api/resources/phone_numbers/methods/list/ **get** `/v1/phone_numbers` **The placed quality band is provisional.** It is this service's current reading of the channel's `quality` token published beside it, and may change when the channel vocabulary is settled under V-19. Lists the currently registered sending numbers behind this brand's chats with their live channel health — quality rating, messaging tier, and display-name status. Numbers still moving through registration and numbers already released are not part of this sending inventory. ### Returns - `PhoneNumberList object { data }` The sending numbers visible to this key's brand. - `data: array of PhoneNumber` - `id: string` The number's id — chats pin to it via `phone_number_id`. - `channel_account_id: string` The channel account this number is registered under. Opaque; compare literally and never parse it. - `messaging_tier: string` The channel's messaging-tier token for this number, e.g. `TIER_1K`. Values are relayed as reported and may grow. - `name_status: string` The display-name review state for this number, as reported by the channel. - `phone_number: string` The number itself, in display form. - `quality: string` The channel's quality rating for this number, e.g. `green`, `yellow`, `red`. Values are relayed as reported and may grow. - `quality_band: optional "green" or "yellow" or "red"` This service's placed band for the current `quality` token. Absent when the token is empty or this build cannot place it. - `"green"` - `"yellow"` - `"red"` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/phone_numbers \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "channel_account_id": "channel_account_id", "messaging_tier": "messaging_tier", "name_status": "name_status", "phone_number": "phone_number", "quality": "quality", "quality_band": "green" } ] } ``` --- # Streams URL: https://docs.linqapp.com/channel/whatsapp/api/resources/streams/ ## Stream events (SSE) **get** `/v1/streams/events` Streams the brand's events as server-sent events, one `data:` frame per event, `id:` carrying the event's cursor. Reconnect with `Last-Event-ID` (or the `cursor` query parameter) to resume without a gap — the stream carries the same events the webhooks deliver, byte for byte, which is why a webhook signature and a stream frame can never disagree. **A frame.** `id:` is the resume cursor and nothing else; `event:` is the event's kind; `data:` is one JSON object `{"id","type","timestamp","data":{...}}` — `id` there is the event's own stable identity (the same value a webhook of it carries, so both sinks deduplicate against one key), and the inner `data` addresses what moved: `chat_id` plus the `seq_from`/`seq_to` range of that chat's event sequence. Read message content and history from `GET /v1/chats/{chat}/events` using that range. Kinds today are `message.received`, `message.sent`, `message.delivered`, `message.read`, `message.failed`, `chat.read` and `chat.window.opened`; the set grows additively, so skip a kind you do not handle rather than failing on it, and expect new members inside `data` too. **One message can produce several events.** An inbound message produces `message.received` and, when it opens the customer window, `chat.window.opened`. Your reply then produces `message.sent` and may later produce `message.delivered`. Do not treat every event as a reason to reply; choose the event kinds that are actual triggers for your integration. **Connecting.** Without a cursor the stream starts at the present moment — it does NOT replay history. During ordinary operation, use the account event log to recover missed account events and `GET /v1/chats/{chat}/events` to read chat content. Account-event history has no fixed minimum retention period in beta, and an environment reset may erase it. The server sends a `retry:` hint on connect and a `: keepalive` comment while idle; neither is an event. There is no scheduled account-event pruning today. If history is no longer available, a cursor older than its retained beginning is refused with HTTP 410 `cursor_expired` rather than answered with silence. A cursor beyond the latest committed event is refused with HTTP 422 `invalid_paging`, while a cursor exactly at the latest event is the ordinary caught-up position. An empty cursor is refused too: omit it to start at the present position. **Cursors are scoped to the brand and the test/live partition selected by the key.** Each partition has its own dense sequence from 1, while that partition is deliberately absent from the wire. If you reconnect at cursor 42 with a key for the other partition, the server answers 200 and resumes at that partition's event 42; `cursor_expired` cannot signal the swap. Keep separate cursors for `sk_test_` and `sk_live_` keys. **Webhooks.** The same events are delivered as signed webhooks (Standard Webhooks: `webhook-id`, `webhook-timestamp`, `webhook-signature`, verifiable with any off-the-shelf library). Register a destination for them under `/v1/webhook_endpoints`. ### Query Parameters - `cursor: optional string` Resume cursor — the last event id you processed. Empty and beyond-latest values are refused. The header form wins when both are set. ### Header Parameters - `"Last-Event-ID": optional string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/streams/events \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` ## Domain Types ### Stream Events Response - `StreamEventsResponse = string` --- # Stream events (SSE) URL: https://docs.linqapp.com/channel/whatsapp/api/resources/streams/methods/events/ **get** `/v1/streams/events` Streams the brand's events as server-sent events, one `data:` frame per event, `id:` carrying the event's cursor. Reconnect with `Last-Event-ID` (or the `cursor` query parameter) to resume without a gap — the stream carries the same events the webhooks deliver, byte for byte, which is why a webhook signature and a stream frame can never disagree. **A frame.** `id:` is the resume cursor and nothing else; `event:` is the event's kind; `data:` is one JSON object `{"id","type","timestamp","data":{...}}` — `id` there is the event's own stable identity (the same value a webhook of it carries, so both sinks deduplicate against one key), and the inner `data` addresses what moved: `chat_id` plus the `seq_from`/`seq_to` range of that chat's event sequence. Read message content and history from `GET /v1/chats/{chat}/events` using that range. Kinds today are `message.received`, `message.sent`, `message.delivered`, `message.read`, `message.failed`, `chat.read` and `chat.window.opened`; the set grows additively, so skip a kind you do not handle rather than failing on it, and expect new members inside `data` too. **One message can produce several events.** An inbound message produces `message.received` and, when it opens the customer window, `chat.window.opened`. Your reply then produces `message.sent` and may later produce `message.delivered`. Do not treat every event as a reason to reply; choose the event kinds that are actual triggers for your integration. **Connecting.** Without a cursor the stream starts at the present moment — it does NOT replay history. During ordinary operation, use the account event log to recover missed account events and `GET /v1/chats/{chat}/events` to read chat content. Account-event history has no fixed minimum retention period in beta, and an environment reset may erase it. The server sends a `retry:` hint on connect and a `: keepalive` comment while idle; neither is an event. There is no scheduled account-event pruning today. If history is no longer available, a cursor older than its retained beginning is refused with HTTP 410 `cursor_expired` rather than answered with silence. A cursor beyond the latest committed event is refused with HTTP 422 `invalid_paging`, while a cursor exactly at the latest event is the ordinary caught-up position. An empty cursor is refused too: omit it to start at the present position. **Cursors are scoped to the brand and the test/live partition selected by the key.** Each partition has its own dense sequence from 1, while that partition is deliberately absent from the wire. If you reconnect at cursor 42 with a key for the other partition, the server answers 200 and resumes at that partition's event 42; `cursor_expired` cannot signal the swap. Keep separate cursors for `sk_test_` and `sk_live_` keys. **Webhooks.** The same events are delivered as signed webhooks (Standard Webhooks: `webhook-id`, `webhook-timestamp`, `webhook-signature`, verifiable with any off-the-shelf library). Register a destination for them under `/v1/webhook_endpoints`. ### Query Parameters - `cursor: optional string` Resume cursor — the last event id you processed. Empty and beyond-latest values are refused. The header form wins when both are set. ### Header Parameters - `"Last-Event-ID": optional string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/streams/events \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` --- # Templates URL: https://docs.linqapp.com/channel/whatsapp/api/resources/templates/ ## Register a template **post** `/v1/templates` Registers a template and submits it for review, synchronously. The call returns once the channel has accepted the submission, so a name or a body it refuses is a `422` you read immediately rather than a silence you discover later. What comes back is almost always `status: "pending"`: review is an unbounded queue and an hour or more is ordinary. **Nothing is sendable until it is `approved`** — watch for it on `GET /v1/templates`. Status is never ours to assert. An approved template can be paused later for quality and approved again afterwards, and this API reports what the channel currently says rather than what it said at submission. A refusal decided before any submission attempt records nothing, and its message says so: the name remains reusable. Once a submission is attempted, its evidence remains visible as `status: "draft"` with the derived parameter schema even if the attempt is refused or its answer is lost. A definitive refusal leaves the name reusable after correction; an unknown outcome permanently fences the name and must not be retried. This ordering prevents an accepted template from becoming invisible here. A name that is already registered beyond draft answers `409` — submitted names are permanent. An account with no messaging channel answers retryable HTTP 503 `no_channel_account` with `error.type` `api_error`, not a validation error. It carries no `Retry-After` because setup has no honest completion time to publish. An invalid channel credential likewise answers HTTP 503 `template_channel_unavailable` with `error.type` `api_error`, not 422 `template_refused`: rotating that credential is this service's repair, not a change to the template. It carries no `Retry-After` because rotation is an operator action rather than a clock boundary. Media-header preparation is bounded and resumable. While the same request is actively preparing, HTTP 503 `template_submission_in_progress` asks you to retry the unchanged body. Exhausted preparation answers HTTP 503 `template_asset_unavailable`. Once the template-create call may have reached the channel but its answer is unknown, HTTP 409 `template_outcome_indeterminate` is permanent: do not resubmit that name; registry reconciliation must establish the outcome. HTTP 201 is returned only after the channel accepted the template submission. ### Body Parameters - `category: string` `utility`, `marketing` or `authentication`. The channel may RECATEGORISE on review — copy that reads as marketing is treated as marketing whatever was requested — and the assigned category is what the send is priced at. Read `category` back off the response and off `GET /v1/templates`. - `language: string` The locale this variant is written in, such as `en_US`. One template name may have several language variants, each registered separately. - `name: string` Lowercase letters, digits and underscores. Permanent once submitted: a name cannot be reused for different copy. - `authentication: optional object { otp_type, add_security_recommendation, autofill_text, 6 more }` Selects the fixed-copy authentication specialization. Valid only when `category` is `authentication`; omit to retain ordinary body authoring. - `otp_type: "copy_code" or "one_tap" or "zero_tap"` `copy_code` works on every client. `one_tap` and `zero_tap` are Android app integrations and require both app identity fields. - `"copy_code"` - `"one_tap"` - `"zero_tap"` - `add_security_recommendation: optional boolean` Include the channel's localized security recommendation. - `autofill_text: optional string` Localized Android one-tap button label, maximum 25 characters. - `code_expiration_minutes: optional number` Show the localized code-expiry footer. Whole minutes from 1 through 90. - `copy_code_text: optional string` Localized fallback copy-button label, maximum 25 characters. - `message_send_ttl_seconds: optional number` Stop attempting delivery after this many seconds. Authentication templates accept 30 through 900. - `package_name: optional string` Android application package name. Required for `one_tap` and `zero_tap`; omit for `copy_code`. - `signature_hash: optional string` Android app signing-key hash. Required for `one_tap` and `zero_tap`; omit for `copy_code`. - `zero_tap_terms_accepted: optional boolean` Must be true for `zero_tap`; omit for the other subtypes. - `body: optional object { text, examples }` The ordinary authoring body. Required unless `authentication` is supplied; specialized authentication templates omit it because their localized body is fixed. - `text: string` The message copy. Use `{{name}}` for each variable — lowercase letters, digits and underscores, the same names a `template` part supplies values for. Numbered placeholders such as `{{1}}` are refused: this API addresses template variables by name. At most 32 distinct placeholders may appear; a larger set is refused at this field. - `examples: optional unknown` One example value per variable, keyed by variable name. **Required for every variable the copy uses** — the channel refuses a template whose variables carry no examples, and reviewers read the examples to decide. Omit it entirely when the copy has no variables. Each value is 1–1024 characters, may not be whitespace-only, and may not contain a line break, a tab, or four consecutive spaces, because a parameter value carries the same published rules at send time. - `buttons: optional array of object { text, url }` One or two fixed URL buttons for an ordinary template. Their non-blank labels and HTTPS destinations are baked into the reviewed template and take no send-time values. - `text: string` Visible label, maximum 25 characters. - `url: string` Fixed HTTPS destination baked into the approved template. Senders cannot replace it. - `carousel: optional object { cards }` Optional media-card carousel definition. All 2–10 cards must use the same media kind, body presence, and ordered button-type sequence. - `cards: array of object { media, body, buttons }` - `media: object { asset_handle, kind }` - `asset_handle: string` The asset handle returned by the channel's template-authoring upload flow. This is neither this API's upload_ref nor an inbound media_id. - `kind: "image" or "video"` - `"image"` - `"video"` - `body: optional object { text, examples }` The body copy of a template, with `{{variable}}` placeholders for the parts that change per send. - `text: string` The message copy. Use `{{name}}` for each variable — lowercase letters, digits and underscores, the same names a `template` part supplies values for. Numbered placeholders such as `{{1}}` are refused: this API addresses template variables by name. At most 32 distinct placeholders may appear; a larger set is refused at this field. - `examples: optional unknown` One example value per variable, keyed by variable name. **Required for every variable the copy uses** — the channel refuses a template whose variables carry no examples, and reviewers read the examples to decide. Omit it entirely when the copy has no variables. Each value is 1–1024 characters, may not be whitespace-only, and may not contain a line break, a tab, or four consecutive spaces, because a parameter value carries the same published rules at send time. - `buttons: optional array of object { text, type, phone_number, url }` - `text: string` - `type: "quick_reply" or "phone_number" or "url"` - `"quick_reply"` - `"phone_number"` - `"url"` - `phone_number: optional string` - `url: optional string` - `footer: optional string` An optional footer line. Takes no variables. - `media_header: optional object { kind, upload_ref }` Optional image, video, or document header for an ordinary template. The referenced bytes are uploaded for template review as part of this submission. It cannot be combined with `authentication` or `carousel`. - `kind: "image" or "video" or "document"` The media shape reviewed with this template. - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by `POST /v1/media` for this account and key mode. Private-beta template authoring accepts JPEG, PNG, MP4, and PDF up to 5 MiB for every kind. This is this API's conservative authoring limit. The `upload_ref` follows the media route's documented beta retention posture and is not permanent. ### Returns - `Template object { category, created_at, language, 9 more }` One registered message template in any status, with the parameters it takes. A template is identified by its `name` and `language` together — that is the pair a `template` part names, and the pair this API addresses it by. There is no separate template id. - `category: string` `utility`, `marketing` or `authentication` — the category the channel ASSIGNED, which may not be the one that was requested: a template that reads as marketing is recategorised on review, and the category is what a send is priced at. Values grow additively. - `created_at: string` - `language: string` The locale this variant is written in, such as `en_US`. - `name: string` Lowercase letters, digits and underscores. Permanent once submitted. - `parameters: unknown` The variables this template takes, as a JSON Schema (2020-12) object — ready to hand to a model verbatim as a tool schema. Every property is a string and every one is required: the channel substitutes all of them. Each property publishes `minLength`, `maxLength` and `pattern` alongside its example, so a model sees the same value rules the send path enforces: values are 1–1024 characters, not whitespace-only, and contain no line break, tab or run of four spaces. An empty `properties` object means the template takes no variables. `null` means something different and specific: this template's variables cannot be addressed by name over this API (they are positional, or they live in a part of the template this API does not read yet). A `template` part naming such a template cannot supply its variables. - `quality: string` The channel's quality signal for this template, when it has one. `null` until it does. Free text; treat an unknown value as no signal. - `rejected_reason: string` Why the template was refused or held, when the channel said. Free text for a human; the wording is not stable and must not be branched on. `null` when there is nothing to say. - `send_schema: unknown` The complete schema for the `template` part that sends this exact template, including its fixed identity, named parameters, media header, and carousel values. `null` means this template cannot be addressed safely through this API. - `status: string` Where this template stands. Only `approved` may be sent. `draft` — a submission attempt has not produced a registered template. Consult `retry_allowed`; draft status alone never permits resubmission. `pending` — under review. Review is an unbounded queue; an hour or more is ordinary for a first submission. `approved` — sendable. `rejected` — refused; `rejected_reason` may say why. `paused` — was approved and is currently held, usually for quality. Not terminal: it can become `approved` again without you doing anything. `disabled` — no longer usable. Values grow additively, and anything you do not recognise is not sendable. - `updated_at: string` - `content: optional object { body, buttons, carousel, 2 more }` Human-readable copy and fixed actions in a registered template. - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `carousel: optional array of object { body, buttons, header }` - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `footer: optional string` - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `retry_allowed: optional boolean` Whether another submission using this draft's name and language is currently allowed. A corrected definition may be required. Present on all responses from current servers. Clients must not offer retry unless this is explicitly `true`. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/templates \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{ "category": "category", "language": "language", "name": "name" }' ``` #### Response ```json { "category": "category", "created_at": "2019-12-27T18:11:19.117Z", "language": "language", "name": "name", "parameters": {}, "quality": "quality", "rejected_reason": "rejected_reason", "send_schema": {}, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "content": { "body": "body", "buttons": [ { "type": "type", "phone_number": "phone_number", "text": "text", "url": "url" } ], "carousel": [ { "body": "body", "buttons": [ { "type": "type", "phone_number": "phone_number", "text": "text", "url": "url" } ], "header": { "kind": "kind", "text": "text" } } ], "footer": "footer", "header": { "kind": "kind", "text": "text" } }, "retry_allowed": true } ``` ## List templates **get** `/v1/templates` Lists the templates registered for this key's account, newest first, with the parameter schema each one takes. This is the read a `422 outside_customer_window` points you at: outside a customer's service window only an approved `template` part sends, and this is where the sendable ones and their variables are. It is also the read to hand an agent — `parameters` is a JSON Schema shaped to be used verbatim as a tool definition. Templates in every status are listed, including ones that are not sendable, so a pause or a rejection is visible rather than looking like a template that vanished. Filter on `status == "approved"` for the sendable set. ### Query Parameters - `cursor: optional string` A `next_cursor` from a previous page. Omit for the first page. - `limit: optional number` How many templates to return, 1–100. Defaults to 100. A value outside the range is refused rather than clamped. ### Returns - `TemplatePage object { data, has_more, next_cursor }` One page of templates, newest first. - `data: array of Template` - `category: string` `utility`, `marketing` or `authentication` — the category the channel ASSIGNED, which may not be the one that was requested: a template that reads as marketing is recategorised on review, and the category is what a send is priced at. Values grow additively. - `created_at: string` - `language: string` The locale this variant is written in, such as `en_US`. - `name: string` Lowercase letters, digits and underscores. Permanent once submitted. - `parameters: unknown` The variables this template takes, as a JSON Schema (2020-12) object — ready to hand to a model verbatim as a tool schema. Every property is a string and every one is required: the channel substitutes all of them. Each property publishes `minLength`, `maxLength` and `pattern` alongside its example, so a model sees the same value rules the send path enforces: values are 1–1024 characters, not whitespace-only, and contain no line break, tab or run of four spaces. An empty `properties` object means the template takes no variables. `null` means something different and specific: this template's variables cannot be addressed by name over this API (they are positional, or they live in a part of the template this API does not read yet). A `template` part naming such a template cannot supply its variables. - `quality: string` The channel's quality signal for this template, when it has one. `null` until it does. Free text; treat an unknown value as no signal. - `rejected_reason: string` Why the template was refused or held, when the channel said. Free text for a human; the wording is not stable and must not be branched on. `null` when there is nothing to say. - `send_schema: unknown` The complete schema for the `template` part that sends this exact template, including its fixed identity, named parameters, media header, and carousel values. `null` means this template cannot be addressed safely through this API. - `status: string` Where this template stands. Only `approved` may be sent. `draft` — a submission attempt has not produced a registered template. Consult `retry_allowed`; draft status alone never permits resubmission. `pending` — under review. Review is an unbounded queue; an hour or more is ordinary for a first submission. `approved` — sendable. `rejected` — refused; `rejected_reason` may say why. `paused` — was approved and is currently held, usually for quality. Not terminal: it can become `approved` again without you doing anything. `disabled` — no longer usable. Values grow additively, and anything you do not recognise is not sendable. - `updated_at: string` - `content: optional object { body, buttons, carousel, 2 more }` Human-readable copy and fixed actions in a registered template. - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `carousel: optional array of object { body, buttons, header }` - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `footer: optional string` - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `retry_allowed: optional boolean` Whether another submission using this draft's name and language is currently allowed. A corrected definition may be required. Present on all responses from current servers. Clients must not offer retry unless this is explicitly `true`. - `has_more: boolean` True when another page follows. - `next_cursor: optional string` Pass as `cursor` to read the next page. Present only when `has_more` is true. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/templates \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "category": "category", "created_at": "2019-12-27T18:11:19.117Z", "language": "language", "name": "name", "parameters": {}, "quality": "quality", "rejected_reason": "rejected_reason", "send_schema": {}, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "content": { "body": "body", "buttons": [ { "type": "type", "phone_number": "phone_number", "text": "text", "url": "url" } ], "carousel": [ { "body": "body", "buttons": [ { "type": "type", "phone_number": "phone_number", "text": "text", "url": "url" } ], "header": { "kind": "kind", "text": "text" } } ], "footer": "footer", "header": { "kind": "kind", "text": "text" } }, "retry_allowed": true } ], "has_more": true, "next_cursor": "next_cursor" } ``` ## Domain Types ### Template - `Template object { category, created_at, language, 9 more }` One registered message template in any status, with the parameters it takes. A template is identified by its `name` and `language` together — that is the pair a `template` part names, and the pair this API addresses it by. There is no separate template id. - `category: string` `utility`, `marketing` or `authentication` — the category the channel ASSIGNED, which may not be the one that was requested: a template that reads as marketing is recategorised on review, and the category is what a send is priced at. Values grow additively. - `created_at: string` - `language: string` The locale this variant is written in, such as `en_US`. - `name: string` Lowercase letters, digits and underscores. Permanent once submitted. - `parameters: unknown` The variables this template takes, as a JSON Schema (2020-12) object — ready to hand to a model verbatim as a tool schema. Every property is a string and every one is required: the channel substitutes all of them. Each property publishes `minLength`, `maxLength` and `pattern` alongside its example, so a model sees the same value rules the send path enforces: values are 1–1024 characters, not whitespace-only, and contain no line break, tab or run of four spaces. An empty `properties` object means the template takes no variables. `null` means something different and specific: this template's variables cannot be addressed by name over this API (they are positional, or they live in a part of the template this API does not read yet). A `template` part naming such a template cannot supply its variables. - `quality: string` The channel's quality signal for this template, when it has one. `null` until it does. Free text; treat an unknown value as no signal. - `rejected_reason: string` Why the template was refused or held, when the channel said. Free text for a human; the wording is not stable and must not be branched on. `null` when there is nothing to say. - `send_schema: unknown` The complete schema for the `template` part that sends this exact template, including its fixed identity, named parameters, media header, and carousel values. `null` means this template cannot be addressed safely through this API. - `status: string` Where this template stands. Only `approved` may be sent. `draft` — a submission attempt has not produced a registered template. Consult `retry_allowed`; draft status alone never permits resubmission. `pending` — under review. Review is an unbounded queue; an hour or more is ordinary for a first submission. `approved` — sendable. `rejected` — refused; `rejected_reason` may say why. `paused` — was approved and is currently held, usually for quality. Not terminal: it can become `approved` again without you doing anything. `disabled` — no longer usable. Values grow additively, and anything you do not recognise is not sendable. - `updated_at: string` - `content: optional object { body, buttons, carousel, 2 more }` Human-readable copy and fixed actions in a registered template. - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `carousel: optional array of object { body, buttons, header }` - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `footer: optional string` - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `retry_allowed: optional boolean` Whether another submission using this draft's name and language is currently allowed. A corrected definition may be required. Present on all responses from current servers. Clients must not offer retry unless this is explicitly `true`. ### Template Page - `TemplatePage object { data, has_more, next_cursor }` One page of templates, newest first. - `data: array of Template` - `category: string` `utility`, `marketing` or `authentication` — the category the channel ASSIGNED, which may not be the one that was requested: a template that reads as marketing is recategorised on review, and the category is what a send is priced at. Values grow additively. - `created_at: string` - `language: string` The locale this variant is written in, such as `en_US`. - `name: string` Lowercase letters, digits and underscores. Permanent once submitted. - `parameters: unknown` The variables this template takes, as a JSON Schema (2020-12) object — ready to hand to a model verbatim as a tool schema. Every property is a string and every one is required: the channel substitutes all of them. Each property publishes `minLength`, `maxLength` and `pattern` alongside its example, so a model sees the same value rules the send path enforces: values are 1–1024 characters, not whitespace-only, and contain no line break, tab or run of four spaces. An empty `properties` object means the template takes no variables. `null` means something different and specific: this template's variables cannot be addressed by name over this API (they are positional, or they live in a part of the template this API does not read yet). A `template` part naming such a template cannot supply its variables. - `quality: string` The channel's quality signal for this template, when it has one. `null` until it does. Free text; treat an unknown value as no signal. - `rejected_reason: string` Why the template was refused or held, when the channel said. Free text for a human; the wording is not stable and must not be branched on. `null` when there is nothing to say. - `send_schema: unknown` The complete schema for the `template` part that sends this exact template, including its fixed identity, named parameters, media header, and carousel values. `null` means this template cannot be addressed safely through this API. - `status: string` Where this template stands. Only `approved` may be sent. `draft` — a submission attempt has not produced a registered template. Consult `retry_allowed`; draft status alone never permits resubmission. `pending` — under review. Review is an unbounded queue; an hour or more is ordinary for a first submission. `approved` — sendable. `rejected` — refused; `rejected_reason` may say why. `paused` — was approved and is currently held, usually for quality. Not terminal: it can become `approved` again without you doing anything. `disabled` — no longer usable. Values grow additively, and anything you do not recognise is not sendable. - `updated_at: string` - `content: optional object { body, buttons, carousel, 2 more }` Human-readable copy and fixed actions in a registered template. - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `carousel: optional array of object { body, buttons, header }` - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `footer: optional string` - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `retry_allowed: optional boolean` Whether another submission using this draft's name and language is currently allowed. A corrected definition may be required. Present on all responses from current servers. Clients must not offer retry unless this is explicitly `true`. - `has_more: boolean` True when another page follows. - `next_cursor: optional string` Pass as `cursor` to read the next page. Present only when `has_more` is true. --- # Register a template URL: https://docs.linqapp.com/channel/whatsapp/api/resources/templates/methods/create/ **post** `/v1/templates` Registers a template and submits it for review, synchronously. The call returns once the channel has accepted the submission, so a name or a body it refuses is a `422` you read immediately rather than a silence you discover later. What comes back is almost always `status: "pending"`: review is an unbounded queue and an hour or more is ordinary. **Nothing is sendable until it is `approved`** — watch for it on `GET /v1/templates`. Status is never ours to assert. An approved template can be paused later for quality and approved again afterwards, and this API reports what the channel currently says rather than what it said at submission. A refusal decided before any submission attempt records nothing, and its message says so: the name remains reusable. Once a submission is attempted, its evidence remains visible as `status: "draft"` with the derived parameter schema even if the attempt is refused or its answer is lost. A definitive refusal leaves the name reusable after correction; an unknown outcome permanently fences the name and must not be retried. This ordering prevents an accepted template from becoming invisible here. A name that is already registered beyond draft answers `409` — submitted names are permanent. An account with no messaging channel answers retryable HTTP 503 `no_channel_account` with `error.type` `api_error`, not a validation error. It carries no `Retry-After` because setup has no honest completion time to publish. An invalid channel credential likewise answers HTTP 503 `template_channel_unavailable` with `error.type` `api_error`, not 422 `template_refused`: rotating that credential is this service's repair, not a change to the template. It carries no `Retry-After` because rotation is an operator action rather than a clock boundary. Media-header preparation is bounded and resumable. While the same request is actively preparing, HTTP 503 `template_submission_in_progress` asks you to retry the unchanged body. Exhausted preparation answers HTTP 503 `template_asset_unavailable`. Once the template-create call may have reached the channel but its answer is unknown, HTTP 409 `template_outcome_indeterminate` is permanent: do not resubmit that name; registry reconciliation must establish the outcome. HTTP 201 is returned only after the channel accepted the template submission. ### Body Parameters - `category: string` `utility`, `marketing` or `authentication`. The channel may RECATEGORISE on review — copy that reads as marketing is treated as marketing whatever was requested — and the assigned category is what the send is priced at. Read `category` back off the response and off `GET /v1/templates`. - `language: string` The locale this variant is written in, such as `en_US`. One template name may have several language variants, each registered separately. - `name: string` Lowercase letters, digits and underscores. Permanent once submitted: a name cannot be reused for different copy. - `authentication: optional object { otp_type, add_security_recommendation, autofill_text, 6 more }` Selects the fixed-copy authentication specialization. Valid only when `category` is `authentication`; omit to retain ordinary body authoring. - `otp_type: "copy_code" or "one_tap" or "zero_tap"` `copy_code` works on every client. `one_tap` and `zero_tap` are Android app integrations and require both app identity fields. - `"copy_code"` - `"one_tap"` - `"zero_tap"` - `add_security_recommendation: optional boolean` Include the channel's localized security recommendation. - `autofill_text: optional string` Localized Android one-tap button label, maximum 25 characters. - `code_expiration_minutes: optional number` Show the localized code-expiry footer. Whole minutes from 1 through 90. - `copy_code_text: optional string` Localized fallback copy-button label, maximum 25 characters. - `message_send_ttl_seconds: optional number` Stop attempting delivery after this many seconds. Authentication templates accept 30 through 900. - `package_name: optional string` Android application package name. Required for `one_tap` and `zero_tap`; omit for `copy_code`. - `signature_hash: optional string` Android app signing-key hash. Required for `one_tap` and `zero_tap`; omit for `copy_code`. - `zero_tap_terms_accepted: optional boolean` Must be true for `zero_tap`; omit for the other subtypes. - `body: optional object { text, examples }` The ordinary authoring body. Required unless `authentication` is supplied; specialized authentication templates omit it because their localized body is fixed. - `text: string` The message copy. Use `{{name}}` for each variable — lowercase letters, digits and underscores, the same names a `template` part supplies values for. Numbered placeholders such as `{{1}}` are refused: this API addresses template variables by name. At most 32 distinct placeholders may appear; a larger set is refused at this field. - `examples: optional unknown` One example value per variable, keyed by variable name. **Required for every variable the copy uses** — the channel refuses a template whose variables carry no examples, and reviewers read the examples to decide. Omit it entirely when the copy has no variables. Each value is 1–1024 characters, may not be whitespace-only, and may not contain a line break, a tab, or four consecutive spaces, because a parameter value carries the same published rules at send time. - `buttons: optional array of object { text, url }` One or two fixed URL buttons for an ordinary template. Their non-blank labels and HTTPS destinations are baked into the reviewed template and take no send-time values. - `text: string` Visible label, maximum 25 characters. - `url: string` Fixed HTTPS destination baked into the approved template. Senders cannot replace it. - `carousel: optional object { cards }` Optional media-card carousel definition. All 2–10 cards must use the same media kind, body presence, and ordered button-type sequence. - `cards: array of object { media, body, buttons }` - `media: object { asset_handle, kind }` - `asset_handle: string` The asset handle returned by the channel's template-authoring upload flow. This is neither this API's upload_ref nor an inbound media_id. - `kind: "image" or "video"` - `"image"` - `"video"` - `body: optional object { text, examples }` The body copy of a template, with `{{variable}}` placeholders for the parts that change per send. - `text: string` The message copy. Use `{{name}}` for each variable — lowercase letters, digits and underscores, the same names a `template` part supplies values for. Numbered placeholders such as `{{1}}` are refused: this API addresses template variables by name. At most 32 distinct placeholders may appear; a larger set is refused at this field. - `examples: optional unknown` One example value per variable, keyed by variable name. **Required for every variable the copy uses** — the channel refuses a template whose variables carry no examples, and reviewers read the examples to decide. Omit it entirely when the copy has no variables. Each value is 1–1024 characters, may not be whitespace-only, and may not contain a line break, a tab, or four consecutive spaces, because a parameter value carries the same published rules at send time. - `buttons: optional array of object { text, type, phone_number, url }` - `text: string` - `type: "quick_reply" or "phone_number" or "url"` - `"quick_reply"` - `"phone_number"` - `"url"` - `phone_number: optional string` - `url: optional string` - `footer: optional string` An optional footer line. Takes no variables. - `media_header: optional object { kind, upload_ref }` Optional image, video, or document header for an ordinary template. The referenced bytes are uploaded for template review as part of this submission. It cannot be combined with `authentication` or `carousel`. - `kind: "image" or "video" or "document"` The media shape reviewed with this template. - `"image"` - `"video"` - `"document"` - `upload_ref: string` A durable handle returned by `POST /v1/media` for this account and key mode. Private-beta template authoring accepts JPEG, PNG, MP4, and PDF up to 5 MiB for every kind. This is this API's conservative authoring limit. The `upload_ref` follows the media route's documented beta retention posture and is not permanent. ### Returns - `Template object { category, created_at, language, 9 more }` One registered message template in any status, with the parameters it takes. A template is identified by its `name` and `language` together — that is the pair a `template` part names, and the pair this API addresses it by. There is no separate template id. - `category: string` `utility`, `marketing` or `authentication` — the category the channel ASSIGNED, which may not be the one that was requested: a template that reads as marketing is recategorised on review, and the category is what a send is priced at. Values grow additively. - `created_at: string` - `language: string` The locale this variant is written in, such as `en_US`. - `name: string` Lowercase letters, digits and underscores. Permanent once submitted. - `parameters: unknown` The variables this template takes, as a JSON Schema (2020-12) object — ready to hand to a model verbatim as a tool schema. Every property is a string and every one is required: the channel substitutes all of them. Each property publishes `minLength`, `maxLength` and `pattern` alongside its example, so a model sees the same value rules the send path enforces: values are 1–1024 characters, not whitespace-only, and contain no line break, tab or run of four spaces. An empty `properties` object means the template takes no variables. `null` means something different and specific: this template's variables cannot be addressed by name over this API (they are positional, or they live in a part of the template this API does not read yet). A `template` part naming such a template cannot supply its variables. - `quality: string` The channel's quality signal for this template, when it has one. `null` until it does. Free text; treat an unknown value as no signal. - `rejected_reason: string` Why the template was refused or held, when the channel said. Free text for a human; the wording is not stable and must not be branched on. `null` when there is nothing to say. - `send_schema: unknown` The complete schema for the `template` part that sends this exact template, including its fixed identity, named parameters, media header, and carousel values. `null` means this template cannot be addressed safely through this API. - `status: string` Where this template stands. Only `approved` may be sent. `draft` — a submission attempt has not produced a registered template. Consult `retry_allowed`; draft status alone never permits resubmission. `pending` — under review. Review is an unbounded queue; an hour or more is ordinary for a first submission. `approved` — sendable. `rejected` — refused; `rejected_reason` may say why. `paused` — was approved and is currently held, usually for quality. Not terminal: it can become `approved` again without you doing anything. `disabled` — no longer usable. Values grow additively, and anything you do not recognise is not sendable. - `updated_at: string` - `content: optional object { body, buttons, carousel, 2 more }` Human-readable copy and fixed actions in a registered template. - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `carousel: optional array of object { body, buttons, header }` - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `footer: optional string` - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `retry_allowed: optional boolean` Whether another submission using this draft's name and language is currently allowed. A corrected definition may be required. Present on all responses from current servers. Clients must not offer retry unless this is explicitly `true`. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/templates \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{ "category": "category", "language": "language", "name": "name" }' ``` #### Response ```json { "category": "category", "created_at": "2019-12-27T18:11:19.117Z", "language": "language", "name": "name", "parameters": {}, "quality": "quality", "rejected_reason": "rejected_reason", "send_schema": {}, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "content": { "body": "body", "buttons": [ { "type": "type", "phone_number": "phone_number", "text": "text", "url": "url" } ], "carousel": [ { "body": "body", "buttons": [ { "type": "type", "phone_number": "phone_number", "text": "text", "url": "url" } ], "header": { "kind": "kind", "text": "text" } } ], "footer": "footer", "header": { "kind": "kind", "text": "text" } }, "retry_allowed": true } ``` --- # List templates URL: https://docs.linqapp.com/channel/whatsapp/api/resources/templates/methods/list/ **get** `/v1/templates` Lists the templates registered for this key's account, newest first, with the parameter schema each one takes. This is the read a `422 outside_customer_window` points you at: outside a customer's service window only an approved `template` part sends, and this is where the sendable ones and their variables are. It is also the read to hand an agent — `parameters` is a JSON Schema shaped to be used verbatim as a tool definition. Templates in every status are listed, including ones that are not sendable, so a pause or a rejection is visible rather than looking like a template that vanished. Filter on `status == "approved"` for the sendable set. ### Query Parameters - `cursor: optional string` A `next_cursor` from a previous page. Omit for the first page. - `limit: optional number` How many templates to return, 1–100. Defaults to 100. A value outside the range is refused rather than clamped. ### Returns - `TemplatePage object { data, has_more, next_cursor }` One page of templates, newest first. - `data: array of Template` - `category: string` `utility`, `marketing` or `authentication` — the category the channel ASSIGNED, which may not be the one that was requested: a template that reads as marketing is recategorised on review, and the category is what a send is priced at. Values grow additively. - `created_at: string` - `language: string` The locale this variant is written in, such as `en_US`. - `name: string` Lowercase letters, digits and underscores. Permanent once submitted. - `parameters: unknown` The variables this template takes, as a JSON Schema (2020-12) object — ready to hand to a model verbatim as a tool schema. Every property is a string and every one is required: the channel substitutes all of them. Each property publishes `minLength`, `maxLength` and `pattern` alongside its example, so a model sees the same value rules the send path enforces: values are 1–1024 characters, not whitespace-only, and contain no line break, tab or run of four spaces. An empty `properties` object means the template takes no variables. `null` means something different and specific: this template's variables cannot be addressed by name over this API (they are positional, or they live in a part of the template this API does not read yet). A `template` part naming such a template cannot supply its variables. - `quality: string` The channel's quality signal for this template, when it has one. `null` until it does. Free text; treat an unknown value as no signal. - `rejected_reason: string` Why the template was refused or held, when the channel said. Free text for a human; the wording is not stable and must not be branched on. `null` when there is nothing to say. - `send_schema: unknown` The complete schema for the `template` part that sends this exact template, including its fixed identity, named parameters, media header, and carousel values. `null` means this template cannot be addressed safely through this API. - `status: string` Where this template stands. Only `approved` may be sent. `draft` — a submission attempt has not produced a registered template. Consult `retry_allowed`; draft status alone never permits resubmission. `pending` — under review. Review is an unbounded queue; an hour or more is ordinary for a first submission. `approved` — sendable. `rejected` — refused; `rejected_reason` may say why. `paused` — was approved and is currently held, usually for quality. Not terminal: it can become `approved` again without you doing anything. `disabled` — no longer usable. Values grow additively, and anything you do not recognise is not sendable. - `updated_at: string` - `content: optional object { body, buttons, carousel, 2 more }` Human-readable copy and fixed actions in a registered template. - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `carousel: optional array of object { body, buttons, header }` - `body: optional string` - `buttons: optional array of object { type, phone_number, text, url }` - `type: string` The action kind; values grow additively. - `phone_number: optional string` A fixed telephone destination baked into the template, when present. - `text: optional string` The label shown to the recipient, when the channel supplies one. - `url: optional string` A fixed destination baked into the template, when present. - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `footer: optional string` - `header: optional object { kind, text }` A media or text header as it will appear to the recipient. - `kind: string` `text`, `image`, `video`, or `document`; values grow additively. - `text: optional string` Fixed header copy. Omitted for media headers. - `retry_allowed: optional boolean` Whether another submission using this draft's name and language is currently allowed. A corrected definition may be required. Present on all responses from current servers. Clients must not offer retry unless this is explicitly `true`. - `has_more: boolean` True when another page follows. - `next_cursor: optional string` Pass as `cursor` to read the next page. Present only when `has_more` is true. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/templates \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "category": "category", "created_at": "2019-12-27T18:11:19.117Z", "language": "language", "name": "name", "parameters": {}, "quality": "quality", "rejected_reason": "rejected_reason", "send_schema": {}, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "content": { "body": "body", "buttons": [ { "type": "type", "phone_number": "phone_number", "text": "text", "url": "url" } ], "carousel": [ { "body": "body", "buttons": [ { "type": "type", "phone_number": "phone_number", "text": "text", "url": "url" } ], "header": { "kind": "kind", "text": "text" } } ], "footer": "footer", "header": { "kind": "kind", "text": "text" } }, "retry_allowed": true } ], "has_more": true, "next_cursor": "next_cursor" } ``` --- # Webhook Deliveries URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_deliveries/ ## List webhook delivery history **get** `/v1/webhook_deliveries` Lists webhook deliveries and their retained applied attempts, newest delivery first. Without an exact `delivery_id`, `event_id`, or `trace_id` lookup, the time window defaults to the latest 24 hours and cannot exceed 30 days. Exact identifiers may reach older retained evidence. Successful delivery detail becomes eligible for sweeping after 30 days; failed evidence may remain longer. The response therefore publishes an inclusive successful-history incompleteness horizon and the instant when attempt-history collection began. Neither is a blanket retention promise. `spent_attempt_count` can exceed `observed_attempt_count` when an attempt ends without a retained verdict. Such a gap is reported, never filled with an invented attempt result. Payloads, bodies, headers, credentials, signatures, diagnostic errors and destination URL paths are never returned. ### Query Parameters - `cursor: optional string` - `delivery_id: optional string` - `endpoint_id: optional string` - `event_id: optional string` - `event_kind: optional string` - `from: optional string` - `limit: optional number` - `outcome: optional string` - `to: optional string` - `trace_id: optional string` ### Returns - `DeliveryPage object { attempt_history_coverage_started_at, data, has_more, 3 more }` A bounded page of webhook deliveries. Parent deliveries are never split across pages. - `attempt_history_coverage_started_at: string` Inclusive instant when attempt-history collection began. Earlier deliveries cannot claim complete attempt history. - `data: array of Delivery` - `id: string` - `attempt_history_complete: boolean` True only when every spent attempt has a retained applied verdict and collection covered this delivery from creation. - `attempts: array of DeliveryAttempt` - `attempt: number` The spent attempt number. Gaps mean a lease expired without an applied verdict. - `attempt_completed_at: string` - `attempt_started_at: string` - `destination: string` Scheme, host and effective port only. Path, query, fragment and credentials are never returned. - `http_status: number` Response status, or `null` when no response was received. - `phase: string` `pre_dial` when no request began, or `wire` after the HTTP round trip began. - `reason_code: string` A bounded machine reason. Treat unknown values as `unknown`. - `verdict: string` `delivered`, `retryable`, or `permanent`. Treat unknown values as an unavailable verdict. - `wire_completed_at: string` - `wire_duration_ms: number` - `wire_started_at: string` - `trace_id: optional string` W3C trace id when one was recorded. Correlation only; never authorization. - `created_at: string` - `endpoint_id: string` - `event_id: string` - `event_kind: string` - `observed_attempt_count: number` Applied attempt verdicts retained below. - `seq_from: number` - `seq_to: number` - `spent_attempt_count: number` Attempts spent by the delivery machine, including leases that expired without an applied verdict. - `state: string` `pending`, `delivering`, `delivered`, `permanent_refusal`, or `retry_exhausted`. - `chat_id: optional string` - `has_more: boolean` - `older_successful_history_may_have_been_swept: boolean` True when successful delivery detail may have been swept from this account partition. - `successful_history_incomplete_through: string` Inclusive creation-time horizon through which successful delivery detail may be incomplete. - `next_cursor: optional string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_deliveries \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "attempt_history_coverage_started_at": "2019-12-27T18:11:19.117Z", "data": [ { "id": "id", "attempt_history_complete": true, "attempts": [ { "attempt": 0, "attempt_completed_at": "2019-12-27T18:11:19.117Z", "attempt_started_at": "2019-12-27T18:11:19.117Z", "destination": "destination", "http_status": 0, "phase": "phase", "reason_code": "reason_code", "verdict": "verdict", "wire_completed_at": "2019-12-27T18:11:19.117Z", "wire_duration_ms": 0, "wire_started_at": "2019-12-27T18:11:19.117Z", "trace_id": "trace_id" } ], "created_at": "2019-12-27T18:11:19.117Z", "endpoint_id": "endpoint_id", "event_id": "event_id", "event_kind": "event_kind", "observed_attempt_count": 0, "seq_from": 0, "seq_to": 0, "spent_attempt_count": 0, "state": "state", "chat_id": "chat_id" } ], "has_more": true, "older_successful_history_may_have_been_swept": true, "successful_history_incomplete_through": "2019-12-27T18:11:19.117Z", "next_cursor": "next_cursor" } ``` ## Domain Types ### Delivery - `Delivery object { id, attempt_history_complete, attempts, 10 more }` One webhook delivery and every retained applied attempt belonging to it. - `id: string` - `attempt_history_complete: boolean` True only when every spent attempt has a retained applied verdict and collection covered this delivery from creation. - `attempts: array of DeliveryAttempt` - `attempt: number` The spent attempt number. Gaps mean a lease expired without an applied verdict. - `attempt_completed_at: string` - `attempt_started_at: string` - `destination: string` Scheme, host and effective port only. Path, query, fragment and credentials are never returned. - `http_status: number` Response status, or `null` when no response was received. - `phase: string` `pre_dial` when no request began, or `wire` after the HTTP round trip began. - `reason_code: string` A bounded machine reason. Treat unknown values as `unknown`. - `verdict: string` `delivered`, `retryable`, or `permanent`. Treat unknown values as an unavailable verdict. - `wire_completed_at: string` - `wire_duration_ms: number` - `wire_started_at: string` - `trace_id: optional string` W3C trace id when one was recorded. Correlation only; never authorization. - `created_at: string` - `endpoint_id: string` - `event_id: string` - `event_kind: string` - `observed_attempt_count: number` Applied attempt verdicts retained below. - `seq_from: number` - `seq_to: number` - `spent_attempt_count: number` Attempts spent by the delivery machine, including leases that expired without an applied verdict. - `state: string` `pending`, `delivering`, `delivered`, `permanent_refusal`, or `retry_exhausted`. - `chat_id: optional string` ### Delivery Attempt - `DeliveryAttempt object { attempt, attempt_completed_at, attempt_started_at, 9 more }` One applied attempt to deliver an account event to a webhook endpoint. - `attempt: number` The spent attempt number. Gaps mean a lease expired without an applied verdict. - `attempt_completed_at: string` - `attempt_started_at: string` - `destination: string` Scheme, host and effective port only. Path, query, fragment and credentials are never returned. - `http_status: number` Response status, or `null` when no response was received. - `phase: string` `pre_dial` when no request began, or `wire` after the HTTP round trip began. - `reason_code: string` A bounded machine reason. Treat unknown values as `unknown`. - `verdict: string` `delivered`, `retryable`, or `permanent`. Treat unknown values as an unavailable verdict. - `wire_completed_at: string` - `wire_duration_ms: number` - `wire_started_at: string` - `trace_id: optional string` W3C trace id when one was recorded. Correlation only; never authorization. ### Delivery Page - `DeliveryPage object { attempt_history_coverage_started_at, data, has_more, 3 more }` A bounded page of webhook deliveries. Parent deliveries are never split across pages. - `attempt_history_coverage_started_at: string` Inclusive instant when attempt-history collection began. Earlier deliveries cannot claim complete attempt history. - `data: array of Delivery` - `id: string` - `attempt_history_complete: boolean` True only when every spent attempt has a retained applied verdict and collection covered this delivery from creation. - `attempts: array of DeliveryAttempt` - `attempt: number` The spent attempt number. Gaps mean a lease expired without an applied verdict. - `attempt_completed_at: string` - `attempt_started_at: string` - `destination: string` Scheme, host and effective port only. Path, query, fragment and credentials are never returned. - `http_status: number` Response status, or `null` when no response was received. - `phase: string` `pre_dial` when no request began, or `wire` after the HTTP round trip began. - `reason_code: string` A bounded machine reason. Treat unknown values as `unknown`. - `verdict: string` `delivered`, `retryable`, or `permanent`. Treat unknown values as an unavailable verdict. - `wire_completed_at: string` - `wire_duration_ms: number` - `wire_started_at: string` - `trace_id: optional string` W3C trace id when one was recorded. Correlation only; never authorization. - `created_at: string` - `endpoint_id: string` - `event_id: string` - `event_kind: string` - `observed_attempt_count: number` Applied attempt verdicts retained below. - `seq_from: number` - `seq_to: number` - `spent_attempt_count: number` Attempts spent by the delivery machine, including leases that expired without an applied verdict. - `state: string` `pending`, `delivering`, `delivered`, `permanent_refusal`, or `retry_exhausted`. - `chat_id: optional string` - `has_more: boolean` - `older_successful_history_may_have_been_swept: boolean` True when successful delivery detail may have been swept from this account partition. - `successful_history_incomplete_through: string` Inclusive creation-time horizon through which successful delivery detail may be incomplete. - `next_cursor: optional string` --- # List webhook delivery history URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_deliveries/methods/list/ **get** `/v1/webhook_deliveries` Lists webhook deliveries and their retained applied attempts, newest delivery first. Without an exact `delivery_id`, `event_id`, or `trace_id` lookup, the time window defaults to the latest 24 hours and cannot exceed 30 days. Exact identifiers may reach older retained evidence. Successful delivery detail becomes eligible for sweeping after 30 days; failed evidence may remain longer. The response therefore publishes an inclusive successful-history incompleteness horizon and the instant when attempt-history collection began. Neither is a blanket retention promise. `spent_attempt_count` can exceed `observed_attempt_count` when an attempt ends without a retained verdict. Such a gap is reported, never filled with an invented attempt result. Payloads, bodies, headers, credentials, signatures, diagnostic errors and destination URL paths are never returned. ### Query Parameters - `cursor: optional string` - `delivery_id: optional string` - `endpoint_id: optional string` - `event_id: optional string` - `event_kind: optional string` - `from: optional string` - `limit: optional number` - `outcome: optional string` - `to: optional string` - `trace_id: optional string` ### Returns - `DeliveryPage object { attempt_history_coverage_started_at, data, has_more, 3 more }` A bounded page of webhook deliveries. Parent deliveries are never split across pages. - `attempt_history_coverage_started_at: string` Inclusive instant when attempt-history collection began. Earlier deliveries cannot claim complete attempt history. - `data: array of Delivery` - `id: string` - `attempt_history_complete: boolean` True only when every spent attempt has a retained applied verdict and collection covered this delivery from creation. - `attempts: array of DeliveryAttempt` - `attempt: number` The spent attempt number. Gaps mean a lease expired without an applied verdict. - `attempt_completed_at: string` - `attempt_started_at: string` - `destination: string` Scheme, host and effective port only. Path, query, fragment and credentials are never returned. - `http_status: number` Response status, or `null` when no response was received. - `phase: string` `pre_dial` when no request began, or `wire` after the HTTP round trip began. - `reason_code: string` A bounded machine reason. Treat unknown values as `unknown`. - `verdict: string` `delivered`, `retryable`, or `permanent`. Treat unknown values as an unavailable verdict. - `wire_completed_at: string` - `wire_duration_ms: number` - `wire_started_at: string` - `trace_id: optional string` W3C trace id when one was recorded. Correlation only; never authorization. - `created_at: string` - `endpoint_id: string` - `event_id: string` - `event_kind: string` - `observed_attempt_count: number` Applied attempt verdicts retained below. - `seq_from: number` - `seq_to: number` - `spent_attempt_count: number` Attempts spent by the delivery machine, including leases that expired without an applied verdict. - `state: string` `pending`, `delivering`, `delivered`, `permanent_refusal`, or `retry_exhausted`. - `chat_id: optional string` - `has_more: boolean` - `older_successful_history_may_have_been_swept: boolean` True when successful delivery detail may have been swept from this account partition. - `successful_history_incomplete_through: string` Inclusive creation-time horizon through which successful delivery detail may be incomplete. - `next_cursor: optional string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_deliveries \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "attempt_history_coverage_started_at": "2019-12-27T18:11:19.117Z", "data": [ { "id": "id", "attempt_history_complete": true, "attempts": [ { "attempt": 0, "attempt_completed_at": "2019-12-27T18:11:19.117Z", "attempt_started_at": "2019-12-27T18:11:19.117Z", "destination": "destination", "http_status": 0, "phase": "phase", "reason_code": "reason_code", "verdict": "verdict", "wire_completed_at": "2019-12-27T18:11:19.117Z", "wire_duration_ms": 0, "wire_started_at": "2019-12-27T18:11:19.117Z", "trace_id": "trace_id" } ], "created_at": "2019-12-27T18:11:19.117Z", "endpoint_id": "endpoint_id", "event_id": "event_id", "event_kind": "event_kind", "observed_attempt_count": 0, "seq_from": 0, "seq_to": 0, "spent_attempt_count": 0, "state": "state", "chat_id": "chat_id" } ], "has_more": true, "older_successful_history_may_have_been_swept": true, "successful_history_incomplete_through": "2019-12-27T18:11:19.117Z", "next_cursor": "next_cursor" } ``` --- # Webhook Endpoints URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/ ## Register a webhook endpoint **post** `/v1/webhook_endpoints` Registers a destination for this brand's events and mints its signing secret. The `201` body carries the secret in plain text, and that is the only time it is ever shown: store it where your receiver can read it before you finish the call. Verify with any Standard Webhooks library. The URL is checked here so an unusable one is refused synchronously rather than becoming silent delivery failures — but the check is a courtesy, not the boundary: every attempt re-checks the address it is actually about to connect to, so a name that resolves somewhere else later is still refused then. Cleartext endpoints are never deliverable: `http://` is refused here and the scheme is checked again before every attempt. Loopback and private addresses are refused too, so a receiver running only on localhost cannot use webhooks. Use `GET /v1/streams/events` while developing locally, until you have a public TLS endpoint. ### Body Parameters - `url: string` Where to POST events. Must be absolute and `https`, and must not carry credentials in the url. **What is checked WHEN.** Registration refuses the faults that are visible in the url itself: the scheme, credentials, and a literal IP address that is private, loopback or link-local. It does NOT resolve hostnames, so a NAME pointing at a private address is accepted here and refused later, at every delivery attempt, when the address it actually resolves to is checked immediately before the connection is made. That second check is the one that is load-bearing, and it cannot be done at registration: a name can resolve differently a second after you register it. So a synchronous `422` means the url can never work. A `201` means it looked fine — watch the endpoint's health for what happened next. - `description: optional string` Your own label for this endpoint. Text a human reads: control characters are refused. - `event_kinds: optional array of string` The event kinds to deliver here. Omit it — or send an empty array — for every kind, including kinds added later, which is what most integrations want. Kinds are the same values the stream's `event:` carries. ### Returns - `EndpointCreated object { id, created_at, disabled_at, 10 more }` A newly registered endpoint, together with its signing secret. The secret appears in this response and never again. - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `secret: string` The signing secret, in the Standard Webhooks presentation form (`whsec_` followed by base64). **Shown exactly once, here.** It is not recoverable from any later read — if you lose it, add a second secret and retire this one. - `secret_id: string` The id of the secret above — the handle you retire it by. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{ "url": "url" }' ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "disabled_at": "2019-12-27T18:11:19.117Z", "event_kinds": [ "string" ], "health": { "consecutive_failures": 0, "last_failure_at": "2019-12-27T18:11:19.117Z", "last_failure_reason": "last_failure_reason", "last_failure_status": 0, "last_success_at": "2019-12-27T18:11:19.117Z" }, "secret": "secret", "secret_id": "secret_id", "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "url": "url", "backlog_dropped": 0, "backlog_resume_cursor": "backlog_resume_cursor", "description": "description" } ``` ## Delete a webhook endpoint **delete** `/v1/webhook_endpoints/{endpoint}` Removes an endpoint. Its queued events are dropped, its signing secrets are retired, and its URL becomes free to register again. The response is the endpoint as it was left. ### Path Parameters - `endpoint: string` ### Returns - `Endpoint object { id, created_at, disabled_at, 8 more }` A destination this brand's events are delivered to. - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT \ -X DELETE \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "disabled_at": "2019-12-27T18:11:19.117Z", "event_kinds": [ "string" ], "health": { "consecutive_failures": 0, "last_failure_at": "2019-12-27T18:11:19.117Z", "last_failure_reason": "last_failure_reason", "last_failure_status": 0, "last_success_at": "2019-12-27T18:11:19.117Z" }, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "url": "url", "backlog_dropped": 0, "backlog_resume_cursor": "backlog_resume_cursor", "description": "description" } ``` ## List webhook endpoints **get** `/v1/webhook_endpoints` Lists this brand's webhook endpoints, oldest first, each with its delivery health. ### Returns - `EndpointList object { data }` The endpoints registered for this key's brand. - `data: array of Endpoint` - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "disabled_at": "2019-12-27T18:11:19.117Z", "event_kinds": [ "string" ], "health": { "consecutive_failures": 0, "last_failure_at": "2019-12-27T18:11:19.117Z", "last_failure_reason": "last_failure_reason", "last_failure_status": 0, "last_success_at": "2019-12-27T18:11:19.117Z" }, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "url": "url", "backlog_dropped": 0, "backlog_resume_cursor": "backlog_resume_cursor", "description": "description" } ] } ``` ## Get a webhook endpoint **get** `/v1/webhook_endpoints/{endpoint}` Reads one endpoint, including its delivery health. ### Path Parameters - `endpoint: string` ### Returns - `Endpoint object { id, created_at, disabled_at, 8 more }` A destination this brand's events are delivered to. - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "disabled_at": "2019-12-27T18:11:19.117Z", "event_kinds": [ "string" ], "health": { "consecutive_failures": 0, "last_failure_at": "2019-12-27T18:11:19.117Z", "last_failure_reason": "last_failure_reason", "last_failure_status": 0, "last_success_at": "2019-12-27T18:11:19.117Z" }, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "url": "url", "backlog_dropped": 0, "backlog_resume_cursor": "backlog_resume_cursor", "description": "description" } ``` ## Update a webhook endpoint **patch** `/v1/webhook_endpoints/{endpoint}` Updates an endpoint: its destination, its label, which kinds it receives, or whether it is enabled. Read `status` before disabling anything — disabling drops what is queued. ### Path Parameters - `endpoint: string` ### Body Parameters - `description: optional string` A new label. Text a human reads: control characters are refused. - `event_kinds: optional array of string` Replaces the subscription list outright. An empty array means every kind. - `status: optional string` `enabled` or `disabled`. **Disabling is a cutoff, not a pause.** Every event still queued for this endpoint is dropped when you disable it, and while it is disabled no new event is queued for it. Enabling it again resumes with what happens next — there is no backlog to replay, by design: a destination that has just come back up is the worst possible moment to hand it hours of stale events in one burst. **Nothing is lost, and the response tells you how to get it.** `backlog_dropped` says how many events were dropped and `backlog_resume_cursor` says where they start; pass that cursor to `GET /v1/streams/events` to read them. Keep it — a webhook does not carry a stream position, so this response is the only place a push-only integration is handed one. One delivery may still arrive after this call returns: an attempt already on the wire cannot be recalled, and it is counted in `backlog_dropped` even if it lands. The count is an upper bound on what was lost. - `url: optional string` A new destination. Validated exactly as at registration. Events already queued go to the new url — a delivery in flight when you change it re-reads the endpoint before it dials, so it follows the change too. ### Returns - `Endpoint object { id, created_at, disabled_at, 8 more }` A destination this brand's events are delivered to. - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT \ -X PATCH \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{}' ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "disabled_at": "2019-12-27T18:11:19.117Z", "event_kinds": [ "string" ], "health": { "consecutive_failures": 0, "last_failure_at": "2019-12-27T18:11:19.117Z", "last_failure_reason": "last_failure_reason", "last_failure_status": 0, "last_success_at": "2019-12-27T18:11:19.117Z" }, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "url": "url", "backlog_dropped": 0, "backlog_resume_cursor": "backlog_resume_cursor", "description": "description" } ``` ## Domain Types ### Endpoint - `Endpoint object { id, created_at, disabled_at, 8 more }` A destination this brand's events are delivered to. - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Endpoint Created - `EndpointCreated object { id, created_at, disabled_at, 10 more }` A newly registered endpoint, together with its signing secret. The secret appears in this response and never again. - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `secret: string` The signing secret, in the Standard Webhooks presentation form (`whsec_` followed by base64). **Shown exactly once, here.** It is not recoverable from any later read — if you lose it, add a second secret and retire this one. - `secret_id: string` The id of the secret above — the handle you retire it by. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Endpoint Health - `EndpointHealth object { consecutive_failures, last_failure_at, last_failure_reason, 2 more }` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. ### Endpoint List - `EndpointList object { data }` The endpoints registered for this key's brand. - `data: array of Endpoint` - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. # Replays ## Replay retained webhook events **post** `/v1/webhook_endpoints/{endpoint}/replays` Accept a bounded replay of events still present in account history. Account-event history has no fixed minimum retention period in beta. Retrying the same acceptance key recovers its response for 24 hours. ### Path Parameters - `endpoint: string` ### Header Parameters - `"Idempotency-Key": string` ### Body Parameters - `after_cursor: optional string` Exclusive event-log cursor. Omit to begin inclusively at retained history. - `through_cursor: optional string` Inclusive event-log cursor. Omit to snapshot the committed tail. ### Returns - `ReplayAcceptance object { id, after_cursor, created_at, 3 more }` - `id: string` - `after_cursor: string` - `created_at: string` - `selected: number` - `state: "pending"` - `"pending"` - `through_cursor: string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/replays \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{}' ``` #### Response ```json { "id": "id", "after_cursor": "after_cursor", "created_at": "2019-12-27T18:11:19.117Z", "selected": 0, "state": "pending", "through_cursor": "through_cursor" } ``` ## Get webhook replay status **get** `/v1/webhook_endpoints/{endpoint}/replays/{replay}` Read durable replay progress and its immutable terminal verdict. During ordinary operation, a successfully completed replay remains readable for at least 30 days after `terminal_at`, then returns the same tenant-scoped 404 as an unknown replay. Pending and running replays are never age-swept; failed replay evidence has no scheduled expiry. ### Path Parameters - `endpoint: string` - `replay: string` ### Returns - `Replay object { id, after_cursor, created_at, 12 more }` - `id: string` - `after_cursor: string` - `created_at: string` - `delivered: number` - `delivering: number` - `exhausted: number` - `failure_reason: string` - `materialized: number` - `pending: number` - `selected: number` - `started_at: string` - `state: "pending" or "running" or "completed" or "failed"` - `"pending"` - `"running"` - `"completed"` - `"failed"` - `terminal_at: string` - `through_cursor: string` - `unmaterialized: number` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/replays/$REPLAY \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "after_cursor": "after_cursor", "created_at": "2019-12-27T18:11:19.117Z", "delivered": 0, "delivering": 0, "exhausted": 0, "failure_reason": "failure_reason", "materialized": 0, "pending": 0, "selected": 0, "started_at": "2019-12-27T18:11:19.117Z", "state": "pending", "terminal_at": "2019-12-27T18:11:19.117Z", "through_cursor": "through_cursor", "unmaterialized": 0 } ``` ## Domain Types ### Replay - `Replay object { id, after_cursor, created_at, 12 more }` - `id: string` - `after_cursor: string` - `created_at: string` - `delivered: number` - `delivering: number` - `exhausted: number` - `failure_reason: string` - `materialized: number` - `pending: number` - `selected: number` - `started_at: string` - `state: "pending" or "running" or "completed" or "failed"` - `"pending"` - `"running"` - `"completed"` - `"failed"` - `terminal_at: string` - `through_cursor: string` - `unmaterialized: number` ### Replay Acceptance - `ReplayAcceptance object { id, after_cursor, created_at, 3 more }` - `id: string` - `after_cursor: string` - `created_at: string` - `selected: number` - `state: "pending"` - `"pending"` - `through_cursor: string` # Secrets ## Add a signing secret **post** `/v1/webhook_endpoints/{endpoint}/secrets` Adds a second signing secret to an endpoint — the first half of a rotation. Every ACTIVE secret signs every event, and all of the signatures ride the one `webhook-signature` header (space delimited). A verifier that accepts any one of them — which is what every Standard Webhooks library does — keeps working throughout. So the rotation is: add a secret here, deploy it to your receiver, confirm traffic is verifying against it, then retire the old one. No delivery is dropped at any point. The signing set is read fresh for each delivery, immediately before it is sent. A delivery already on the wire when this call returns may therefore carry the previous set — the overlap is what makes that harmless, and it is why you deploy the new secret before retiring the old one rather than relying on an instant. An endpoint may hold up to five ACTIVE secrets at once; past that the answer is HTTP 409 `too_many_active_secrets`. Every one of them signs every event, so the header grows with the count — a rotation needs two, and the rest of the room is for one you started and did not finish. The new secret is shown exactly once, in this response. ### Path Parameters - `endpoint: string` ### Returns - `SigningSecretCreated object { id, created_at, retired_at, 2 more }` A newly added signing secret, with its value — shown exactly once. - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `secret: string` The signing secret in presentation form (`whsec_` + base64). **Shown exactly once, here.** - `status: string` `active` or `retired`. Values grow additively. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/secrets \ -X POST \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "retired_at": "2019-12-27T18:11:19.117Z", "secret": "secret", "status": "status" } ``` ## Retire a signing secret **delete** `/v1/webhook_endpoints/{endpoint}/secrets/{secret}` Retires a signing secret — the second half of a rotation. It stops signing immediately; signatures already sent are unaffected. An endpoint's LAST active secret cannot be retired: an endpoint that could not sign would have to stop delivering, and silently unsigned webhooks are indistinguishable from forgeries. Add the replacement first. ### Path Parameters - `endpoint: string` - `secret: string` ### Returns - `SigningSecret object { id, created_at, retired_at, status }` One signing secret. Every ACTIVE secret signs every event, and all of the signatures ride the one `webhook-signature` header — which is what makes a rotation lossless. - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `status: string` `active` or `retired`. Values grow additively. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/secrets/$SECRET \ -X DELETE \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "retired_at": "2019-12-27T18:11:19.117Z", "status": "status" } ``` ## List signing secrets **get** `/v1/webhook_endpoints/{endpoint}/secrets` Lists an endpoint's signing secrets — ids and status only, never the key material. Oldest first, which is the order their signatures appear in the header. ### Path Parameters - `endpoint: string` ### Returns - `SigningSecretList object { data }` An endpoint's signing secrets, oldest first — the order they sign in. - `data: array of SigningSecret` - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `status: string` `active` or `retired`. Values grow additively. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/secrets \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "retired_at": "2019-12-27T18:11:19.117Z", "status": "status" } ] } ``` ## Domain Types ### Signing Secret - `SigningSecret object { id, created_at, retired_at, status }` One signing secret. Every ACTIVE secret signs every event, and all of the signatures ride the one `webhook-signature` header — which is what makes a rotation lossless. - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `status: string` `active` or `retired`. Values grow additively. ### Signing Secret Created - `SigningSecretCreated object { id, created_at, retired_at, 2 more }` A newly added signing secret, with its value — shown exactly once. - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `secret: string` The signing secret in presentation form (`whsec_` + base64). **Shown exactly once, here.** - `status: string` `active` or `retired`. Values grow additively. ### Signing Secret List - `SigningSecretList object { data }` An endpoint's signing secrets, oldest first — the order they sign in. - `data: array of SigningSecret` - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `status: string` `active` or `retired`. Values grow additively. --- # Register a webhook endpoint URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/methods/create/ **post** `/v1/webhook_endpoints` Registers a destination for this brand's events and mints its signing secret. The `201` body carries the secret in plain text, and that is the only time it is ever shown: store it where your receiver can read it before you finish the call. Verify with any Standard Webhooks library. The URL is checked here so an unusable one is refused synchronously rather than becoming silent delivery failures — but the check is a courtesy, not the boundary: every attempt re-checks the address it is actually about to connect to, so a name that resolves somewhere else later is still refused then. Cleartext endpoints are never deliverable: `http://` is refused here and the scheme is checked again before every attempt. Loopback and private addresses are refused too, so a receiver running only on localhost cannot use webhooks. Use `GET /v1/streams/events` while developing locally, until you have a public TLS endpoint. ### Body Parameters - `url: string` Where to POST events. Must be absolute and `https`, and must not carry credentials in the url. **What is checked WHEN.** Registration refuses the faults that are visible in the url itself: the scheme, credentials, and a literal IP address that is private, loopback or link-local. It does NOT resolve hostnames, so a NAME pointing at a private address is accepted here and refused later, at every delivery attempt, when the address it actually resolves to is checked immediately before the connection is made. That second check is the one that is load-bearing, and it cannot be done at registration: a name can resolve differently a second after you register it. So a synchronous `422` means the url can never work. A `201` means it looked fine — watch the endpoint's health for what happened next. - `description: optional string` Your own label for this endpoint. Text a human reads: control characters are refused. - `event_kinds: optional array of string` The event kinds to deliver here. Omit it — or send an empty array — for every kind, including kinds added later, which is what most integrations want. Kinds are the same values the stream's `event:` carries. ### Returns - `EndpointCreated object { id, created_at, disabled_at, 10 more }` A newly registered endpoint, together with its signing secret. The secret appears in this response and never again. - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `secret: string` The signing secret, in the Standard Webhooks presentation form (`whsec_` followed by base64). **Shown exactly once, here.** It is not recoverable from any later read — if you lose it, add a second secret and retire this one. - `secret_id: string` The id of the secret above — the handle you retire it by. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{ "url": "url" }' ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "disabled_at": "2019-12-27T18:11:19.117Z", "event_kinds": [ "string" ], "health": { "consecutive_failures": 0, "last_failure_at": "2019-12-27T18:11:19.117Z", "last_failure_reason": "last_failure_reason", "last_failure_status": 0, "last_success_at": "2019-12-27T18:11:19.117Z" }, "secret": "secret", "secret_id": "secret_id", "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "url": "url", "backlog_dropped": 0, "backlog_resume_cursor": "backlog_resume_cursor", "description": "description" } ``` --- # Delete a webhook endpoint URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/methods/delete/ **delete** `/v1/webhook_endpoints/{endpoint}` Removes an endpoint. Its queued events are dropped, its signing secrets are retired, and its URL becomes free to register again. The response is the endpoint as it was left. ### Path Parameters - `endpoint: string` ### Returns - `Endpoint object { id, created_at, disabled_at, 8 more }` A destination this brand's events are delivered to. - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT \ -X DELETE \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "disabled_at": "2019-12-27T18:11:19.117Z", "event_kinds": [ "string" ], "health": { "consecutive_failures": 0, "last_failure_at": "2019-12-27T18:11:19.117Z", "last_failure_reason": "last_failure_reason", "last_failure_status": 0, "last_success_at": "2019-12-27T18:11:19.117Z" }, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "url": "url", "backlog_dropped": 0, "backlog_resume_cursor": "backlog_resume_cursor", "description": "description" } ``` --- # List webhook endpoints URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/methods/list/ **get** `/v1/webhook_endpoints` Lists this brand's webhook endpoints, oldest first, each with its delivery health. ### Returns - `EndpointList object { data }` The endpoints registered for this key's brand. - `data: array of Endpoint` - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "disabled_at": "2019-12-27T18:11:19.117Z", "event_kinds": [ "string" ], "health": { "consecutive_failures": 0, "last_failure_at": "2019-12-27T18:11:19.117Z", "last_failure_reason": "last_failure_reason", "last_failure_status": 0, "last_success_at": "2019-12-27T18:11:19.117Z" }, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "url": "url", "backlog_dropped": 0, "backlog_resume_cursor": "backlog_resume_cursor", "description": "description" } ] } ``` --- # Get a webhook endpoint URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/methods/retrieve/ **get** `/v1/webhook_endpoints/{endpoint}` Reads one endpoint, including its delivery health. ### Path Parameters - `endpoint: string` ### Returns - `Endpoint object { id, created_at, disabled_at, 8 more }` A destination this brand's events are delivered to. - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "disabled_at": "2019-12-27T18:11:19.117Z", "event_kinds": [ "string" ], "health": { "consecutive_failures": 0, "last_failure_at": "2019-12-27T18:11:19.117Z", "last_failure_reason": "last_failure_reason", "last_failure_status": 0, "last_success_at": "2019-12-27T18:11:19.117Z" }, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "url": "url", "backlog_dropped": 0, "backlog_resume_cursor": "backlog_resume_cursor", "description": "description" } ``` --- # Update a webhook endpoint URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/methods/update/ **patch** `/v1/webhook_endpoints/{endpoint}` Updates an endpoint: its destination, its label, which kinds it receives, or whether it is enabled. Read `status` before disabling anything — disabling drops what is queued. ### Path Parameters - `endpoint: string` ### Body Parameters - `description: optional string` A new label. Text a human reads: control characters are refused. - `event_kinds: optional array of string` Replaces the subscription list outright. An empty array means every kind. - `status: optional string` `enabled` or `disabled`. **Disabling is a cutoff, not a pause.** Every event still queued for this endpoint is dropped when you disable it, and while it is disabled no new event is queued for it. Enabling it again resumes with what happens next — there is no backlog to replay, by design: a destination that has just come back up is the worst possible moment to hand it hours of stale events in one burst. **Nothing is lost, and the response tells you how to get it.** `backlog_dropped` says how many events were dropped and `backlog_resume_cursor` says where they start; pass that cursor to `GET /v1/streams/events` to read them. Keep it — a webhook does not carry a stream position, so this response is the only place a push-only integration is handed one. One delivery may still arrive after this call returns: an attempt already on the wire cannot be recalled, and it is counted in `backlog_dropped` even if it lands. The count is an upper bound on what was lost. - `url: optional string` A new destination. Validated exactly as at registration. Events already queued go to the new url — a delivery in flight when you change it re-reads the endpoint before it dials, so it follows the change too. ### Returns - `Endpoint object { id, created_at, disabled_at, 8 more }` A destination this brand's events are delivered to. - `id: string` The endpoint's id. - `created_at: string` - `disabled_at: string` When this endpoint was last disabled. `null` while it is enabled. - `event_kinds: array of string` The event kinds delivered here, or `null` for every kind (including kinds added later). - `health: EndpointHealth` How a destination has been behaving, taken from the deliveries actually attempted against it. Worth reading before assuming an integration is healthy: a destination that has been refusing events shows up here well before anyone notices they are missing. - `consecutive_failures: number` Failed attempts since the last success. Reset to 0 by any success. - `last_failure_at: string` When a delivery to this endpoint last failed. `null` if none ever has. - `last_failure_reason: string` A short reason for the last failure, written for a human reading it. The wording is not stable; do not branch on it. - `last_failure_status: number` The HTTP status of that last failure. `null` when the attempt never reached a response at all — a name that did not resolve, a refused connection, a timeout, or an address we will not dial. - `last_success_at: string` When a delivery to this endpoint last succeeded. `null` if none ever has. - `status: string` `enabled` or `disabled`. A disabled endpoint receives nothing, and events are not queued for it — see the update operation. Values grow additively. - `updated_at: string` - `url: string` Where events are POSTed. `https` only. - `backlog_dropped: optional number` How many undelivered events were dropped by THIS request, present only on the response to a request that disabled or deleted the endpoint. Disabling is a cutoff: everything still queued for this endpoint is dropped then and there rather than waiting to be replayed later. - `backlog_resume_cursor: optional string` Where to pick the dropped events back up: pass it as the `cursor` query parameter to `GET /v1/streams/events` and the first event you receive is the first one dropped. Present alongside `backlog_dropped` and only then. **Keep it if you rely on webhooks alone.** A webhook carries the event's identity but not its position in the stream, so this response is the only place a push-only integration is handed the position of the gap it just created. Reconnecting without a cursor starts at the present moment and skips the gap entirely. It stays valid while the events behind it are retained; past that the stream answers `410 cursor_expired` rather than pretending. - `description: optional string` Your own label for this endpoint. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT \ -X PATCH \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{}' ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "disabled_at": "2019-12-27T18:11:19.117Z", "event_kinds": [ "string" ], "health": { "consecutive_failures": 0, "last_failure_at": "2019-12-27T18:11:19.117Z", "last_failure_reason": "last_failure_reason", "last_failure_status": 0, "last_success_at": "2019-12-27T18:11:19.117Z" }, "status": "status", "updated_at": "2019-12-27T18:11:19.117Z", "url": "url", "backlog_dropped": 0, "backlog_resume_cursor": "backlog_resume_cursor", "description": "description" } ``` --- # Replays URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/subresources/replays/ ## Replay retained webhook events **post** `/v1/webhook_endpoints/{endpoint}/replays` Accept a bounded replay of events still present in account history. Account-event history has no fixed minimum retention period in beta. Retrying the same acceptance key recovers its response for 24 hours. ### Path Parameters - `endpoint: string` ### Header Parameters - `"Idempotency-Key": string` ### Body Parameters - `after_cursor: optional string` Exclusive event-log cursor. Omit to begin inclusively at retained history. - `through_cursor: optional string` Inclusive event-log cursor. Omit to snapshot the committed tail. ### Returns - `ReplayAcceptance object { id, after_cursor, created_at, 3 more }` - `id: string` - `after_cursor: string` - `created_at: string` - `selected: number` - `state: "pending"` - `"pending"` - `through_cursor: string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/replays \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{}' ``` #### Response ```json { "id": "id", "after_cursor": "after_cursor", "created_at": "2019-12-27T18:11:19.117Z", "selected": 0, "state": "pending", "through_cursor": "through_cursor" } ``` ## Get webhook replay status **get** `/v1/webhook_endpoints/{endpoint}/replays/{replay}` Read durable replay progress and its immutable terminal verdict. During ordinary operation, a successfully completed replay remains readable for at least 30 days after `terminal_at`, then returns the same tenant-scoped 404 as an unknown replay. Pending and running replays are never age-swept; failed replay evidence has no scheduled expiry. ### Path Parameters - `endpoint: string` - `replay: string` ### Returns - `Replay object { id, after_cursor, created_at, 12 more }` - `id: string` - `after_cursor: string` - `created_at: string` - `delivered: number` - `delivering: number` - `exhausted: number` - `failure_reason: string` - `materialized: number` - `pending: number` - `selected: number` - `started_at: string` - `state: "pending" or "running" or "completed" or "failed"` - `"pending"` - `"running"` - `"completed"` - `"failed"` - `terminal_at: string` - `through_cursor: string` - `unmaterialized: number` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/replays/$REPLAY \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "after_cursor": "after_cursor", "created_at": "2019-12-27T18:11:19.117Z", "delivered": 0, "delivering": 0, "exhausted": 0, "failure_reason": "failure_reason", "materialized": 0, "pending": 0, "selected": 0, "started_at": "2019-12-27T18:11:19.117Z", "state": "pending", "terminal_at": "2019-12-27T18:11:19.117Z", "through_cursor": "through_cursor", "unmaterialized": 0 } ``` ## Domain Types ### Replay - `Replay object { id, after_cursor, created_at, 12 more }` - `id: string` - `after_cursor: string` - `created_at: string` - `delivered: number` - `delivering: number` - `exhausted: number` - `failure_reason: string` - `materialized: number` - `pending: number` - `selected: number` - `started_at: string` - `state: "pending" or "running" or "completed" or "failed"` - `"pending"` - `"running"` - `"completed"` - `"failed"` - `terminal_at: string` - `through_cursor: string` - `unmaterialized: number` ### Replay Acceptance - `ReplayAcceptance object { id, after_cursor, created_at, 3 more }` - `id: string` - `after_cursor: string` - `created_at: string` - `selected: number` - `state: "pending"` - `"pending"` - `through_cursor: string` --- # Replay retained webhook events URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/subresources/replays/methods/create/ **post** `/v1/webhook_endpoints/{endpoint}/replays` Accept a bounded replay of events still present in account history. Account-event history has no fixed minimum retention period in beta. Retrying the same acceptance key recovers its response for 24 hours. ### Path Parameters - `endpoint: string` ### Header Parameters - `"Idempotency-Key": string` ### Body Parameters - `after_cursor: optional string` Exclusive event-log cursor. Omit to begin inclusively at retained history. - `through_cursor: optional string` Inclusive event-log cursor. Omit to snapshot the committed tail. ### Returns - `ReplayAcceptance object { id, after_cursor, created_at, 3 more }` - `id: string` - `after_cursor: string` - `created_at: string` - `selected: number` - `state: "pending"` - `"pending"` - `through_cursor: string` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/replays \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" \ -d '{}' ``` #### Response ```json { "id": "id", "after_cursor": "after_cursor", "created_at": "2019-12-27T18:11:19.117Z", "selected": 0, "state": "pending", "through_cursor": "through_cursor" } ``` --- # Get webhook replay status URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/subresources/replays/methods/retrieve/ **get** `/v1/webhook_endpoints/{endpoint}/replays/{replay}` Read durable replay progress and its immutable terminal verdict. During ordinary operation, a successfully completed replay remains readable for at least 30 days after `terminal_at`, then returns the same tenant-scoped 404 as an unknown replay. Pending and running replays are never age-swept; failed replay evidence has no scheduled expiry. ### Path Parameters - `endpoint: string` - `replay: string` ### Returns - `Replay object { id, after_cursor, created_at, 12 more }` - `id: string` - `after_cursor: string` - `created_at: string` - `delivered: number` - `delivering: number` - `exhausted: number` - `failure_reason: string` - `materialized: number` - `pending: number` - `selected: number` - `started_at: string` - `state: "pending" or "running" or "completed" or "failed"` - `"pending"` - `"running"` - `"completed"` - `"failed"` - `terminal_at: string` - `through_cursor: string` - `unmaterialized: number` ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/replays/$REPLAY \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "after_cursor": "after_cursor", "created_at": "2019-12-27T18:11:19.117Z", "delivered": 0, "delivering": 0, "exhausted": 0, "failure_reason": "failure_reason", "materialized": 0, "pending": 0, "selected": 0, "started_at": "2019-12-27T18:11:19.117Z", "state": "pending", "terminal_at": "2019-12-27T18:11:19.117Z", "through_cursor": "through_cursor", "unmaterialized": 0 } ``` --- # Secrets URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/subresources/secrets/ ## Add a signing secret **post** `/v1/webhook_endpoints/{endpoint}/secrets` Adds a second signing secret to an endpoint — the first half of a rotation. Every ACTIVE secret signs every event, and all of the signatures ride the one `webhook-signature` header (space delimited). A verifier that accepts any one of them — which is what every Standard Webhooks library does — keeps working throughout. So the rotation is: add a secret here, deploy it to your receiver, confirm traffic is verifying against it, then retire the old one. No delivery is dropped at any point. The signing set is read fresh for each delivery, immediately before it is sent. A delivery already on the wire when this call returns may therefore carry the previous set — the overlap is what makes that harmless, and it is why you deploy the new secret before retiring the old one rather than relying on an instant. An endpoint may hold up to five ACTIVE secrets at once; past that the answer is HTTP 409 `too_many_active_secrets`. Every one of them signs every event, so the header grows with the count — a rotation needs two, and the rest of the room is for one you started and did not finish. The new secret is shown exactly once, in this response. ### Path Parameters - `endpoint: string` ### Returns - `SigningSecretCreated object { id, created_at, retired_at, 2 more }` A newly added signing secret, with its value — shown exactly once. - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `secret: string` The signing secret in presentation form (`whsec_` + base64). **Shown exactly once, here.** - `status: string` `active` or `retired`. Values grow additively. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/secrets \ -X POST \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "retired_at": "2019-12-27T18:11:19.117Z", "secret": "secret", "status": "status" } ``` ## Retire a signing secret **delete** `/v1/webhook_endpoints/{endpoint}/secrets/{secret}` Retires a signing secret — the second half of a rotation. It stops signing immediately; signatures already sent are unaffected. An endpoint's LAST active secret cannot be retired: an endpoint that could not sign would have to stop delivering, and silently unsigned webhooks are indistinguishable from forgeries. Add the replacement first. ### Path Parameters - `endpoint: string` - `secret: string` ### Returns - `SigningSecret object { id, created_at, retired_at, status }` One signing secret. Every ACTIVE secret signs every event, and all of the signatures ride the one `webhook-signature` header — which is what makes a rotation lossless. - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `status: string` `active` or `retired`. Values grow additively. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/secrets/$SECRET \ -X DELETE \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "retired_at": "2019-12-27T18:11:19.117Z", "status": "status" } ``` ## List signing secrets **get** `/v1/webhook_endpoints/{endpoint}/secrets` Lists an endpoint's signing secrets — ids and status only, never the key material. Oldest first, which is the order their signatures appear in the header. ### Path Parameters - `endpoint: string` ### Returns - `SigningSecretList object { data }` An endpoint's signing secrets, oldest first — the order they sign in. - `data: array of SigningSecret` - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `status: string` `active` or `retired`. Values grow additively. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/secrets \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "retired_at": "2019-12-27T18:11:19.117Z", "status": "status" } ] } ``` ## Domain Types ### Signing Secret - `SigningSecret object { id, created_at, retired_at, status }` One signing secret. Every ACTIVE secret signs every event, and all of the signatures ride the one `webhook-signature` header — which is what makes a rotation lossless. - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `status: string` `active` or `retired`. Values grow additively. ### Signing Secret Created - `SigningSecretCreated object { id, created_at, retired_at, 2 more }` A newly added signing secret, with its value — shown exactly once. - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `secret: string` The signing secret in presentation form (`whsec_` + base64). **Shown exactly once, here.** - `status: string` `active` or `retired`. Values grow additively. ### Signing Secret List - `SigningSecretList object { data }` An endpoint's signing secrets, oldest first — the order they sign in. - `data: array of SigningSecret` - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `status: string` `active` or `retired`. Values grow additively. --- # Add a signing secret URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/subresources/secrets/methods/create/ **post** `/v1/webhook_endpoints/{endpoint}/secrets` Adds a second signing secret to an endpoint — the first half of a rotation. Every ACTIVE secret signs every event, and all of the signatures ride the one `webhook-signature` header (space delimited). A verifier that accepts any one of them — which is what every Standard Webhooks library does — keeps working throughout. So the rotation is: add a secret here, deploy it to your receiver, confirm traffic is verifying against it, then retire the old one. No delivery is dropped at any point. The signing set is read fresh for each delivery, immediately before it is sent. A delivery already on the wire when this call returns may therefore carry the previous set — the overlap is what makes that harmless, and it is why you deploy the new secret before retiring the old one rather than relying on an instant. An endpoint may hold up to five ACTIVE secrets at once; past that the answer is HTTP 409 `too_many_active_secrets`. Every one of them signs every event, so the header grows with the count — a rotation needs two, and the rest of the room is for one you started and did not finish. The new secret is shown exactly once, in this response. ### Path Parameters - `endpoint: string` ### Returns - `SigningSecretCreated object { id, created_at, retired_at, 2 more }` A newly added signing secret, with its value — shown exactly once. - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `secret: string` The signing secret in presentation form (`whsec_` + base64). **Shown exactly once, here.** - `status: string` `active` or `retired`. Values grow additively. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/secrets \ -X POST \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "retired_at": "2019-12-27T18:11:19.117Z", "secret": "secret", "status": "status" } ``` --- # Retire a signing secret URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/subresources/secrets/methods/delete/ **delete** `/v1/webhook_endpoints/{endpoint}/secrets/{secret}` Retires a signing secret — the second half of a rotation. It stops signing immediately; signatures already sent are unaffected. An endpoint's LAST active secret cannot be retired: an endpoint that could not sign would have to stop delivering, and silently unsigned webhooks are indistinguishable from forgeries. Add the replacement first. ### Path Parameters - `endpoint: string` - `secret: string` ### Returns - `SigningSecret object { id, created_at, retired_at, status }` One signing secret. Every ACTIVE secret signs every event, and all of the signatures ride the one `webhook-signature` header — which is what makes a rotation lossless. - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `status: string` `active` or `retired`. Values grow additively. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/secrets/$SECRET \ -X DELETE \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "retired_at": "2019-12-27T18:11:19.117Z", "status": "status" } ``` --- # List signing secrets URL: https://docs.linqapp.com/channel/whatsapp/api/resources/webhook_endpoints/subresources/secrets/methods/list/ **get** `/v1/webhook_endpoints/{endpoint}/secrets` Lists an endpoint's signing secrets — ids and status only, never the key material. Oldest first, which is the order their signatures appear in the header. ### Path Parameters - `endpoint: string` ### Returns - `SigningSecretList object { data }` An endpoint's signing secrets, oldest first — the order they sign in. - `data: array of SigningSecret` - `id: string` The secret's id. Safe to log: it is a handle, not key material. - `created_at: string` - `retired_at: string` When this secret stopped signing. `null` while it is active. - `status: string` `active` or `retired`. Values grow additively. ### Example ```http curl https://whatsapp.messages.api.linqapp.com/v1/webhook_endpoints/$ENDPOINT/secrets \ -H "Authorization: Bearer $LINQ_WHATSAPP_API_KEY" ``` #### Response ```json { "data": [ { "id": "id", "created_at": "2019-12-27T18:11:19.117Z", "retired_at": "2019-12-27T18:11:19.117Z", "status": "status" } ] } ```