# Apple Messages Partner API — Full Documentation > Apple Messages for Business — inbound-first, brand-scoped, consent-gated. This file is the complete documentation for Apple Messages Partner API, the Apple Messages 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/amb/llms.txt) is a shorter index pointing at individual pages. **Pages included:** 242 --- # Apple Messages URL: https://docs.linqapp.com/channel/amb/ A separate API from Linq V3: its own spec, SDK, credentials, and base URL (`https://messages.api.linqapp.com/v1`). Building with an AI agent? Feed these docs straight to your LLM or coding agent: [`llms-full.txt`](https://docs.linqapp.com/channel/amb/llms-full.txt) is the complete Apple Messages documentation in one file, and [`llms.txt`](https://docs.linqapp.com/channel/amb/llms.txt) is a concise index. Both cover this channel only — iMessage and WhatsApp publish their own. ## How it differs from iMessage | | iMessage (V3) | Apple Messages | | ---------------- | ------------------------- | ------------------------------------------------------ | | Identity | phone lines you own | one registered business | | Chat shape | n-party, up to 31 handles | strictly 1:1 | | Who speaks first | you may message first | **inbound-first** — invitations are the only exception | | Idempotency | optional field | **required header** | | Delivery signal | delivered + read receipts | neither exists; `sent` means accepted | | Consent | STOP keywords | a first-class consent ledger | [API reference ](/channel/amb/api/index.md)All 52 operations, with a request and response for each. [Sending ](/channel/amb/api/resources/chats/subresources/messages/methods/send/index.md)Parts, idempotency, and why one send can become several messages. [Consent ](/channel/amb/api/resources/consent/methods/attest/index.md)Attestation, categories, and automation pause. [Invitations ](/channel/amb/api/resources/invitations/index.md)The one way to start a conversation. --- # Error Codes URL: https://docs.linqapp.com/channel/amb/error/ Every error returns the same JSON envelope: a `success: false` flag, a nested `error` object, and a `request_id` naming the request that failed. The `error` object carries a numeric `code` and a `doc_url` pointing at that code’s page here. ``` { "success": false, "error": { "status": 404, "code": 2001, "message": "Chat not found", "doc_url": "https://docs.linqapp.com/channel/amb/error/codes/2xxx/2001/" }, "request_id": "req_01JQZ8X4K2N3P5R7T9V1W3Y5Z7" } ``` `error.code` is a number and is the field to branch on. `error.message` is written for a human reading a log and may be reworded at any time, so matching on it will break. Three fields appear only when they apply: `param` names the field a validation error is about, `retry_after` gives the seconds to wait, and `details` carries an array of field-level conditions. `trace_id` appears when tracing is enabled. **These codes belong to Apple Messages.** Every channel numbers its own errors, which is why the `doc_url` is namespaced to `/channel/amb/` — the same integer means something different on another channel, so branch on the code only in code paths you know are talking to this API. Tip Include `request_id` when contacting Linq support. It identifies the exact request in our logs. ## Error code ranges | Range | Category | Retry? | | ------------------------------------ | ---------------------- | ----------------------------------- | | [1xxx](#1xxx--clientrequest-errors) | Client/Request Errors | No — fix the request | | [2xxx](#2xxx--resource-errors) | Resource Errors | No — fix auth or resource reference | | [3xxx](#3xxx--server-errors) | Server Errors | Yes — retry with backoff | | [4xxx](#4xxx--delivery-errors) | Delivery Errors | Sometimes — depends on cause | | [5xxx](#5xxx--attachmentfile-errors) | Attachment/File Errors | Sometimes — depends on cause | When `retry_after` is present, it overrides the guidance above: wait that many seconds, then retry. ## 1xxx — Client/Request Errors The request never reached the thing it was about. Fix the request and send it again; retrying it unchanged fails identically. | Code | Message | HTTP | Troubleshooting | | --------------------------------------------------- | ------------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------ | | [1001](/channel/amb/error/codes/1xxx/1001/index.md) | Missing required field | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1002](/channel/amb/error/codes/1xxx/1002/index.md) | Backlog horizon exceeded | 429 | Wait the number of seconds in the `Retry-After` header (and `retry_after` in the body), then retry the same request unchanged. | | [1003](/channel/amb/error/codes/1xxx/1003/index.md) | Invalid request body | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1004](/channel/amb/error/codes/1xxx/1004/index.md) | Batch exceeds horizon | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1005](/channel/amb/error/codes/1xxx/1005/index.md) | Batch invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1006](/channel/amb/error/codes/1xxx/1006/index.md) | a JSON body is required; `{}` to change nothing | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1007](/channel/amb/error/codes/1xxx/1007/index.md) | Rate limit exceeded | 429 | Wait the number of seconds in the `Retry-After` header (and `retry_after` in the body), then retry the same request unchanged. | | [1008](/channel/amb/error/codes/1xxx/1008/index.md) | Business hours invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1009](/channel/amb/error/codes/1xxx/1009/index.md) | Business id invalid | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1010](/channel/amb/error/codes/1xxx/1010/index.md) | Category invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1011](/channel/amb/error/codes/1xxx/1011/index.md) | Channel unsupported | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1012](/channel/amb/error/codes/1xxx/1012/index.md) | Count out of range | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1013](/channel/amb/error/codes/1xxx/1013/index.md) | resume token is not one this API issued; reconnect without Last-Event-ID (and re-list | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1014](/channel/amb/error/codes/1xxx/1014/index.md) | Description too long | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1015](/channel/amb/error/codes/1xxx/1015/index.md) | Duplicate | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1016](/channel/amb/error/codes/1xxx/1016/index.md) | Duplicate id | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1017](/channel/amb/error/codes/1xxx/1017/index.md) | attachment body is empty | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1018](/channel/amb/error/codes/1xxx/1018/index.md) | Enabled events duplicate | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1019](/channel/amb/error/codes/1xxx/1019/index.md) | Enabled events required | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1020](/channel/amb/error/codes/1xxx/1020/index.md) | Enabled events unknown | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1021](/channel/amb/error/codes/1xxx/1021/index.md) | Escalation handling invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1022](/channel/amb/error/codes/1xxx/1022/index.md) | Expiry invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1023](/channel/amb/error/codes/1xxx/1023/index.md) | Field mismatch | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1024](/channel/amb/error/codes/1xxx/1024/index.md) | Field removed | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1025](/channel/amb/error/codes/1xxx/1025/index.md) | the body carries trailing content after the definition | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1026](/channel/amb/error/codes/1xxx/1026/index.md) | Flow reserved | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1027](/channel/amb/error/codes/1xxx/1027/index.md) | Forbidden character | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1028](/channel/amb/error/codes/1xxx/1028/index.md) | Forbidden identifier | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1029](/channel/amb/error/codes/1xxx/1029/index.md) | Forbidden policy phrase | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1030](/channel/amb/error/codes/1xxx/1030/index.md) | hours\_timezone must be an IANA zone name this platform can load, e.g. America/New\_York | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1031](/channel/amb/error/codes/1xxx/1031/index.md) | Idempotency-Key header is required on all sends; it makes blind retries safe | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1032](/channel/amb/error/codes/1xxx/1032/index.md) | If match invalid | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1033](/channel/amb/error/codes/1xxx/1033/index.md) | If match required | 428 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1034](/channel/amb/error/codes/1xxx/1034/index.md) | Integration invalid | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1035](/channel/amb/error/codes/1xxx/1035/index.md) | Invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1036](/channel/amb/error/codes/1xxx/1036/index.md) | Invalid timestamp | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1037](/channel/amb/error/codes/1xxx/1037/index.md) | Invalid type | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1038](/channel/amb/error/codes/1xxx/1038/index.md) | Invalid url | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1039](/channel/amb/error/codes/1xxx/1039/index.md) | the body must be multipart/form-data with a `name` field and an optional `logo` file part | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1040](/channel/amb/error/codes/1xxx/1040/index.md) | name is required; it is the brand name printed on the invitation card | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1041](/channel/amb/error/codes/1xxx/1041/index.md) | Non bmp character | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1042](/channel/amb/error/codes/1xxx/1042/index.md) | Not before too far | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1043](/channel/amb/error/codes/1xxx/1043/index.md) | Not future | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1044](/channel/amb/error/codes/1xxx/1044/index.md) | Not single line | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1045](/channel/amb/error/codes/1xxx/1045/index.md) | Not whole minute | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1046](/channel/amb/error/codes/1xxx/1046/index.md) | On invalid invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1047](/channel/amb/error/codes/1xxx/1047/index.md) | Operator invalid | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1048](/channel/amb/error/codes/1xxx/1048/index.md) | Out of range | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1049](/channel/amb/error/codes/1xxx/1049/index.md) | Overlap hours invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1050](/channel/amb/error/codes/1xxx/1050/index.md) | Param invalid | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1051](/channel/amb/error/codes/1xxx/1051/index.md) | Part invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1052](/channel/amb/error/codes/1xxx/1052/index.md) | Parts empty | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1053](/channel/amb/error/codes/1xxx/1053/index.md) | Parts invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1054](/channel/amb/error/codes/1xxx/1054/index.md) | Replay max out of range | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1055](/channel/amb/error/codes/1xxx/1055/index.md) | until is before since, so the range names no events | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1056](/channel/amb/error/codes/1xxx/1056/index.md) | Replay range too large | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1057](/channel/amb/error/codes/1xxx/1057/index.md) | Replay selector conflict | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1058](/channel/amb/error/codes/1xxx/1058/index.md) | Replay selector required | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1059](/channel/amb/error/codes/1xxx/1059/index.md) | Replay too many ids | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1060](/channel/amb/error/codes/1xxx/1060/index.md) | no flow named | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1061](/channel/amb/error/codes/1xxx/1061/index.md) | Secret value required | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1062](/channel/amb/error/codes/1xxx/1062/index.md) | Server owned | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1063](/channel/amb/error/codes/1xxx/1063/index.md) | since must be an RFC3339 timestamp, e.g. 2026-08-14T00:00:00Z | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1064](/channel/amb/error/codes/1xxx/1064/index.md) | Source invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1065](/channel/amb/error/codes/1xxx/1065/index.md) | Spread over seconds invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1066](/channel/amb/error/codes/1xxx/1066/index.md) | State invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1067](/channel/amb/error/codes/1xxx/1067/index.md) | Subject invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1068](/channel/amb/error/codes/1xxx/1068/index.md) | Subject kind invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1069](/channel/amb/error/codes/1xxx/1069/index.md) | subject is required | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1070](/channel/amb/error/codes/1xxx/1070/index.md) | template\_id is required: a batch sends one of your invitation templates to every recipient | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1071](/channel/amb/error/codes/1xxx/1071/index.md) | To invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1072](/channel/amb/error/codes/1xxx/1072/index.md) | To required | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1073](/channel/amb/error/codes/1xxx/1073/index.md) | flow definition exceeds the size ceiling | 413 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1074](/channel/amb/error/codes/1xxx/1074/index.md) | Too long | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1075](/channel/amb/error/codes/1xxx/1075/index.md) | Too many messages | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1076](/channel/amb/error/codes/1xxx/1076/index.md) | Unhandled form kind | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1077](/channel/amb/error/codes/1xxx/1077/index.md) | Unknown app extension | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1078](/channel/amb/error/codes/1xxx/1078/index.md) | Unknown page | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1079](/channel/amb/error/codes/1xxx/1079/index.md) | Unknown parameter | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1080](/channel/amb/error/codes/1xxx/1080/index.md) | Unknown part type | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1081](/channel/amb/error/codes/1xxx/1081/index.md) | Unknown template | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1082](/channel/amb/error/codes/1xxx/1082/index.md) | Url invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1090](/channel/amb/error/codes/1xxx/1090/index.md) | Claim required | 400 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1091](/channel/amb/error/codes/1xxx/1091/index.md) | Degrade invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1092](/channel/amb/error/codes/1xxx/1092/index.md) | Duplicate recipient | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1093](/channel/amb/error/codes/1xxx/1093/index.md) | No valid recipients | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1094](/channel/amb/error/codes/1xxx/1094/index.md) | recipients must carry at least one row | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1095](/channel/amb/error/codes/1xxx/1095/index.md) | Too many recipients | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1096](/channel/amb/error/codes/1xxx/1096/index.md) | Typing lead invalid | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1097](/channel/amb/error/codes/1xxx/1097/index.md) | File conflicting | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1098](/channel/amb/error/codes/1xxx/1098/index.md) | File part misplaced | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1099](/channel/amb/error/codes/1xxx/1099/index.md) | File part missing | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1100](/channel/amb/error/codes/1xxx/1100/index.md) | File part unused | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1101](/channel/amb/error/codes/1xxx/1101/index.md) | Image att id reserved | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1102](/channel/amb/error/codes/1xxx/1102/index.md) | Logo conflicting | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | | [1103](/channel/amb/error/codes/1xxx/1103/index.md) | Unknown image id | 422 | Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. | ## 2xxx — Resource Errors The request was well formed and the thing it named was missing, not yours, or not in a state that allows this. An id that belongs to another brand answers exactly like one that does not exist. That is deliberate: it keeps one brand from probing another’s ids. | Code | Message | HTTP | Troubleshooting | | --------------------------------------------------- | ------------------------------------------------------------------------------------ | ---- | ----------------------------------------------------------------------- | | [2001](/channel/amb/error/codes/2xxx/2001/index.md) | Chat not found | 404 | Verify the id came from a previous response on this brand’s credential. | | [2002](/channel/amb/error/codes/2xxx/2002/index.md) | Message not found | 404 | Verify the id came from a previous response on this brand’s credential. | | [2003](/channel/amb/error/codes/2xxx/2003/index.md) | Attachment not found | 422 | Verify the id came from a previous response on this brand’s credential. | | [2005](/channel/amb/error/codes/2xxx/2005/index.md) | App extension not configured | 409 | Verify the id came from a previous response on this brand’s credential. | | [2006](/channel/amb/error/codes/2xxx/2006/index.md) | Apple pay not configured | 409 | Verify the id came from a previous response on this brand’s credential. | | [2007](/channel/amb/error/codes/2xxx/2007/index.md) | Attachment not ready | 409 | Verify the id came from a previous response on this brand’s credential. | | [2008](/channel/amb/error/codes/2xxx/2008/index.md) | your brand already holds a different business id on channel | 409 | Verify the id came from a previous response on this brand’s credential. | | [2009](/channel/amb/error/codes/2xxx/2009/index.md) | Chat already claimed | 409 | Verify the id came from a previous response on this brand’s credential. | | [2010](/channel/amb/error/codes/2xxx/2010/index.md) | the chat is closed | 409 | Verify the id came from a previous response on this brand’s credential. | | [2011](/channel/amb/error/codes/2xxx/2011/index.md) | a flow can only start while the partner owns the chat | 409 | Verify the id came from a previous response on this brand’s credential. | | [2012](/channel/amb/error/codes/2xxx/2012/index.md) | Chat superseded | 409 | Verify the id came from a previous response on this brand’s credential. | | [2013](/channel/amb/error/codes/2xxx/2013/index.md) | Claim invalid | 403 | Verify the id came from a previous response on this brand’s credential. | | [2014](/channel/amb/error/codes/2xxx/2014/index.md) | this brand does not attest its own consent (its consent\_mode is linq\_managed) | 409 | Verify the id came from a previous response on this brand’s credential. | | [2015](/channel/amb/error/codes/2xxx/2015/index.md) | no valid opt-in on file for this notification category | 403 | Verify the id came from a previous response on this brand’s credential. | | [2016](/channel/amb/error/codes/2xxx/2016/index.md) | Cross brand conflict | 409 | Verify the id came from a previous response on this brand’s credential. | | [2017](/channel/amb/error/codes/2xxx/2017/index.md) | Endpoint limit reached | 409 | Verify the id came from a previous response on this brand’s credential. | | [2018](/channel/amb/error/codes/2xxx/2018/index.md) | this brand already has an endpoint at that URL. If you are retrying a create whose | 409 | Verify the id came from a previous response on this brand’s credential. | | [2019](/channel/amb/error/codes/2xxx/2019/index.md) | we are holding this brand on managed escalation; contact support to release the lock | 409 | Verify the id came from a previous response on this brand’s credential. | | [2020](/channel/amb/error/codes/2xxx/2020/index.md) | no such event | 404 | Verify the id came from a previous response on this brand’s credential. | | [2021](/channel/amb/error/codes/2xxx/2021/index.md) | no flow with that id | 404 | Verify the id came from a previous response on this brand’s credential. | | [2022](/channel/amb/error/codes/2xxx/2022/index.md) | no such flow or chat | 404 | Verify the id came from a previous response on this brand’s credential. | | [2023](/channel/amb/error/codes/2xxx/2023/index.md) | the original request with this key is still processing; retry shortly | 409 | Verify the id came from a previous response on this brand’s credential. | | [2024](/channel/amb/error/codes/2xxx/2024/index.md) | this Idempotency-Key was used with a different request body | 409 | Verify the id came from a previous response on this brand’s credential. | | [2025](/channel/amb/error/codes/2xxx/2025/index.md) | that ownership change isn’t valid from the chat’s current owner | 409 | Verify the id came from a previous response on this brand’s credential. | | [2026](/channel/amb/error/codes/2xxx/2026/index.md) | Integration group not found | 404 | Verify the id came from a previous response on this brand’s credential. | | [2027](/channel/amb/error/codes/2xxx/2027/index.md) | this request carries no resolved API key | 401 | Verify the id came from a previous response on this brand’s credential. | | [2028](/channel/amb/error/codes/2xxx/2028/index.md) | invitation batch not found | 404 | Verify the id came from a previous response on this brand’s credential. | | [2029](/channel/amb/error/codes/2xxx/2029/index.md) | Invitation card not granted | 403 | Verify the id came from a previous response on this brand’s credential. | | [2030](/channel/amb/error/codes/2xxx/2030/index.md) | Invitation chat not addressable | 409 | Verify the id came from a previous response on this brand’s credential. | | [2031](/channel/amb/error/codes/2xxx/2031/index.md) | Invitation not found | 404 | Verify the id came from a previous response on this brand’s credential. | | [2032](/channel/amb/error/codes/2xxx/2032/index.md) | Invitation template not authorized | 403 | Verify the id came from a previous response on this brand’s credential. | | [2033](/channel/amb/error/codes/2xxx/2033/index.md) | no invitation template with that id | 404 | Verify the id came from a previous response on this brand’s credential. | | [2034](/channel/amb/error/codes/2xxx/2034/index.md) | message template not found | 404 | Verify the id came from a previous response on this brand’s credential. | | [2035](/channel/amb/error/codes/2xxx/2035/index.md) | provide Authorization: Bearer sk\_dev\_… | 401 | Verify the id came from a previous response on this brand’s credential. | | [2036](/channel/amb/error/codes/2xxx/2036/index.md) | Oauth not configured | 409 | Verify the id came from a previous response on this brand’s credential. | | [2037](/channel/amb/error/codes/2xxx/2037/index.md) | unknown part type | 404 | Verify the id came from a previous response on this brand’s credential. | | [2038](/channel/amb/error/codes/2xxx/2038/index.md) | a signing-key rotation is already in flight for this endpoint. The previous secret | 409 | Verify the id came from a previous response on this brand’s credential. | | [2039](/channel/amb/error/codes/2xxx/2039/index.md) | Structure unsupported | 409 | Verify the id came from a previous response on this brand’s credential. | | [2040](/channel/amb/error/codes/2xxx/2040/index.md) | Version conflict | 412 | Verify the id came from a previous response on this brand’s credential. | | [2041](/channel/amb/error/codes/2xxx/2041/index.md) | Webhook endpoint not found | 404 | Verify the id came from a previous response on this brand’s credential. | | [2042](/channel/amb/error/codes/2xxx/2042/index.md) | Webhook endpoint not receiving | 409 | Verify the id came from a previous response on this brand’s credential. | | [2052](/channel/amb/error/codes/2xxx/2052/index.md) | Degrade requires sequence | 409 | Verify the id came from a previous response on this brand’s credential. | | [2053](/channel/amb/error/codes/2xxx/2053/index.md) | Attachment failed | 422 | Verify the id came from a previous response on this brand’s credential. | | [2054](/channel/amb/error/codes/2xxx/2054/index.md) | Attachment not sendable | 422 | Verify the id came from a previous response on this brand’s credential. | | [2056](/channel/amb/error/codes/2xxx/2056/index.md) | this invitation template has no logo | 404 | Verify the id came from a previous response on this brand’s credential. | | [2057](/channel/amb/error/codes/2xxx/2057/index.md) | Operator forbidden | 403 | Verify the id came from a previous response on this brand’s credential. | ## 3xxx — Server Errors Transient, and ours. Retry with exponential backoff, starting at 1 second and capping at 30. Sends are idempotent on `Idempotency-Key`, so a retry of a send that actually succeeded returns the original result rather than sending twice. | Code | Message | HTTP | Troubleshooting | | --------------------------------------------------- | ------------------------------------------------------------------------ | ---- | ------------------------------------------------------------------------ | | [3001](/channel/amb/error/codes/3xxx/3001/index.md) | brand has no outbound channel account; cannot send as this brand | 503 | Retry with exponential backoff: this is our failure, not your request’s. | | [3002](/channel/amb/error/codes/3xxx/3002/index.md) | AMB credentials not configured | 503 | Retry with exponential backoff: this is our failure, not your request’s. | | [3003](/channel/amb/error/codes/3xxx/3003/index.md) | the server could not encode this frame | 500 | Retry with exponential backoff: this is our failure, not your request’s. | | [3004](/channel/amb/error/codes/3xxx/3004/index.md) | the typing indicator could not be handed to the messaging channel; retry | 502 | Retry with exponential backoff: this is our failure, not your request’s. | | [3005](/channel/amb/error/codes/3xxx/3005/index.md) | Streaming unsupported | 500 | Retry with exponential backoff: this is our failure, not your request’s. | | [3006](/channel/amb/error/codes/3xxx/3006/index.md) | Internal server error | 500 | Retry with exponential backoff: this is our failure, not your request’s. | ## 4xxx — Delivery Errors What the send attempt discovered, or an operational refusal. Apple Messages has no delivery or read receipts — `sent` means Apple accepted the message — so this range is small by construction, and a code here is something we learned while handing the message over, not afterwards. | Code | Message | HTTP | Troubleshooting | | --------------------------------------------------- | ----------------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------- | | [4001](/channel/amb/error/codes/4xxx/4001/index.md) | Capability unknown | 409 | This is an operational or capability refusal, not a malformed request; the same call may succeed later. | | [4002](/channel/amb/error/codes/4xxx/4002/index.md) | this customer asked for the automated messages to stop; only the human desk may send here | 403 | This is an operational or capability refusal, not a malformed request; the same call may succeed later. | | [4003](/channel/amb/error/codes/4xxx/4003/index.md) | sending is temporarily paused: the kill-switch state is not yet loaded; retry shortly | 503 | This is an operational or capability refusal, not a malformed request; the same call may succeed later. | | [4005](/channel/amb/error/codes/4xxx/4005/index.md) | Capability unsupported | 409 | This is an operational or capability refusal, not a malformed request; the same call may succeed later. | ## 5xxx — Attachment/File Errors The file’s bytes: its format, its size, where it came from, or storing it. An attachment that is merely missing or not yet readable is a 2xxx resource error instead. | Code | Message | HTTP | Troubleshooting | | --------------------------------------------------- | -------------------------------------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------ | | [5001](/channel/amb/error/codes/5xxx/5001/index.md) | attachment source capacity is busy; retry shortly | 503 | Wait the number of seconds in the `Retry-After` header (and `retry_after` in the body), then retry the same request unchanged. | | [5002](/channel/amb/error/codes/5xxx/5002/index.md) | attachment source returned an empty body | 422 | Check the file against the documented size and format limits for this field. | | [5003](/channel/amb/error/codes/5xxx/5003/index.md) | attachment source was refused by the secure URL policy | 422 | Check the file against the documented size and format limits for this field. | | [5004](/channel/amb/error/codes/5xxx/5004/index.md) | attachment source bytes exceed the 100 MB request limit | 413 | Check the file against the documented size and format limits for this field. | | [5005](/channel/amb/error/codes/5xxx/5005/index.md) | attachment source fetching is temporarily unavailable | 502 | Check the file against the documented size and format limits for this field. | | [5006](/channel/amb/error/codes/5xxx/5006/index.md) | attachment storage is unavailable for this brand right now; | 503 | Wait the number of seconds in the `Retry-After` header (and `retry_after` in the body), then retry the same request unchanged. | | [5007](/channel/amb/error/codes/5xxx/5007/index.md) | the logo must be a real PNG (checked by signature, not filename or content-type) | 422 | Check the file against the documented size and format limits for this field. | | [5008](/channel/amb/error/codes/5xxx/5008/index.md) | Bubble image aspect | 422 | Check the file against the documented size and format limits for this field. | | [5009](/channel/amb/error/codes/5xxx/5009/index.md) | Interactive image format unsupported | 422 | Check the file against the documented size and format limits for this field. | | [5010](/channel/amb/error/codes/5xxx/5010/index.md) | Interactive image too large | 413 | Check the file against the documented size and format limits for this field. | | [5011](/channel/amb/error/codes/5xxx/5011/index.md) | Interactive images too large | 413 | Check the file against the documented size and format limits for this field. | | [5012](/channel/amb/error/codes/5xxx/5012/index.md) | Logo too large | 422 | Check the file against the documented size and format limits for this field. | | [5013](/channel/amb/error/codes/5xxx/5013/index.md) | the default card image must be a PNG | 422 | Check the file against the documented size and format limits for this field. | | [5014](/channel/amb/error/codes/5xxx/5014/index.md) | the default card image is over the 200 KB decoded cap for a preview card | 413 | Check the file against the documented size and format limits for this field. | --- # 1001: Missing required field URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1001/ Missing required field. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1002: Backlog horizon exceeded URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1002/ Backlog horizon exceeded. This is an HTTP 429: you are sending faster than your allowance. ## Troubleshooting - Wait the number of seconds in the `Retry-After` header (and `retry_after` in the body), then retry the same request unchanged. - Nothing about the request is wrong: this is backpressure, so re-sending it after the wait is the correct handling. - Spread sustained load rather than retrying in a tight loop; the window is per brand. --- # 1003: Invalid request body URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1003/ Invalid request body. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1004: Batch exceeds horizon URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1004/ Batch exceeds horizon. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1005: Batch invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1005/ Batch invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1006: a JSON body is required; `{}` to change nothing URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1006/ a JSON body is required; `{}` to change nothing. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1007: Rate limit exceeded URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1007/ Rate limit exceeded. This is an HTTP 429: you are sending faster than your allowance. ## Troubleshooting - Wait the number of seconds in the `Retry-After` header (and `retry_after` in the body), then retry the same request unchanged. - Nothing about the request is wrong: this is backpressure, so re-sending it after the wait is the correct handling. - Spread sustained load rather than retrying in a tight loop; the window is per brand. ## Related - [API reference](/channel/amb/api/index.md) --- # 1008: Business hours invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1008/ Business hours invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1009: Business id invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1009/ Business id invalid. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1010: Category invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1010/ Category invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1011: Channel unsupported URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1011/ Channel unsupported. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1012: Count out of range URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1012/ Count out of range. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1013: resume token is not one this API issued; reconnect without Last-Event-ID (and re-list URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1013/ resume token is not one this API issued; reconnect without Last-Event-ID (and re-list. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1014: Description too long URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1014/ Description too long. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1015: Duplicate URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1015/ Duplicate. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1016: Duplicate id URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1016/ Duplicate id. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1017: attachment body is empty URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1017/ attachment body is empty. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1018: Enabled events duplicate URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1018/ Enabled events duplicate. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1019: Enabled events required URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1019/ Enabled events required. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1020: Enabled events unknown URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1020/ Enabled events unknown. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1021: Escalation handling invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1021/ Escalation handling invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1022: Expiry invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1022/ Expiry invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1023: Field mismatch URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1023/ Field mismatch. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1024: Field removed URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1024/ Field removed. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1025: the body carries trailing content after the definition URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1025/ the body carries trailing content after the definition. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1026: Flow reserved URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1026/ Flow reserved. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1027: Forbidden character URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1027/ Forbidden character. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1028: Forbidden identifier URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1028/ Forbidden identifier. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1029: Forbidden policy phrase URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1029/ Forbidden policy phrase. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1030: hours_timezone must be an IANA zone name this platform can load, e.g. America/New_York URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1030/ hours\_timezone must be an IANA zone name this platform can load, e.g. America/New\_York. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1031: Idempotency-Key header is required on all sends; it makes blind retries safe URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1031/ Idempotency-Key header is required on all sends; it makes blind retries safe. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. ## Related - [Sending a message](/channel/amb/api/resources/chats/subresources/messages/methods/send/index.md) --- # 1032: If match invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1032/ If match invalid. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1033: If match required URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1033/ If match required. This is an HTTP 428: a precondition was not met. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1034: Integration invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1034/ Integration invalid. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1035: Invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1035/ Invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1036: Invalid timestamp URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1036/ Invalid timestamp. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1037: Invalid type URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1037/ Invalid type. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1038: Invalid url URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1038/ Invalid url. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1039: the body must be multipart/form-data with a `name` field and an optional `logo` file part URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1039/ the body must be multipart/form-data with a `name` field and an optional `logo` file part. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1040: name is required; it is the brand name printed on the invitation card URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1040/ name is required; it is the brand name printed on the invitation card. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1041: Non bmp character URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1041/ Non bmp character. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1042: Not before too far URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1042/ Not before too far. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1043: Not future URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1043/ Not future. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1044: Not single line URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1044/ Not single line. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1045: Not whole minute URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1045/ Not whole minute. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1046: On invalid invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1046/ On invalid invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1047: Operator invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1047/ Operator invalid. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1048: Out of range URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1048/ Out of range. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1049: Overlap hours invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1049/ Overlap hours invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1050: Param invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1050/ Param invalid. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1051: Part invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1051/ Part invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1052: Parts empty URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1052/ Parts empty. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1053: Parts invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1053/ Parts invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1054: Replay max out of range URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1054/ Replay max out of range. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1055: until is before since, so the range names no events URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1055/ until is before since, so the range names no events. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1056: Replay range too large URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1056/ Replay range too large. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1057: Replay selector conflict URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1057/ Replay selector conflict. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1058: Replay selector required URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1058/ Replay selector required. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1059: Replay too many ids URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1059/ Replay too many ids. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1060: no flow named URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1060/ no flow named. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1061: Secret value required URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1061/ Secret value required. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1062: Server owned URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1062/ Server owned. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1063: since must be an RFC3339 timestamp, e.g. 2026-08-14T00:00:00Z URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1063/ since must be an RFC3339 timestamp, e.g. 2026-08-14T00:00:00Z. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1064: Source invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1064/ Source invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1065: Spread over seconds invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1065/ Spread over seconds invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1066: State invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1066/ State invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1067: Subject invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1067/ Subject invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1068: Subject kind invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1068/ Subject kind invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1069: subject is required URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1069/ subject is required. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1070: template_id is required: a batch sends one of your invitation templates to every recipient URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1070/ template\_id is required: a batch sends one of your invitation templates to every recipient. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1071: To invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1071/ To invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1072: To required URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1072/ To required. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1073: flow definition exceeds the size ceiling URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1073/ flow definition exceeds the size ceiling. This is an HTTP 413: the payload is over a documented ceiling. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. - Compare the payload against the documented size ceiling for this field. --- # 1074: Too long URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1074/ Too long. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1075: Too many messages URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1075/ Too many messages. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1076: Unhandled form kind URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1076/ Unhandled form kind. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1077: Unknown app extension URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1077/ Unknown app extension. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1078: Unknown page URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1078/ Unknown page. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1079: Unknown parameter URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1079/ Unknown parameter. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1080: Unknown part type URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1080/ Unknown part type. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1081: Unknown template URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1081/ Unknown template. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1082: Url invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1082/ Url invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1090: Claim required URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1090/ Claim required. This is an HTTP 400: the request could not be parsed. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1091: Degrade invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1091/ Degrade invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1092: Duplicate recipient URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1092/ Duplicate recipient. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1093: No valid recipients URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1093/ No valid recipients. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1094: recipients must carry at least one row URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1094/ recipients must carry at least one row. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1095: Too many recipients URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1095/ Too many recipients. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1096: Typing lead invalid URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1096/ Typing lead invalid. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1097: File conflicting URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1097/ File conflicting. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1098: File part misplaced URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1098/ File part misplaced. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1099: File part missing URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1099/ File part missing. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1100: File part unused URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1100/ File part unused. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1101: Image att id reserved URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1101/ Image att id reserved. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1102: Logo conflicting URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1102/ Logo conflicting. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 1103: Unknown image id URL: https://docs.linqapp.com/channel/amb/error/codes/1xxx/1103/ Unknown image id. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check this endpoint’s request schema in the API reference; `param` in the response names the exact field at fault. - Use an official SDK, which enforces required fields and types before the call is made. --- # 2001: Chat not found URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2001/ Chat not found. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. ## Related - [Sending a message](/channel/amb/api/resources/chats/subresources/messages/methods/send/index.md) --- # 2002: Message not found URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2002/ Message not found. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2003: Attachment not found URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2003/ Attachment not found. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2005: App extension not configured URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2005/ App extension not configured. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2006: Apple pay not configured URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2006/ Apple pay not configured. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2007: Attachment not ready URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2007/ Attachment not ready. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2008: your brand already holds a different business id on channel URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2008/ your brand already holds a different business id on channel. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2009: Chat already claimed URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2009/ Chat already claimed. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2010: the chat is closed URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2010/ the chat is closed. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2011: a flow can only start while the partner owns the chat URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2011/ a flow can only start while the partner owns the chat. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2012: Chat superseded URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2012/ Chat superseded. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2013: Claim invalid URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2013/ Claim invalid. This is an HTTP 403: the credential resolved but may not do this. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Check the key’s scopes; a resolved key may still lack the scope this route needs. --- # 2014: this brand does not attest its own consent (its consent_mode is linq_managed) URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2014/ this brand does not attest its own consent (its consent\_mode is linq\_managed). This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. ## Related - [Consent attestation](/channel/amb/api/resources/consent/methods/attest/index.md) --- # 2015: no valid opt-in on file for this notification category URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2015/ no valid opt-in on file for this notification category. This is an HTTP 403: the credential resolved but may not do this. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Check the key’s scopes; a resolved key may still lack the scope this route needs. ## Related - [Consent attestation](/channel/amb/api/resources/consent/methods/attest/index.md) --- # 2016: Cross brand conflict URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2016/ Cross brand conflict. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2017: Endpoint limit reached URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2017/ Endpoint limit reached. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2018: this brand already has an endpoint at that URL. If you are retrying a create whose URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2018/ this brand already has an endpoint at that URL. If you are retrying a create whose. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2019: we are holding this brand on managed escalation; contact support to release the lock URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2019/ we are holding this brand on managed escalation; contact support to release the lock. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2020: no such event URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2020/ no such event. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2021: no flow with that id URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2021/ no flow with that id. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2022: no such flow or chat URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2022/ no such flow or chat. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2023: the original request with this key is still processing; retry shortly URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2023/ the original request with this key is still processing; retry shortly. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. ## Related - [Sending a message](/channel/amb/api/resources/chats/subresources/messages/methods/send/index.md) --- # 2024: this Idempotency-Key was used with a different request body URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2024/ this Idempotency-Key was used with a different request body. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. ## Related - [Sending a message](/channel/amb/api/resources/chats/subresources/messages/methods/send/index.md) --- # 2025: that ownership change isn't valid from the chat's current owner URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2025/ that ownership change isn’t valid from the chat’s current owner. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2026: Integration group not found URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2026/ Integration group not found. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2027: this request carries no resolved API key URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2027/ this request carries no resolved API key. This is an HTTP 401: the credential did not resolve. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Check the key’s scopes; a resolved key may still lack the scope this route needs. --- # 2028: invitation batch not found URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2028/ invitation batch not found. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2029: Invitation card not granted URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2029/ Invitation card not granted. This is an HTTP 403: the credential resolved but may not do this. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Check the key’s scopes; a resolved key may still lack the scope this route needs. --- # 2030: Invitation chat not addressable URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2030/ Invitation chat not addressable. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2031: Invitation not found URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2031/ Invitation not found. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. ## Related - [Invitations](/channel/amb/api/resources/invitations/index.md) --- # 2032: Invitation template not authorized URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2032/ Invitation template not authorized. This is an HTTP 403: the credential resolved but may not do this. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Check the key’s scopes; a resolved key may still lack the scope this route needs. --- # 2033: no invitation template with that id URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2033/ no invitation template with that id. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. ## Related - [Invitations](/channel/amb/api/resources/invitations/index.md) --- # 2034: message template not found URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2034/ message template not found. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2035: provide Authorization: Bearer sk_dev_… URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2035/ provide Authorization: Bearer sk\_dev\_…. This is an HTTP 401: the credential did not resolve. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Check the key’s scopes; a resolved key may still lack the scope this route needs. --- # 2036: Oauth not configured URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2036/ Oauth not configured. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2037: unknown part type URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2037/ unknown part type. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2038: a signing-key rotation is already in flight for this endpoint. The previous secret URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2038/ a signing-key rotation is already in flight for this endpoint. The previous secret. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2039: Structure unsupported URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2039/ Structure unsupported. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2040: Version conflict URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2040/ Version conflict. This is an HTTP 412: a precondition on the current state failed. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2041: Webhook endpoint not found URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2041/ Webhook endpoint not found. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2042: Webhook endpoint not receiving URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2042/ Webhook endpoint not receiving. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2052: Degrade requires sequence URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2052/ Degrade requires sequence. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. - Re-read the object and retry once its state allows the operation. --- # 2053: Attachment failed URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2053/ Attachment failed. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2054: Attachment not sendable URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2054/ Attachment not sendable. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2056: this invitation template has no logo URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2056/ this invitation template has no logo. This is an HTTP 404: the referenced object does not exist for this brand. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - An id belonging to another brand answers 404 exactly like one that does not exist, so a valid-looking id may simply not be yours. --- # 2057: Operator forbidden URL: https://docs.linqapp.com/channel/amb/error/codes/2xxx/2057/ Operator forbidden. This is an HTTP 403: the credential resolved but may not do this. ## Troubleshooting - Verify the id came from a previous response on this brand’s credential. - Check the key’s scopes; a resolved key may still lack the scope this route needs. --- # 3001: brand has no outbound channel account; cannot send as this brand URL: https://docs.linqapp.com/channel/amb/error/codes/3xxx/3001/ brand has no outbound channel account; cannot send as this brand. This is an HTTP 503: a dependency is unavailable right now. ## Troubleshooting - Retry with exponential backoff: this is our failure, not your request’s. - Quote `request_id` (and `trace_id` when present) if the failure persists. --- # 3002: AMB credentials not configured URL: https://docs.linqapp.com/channel/amb/error/codes/3xxx/3002/ AMB credentials not configured. This is an HTTP 503: a dependency is unavailable right now. ## Troubleshooting - Retry with exponential backoff: this is our failure, not your request’s. - Quote `request_id` (and `trace_id` when present) if the failure persists. --- # 3003: the server could not encode this frame URL: https://docs.linqapp.com/channel/amb/error/codes/3xxx/3003/ the server could not encode this frame. This is an HTTP 500: something on our side failed. ## Troubleshooting - Retry with exponential backoff: this is our failure, not your request’s. - Quote `request_id` (and `trace_id` when present) if the failure persists. --- # 3004: the typing indicator could not be handed to the messaging channel; retry URL: https://docs.linqapp.com/channel/amb/error/codes/3xxx/3004/ the typing indicator could not be handed to the messaging channel; retry. This is an HTTP 502: a dependency we call failed. ## Troubleshooting - Retry with exponential backoff: this is our failure, not your request’s. - Quote `request_id` (and `trace_id` when present) if the failure persists. --- # 3005: Streaming unsupported URL: https://docs.linqapp.com/channel/amb/error/codes/3xxx/3005/ Streaming unsupported. This is an HTTP 500: something on our side failed. ## Troubleshooting - Retry with exponential backoff: this is our failure, not your request’s. - Quote `request_id` (and `trace_id` when present) if the failure persists. --- # 3006: Internal server error URL: https://docs.linqapp.com/channel/amb/error/codes/3xxx/3006/ Internal server error. This is an HTTP 500: something on our side failed. ## Troubleshooting - Retry with exponential backoff: this is our failure, not your request’s. - Quote `request_id` (and `trace_id` when present) if the failure persists. --- # 4001: Capability unknown URL: https://docs.linqapp.com/channel/amb/error/codes/4xxx/4001/ Capability unknown. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - This is an operational or capability refusal, not a malformed request; the same call may succeed later. - For a pause, honour `Retry-After`; for a capability refusal, send a part type the recipient’s device announced. --- # 4002: this customer asked for the automated messages to stop; only the human desk may send here URL: https://docs.linqapp.com/channel/amb/error/codes/4xxx/4002/ this customer asked for the automated messages to stop; only the human desk may send here. This is an HTTP 403: the credential resolved but may not do this. ## Troubleshooting - This is an operational or capability refusal, not a malformed request; the same call may succeed later. - For a pause, honour `Retry-After`; for a capability refusal, send a part type the recipient’s device announced. --- # 4003: sending is temporarily paused: the kill-switch state is not yet loaded; retry shortly URL: https://docs.linqapp.com/channel/amb/error/codes/4xxx/4003/ sending is temporarily paused: the kill-switch state is not yet loaded; retry shortly. This is an HTTP 503: a dependency is unavailable right now. ## Troubleshooting - This is an operational or capability refusal, not a malformed request; the same call may succeed later. - For a pause, honour `Retry-After`; for a capability refusal, send a part type the recipient’s device announced. --- # 4005: Capability unsupported URL: https://docs.linqapp.com/channel/amb/error/codes/4xxx/4005/ Capability unsupported. This is an HTTP 409: the object’s current state conflicts with the request. ## Troubleshooting - This is an operational or capability refusal, not a malformed request; the same call may succeed later. - For a pause, honour `Retry-After`; for a capability refusal, send a part type the recipient’s device announced. --- # 5001: attachment source capacity is busy; retry shortly URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5001/ attachment source capacity is busy; retry shortly. This is an HTTP 503: a dependency is unavailable right now. ## Troubleshooting - Wait the number of seconds in the `Retry-After` header (and `retry_after` in the body), then retry the same request unchanged. - Nothing about the request is wrong: this is backpressure, so re-sending it after the wait is the correct handling. - Spread sustained load rather than retrying in a tight loop; the window is per brand. --- # 5002: attachment source returned an empty body URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5002/ attachment source returned an empty body. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # 5003: attachment source was refused by the secure URL policy URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5003/ attachment source was refused by the secure URL policy. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # 5004: attachment source bytes exceed the 100 MB request limit URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5004/ attachment source bytes exceed the 100 MB request limit. This is an HTTP 413: the payload is over a documented ceiling. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # 5005: attachment source fetching is temporarily unavailable URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5005/ attachment source fetching is temporarily unavailable. This is an HTTP 502: a dependency we call failed. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. - Storage and fetch failures are transient: retry with backoff before treating the attachment as bad. --- # 5006: attachment storage is unavailable for this brand right now; URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5006/ attachment storage is unavailable for this brand right now;. This is an HTTP 503: a dependency is unavailable right now. ## Troubleshooting - Wait the number of seconds in the `Retry-After` header (and `retry_after` in the body), then retry the same request unchanged. - Nothing about the request is wrong: this is backpressure, so re-sending it after the wait is the correct handling. - Spread sustained load rather than retrying in a tight loop; the window is per brand. --- # 5007: the logo must be a real PNG (checked by signature, not filename or content-type) URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5007/ the logo must be a real PNG (checked by signature, not filename or content-type). This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # 5008: Bubble image aspect URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5008/ Bubble image aspect. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # 5009: Interactive image format unsupported URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5009/ Interactive image format unsupported. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # 5010: Interactive image too large URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5010/ Interactive image too large. This is an HTTP 413: the payload is over a documented ceiling. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # 5011: Interactive images too large URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5011/ Interactive images too large. This is an HTTP 413: the payload is over a documented ceiling. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # 5012: Logo too large URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5012/ Logo too large. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # 5013: the default card image must be a PNG URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5013/ the default card image must be a PNG. This is an HTTP 422: the request parsed but a value is not acceptable. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # 5014: the default card image is over the 200 KB decoded cap for a preview card URL: https://docs.linqapp.com/channel/amb/error/codes/5xxx/5014/ the default card image is over the 200 KB decoded cap for a preview card. This is an HTTP 413: the payload is over a documented ceiling. ## Troubleshooting - Check the file against the documented size and format limits for this field. - For a source URL, confirm it is reachable, returns the declared content type, and is not behind auth. --- # API Reference URL: https://docs.linqapp.com/channel/amb/api/ ## [Shared](/channel/amb/api/resources/$shared/index.md) ## [Chats](/channel/amb/api/resources/chats/index.md) - [List](/channel/amb/api/resources/chats/methods/list/index.md) - [Retrieve](/channel/amb/api/resources/chats/methods/retrieve/index.md) - [Clarify](/channel/amb/api/resources/chats/methods/clarify/index.md) - [Resolve](/channel/amb/api/resources/chats/methods/resolve/index.md) - [Messages](/channel/amb/api/resources/chats/subresources/messages/index.md) - [Send](/channel/amb/api/resources/chats/subresources/messages/methods/send/index.md) - [Preview](/channel/amb/api/resources/chats/subresources/messages/methods/preview/index.md) - [Typing](/channel/amb/api/resources/chats/subresources/typing/index.md) - [Set](/channel/amb/api/resources/chats/subresources/typing/methods/set/index.md) - [Events](/channel/amb/api/resources/chats/subresources/events/index.md) - [List](/channel/amb/api/resources/chats/subresources/events/methods/list/index.md) - [Transcript](/channel/amb/api/resources/chats/subresources/transcript/index.md) - [List](/channel/amb/api/resources/chats/subresources/transcript/methods/list/index.md) - [Activity](/channel/amb/api/resources/chats/subresources/activity/index.md) - [Retrieve](/channel/amb/api/resources/chats/subresources/activity/methods/retrieve/index.md) - [Handoff](/channel/amb/api/resources/chats/subresources/handoff/index.md) - [Request](/channel/amb/api/resources/chats/subresources/handoff/methods/request/index.md) - [Accept](/channel/amb/api/resources/chats/subresources/handoff/methods/accept/index.md) ## [Messages](/channel/amb/api/resources/messages/index.md) - [Retrieve](/channel/amb/api/resources/messages/methods/retrieve/index.md) - [Timeline](/channel/amb/api/resources/messages/methods/timeline/index.md) ## [Attachments](/channel/amb/api/resources/attachments/index.md) - [Create](/channel/amb/api/resources/attachments/methods/create/index.md) - [Retrieve](/channel/amb/api/resources/attachments/methods/retrieve/index.md) - [Content](/channel/amb/api/resources/attachments/methods/content/index.md) ## [Consent](/channel/amb/api/resources/consent/index.md) - [Attest](/channel/amb/api/resources/consent/methods/attest/index.md) ## [Invitations](/channel/amb/api/resources/invitations/index.md) - [Create](/channel/amb/api/resources/invitations/methods/create/index.md) - [Retrieve](/channel/amb/api/resources/invitations/methods/retrieve/index.md) ## [Invitation Templates](/channel/amb/api/resources/invitation_templates/index.md) - [List](/channel/amb/api/resources/invitation_templates/methods/list/index.md) - [Create](/channel/amb/api/resources/invitation_templates/methods/create/index.md) - [Retrieve](/channel/amb/api/resources/invitation_templates/methods/retrieve/index.md) - [Logo](/channel/amb/api/resources/invitation_templates/methods/logo/index.md) - [Update](/channel/amb/api/resources/invitation_templates/methods/update/index.md) - [Delete](/channel/amb/api/resources/invitation_templates/methods/delete/index.md) ## [Invitation Batches](/channel/amb/api/resources/invitation_batches/index.md) - [Create](/channel/amb/api/resources/invitation_batches/methods/create/index.md) - [Retrieve](/channel/amb/api/resources/invitation_batches/methods/retrieve/index.md) - [List Recipients](/channel/amb/api/resources/invitation_batches/methods/list_recipients/index.md) - [Cancel](/channel/amb/api/resources/invitation_batches/methods/cancel/index.md) ## [Contract](/channel/amb/api/resources/contract/index.md) - [OpenAPI](/channel/amb/api/resources/contract/methods/openapi/index.md) - [Part Schema](/channel/amb/api/resources/contract/methods/part_schema/index.md) ## [Me](/channel/amb/api/resources/me/index.md) - [Retrieve](/channel/amb/api/resources/me/methods/retrieve/index.md) ## [Meta](/channel/amb/api/resources/meta/index.md) - [Parts](/channel/amb/api/resources/meta/methods/parts/index.md) ## [Webhook Endpoints](/channel/amb/api/resources/webhook_endpoints/index.md) - [List](/channel/amb/api/resources/webhook_endpoints/methods/list/index.md) - [Create](/channel/amb/api/resources/webhook_endpoints/methods/create/index.md) - [Retrieve](/channel/amb/api/resources/webhook_endpoints/methods/retrieve/index.md) - [Update](/channel/amb/api/resources/webhook_endpoints/methods/update/index.md) - [Delete](/channel/amb/api/resources/webhook_endpoints/methods/delete/index.md) - [Rotate Secret](/channel/amb/api/resources/webhook_endpoints/methods/rotate_secret/index.md) - [Replay](/channel/amb/api/resources/webhook_endpoints/methods/replay/index.md) ## [Webhooks](/channel/amb/api/resources/webhooks/index.md) ## [Events](/channel/amb/api/resources/events/index.md) - [List](/channel/amb/api/resources/events/methods/list/index.md) - [Retrieve](/channel/amb/api/resources/events/methods/retrieve/index.md) ## [Channels](/channel/amb/api/resources/channels/index.md) - [List](/channel/amb/api/resources/channels/methods/list/index.md) - [Link](/channel/amb/api/resources/channels/methods/link/index.md) ## [Settings](/channel/amb/api/resources/settings/index.md) - [Retrieve](/channel/amb/api/resources/settings/methods/retrieve/index.md) - [Update](/channel/amb/api/resources/settings/methods/update/index.md) - [Integrations](/channel/amb/api/resources/settings/subresources/integrations/index.md) - [Set Apple Pay](/channel/amb/api/resources/settings/subresources/integrations/methods/set_apple_pay/index.md) - [Set OAuth](/channel/amb/api/resources/settings/subresources/integrations/methods/set_oauth/index.md) - [Set App Extensions](/channel/amb/api/resources/settings/subresources/integrations/methods/set_app_extensions/index.md) - [Clear](/channel/amb/api/resources/settings/subresources/integrations/methods/clear/index.md) --- # Attachments URL: https://docs.linqapp.com/channel/amb/api/resources/attachments/ ## Upload an attachment **post** `/v1/attachments` Uploads an attachment. Reference the returned `att_id` from message parts. **Requirements** - The request body is the raw bytes: no multipart, no JSON wrapper. - `content-type` declares the attachment's MIME type; `x-filename` declares its name. - Maximum 100,000,000 bytes (100 MB). Larger payloads return HTTP 413 `too_large`. An empty body returns HTTP 400 `code` 1017. - No `Idempotency-Key`: the server neither requires nor consults one here. **Behavior** - Uploads are idempotent over your brand and the bytes: the `att_id` is derived from them, so re-uploading a file you already hold returns the same id and stores nothing twice. A retry is free and needs no key. - The stored `mime` and `name` stay the first upload's, and the response reports the stored values: a later upload declaring a different `content-type` gets the original back. To change a stored type, upload altered bytes, which are a different attachment. - Attachments are scoped to your brand. Another brand uploading the identical file gets a different id and its own copy. ### Header Parameters - `"x-filename": optional string` ### Returns - `id: string` Attachment id (`att_…`) to reference from parts. - `mime: string` The STORED MIME type: the same value `GET /v1/attachments/{att}` reports, not an echo of the request header. It is your `content-type` when you sent one, `application/octet-stream` when you did not, and on a duplicate upload it is the type the FIRST upload of these bytes declared. - `size: number` Stored size in bytes. ### Example ```http curl https://messages.api.linqapp.com/v1/attachments \ -H 'Content-Type: */*' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{}' ``` #### Response ```json { "id": "att_1a3f77", "mime": "image/png", "size": 20481 } ``` ## Get an attachment's metadata **get** `/v1/attachments/{att}` Retrieves an attachment's stored metadata. **Behavior** - Reports the fields as stored: `mime` is `application/octet-stream` when the upload declared no `content-type`. The upload's own response reports the same stored values, so the two reads always agree. - Serves ids this API minted and ids created for attachments a customer sent inbound; those arrive on the `message.received` payload's `attachments` entries, are readable here and at `/content`, and cannot be re-sent (`attachment_not_sendable`). - Answers for any `status`, including one a send or download refuses; this is where you find out why. **Limits** - Inbound attachments are deleted 30 days after receipt. Copy the bytes if you need them longer. - Scoped to your brand. Another brand's `att_id` returns HTTP 404 `attachment_not_found`: the same response as an unknown id, so a response never confirms an id exists. ### Path Parameters - `att: string` ### Returns - `AttachmentDetail object { id, mime, size, 2 more }` One stored attachment's metadata, as stored. - `id: string` Attachment id (`att_…`). - `mime: string` The STORED MIME type; `application/octet-stream` when the upload declared none. - `size: number` Stored size in bytes. - `status: string` Whether this attachment's bytes are usable. `ready` is the only value a send, a template save or a download accepts; anything else is why one of those was refused. This read is never gated on it; it is where you look up the answer. Today's values are `ready`, `pending` (bytes not stored yet) and `failed` (they never will be). Everything `POST /v1/attachments` returns is `ready` the moment it responds: the bytes are in the request. The other two exist because an upload that mints an id BEFORE the bytes move cannot say that, and the field is here now so that shape is not a second breaking response change. Modelled as a string rather than a closed union **deliberately**: the set of values can grow (a content scanner adds a verdict of its own), and a closed enum would make that addition the breaking change this field exists to avoid. Branch on `ready` and treat every other value as unusable-with-a-name; do not try to list them all. - `name: optional string` Stored display name; absent when the upload sent no `x-filename`. ### Example ```http curl https://messages.api.linqapp.com/v1/attachments/$ATT \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "att_1a3f77", "mime": "image/png", "name": "receipt.png", "size": 20481, "status": "ready" } ``` ## Download an attachment's bytes **get** `/v1/attachments/{att}/content` Downloads an attachment's stored bytes, verbatim, under its stored MIME type. **Behavior** - Served with `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`: the bytes are caller-supplied content, not a page this origin vouches for. - Same brand scope and the same 404 posture as the metadata read. **Errors** - HTTP 409 `code` 2007: the bytes are not stored yet. Wait and retry. - HTTP 422 `code` 2053: the bytes never arrived and never will. Upload again. Only the 409 is worth retrying. - Read `GET /v1/attachments/{att}` for the current `status`. ### Path Parameters - `att: string` ### Example ```http curl https://messages.api.linqapp.com/v1/attachments/$ATT/content \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` ## Domain Types ### Attachment Detail - `AttachmentDetail object { id, mime, size, 2 more }` One stored attachment's metadata, as stored. - `id: string` Attachment id (`att_…`). - `mime: string` The STORED MIME type; `application/octet-stream` when the upload declared none. - `size: number` Stored size in bytes. - `status: string` Whether this attachment's bytes are usable. `ready` is the only value a send, a template save or a download accepts; anything else is why one of those was refused. This read is never gated on it; it is where you look up the answer. Today's values are `ready`, `pending` (bytes not stored yet) and `failed` (they never will be). Everything `POST /v1/attachments` returns is `ready` the moment it responds: the bytes are in the request. The other two exist because an upload that mints an id BEFORE the bytes move cannot say that, and the field is here now so that shape is not a second breaking response change. Modelled as a string rather than a closed union **deliberately**: the set of values can grow (a content scanner adds a verdict of its own), and a closed enum would make that addition the breaking change this field exists to avoid. Branch on `ready` and treat every other value as unusable-with-a-name; do not try to list them all. - `name: optional string` Stored display name; absent when the upload sent no `x-filename`. ### Attachment Create Response - `AttachmentCreateResponse object { id, mime, size }` The stored attachment. - `id: string` Attachment id (`att_…`) to reference from parts. - `mime: string` The STORED MIME type: the same value `GET /v1/attachments/{att}` reports, not an echo of the request header. It is your `content-type` when you sent one, `application/octet-stream` when you did not, and on a duplicate upload it is the type the FIRST upload of these bytes declared. - `size: number` Stored size in bytes. --- # Download an attachment's bytes URL: https://docs.linqapp.com/channel/amb/api/resources/attachments/methods/content/ **get** `/v1/attachments/{att}/content` Downloads an attachment's stored bytes, verbatim, under its stored MIME type. **Behavior** - Served with `Content-Disposition: attachment` and `X-Content-Type-Options: nosniff`: the bytes are caller-supplied content, not a page this origin vouches for. - Same brand scope and the same 404 posture as the metadata read. **Errors** - HTTP 409 `code` 2007: the bytes are not stored yet. Wait and retry. - HTTP 422 `code` 2053: the bytes never arrived and never will. Upload again. Only the 409 is worth retrying. - Read `GET /v1/attachments/{att}` for the current `status`. ### Path Parameters - `att: string` ### Example ```http curl https://messages.api.linqapp.com/v1/attachments/$ATT/content \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` --- # Upload an attachment URL: https://docs.linqapp.com/channel/amb/api/resources/attachments/methods/create/ **post** `/v1/attachments` Uploads an attachment. Reference the returned `att_id` from message parts. **Requirements** - The request body is the raw bytes: no multipart, no JSON wrapper. - `content-type` declares the attachment's MIME type; `x-filename` declares its name. - Maximum 100,000,000 bytes (100 MB). Larger payloads return HTTP 413 `too_large`. An empty body returns HTTP 400 `code` 1017. - No `Idempotency-Key`: the server neither requires nor consults one here. **Behavior** - Uploads are idempotent over your brand and the bytes: the `att_id` is derived from them, so re-uploading a file you already hold returns the same id and stores nothing twice. A retry is free and needs no key. - The stored `mime` and `name` stay the first upload's, and the response reports the stored values: a later upload declaring a different `content-type` gets the original back. To change a stored type, upload altered bytes, which are a different attachment. - Attachments are scoped to your brand. Another brand uploading the identical file gets a different id and its own copy. ### Header Parameters - `"x-filename": optional string` ### Returns - `id: string` Attachment id (`att_…`) to reference from parts. - `mime: string` The STORED MIME type: the same value `GET /v1/attachments/{att}` reports, not an echo of the request header. It is your `content-type` when you sent one, `application/octet-stream` when you did not, and on a duplicate upload it is the type the FIRST upload of these bytes declared. - `size: number` Stored size in bytes. ### Example ```http curl https://messages.api.linqapp.com/v1/attachments \ -H 'Content-Type: */*' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{}' ``` #### Response ```json { "id": "att_1a3f77", "mime": "image/png", "size": 20481 } ``` --- # Get an attachment's metadata URL: https://docs.linqapp.com/channel/amb/api/resources/attachments/methods/retrieve/ **get** `/v1/attachments/{att}` Retrieves an attachment's stored metadata. **Behavior** - Reports the fields as stored: `mime` is `application/octet-stream` when the upload declared no `content-type`. The upload's own response reports the same stored values, so the two reads always agree. - Serves ids this API minted and ids created for attachments a customer sent inbound; those arrive on the `message.received` payload's `attachments` entries, are readable here and at `/content`, and cannot be re-sent (`attachment_not_sendable`). - Answers for any `status`, including one a send or download refuses; this is where you find out why. **Limits** - Inbound attachments are deleted 30 days after receipt. Copy the bytes if you need them longer. - Scoped to your brand. Another brand's `att_id` returns HTTP 404 `attachment_not_found`: the same response as an unknown id, so a response never confirms an id exists. ### Path Parameters - `att: string` ### Returns - `AttachmentDetail object { id, mime, size, 2 more }` One stored attachment's metadata, as stored. - `id: string` Attachment id (`att_…`). - `mime: string` The STORED MIME type; `application/octet-stream` when the upload declared none. - `size: number` Stored size in bytes. - `status: string` Whether this attachment's bytes are usable. `ready` is the only value a send, a template save or a download accepts; anything else is why one of those was refused. This read is never gated on it; it is where you look up the answer. Today's values are `ready`, `pending` (bytes not stored yet) and `failed` (they never will be). Everything `POST /v1/attachments` returns is `ready` the moment it responds: the bytes are in the request. The other two exist because an upload that mints an id BEFORE the bytes move cannot say that, and the field is here now so that shape is not a second breaking response change. Modelled as a string rather than a closed union **deliberately**: the set of values can grow (a content scanner adds a verdict of its own), and a closed enum would make that addition the breaking change this field exists to avoid. Branch on `ready` and treat every other value as unusable-with-a-name; do not try to list them all. - `name: optional string` Stored display name; absent when the upload sent no `x-filename`. ### Example ```http curl https://messages.api.linqapp.com/v1/attachments/$ATT \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "att_1a3f77", "mime": "image/png", "name": "receipt.png", "size": 20481, "status": "ready" } ``` --- # Channels URL: https://docs.linqapp.com/channel/amb/api/resources/channels/ ## List linked channels **get** `/v1/channels` Lists the channel identities linked to your brand. An empty list means nothing is linked yet. ### Returns - `channels: array of ChannelBinding` Always an array; `[]` when nothing is linked yet, never null. - `business_id: string` The linked business id: a UUID issued at business registration. - `channel: "amb"` The messaging channel. `amb` is the only channel this API serves today. - `"amb"` - `created_at: string` - `entry_point_url: string` The Messages entry deep link for this binding (`https://bcrw.apple.com/urn:biz:`); open it from an approved tester device to start a chat with the business and verify the link end to end. DERIVED from `business_id`, not stored: the URL format is platform knowledge, served here so no client composes it by hand. - `origin: "seed" or "connect" or "control"` Where the link came from: `connect` is a self-service link (the Connect landing page, or `POST /v1/channels`: the same self-service path over this API), `seed` is boot configuration, `control` was provisioned by Linq. Provenance only; all three behave identically. - `"seed"` - `"connect"` - `"control"` ### Example ```http curl https://messages.api.linqapp.com/v1/channels \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "channels": [ { "channel": "amb", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "origin": "connect", "entry_point_url": "https://bcrw.apple.com/urn:biz:a884eddf-b0ad-4be4-9c0e-071531638768", "created_at": "2026-08-01T12:00:00Z" } ] } ``` ## Link a channel **post** `/v1/channels` Links an Apple Messages for Business business to your brand. **Behavior** - Repeating a link you already hold returns HTTP 200 `already_linked`; idempotent, never an error. - Never re-points an existing binding: each collision returns a distinct HTTP 409 naming the conflict (`brand_occupied`, `cross_brand_conflict`). - Unlinking is not available through this API; contact us. ### Body Parameters - `business_id: string` The business id to link, as a UUID. Anything else is `400 business_id_invalid`. - `channel: "amb"` Must be `amb`; any other value is `400 channel_unsupported`. - `"amb"` - `claim_token: optional string` The claim token authorising this link, issued to you by Linq for this specific business. Required to CREATE a link: without it the call is `400 claim_required`, and a token that does not match this brand and this business is `403 claim_invalid`. Optional rather than required because re-posting a link your brand already holds answers `200 already_linked` and needs no token. A claim is single-use, so a replay has none left to send. Omit it in that case. Every rejection is the same `claim_invalid` regardless of why (unknown, expired, already used, withdrawn, or issued for a different business), so the endpoint cannot be used to discover which businesses exist or who holds them. ### Returns - `ChannelLinkResponse object { channel, outcome }` A decided link. `outcome` says whether this call created it. - `channel: ChannelBinding` One linked channel identity: the Apple Messages for Business business this brand receives from and sends as. - `business_id: string` The linked business id: a UUID issued at business registration. - `channel: "amb"` The messaging channel. `amb` is the only channel this API serves today. - `"amb"` - `created_at: string` - `entry_point_url: string` The Messages entry deep link for this binding (`https://bcrw.apple.com/urn:biz:`); open it from an approved tester device to start a chat with the business and verify the link end to end. DERIVED from `business_id`, not stored: the URL format is platform knowledge, served here so no client composes it by hand. - `origin: "seed" or "connect" or "control"` Where the link came from: `connect` is a self-service link (the Connect landing page, or `POST /v1/channels`: the same self-service path over this API), `seed` is boot configuration, `control` was provisioned by Linq. Provenance only; all three behave identically. - `"seed"` - `"connect"` - `"control"` - `outcome: "linked" or "already_linked"` `linked` (status 201); this call created the binding. `already_linked` (status 200): your brand already held exactly this binding; nothing changed and nothing is wrong. The two conflicts are errors, not outcomes: `409 brand_occupied` (your brand already holds a DIFFERENT business id on the channel) and `409 cross_brand_conflict` (the business id is held by another brand). A link is never re-pointed by this endpoint. - `"linked"` - `"already_linked"` ### Example ```http curl https://messages.api.linqapp.com/v1/channels \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "channel": "amb" }' ``` #### Response ```json { "outcome": "linked", "channel": { "channel": "amb", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "origin": "connect", "entry_point_url": "https://bcrw.apple.com/urn:biz:a884eddf-b0ad-4be4-9c0e-071531638768", "created_at": "2026-08-01T12:00:00Z" } } ``` ## Domain Types ### Channel Binding - `ChannelBinding object { business_id, channel, created_at, 2 more }` One linked channel identity: the Apple Messages for Business business this brand receives from and sends as. - `business_id: string` The linked business id: a UUID issued at business registration. - `channel: "amb"` The messaging channel. `amb` is the only channel this API serves today. - `"amb"` - `created_at: string` - `entry_point_url: string` The Messages entry deep link for this binding (`https://bcrw.apple.com/urn:biz:`); open it from an approved tester device to start a chat with the business and verify the link end to end. DERIVED from `business_id`, not stored: the URL format is platform knowledge, served here so no client composes it by hand. - `origin: "seed" or "connect" or "control"` Where the link came from: `connect` is a self-service link (the Connect landing page, or `POST /v1/channels`: the same self-service path over this API), `seed` is boot configuration, `control` was provisioned by Linq. Provenance only; all three behave identically. - `"seed"` - `"connect"` - `"control"` ### Channel Link Response - `ChannelLinkResponse object { channel, outcome }` A decided link. `outcome` says whether this call created it. - `channel: ChannelBinding` One linked channel identity: the Apple Messages for Business business this brand receives from and sends as. - `business_id: string` The linked business id: a UUID issued at business registration. - `channel: "amb"` The messaging channel. `amb` is the only channel this API serves today. - `"amb"` - `created_at: string` - `entry_point_url: string` The Messages entry deep link for this binding (`https://bcrw.apple.com/urn:biz:`); open it from an approved tester device to start a chat with the business and verify the link end to end. DERIVED from `business_id`, not stored: the URL format is platform knowledge, served here so no client composes it by hand. - `origin: "seed" or "connect" or "control"` Where the link came from: `connect` is a self-service link (the Connect landing page, or `POST /v1/channels`: the same self-service path over this API), `seed` is boot configuration, `control` was provisioned by Linq. Provenance only; all three behave identically. - `"seed"` - `"connect"` - `"control"` - `outcome: "linked" or "already_linked"` `linked` (status 201); this call created the binding. `already_linked` (status 200): your brand already held exactly this binding; nothing changed and nothing is wrong. The two conflicts are errors, not outcomes: `409 brand_occupied` (your brand already holds a DIFFERENT business id on the channel) and `409 cross_brand_conflict` (the business id is held by another brand). A link is never re-pointed by this endpoint. - `"linked"` - `"already_linked"` ### Channel List Response - `ChannelListResponse object { channels }` The brand's linked channel identities. - `channels: array of ChannelBinding` Always an array; `[]` when nothing is linked yet, never null. - `business_id: string` The linked business id: a UUID issued at business registration. - `channel: "amb"` The messaging channel. `amb` is the only channel this API serves today. - `"amb"` - `created_at: string` - `entry_point_url: string` The Messages entry deep link for this binding (`https://bcrw.apple.com/urn:biz:`); open it from an approved tester device to start a chat with the business and verify the link end to end. DERIVED from `business_id`, not stored: the URL format is platform knowledge, served here so no client composes it by hand. - `origin: "seed" or "connect" or "control"` Where the link came from: `connect` is a self-service link (the Connect landing page, or `POST /v1/channels`: the same self-service path over this API), `seed` is boot configuration, `control` was provisioned by Linq. Provenance only; all three behave identically. - `"seed"` - `"connect"` - `"control"` --- # Link a channel URL: https://docs.linqapp.com/channel/amb/api/resources/channels/methods/link/ **post** `/v1/channels` Links an Apple Messages for Business business to your brand. **Behavior** - Repeating a link you already hold returns HTTP 200 `already_linked`; idempotent, never an error. - Never re-points an existing binding: each collision returns a distinct HTTP 409 naming the conflict (`brand_occupied`, `cross_brand_conflict`). - Unlinking is not available through this API; contact us. ### Body Parameters - `business_id: string` The business id to link, as a UUID. Anything else is `400 business_id_invalid`. - `channel: "amb"` Must be `amb`; any other value is `400 channel_unsupported`. - `"amb"` - `claim_token: optional string` The claim token authorising this link, issued to you by Linq for this specific business. Required to CREATE a link: without it the call is `400 claim_required`, and a token that does not match this brand and this business is `403 claim_invalid`. Optional rather than required because re-posting a link your brand already holds answers `200 already_linked` and needs no token. A claim is single-use, so a replay has none left to send. Omit it in that case. Every rejection is the same `claim_invalid` regardless of why (unknown, expired, already used, withdrawn, or issued for a different business), so the endpoint cannot be used to discover which businesses exist or who holds them. ### Returns - `ChannelLinkResponse object { channel, outcome }` A decided link. `outcome` says whether this call created it. - `channel: ChannelBinding` One linked channel identity: the Apple Messages for Business business this brand receives from and sends as. - `business_id: string` The linked business id: a UUID issued at business registration. - `channel: "amb"` The messaging channel. `amb` is the only channel this API serves today. - `"amb"` - `created_at: string` - `entry_point_url: string` The Messages entry deep link for this binding (`https://bcrw.apple.com/urn:biz:`); open it from an approved tester device to start a chat with the business and verify the link end to end. DERIVED from `business_id`, not stored: the URL format is platform knowledge, served here so no client composes it by hand. - `origin: "seed" or "connect" or "control"` Where the link came from: `connect` is a self-service link (the Connect landing page, or `POST /v1/channels`: the same self-service path over this API), `seed` is boot configuration, `control` was provisioned by Linq. Provenance only; all three behave identically. - `"seed"` - `"connect"` - `"control"` - `outcome: "linked" or "already_linked"` `linked` (status 201); this call created the binding. `already_linked` (status 200): your brand already held exactly this binding; nothing changed and nothing is wrong. The two conflicts are errors, not outcomes: `409 brand_occupied` (your brand already holds a DIFFERENT business id on the channel) and `409 cross_brand_conflict` (the business id is held by another brand). A link is never re-pointed by this endpoint. - `"linked"` - `"already_linked"` ### Example ```http curl https://messages.api.linqapp.com/v1/channels \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "channel": "amb" }' ``` #### Response ```json { "outcome": "linked", "channel": { "channel": "amb", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "origin": "connect", "entry_point_url": "https://bcrw.apple.com/urn:biz:a884eddf-b0ad-4be4-9c0e-071531638768", "created_at": "2026-08-01T12:00:00Z" } } ``` --- # List linked channels URL: https://docs.linqapp.com/channel/amb/api/resources/channels/methods/list/ **get** `/v1/channels` Lists the channel identities linked to your brand. An empty list means nothing is linked yet. ### Returns - `channels: array of ChannelBinding` Always an array; `[]` when nothing is linked yet, never null. - `business_id: string` The linked business id: a UUID issued at business registration. - `channel: "amb"` The messaging channel. `amb` is the only channel this API serves today. - `"amb"` - `created_at: string` - `entry_point_url: string` The Messages entry deep link for this binding (`https://bcrw.apple.com/urn:biz:`); open it from an approved tester device to start a chat with the business and verify the link end to end. DERIVED from `business_id`, not stored: the URL format is platform knowledge, served here so no client composes it by hand. - `origin: "seed" or "connect" or "control"` Where the link came from: `connect` is a self-service link (the Connect landing page, or `POST /v1/channels`: the same self-service path over this API), `seed` is boot configuration, `control` was provisioned by Linq. Provenance only; all three behave identically. - `"seed"` - `"connect"` - `"control"` ### Example ```http curl https://messages.api.linqapp.com/v1/channels \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "channels": [ { "channel": "amb", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "origin": "connect", "entry_point_url": "https://bcrw.apple.com/urn:biz:a884eddf-b0ad-4be4-9c0e-071531638768", "created_at": "2026-08-01T12:00:00Z" } ] } ``` --- # Chats URL: https://docs.linqapp.com/channel/amb/api/resources/chats/ ## List chats **get** `/v1/chats` Lists your brand's chats, newest first, one page at a time. **Behavior** - Scoped to your API key's brand by construction: no parameter names a brand, and another brand's chats are invisible rather than forbidden. - Page with `cursor`: pass the previous response's `next_cursor` back verbatim, and stop when it comes back empty. ### Query Parameters - `assigned_operator: optional string` Filter by the operator holding the chat; absent means every row, assigned or not. - `cursor: optional string` The previous page's `next_cursor`, verbatim; absent starts from the first page. Not a cursor this API issued returns HTTP 400 `code` 1013. - `limit: optional number` Page size; absent (or non-numeric) reads as 25, above 100 reads as 100. - `owner: optional string` Filter by turn owner (`partner`, `flow`, `human_pending`, `human`); absent means every owner. - `state: optional string` Filter by lifecycle phase (`open`, `closed`); absent means both. ### Returns - `data: array of object { capabilities, chat_id, customer_handle, 10 more }` - `capabilities: array of string` The device's most recently announced capability tokens. Empty means unknown, never "supports nothing". - `chat_id: string` Chat id (`chat_…`); pass it to `GET /v1/chats/{chat}` for the full view. - `customer_handle: string` The customer identifier: an Apple Opaque ID, or canonical `tel:+E164`, per `handle_kind`. - `handle_kind: "opaque" or "tel"` Which identifier kind `customer_handle` holds; branch on this, never read the string itself. - `"opaque"` - `"tel"` - `origin: "customer" or "invitation"` How the chat began. Not derivable from `handle_kind`: an accepted invitation arrives under a brand-new opaque id. - `"customer"` - `"invitation"` - `owner: string` Turn owner: `partner`, `flow`, `human_pending` or `human`. `human_pending` is the queue. - `owner_since: string` When the chat entered its CURRENT owner state: the queue clock, NOT `updated_at` (which any progress bumps). Stamped only when the owner actually changes; see `GET /v1/chats/{chat}` for the one self-clearing exception. - `state: string` Lifecycle phase: `open` or `closed`. - `updated_at: string` Last change of any kind: the list's sort key, and the first half of the cursor. - `assigned_operator: optional string` The operator holding this chat; absent when nobody does (the common case). - `business_id: optional string` The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. The same value `GET /v1/chats/{chat}` serves. - `last_inbound_at: optional string` When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet). - `last_message: optional object { actor, at, has_attachment, 2 more }` The newest message on this chat, so the row has a subject line instead of only a handle and a wait age. ABSENT when the chat has nothing previewable: an invitation nobody has answered, a chat whose only events are ownership changes, or a message that decoded to nothing. Render nothing in that case; never invent a preview. - `actor: string` Who spoke: `customer` on an inbound, `partner` on an outbound. It is the event log's actor, not the composer: a send is recorded by the worker that delivered it, so a reply a human agent typed still reads `partner` here. `GET /v1/chats/{chat}/transcript` is where a human is named. - `at: string` When that message was recorded. Distinct from both of its neighbours on this row: `last_inbound_at` moves only on inbound, and `updated_at` moves on any machine progress. Do not treat the three as one clock. - `has_attachment: boolean` Whether the message carried files. Deliberately ORTHOGONAL to `kind`, because the two answer different questions: `kind` says what to write on the row and this says whether to badge it. `kind: "text"` with this `true` is words PLUS a file: the one multi-part combination Apple admits. - `kind: "text" or "attachment" or "interactive" or "reply"` What kind of thing it was: a CLOSED set. `text`: there were words. `attachment`: there were none, only files. `reply`: the customer answered an interactive message instead of typing. `interactive`: the business sent one. Words win when a message has both. - `"text"` - `"attachment"` - `"interactive"` - `"reply"` - `preview: optional string` The opening words, with attachment placeholders (``) removed and a single `…` where it was cut. ABSENT rather than empty when no words survived: an attachment-only message has a body of exactly ``, and a preview built from it verbatim would put an invisible character on the row. Say "sent a file" in your own words instead; this API does not ship desk copy. - `next_cursor: string` Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the list, that, not an empty `data`, is how paging ends. ### Example ```http curl https://messages.api.linqapp.com/v1/chats \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "chat_id": "chat_4f81b2", "state": "open", "owner": "human_pending", "customer_handle": "urn:mbid:AQAAY7c1", "handle_kind": "opaque", "origin": "customer", "capabilities": [ "TEXT", "LIST", "TIME", "QUICK" ], "owner_since": "2026-08-06T14:02:11Z", "last_inbound_at": "2026-08-06T14:01:58Z", "updated_at": "2026-08-06T14:02:11Z", "last_message": { "actor": "customer", "at": "2026-08-06T14:01:58Z", "kind": "text", "has_attachment": false, "preview": "I need a human agent" } } ], "next_cursor": "MjAyNi0wOC0wNlQxNDowMTo1OFogY2hhdF80ZjgxYjI" } ``` ## Get a chat **get** `/v1/chats/{chat}` Retrieves a chat, including the customer's per-category consent and whether a send would currently be refused. ### Path Parameters - `chat: string` ### Returns - `ChatView object { id, brand, capabilities, 14 more }` The partner view of a chat. - `id: string` Chat id (`chat_…`). The LIST endpoint spells the same fact `chat_id`; neither is renamed to match the other. - `brand: object { id, business_hours, display_name, hours_timezone }` This chat's brand and its configured display/hours. - `id: string` The brand this chat belongs to; always your own. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a split shift is expressible. Absent when no hours are configured. - `display_name: optional string` The brand's display name; absent when it has none. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Hours are never configured without it; it may be configured without hours. - `capabilities: array of string` The device's most recently announced capability tokens. Empty means unknown, never "supports nothing". - `consent: unknown` Per-category consent for this chat's subject, scoped to the caller's brand (empty when none is recorded). - `customer_handle: string` The customer identifier: an Apple Opaque ID, or canonical `tel:+E164`, per `handle_kind`. - `handle_kind: "opaque" or "tel"` Which identifier kind `customer_handle` holds; branch on this, never read the string itself. A `tel` chat is not addressable for sends or typing. - `"opaque"` - `"tel"` - `origin: "customer" or "invitation"` How the chat began. Not derivable from `handle_kind`: an accepted invitation arrives under a brand-new opaque id. - `"customer"` - `"invitation"` - `owner: string` Turn owner: `partner`, `flow`, `human_pending` or `human`. Sends while not `partner` return HTTP 409 `code` 2011. `flow` is a platform flow holding the turn, typically the survey `/resolve` starts or the question `/clarify` asks: it ends on the customer's answer or its own timeout, and ownership then returns, recorded by `chat.owner_changed`. - `owner_since: string` When the chat entered its CURRENT owner state: the queue clock, NOT `updated_at` (which any progress bumps). Stamped only when the owner actually changes. One exception, and it is self-clearing: a chat that has not changed owner since the column was deployed reports the deploy time instead, which is a CEILING: deploy time is later than the true entry, so a wait derived from it reads SHORTER than the real one until that chat's next owner move. Don't page on it alone. - `send_blocked: object { blocked, code, message }` Whether a send would be refused right now, and why; read it BEFORE composing. - `blocked: boolean` - `code: number` The code the send would return, byte-identical to the `error.code` a real send would answer with; `0` when `blocked` is false. - `message: string` The message the send would return, verbatim; empty when `blocked` is false. - `state: string` Lifecycle phase: `open` or `closed`. Sends to a closed chat returns HTTP 409 `code` 2010. - `updated_at: string` Last change of any kind, machine progress included. - `assigned_operator: optional string` The operator holding this chat; absent when nobody does (the common case). - `business_id: optional string` The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. This is the current binding, not a per-message historical value, so it is the id you can act on today. Distinct from `brand.id`, which is your logical brand on this platform. - `capabilities_announced_at: optional string` When a device last ANNOUNCED a `capability-list`; NOT when we last heard from them (an inbound carrying no capability header moves `last_inbound_at` and leaves this alone). Absent means no device has ever announced. - `capture: optional object { expires_at, group_id, opened_at, page_id }` The chat's open reply capture (a degraded form mid-collection), so a takeover desk can warn before ending it: the group id `form.response` will carry, your page id, and the open/expiry instants. Absent when none is open; an expired capture is filtered out here, never closed by a read. - `expires_at: string` - `group_id: string` The degraded fan's correlation id: the primary prompt message's own id. - `opened_at: string` - `page_id: optional string` Your form page's id. - `last_inbound_at: optional string` When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet). A typing indicator is not recorded and does not move it; neither does anything the business sends. ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "chat_4f81b2", "state": "open", "owner": "partner", "capabilities": [ "TEXT", "LIST", "TIME", "QUICK", "FORM" ], "customer_handle": "urn:mbid:AQAAY7c1", "handle_kind": "opaque", "origin": "customer", "owner_since": "2026-08-06T14:02:11Z", "last_inbound_at": "2026-08-06T14:01:58Z", "updated_at": "2026-08-06T14:02:11Z", "capabilities_announced_at": "2026-08-06T14:01:58Z", "consent": { "marketing": { "state": "granted" }, "account_notification": { "state": "granted" } }, "brand": { "id": "biz_9f2c1a", "display_name": "CurbFare" }, "send_blocked": { "blocked": false, "code": 0, "message": "" } } ``` ## Clarify an unclassifiable turn **post** `/v1/chats/{chat}/clarify` Asks the customer one clarification question on your behalf, or escalates when this chat has already used it. **Behavior** - No request body. - Each chat gets one clarification question. A second call escalates instead of asking again, and the response's `action` says which happened. - Returns HTTP 202: the question is applied a moment after the response. - The question runs as a platform flow: the chat's `owner` becomes `flow` while it awaits the customer, and your sends during that window return HTTP 409 `code` 2011. A rephrase hands the turn straight back to you to classify; a reply asking for a person, or five minutes of silence, escalates instead. Every ownership movement is recorded by `chat.owner_changed`, and the flow's own steps appear on `GET /v1/chats/{chat}/events`. **Errors** - HTTP 409 `code` 2011: you no longer hold the send turn. ### Path Parameters - `chat: string` ### Returns - `action: "clarified" or "escalated"` `clarified`: the one bounded question was asked; `escalated`: this chat had already used its question, so we escalated instead. - `"clarified"` - `"escalated"` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/clarify \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "action": "clarified" } ``` ## Resolve a chat **post** `/v1/chats/{chat}/resolve` Resolves the chat and starts the satisfaction survey Apple asks for at resolution. **Errors** - HTTP 404 `code` 2001 when the chat does not exist for your brand: the same answer `/clarify` gives: both resolve the chat before they touch a flow. **Behavior** - No request body. - Returns HTTP 202: the resolution is applied a moment after the response. - Idempotent for 10 minutes: a second resolve inside that window returns HTTP 202 with `action: "already_resolved"` and does nothing, so a customer who answers the survey late is never surveyed twice. Past the window, a chat that genuinely finishes again can be resolved again. - The survey runs as a platform flow: the chat's `owner` becomes `flow` while the satisfaction card awaits an answer, and your sends during that window return HTTP 409 `code` 2011. The flow ends on the customer's reply (the next message is consumed as the survey answer; one asking for a person escalates instead) or after 120 seconds of silence, and the turn then returns, recorded by `chat.owner_changed`. So resolve when the conversation is finished: a customer who writes back inside that window is answering the survey, not restarting the thread with you. ### Path Parameters - `chat: string` ### Returns - `action: "resolved" or "already_resolved"` `resolved`: the CSAT flow was started; `already_resolved`: this chat was resolved within the last 10 minutes, so the call was a no-op; never retry it. - `"resolved"` - `"already_resolved"` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/resolve \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "action": "resolved" } ``` ## Domain Types ### Chat View - `ChatView object { id, brand, capabilities, 14 more }` The partner view of a chat. - `id: string` Chat id (`chat_…`). The LIST endpoint spells the same fact `chat_id`; neither is renamed to match the other. - `brand: object { id, business_hours, display_name, hours_timezone }` This chat's brand and its configured display/hours. - `id: string` The brand this chat belongs to; always your own. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a split shift is expressible. Absent when no hours are configured. - `display_name: optional string` The brand's display name; absent when it has none. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Hours are never configured without it; it may be configured without hours. - `capabilities: array of string` The device's most recently announced capability tokens. Empty means unknown, never "supports nothing". - `consent: unknown` Per-category consent for this chat's subject, scoped to the caller's brand (empty when none is recorded). - `customer_handle: string` The customer identifier: an Apple Opaque ID, or canonical `tel:+E164`, per `handle_kind`. - `handle_kind: "opaque" or "tel"` Which identifier kind `customer_handle` holds; branch on this, never read the string itself. A `tel` chat is not addressable for sends or typing. - `"opaque"` - `"tel"` - `origin: "customer" or "invitation"` How the chat began. Not derivable from `handle_kind`: an accepted invitation arrives under a brand-new opaque id. - `"customer"` - `"invitation"` - `owner: string` Turn owner: `partner`, `flow`, `human_pending` or `human`. Sends while not `partner` return HTTP 409 `code` 2011. `flow` is a platform flow holding the turn, typically the survey `/resolve` starts or the question `/clarify` asks: it ends on the customer's answer or its own timeout, and ownership then returns, recorded by `chat.owner_changed`. - `owner_since: string` When the chat entered its CURRENT owner state: the queue clock, NOT `updated_at` (which any progress bumps). Stamped only when the owner actually changes. One exception, and it is self-clearing: a chat that has not changed owner since the column was deployed reports the deploy time instead, which is a CEILING: deploy time is later than the true entry, so a wait derived from it reads SHORTER than the real one until that chat's next owner move. Don't page on it alone. - `send_blocked: object { blocked, code, message }` Whether a send would be refused right now, and why; read it BEFORE composing. - `blocked: boolean` - `code: number` The code the send would return, byte-identical to the `error.code` a real send would answer with; `0` when `blocked` is false. - `message: string` The message the send would return, verbatim; empty when `blocked` is false. - `state: string` Lifecycle phase: `open` or `closed`. Sends to a closed chat returns HTTP 409 `code` 2010. - `updated_at: string` Last change of any kind, machine progress included. - `assigned_operator: optional string` The operator holding this chat; absent when nobody does (the common case). - `business_id: optional string` The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. This is the current binding, not a per-message historical value, so it is the id you can act on today. Distinct from `brand.id`, which is your logical brand on this platform. - `capabilities_announced_at: optional string` When a device last ANNOUNCED a `capability-list`; NOT when we last heard from them (an inbound carrying no capability header moves `last_inbound_at` and leaves this alone). Absent means no device has ever announced. - `capture: optional object { expires_at, group_id, opened_at, page_id }` The chat's open reply capture (a degraded form mid-collection), so a takeover desk can warn before ending it: the group id `form.response` will carry, your page id, and the open/expiry instants. Absent when none is open; an expired capture is filtered out here, never closed by a read. - `expires_at: string` - `group_id: string` The degraded fan's correlation id: the primary prompt message's own id. - `opened_at: string` - `page_id: optional string` Your form page's id. - `last_inbound_at: optional string` When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet). A typing indicator is not recorded and does not move it; neither does anything the business sends. ### Consent Summary - `ConsentSummary object { state, expires_at }` A subject's current consent for one category. - `state: string` `granted` or `revoked`. - `expires_at: optional string` Grant expiry; absent when the record carries none. ### Chat List Response - `ChatListResponse object { data, next_cursor }` One page of chats, newest-first. - `data: array of object { capabilities, chat_id, customer_handle, 10 more }` - `capabilities: array of string` The device's most recently announced capability tokens. Empty means unknown, never "supports nothing". - `chat_id: string` Chat id (`chat_…`); pass it to `GET /v1/chats/{chat}` for the full view. - `customer_handle: string` The customer identifier: an Apple Opaque ID, or canonical `tel:+E164`, per `handle_kind`. - `handle_kind: "opaque" or "tel"` Which identifier kind `customer_handle` holds; branch on this, never read the string itself. - `"opaque"` - `"tel"` - `origin: "customer" or "invitation"` How the chat began. Not derivable from `handle_kind`: an accepted invitation arrives under a brand-new opaque id. - `"customer"` - `"invitation"` - `owner: string` Turn owner: `partner`, `flow`, `human_pending` or `human`. `human_pending` is the queue. - `owner_since: string` When the chat entered its CURRENT owner state: the queue clock, NOT `updated_at` (which any progress bumps). Stamped only when the owner actually changes; see `GET /v1/chats/{chat}` for the one self-clearing exception. - `state: string` Lifecycle phase: `open` or `closed`. - `updated_at: string` Last change of any kind: the list's sort key, and the first half of the cursor. - `assigned_operator: optional string` The operator holding this chat; absent when nobody does (the common case). - `business_id: optional string` The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. The same value `GET /v1/chats/{chat}` serves. - `last_inbound_at: optional string` When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet). - `last_message: optional object { actor, at, has_attachment, 2 more }` The newest message on this chat, so the row has a subject line instead of only a handle and a wait age. ABSENT when the chat has nothing previewable: an invitation nobody has answered, a chat whose only events are ownership changes, or a message that decoded to nothing. Render nothing in that case; never invent a preview. - `actor: string` Who spoke: `customer` on an inbound, `partner` on an outbound. It is the event log's actor, not the composer: a send is recorded by the worker that delivered it, so a reply a human agent typed still reads `partner` here. `GET /v1/chats/{chat}/transcript` is where a human is named. - `at: string` When that message was recorded. Distinct from both of its neighbours on this row: `last_inbound_at` moves only on inbound, and `updated_at` moves on any machine progress. Do not treat the three as one clock. - `has_attachment: boolean` Whether the message carried files. Deliberately ORTHOGONAL to `kind`, because the two answer different questions: `kind` says what to write on the row and this says whether to badge it. `kind: "text"` with this `true` is words PLUS a file: the one multi-part combination Apple admits. - `kind: "text" or "attachment" or "interactive" or "reply"` What kind of thing it was: a CLOSED set. `text`: there were words. `attachment`: there were none, only files. `reply`: the customer answered an interactive message instead of typing. `interactive`: the business sent one. Words win when a message has both. - `"text"` - `"attachment"` - `"interactive"` - `"reply"` - `preview: optional string` The opening words, with attachment placeholders (``) removed and a single `…` where it was cut. ABSENT rather than empty when no words survived: an attachment-only message has a body of exactly ``, and a preview built from it verbatim would put an invisible character on the row. Say "sent a file" in your own words instead; this API does not ship desk copy. - `next_cursor: string` Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the list, that, not an empty `data`, is how paging ends. ### Chat Clarify Response - `ChatClarifyResponse object { action }` What `/clarify` did on your behalf. - `action: "clarified" or "escalated"` `clarified`: the one bounded question was asked; `escalated`: this chat had already used its question, so we escalated instead. - `"clarified"` - `"escalated"` ### Chat Resolve Response - `ChatResolveResponse object { action }` What `/resolve` did. - `action: "resolved" or "already_resolved"` `resolved`: the CSAT flow was started; `already_resolved`: this chat was resolved within the last 10 minutes, so the call was a no-op; never retry it. - `"resolved"` - `"already_resolved"` # Messages ## Send a message **post** `/v1/chats/{chat}/messages` Sends a message into a chat. **Requirements** - `Idempotency-Key` header: required. A UUID you mint per logical send. - The chat must be open, and you must hold the send turn (see Chat control). **Behavior** - Replaying the same `Idempotency-Key` within 24 hours returns the original response. A refused request records no key, so the same key succeeds on retry. - An `https` URL in message text may be promoted to its own rich-link message. Each resulting message has its own `message.sent` or `message.failed` lifecycle, correlated by `group_id`. - Rate limiting is per brand: only your own traffic can produce it, and `Retry-After` is computed from your brand's own refill rate. A rate-limited request is not validated: an invalid send still fails with its own error once you are back within rate. A request that fans out into several messages costs one rate credit per message. **Errors** - HTTP 400: `idempotency_key_required`, `body_invalid` - HTTP 403: `consent_required` - HTTP 409: `chat_closed`, `chat_owned`, `capability_unsupported`, `idempotency_key_reused`, `idempotency_key_processing` while another request with the same key is still in flight - HTTP 413: `attachment_source_too_large` - HTTP 422: field-level validation (`category_invalid`, `degrade_invalid`, `attachment_source_invalid`, `attachment_source_empty`, and per-part codes), each naming the exact field at fault - HTTP 429: `rate_limited`, with `Retry-After` - HTTP 502: `attachment_source_unavailable` - HTTP 503: `send_paused`, `attachment_source_busy` (with `Retry-After`), or `attachment_storage_unavailable` ### Path Parameters - `chat: string` ### Query Parameters - `force: optional boolean` `true` bypasses the exclusive-owner gate (sends while `owner != partner`). A real override of a non-partner owner is audited (`chat.send_forced`). Does NOT override consent policy. ### Header Parameters - `"Idempotency-Key": string` ### Body Parameters - `parts: array of TextPart or ChoicesPart or ListPickerPart or 8 more` The message content, ≤20 parts. Structural rule (adapter L3): at most one interactive part per message, and `text` may not be combined with an interactive part; send two messages. An empty array returns HTTP 422 `code` 1052. - `TextPart object { body, type, subject }` A plain text message. subject renders bold on AMB. - `body: string` - `type: "text"` - `"text"` - `subject: optional string` - `ChoicesPart object { items, summary_text, type }` AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message. - `items: array of object { id, label }` - `id: string` - `label: string` - `summary_text: string` - `type: "choices"` - `"choices"` - `ListPickerPart object { sections, summary_text, type, 4 more }` AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id. - `sections: array of object { items, title, multi_select }` - `items: array of object { id, label, detail, image_id }` - `id: string` - `label: string` - `detail: optional string` - `image_id: optional string` - `title: string` - `multi_select: optional boolean` - `summary_text: string` - `type: "list_picker"` - `"list_picker"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `TimeSlotsPart object { event, slots, type, 5 more }` AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes. - `event: object { title, image_id, location }` - `title: string` - `image_id: optional string` - `location: optional object { latitude, longitude, radius_m, title }` - `latitude: optional number` - `longitude: optional number` - `radius_m: optional number` - `title: optional string` - `slots: array of object { id, duration_s, start }` - `id: string` - `duration_s: number` - `start: string` - `type: "time_slots"` - `"time_slots"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `timezone_offset_min: optional number` - `FormPart object { header, pages, type, 7 more }` AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true. - `header: string` - `pages: array of object { id, kind, subtitle, 15 more }` - `id: string` - `kind: "select" or "picker" or "datePicker" or "input"` - `"select"` - `"picker"` - `"datePicker"` - `"input"` - `subtitle: string` The question shown to the user. - `date_format: optional string` datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings. - `hint_text: optional string` datePicker only. - `items: optional array of object { id, label, image_id }` select and picker only. - `id: string` - `label: string` - `image_id: optional string` select items only. - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more` input only. - `"default"` - `"asciiCapable"` - `"numbersAndPunctuation"` - `"URL"` - `"numberPad"` - `"phonePad"` - `"namePhonePad"` - `"emailAddress"` - `"decimalPad"` - `"webSearch"` - `label_text: optional string` datePicker only; defaults to "Date". - `max_chars: optional number` input only. - `maximum_date: optional string` datePicker only, in date_format. - `minimum_date: optional string` datePicker only, in date_format. - `multiple: optional boolean` select only. - `next_page_id: optional string` - `picker_title: optional string` picker only; empty centers the field. - `required: optional boolean` input only. - `selected_item_index: optional number` picker only. - `start_date: optional string` datePicker only, in date_format. - `title: optional string` - `type: "form"` - `"form"` - `bubble_image_id: optional string` Icon on the received/reply message bubble. - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `show_summary: optional boolean` - `splash_button_title: optional string` Defaults to "Start". The splash view shows when any splash field is set. - `splash_image_id: optional string` - `splash_text: optional string` - `RichLinkPart object { title, type, url, 5 more }` A rich link card. Omit image_att_id to get the default card. The image must be a real PNG. - `title: string` - `type: "rich_link"` - `"rich_link"` - `url: string` - `app_clip: optional boolean` Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card. - `image_att_id: optional string` - `store_region: optional string` The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored. - `video_mime_type: optional string` MIME type of the video at video_url. video/mp4 is the recommended container. - `video_url: optional string` Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id. - `AttachmentPart object { att_id, type }` An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64. - `att_id: string` - `type: "attachment"` - `"attachment"` - `ApplePayPart object { payment_request, received_message, type }` AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings. - `payment_request: object { country_code, currency_code, line_items, 7 more }` - `country_code: string` - `currency_code: string` - `line_items: array of object { amount, label, type }` - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"` - `"supports3DS"` - `"supportsCredit"` - `"supportsDebit"` - `"supportsEMV"` - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more` - `"amex"` - `"discover"` - `"jcb"` - `"masterCard"` - `"privateLabel"` - `"visa"` - `total: object { amount, label, type }` The grand total. - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `shipping_methods: optional array of object { amount, detail, identifier, label }` - `amount: string` - `detail: string` - `identifier: string` - `label: string` - `supported_countries: optional array of string` - `received_message: object { title, image_att_id, subtitle }` The bubble the customer sees before authorizing. - `title: string` - `image_att_id: optional string` - `subtitle: optional string` - `type: "apple_pay"` - `"apple_pay"` - `AuthenticatePart object { received_title, reply_title, scope, 3 more }` Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts. - `received_title: string` The bubble the customer sees. - `reply_title: string` The bubble after completion. - `scope: array of string` - `type: "authenticate"` - `"authenticate"` - `redirect_uri: optional string` - `state: optional string` - `AppExtensionPart object { app, received_message, reply_message, 4 more }` A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it. - `app: string` Which of the brand's registered apps to send under. - `received_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `reply_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `type: "app_extension"` - `"app_extension"` - `url: string` The payload the Messages app hands to the extension. Opaque: never parsed or rewritten. - `session_id: optional string` Reuse to address a card already sent; omit and Apple generates one. - `use_live_layout: optional boolean` Defaults to true. - `SourceAttachmentPart object { name, source_url, type }` A partner-hosted attachment imported synchronously before the send is accepted. Linq stores a frozen copy; later source changes cannot alter delivery. The URL must be public HTTPS on port 443 and require no headers, cookies or authentication. Redirects are accepted only when every hop passes the same secure URL policy. - `name: string` Filename delivered to the customer. - `source_url: string` Public HTTPS URL fetched before acceptance. - `type: "attachment"` - `"attachment"` - `category: optional "transactional" or "account_notification" or "marketing"` Apple notification category. OPTIONAL by contract: absent means `transactional` (an in-chat reply): a bare `{"parts":[…]}` must keep working bit-for-bit. Unknown value → 422 `code` 1010. A non-transactional send with no valid opt-in on file returns HTTP 403 `code` 2015 (`force=true` does NOT override consent). - `"transactional"` - `"account_notification"` - `"marketing"` - `degrade: optional "reject" or "auto" or "acknowledged"` Capability fallback opt-in. Absent or `reject` (the default): an unsupported part type is refused 409 `code` 4005. `auto`: the part is rewritten to the best representation the device renders, and the response reports each swap it made in `fallbacks`. `acknowledged`: `auto`, plus the statement that a HUMAN has seen what the downgrade produces and accepted it; identical to `auto` for an automated caller, and the ONLY value that lets a human-attributed send be rewritten (a send attributed to a named operator that `auto` would rewrite is refused 409 `code` 4005, outright rather than warned about). Any other value → 422 `code` 1091. - `"reject"` - `"auto"` - `"acknowledged"` - `typing_lead_ms: optional number` How long the typing indicator runs before this message, in MILLISECONDS. THE INDICATOR IS YOURS TO ASK FOR. We show one for exactly as long as you request and never on our own, so this field is the whole control: absent or `0` shows no indicator at all, and a positive value shows one for precisely that long before the message reaches the customer. OPTIONAL by contract, and absent means the same as `0`: no indicator. We recommend asking for about 1000 ms before each message you send, and longer when the customer is about to wait on a lookup or a handoff, so the pause reads as thinking rather than as a stall. Bounded at 15000 (422 `code` 1096, which is also what a negative value gets). The cap is derived from the budget one send attempt gets: a longer indicator eats the time the message send itself needs, so the send would be cut short and retried and the customer would watch a long indicator followed by a late message or none. ### Returns - `id: string` The FIRST message's id: the send's primary id. - `chat_id: string` - `delivery: "queued"` - `"queued"` - `messages: array of SentMessage` Every message this request was accepted as, in delivery order; ALWAYS present, one element for an ordinary send. Longer when an `https` URL in your text was promoted to its own rich-link message: each element then has its own `message.sent`/`message.failed` lifecycle, correlated by `group_id`. More than 10 messages returns HTTP 422 `code` 1075. - `id: string` Message id (`msg_…`). - `type: string` The part type of that message's FIRST part. - `parts: optional array of unknown` The message's canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason `TranscriptRow.parts` is. - `capture: optional object { collection, expects, page_id, 4 more }` The reply capture this send opened; see `CaptureObject`. Absent when no capture opened. - `collection: string` `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved. - `expects: string` The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots). - `page_id: string` Your form page's own id: the key the eventual `reply.values` uses. - `reasks: number` How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1. - `timeout_s: number` How long the collection stays open, in seconds. Fixed at 1800. - `group_id: optional string` ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back. - `options: optional array of object { index, item_id, label }` Present for the choice shapes only: what the customer can answer with, by number or label. - `index: number` - `item_id: string` - `label: string` - `fallbacks: optional array of Fallback` Present ONLY when `degrade` (`"auto"` or `"acknowledged"`) actually rewrote a part: its presence is the signal that the customer saw something other than what you composed, and an acknowledgement does not suppress it. Absent otherwise (never an empty array). - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/messages \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "parts": [ { "body": "Your driver Marta is two minutes away in a blue Prius.", "type": "text" } ] }' ``` #### Response ```json { "id": "msg_2c7d90", "chat_id": "chat_4f81b2", "delivery": "queued", "messages": [ { "id": "msg_2c7d90", "type": "text" } ] } ``` ## Preview a message **post** `/v1/chats/{chat}/messages/preview` Previews a send without sending it. Use it to surface field-level errors and capability rewrites in a composer before anything reaches the customer. **Behavior** - Runs the identical accept pipeline as the send: the same validation, chat gates, `degrade` decision, and consent policy, but records nothing: no message id is minted, no idempotency claim is made, and no `Idempotency-Key` is required. - A body this endpoint refuses, the send refuses with the same `code` and `param`, except that a `source_url` attachment is not fetched during preview. Its reachability, MIME, byte size and storage readiness are therefore send-time facts, and the preview returns the unresolved source form rather than an `att_id`. Otherwise a body preview accepts is accepted by send, chat state permitting, since a preview cannot freeze the chat between the two calls. **Limits** - The per-message `wire` field is representative, never exact; `wire_fidelity` states this in the response. Values resolved only at delivery are substituted or omitted: payment sessions and app identities appear as placeholders, image bytes are omitted, and a plain attachment message has no `wire` at all. Do not present it as the exact payload Apple will receive. ### Path Parameters - `chat: string` ### Query Parameters - `force: optional boolean` Same meaning as on the send: `true` previews the send as an override of the exclusive-owner gate. ### Body Parameters - `parts: array of TextPart or ChoicesPart or ListPickerPart or 8 more` The message content, ≤20 parts. Structural rule (adapter L3): at most one interactive part per message, and `text` may not be combined with an interactive part; send two messages. An empty array returns HTTP 422 `code` 1052. - `TextPart object { body, type, subject }` A plain text message. subject renders bold on AMB. - `body: string` - `type: "text"` - `"text"` - `subject: optional string` - `ChoicesPart object { items, summary_text, type }` AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message. - `items: array of object { id, label }` - `id: string` - `label: string` - `summary_text: string` - `type: "choices"` - `"choices"` - `ListPickerPart object { sections, summary_text, type, 4 more }` AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id. - `sections: array of object { items, title, multi_select }` - `items: array of object { id, label, detail, image_id }` - `id: string` - `label: string` - `detail: optional string` - `image_id: optional string` - `title: string` - `multi_select: optional boolean` - `summary_text: string` - `type: "list_picker"` - `"list_picker"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `TimeSlotsPart object { event, slots, type, 5 more }` AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes. - `event: object { title, image_id, location }` - `title: string` - `image_id: optional string` - `location: optional object { latitude, longitude, radius_m, title }` - `latitude: optional number` - `longitude: optional number` - `radius_m: optional number` - `title: optional string` - `slots: array of object { id, duration_s, start }` - `id: string` - `duration_s: number` - `start: string` - `type: "time_slots"` - `"time_slots"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `timezone_offset_min: optional number` - `FormPart object { header, pages, type, 7 more }` AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true. - `header: string` - `pages: array of object { id, kind, subtitle, 15 more }` - `id: string` - `kind: "select" or "picker" or "datePicker" or "input"` - `"select"` - `"picker"` - `"datePicker"` - `"input"` - `subtitle: string` The question shown to the user. - `date_format: optional string` datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings. - `hint_text: optional string` datePicker only. - `items: optional array of object { id, label, image_id }` select and picker only. - `id: string` - `label: string` - `image_id: optional string` select items only. - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more` input only. - `"default"` - `"asciiCapable"` - `"numbersAndPunctuation"` - `"URL"` - `"numberPad"` - `"phonePad"` - `"namePhonePad"` - `"emailAddress"` - `"decimalPad"` - `"webSearch"` - `label_text: optional string` datePicker only; defaults to "Date". - `max_chars: optional number` input only. - `maximum_date: optional string` datePicker only, in date_format. - `minimum_date: optional string` datePicker only, in date_format. - `multiple: optional boolean` select only. - `next_page_id: optional string` - `picker_title: optional string` picker only; empty centers the field. - `required: optional boolean` input only. - `selected_item_index: optional number` picker only. - `start_date: optional string` datePicker only, in date_format. - `title: optional string` - `type: "form"` - `"form"` - `bubble_image_id: optional string` Icon on the received/reply message bubble. - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `show_summary: optional boolean` - `splash_button_title: optional string` Defaults to "Start". The splash view shows when any splash field is set. - `splash_image_id: optional string` - `splash_text: optional string` - `RichLinkPart object { title, type, url, 5 more }` A rich link card. Omit image_att_id to get the default card. The image must be a real PNG. - `title: string` - `type: "rich_link"` - `"rich_link"` - `url: string` - `app_clip: optional boolean` Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card. - `image_att_id: optional string` - `store_region: optional string` The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored. - `video_mime_type: optional string` MIME type of the video at video_url. video/mp4 is the recommended container. - `video_url: optional string` Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id. - `AttachmentPart object { att_id, type }` An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64. - `att_id: string` - `type: "attachment"` - `"attachment"` - `ApplePayPart object { payment_request, received_message, type }` AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings. - `payment_request: object { country_code, currency_code, line_items, 7 more }` - `country_code: string` - `currency_code: string` - `line_items: array of object { amount, label, type }` - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"` - `"supports3DS"` - `"supportsCredit"` - `"supportsDebit"` - `"supportsEMV"` - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more` - `"amex"` - `"discover"` - `"jcb"` - `"masterCard"` - `"privateLabel"` - `"visa"` - `total: object { amount, label, type }` The grand total. - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `shipping_methods: optional array of object { amount, detail, identifier, label }` - `amount: string` - `detail: string` - `identifier: string` - `label: string` - `supported_countries: optional array of string` - `received_message: object { title, image_att_id, subtitle }` The bubble the customer sees before authorizing. - `title: string` - `image_att_id: optional string` - `subtitle: optional string` - `type: "apple_pay"` - `"apple_pay"` - `AuthenticatePart object { received_title, reply_title, scope, 3 more }` Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts. - `received_title: string` The bubble the customer sees. - `reply_title: string` The bubble after completion. - `scope: array of string` - `type: "authenticate"` - `"authenticate"` - `redirect_uri: optional string` - `state: optional string` - `AppExtensionPart object { app, received_message, reply_message, 4 more }` A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it. - `app: string` Which of the brand's registered apps to send under. - `received_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `reply_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `type: "app_extension"` - `"app_extension"` - `url: string` The payload the Messages app hands to the extension. Opaque: never parsed or rewritten. - `session_id: optional string` Reuse to address a card already sent; omit and Apple generates one. - `use_live_layout: optional boolean` Defaults to true. - `SourceAttachmentPart object { name, source_url, type }` A partner-hosted attachment imported synchronously before the send is accepted. Linq stores a frozen copy; later source changes cannot alter delivery. The URL must be public HTTPS on port 443 and require no headers, cookies or authentication. Redirects are accepted only when every hop passes the same secure URL policy. - `name: string` Filename delivered to the customer. - `source_url: string` Public HTTPS URL fetched before acceptance. - `type: "attachment"` - `"attachment"` - `category: optional "transactional" or "account_notification" or "marketing"` Apple notification category. OPTIONAL by contract: absent means `transactional` (an in-chat reply): a bare `{"parts":[…]}` must keep working bit-for-bit. Unknown value → 422 `code` 1010. A non-transactional send with no valid opt-in on file returns HTTP 403 `code` 2015 (`force=true` does NOT override consent). - `"transactional"` - `"account_notification"` - `"marketing"` - `degrade: optional "reject" or "auto" or "acknowledged"` Capability fallback opt-in. Absent or `reject` (the default): an unsupported part type is refused 409 `code` 4005. `auto`: the part is rewritten to the best representation the device renders, and the response reports each swap it made in `fallbacks`. `acknowledged`: `auto`, plus the statement that a HUMAN has seen what the downgrade produces and accepted it; identical to `auto` for an automated caller, and the ONLY value that lets a human-attributed send be rewritten (a send attributed to a named operator that `auto` would rewrite is refused 409 `code` 4005, outright rather than warned about). Any other value → 422 `code` 1091. - `"reject"` - `"auto"` - `"acknowledged"` - `typing_lead_ms: optional number` How long the typing indicator runs before this message, in MILLISECONDS. THE INDICATOR IS YOURS TO ASK FOR. We show one for exactly as long as you request and never on our own, so this field is the whole control: absent or `0` shows no indicator at all, and a positive value shows one for precisely that long before the message reaches the customer. OPTIONAL by contract, and absent means the same as `0`: no indicator. We recommend asking for about 1000 ms before each message you send, and longer when the customer is about to wait on a lookup or a handoff, so the pause reads as thinking rather than as a stall. Bounded at 15000 (422 `code` 1096, which is also what a negative value gets). The cap is derived from the budget one send attempt gets: a longer indicator eats the time the message send itself needs, so the send would be cut short and retried and the customer would watch a long indicator followed by a late message or none. ### Returns - `PreviewMessageResponse object { chat_id, messages, wire_fidelity, 2 more }` The previewed send. Nothing was written; nothing will be sent. - `chat_id: string` - `messages: array of PreviewedMessage` What the ONE request would become, message by message (URL promotion can make several). - `parts: array of unknown` The message's post-degrade, post-promotion parts. A URL attachment remains in its source form because preview does not fetch it or mint the real `att_id` that an accepted send returns. Typed as an opaque array rather than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is. - `type: string` The part type of this message's FIRST part: the same value the send's `200` reports per message. - `wire: optional unknown` The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when `wire_unavailable` says why. - `wire_unavailable: optional string` Present only when this message has no wire preview (a plain attachment message), and why. - `wire_fidelity: "representative"` Always `representative`: the wire bytes are an approximation of what delivery sends, never the exact payload. - `"representative"` - `capture: optional object { collection, expects, page_id, 4 more }` The reply capture this send WOULD open, identical to the send `200`'s object except `group_id`, which is ABSENT here (nothing was minted). Absent entirely when no capture would open. - `collection: string` `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved. - `expects: string` The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots). - `page_id: string` Your form page's own id: the key the eventual `reply.values` uses. - `reasks: number` How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1. - `timeout_s: number` How long the collection stays open, in seconds. Fixed at 1800. - `group_id: optional string` ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back. - `options: optional array of object { index, item_id, label }` Present for the choice shapes only: what the customer can answer with, by number or label. - `index: number` - `item_id: string` - `label: string` - `fallbacks: optional array of Fallback` The capability rewrites `degrade` would apply: the same array the send's `200` would carry. Present only when something would be rewritten. - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/messages/preview \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "parts": [ { "items": [ { "id": "curb", "label": "Curbside" }, { "id": "garage", "label": "Garage level 2" } ], "summary_text": "Pickup options", "type": "choices" } ] }' ``` #### Response ```json { "chat_id": "chat_4f81b2", "wire_fidelity": "representative", "messages": [ { "type": "choices", "parts": [ { "type": "choices", "summary_text": "Pickup options", "items": [ { "id": "curb", "label": "Curbside" }, { "id": "garage", "label": "Garage level 2" } ] } ] } ] } ``` ## Domain Types ### App Extension Part - `AppExtensionPart object { app, received_message, reply_message, 4 more }` A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it. - `app: string` Which of the brand's registered apps to send under. - `received_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `reply_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `type: "app_extension"` - `"app_extension"` - `url: string` The payload the Messages app hands to the extension. Opaque: never parsed or rewritten. - `session_id: optional string` Reuse to address a card already sent; omit and Apple generates one. - `use_live_layout: optional boolean` Defaults to true. ### Apple Pay Part - `ApplePayPart object { payment_request, received_message, type }` AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings. - `payment_request: object { country_code, currency_code, line_items, 7 more }` - `country_code: string` - `currency_code: string` - `line_items: array of object { amount, label, type }` - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"` - `"supports3DS"` - `"supportsCredit"` - `"supportsDebit"` - `"supportsEMV"` - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more` - `"amex"` - `"discover"` - `"jcb"` - `"masterCard"` - `"privateLabel"` - `"visa"` - `total: object { amount, label, type }` The grand total. - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `shipping_methods: optional array of object { amount, detail, identifier, label }` - `amount: string` - `detail: string` - `identifier: string` - `label: string` - `supported_countries: optional array of string` - `received_message: object { title, image_att_id, subtitle }` The bubble the customer sees before authorizing. - `title: string` - `image_att_id: optional string` - `subtitle: optional string` - `type: "apple_pay"` - `"apple_pay"` ### Attachment Part - `AttachmentPart object { att_id, type }` An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64. - `att_id: string` - `type: "attachment"` - `"attachment"` ### Authenticate Part - `AuthenticatePart object { received_title, reply_title, scope, 3 more }` Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts. - `received_title: string` The bubble the customer sees. - `reply_title: string` The bubble after completion. - `scope: array of string` - `type: "authenticate"` - `"authenticate"` - `redirect_uri: optional string` - `state: optional string` ### Choices Part - `ChoicesPart object { items, summary_text, type }` AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message. - `items: array of object { id, label }` - `id: string` - `label: string` - `summary_text: string` - `type: "choices"` - `"choices"` ### Fallback - `Fallback object { token, from, rule, 2 more }` One capability rewrite performed under `degrade: "auto"`, or `"acknowledged"`, which rewrites on exactly the same rules and records the swap the same way. - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Fallback Option - `FallbackOption object { index, item_id, label }` One entry of a menu a degrade rewrite flattened into words. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Form Part - `FormPart object { header, pages, type, 7 more }` AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true. - `header: string` - `pages: array of object { id, kind, subtitle, 15 more }` - `id: string` - `kind: "select" or "picker" or "datePicker" or "input"` - `"select"` - `"picker"` - `"datePicker"` - `"input"` - `subtitle: string` The question shown to the user. - `date_format: optional string` datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings. - `hint_text: optional string` datePicker only. - `items: optional array of object { id, label, image_id }` select and picker only. - `id: string` - `label: string` - `image_id: optional string` select items only. - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more` input only. - `"default"` - `"asciiCapable"` - `"numbersAndPunctuation"` - `"URL"` - `"numberPad"` - `"phonePad"` - `"namePhonePad"` - `"emailAddress"` - `"decimalPad"` - `"webSearch"` - `label_text: optional string` datePicker only; defaults to "Date". - `max_chars: optional number` input only. - `maximum_date: optional string` datePicker only, in date_format. - `minimum_date: optional string` datePicker only, in date_format. - `multiple: optional boolean` select only. - `next_page_id: optional string` - `picker_title: optional string` picker only; empty centers the field. - `required: optional boolean` input only. - `selected_item_index: optional number` picker only. - `start_date: optional string` datePicker only, in date_format. - `title: optional string` - `type: "form"` - `"form"` - `bubble_image_id: optional string` Icon on the received/reply message bubble. - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `show_summary: optional boolean` - `splash_button_title: optional string` Defaults to "Start". The splash view shows when any splash field is set. - `splash_image_id: optional string` - `splash_text: optional string` ### List Picker Part - `ListPickerPart object { sections, summary_text, type, 4 more }` AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id. - `sections: array of object { items, title, multi_select }` - `items: array of object { id, label, detail, image_id }` - `id: string` - `label: string` - `detail: optional string` - `image_id: optional string` - `title: string` - `multi_select: optional boolean` - `summary_text: string` - `type: "list_picker"` - `"list_picker"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` ### Part - `Part = TextPart or ChoicesPart or ListPickerPart or 7 more` The sealed part union: one variant per member of the canonical content model (10 types). Discriminated on the wire by each part's `type` const. - `TextPart object { body, type, subject }` A plain text message. subject renders bold on AMB. - `body: string` - `type: "text"` - `"text"` - `subject: optional string` - `ChoicesPart object { items, summary_text, type }` AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message. - `items: array of object { id, label }` - `id: string` - `label: string` - `summary_text: string` - `type: "choices"` - `"choices"` - `ListPickerPart object { sections, summary_text, type, 4 more }` AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id. - `sections: array of object { items, title, multi_select }` - `items: array of object { id, label, detail, image_id }` - `id: string` - `label: string` - `detail: optional string` - `image_id: optional string` - `title: string` - `multi_select: optional boolean` - `summary_text: string` - `type: "list_picker"` - `"list_picker"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `TimeSlotsPart object { event, slots, type, 5 more }` AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes. - `event: object { title, image_id, location }` - `title: string` - `image_id: optional string` - `location: optional object { latitude, longitude, radius_m, title }` - `latitude: optional number` - `longitude: optional number` - `radius_m: optional number` - `title: optional string` - `slots: array of object { id, duration_s, start }` - `id: string` - `duration_s: number` - `start: string` - `type: "time_slots"` - `"time_slots"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `timezone_offset_min: optional number` - `FormPart object { header, pages, type, 7 more }` AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true. - `header: string` - `pages: array of object { id, kind, subtitle, 15 more }` - `id: string` - `kind: "select" or "picker" or "datePicker" or "input"` - `"select"` - `"picker"` - `"datePicker"` - `"input"` - `subtitle: string` The question shown to the user. - `date_format: optional string` datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings. - `hint_text: optional string` datePicker only. - `items: optional array of object { id, label, image_id }` select and picker only. - `id: string` - `label: string` - `image_id: optional string` select items only. - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more` input only. - `"default"` - `"asciiCapable"` - `"numbersAndPunctuation"` - `"URL"` - `"numberPad"` - `"phonePad"` - `"namePhonePad"` - `"emailAddress"` - `"decimalPad"` - `"webSearch"` - `label_text: optional string` datePicker only; defaults to "Date". - `max_chars: optional number` input only. - `maximum_date: optional string` datePicker only, in date_format. - `minimum_date: optional string` datePicker only, in date_format. - `multiple: optional boolean` select only. - `next_page_id: optional string` - `picker_title: optional string` picker only; empty centers the field. - `required: optional boolean` input only. - `selected_item_index: optional number` picker only. - `start_date: optional string` datePicker only, in date_format. - `title: optional string` - `type: "form"` - `"form"` - `bubble_image_id: optional string` Icon on the received/reply message bubble. - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `show_summary: optional boolean` - `splash_button_title: optional string` Defaults to "Start". The splash view shows when any splash field is set. - `splash_image_id: optional string` - `splash_text: optional string` - `RichLinkPart object { title, type, url, 5 more }` A rich link card. Omit image_att_id to get the default card. The image must be a real PNG. - `title: string` - `type: "rich_link"` - `"rich_link"` - `url: string` - `app_clip: optional boolean` Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card. - `image_att_id: optional string` - `store_region: optional string` The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored. - `video_mime_type: optional string` MIME type of the video at video_url. video/mp4 is the recommended container. - `video_url: optional string` Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id. - `AttachmentPart object { att_id, type }` An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64. - `att_id: string` - `type: "attachment"` - `"attachment"` - `ApplePayPart object { payment_request, received_message, type }` AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings. - `payment_request: object { country_code, currency_code, line_items, 7 more }` - `country_code: string` - `currency_code: string` - `line_items: array of object { amount, label, type }` - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"` - `"supports3DS"` - `"supportsCredit"` - `"supportsDebit"` - `"supportsEMV"` - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more` - `"amex"` - `"discover"` - `"jcb"` - `"masterCard"` - `"privateLabel"` - `"visa"` - `total: object { amount, label, type }` The grand total. - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `shipping_methods: optional array of object { amount, detail, identifier, label }` - `amount: string` - `detail: string` - `identifier: string` - `label: string` - `supported_countries: optional array of string` - `received_message: object { title, image_att_id, subtitle }` The bubble the customer sees before authorizing. - `title: string` - `image_att_id: optional string` - `subtitle: optional string` - `type: "apple_pay"` - `"apple_pay"` - `AuthenticatePart object { received_title, reply_title, scope, 3 more }` Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts. - `received_title: string` The bubble the customer sees. - `reply_title: string` The bubble after completion. - `scope: array of string` - `type: "authenticate"` - `"authenticate"` - `redirect_uri: optional string` - `state: optional string` - `AppExtensionPart object { app, received_message, reply_message, 4 more }` A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it. - `app: string` Which of the brand's registered apps to send under. - `received_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `reply_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `type: "app_extension"` - `"app_extension"` - `url: string` The payload the Messages app hands to the extension. Opaque: never parsed or rewritten. - `session_id: optional string` Reuse to address a card already sent; omit and Apple generates one. - `use_live_layout: optional boolean` Defaults to true. ### Preview Message Response - `PreviewMessageResponse object { chat_id, messages, wire_fidelity, 2 more }` The previewed send. Nothing was written; nothing will be sent. - `chat_id: string` - `messages: array of PreviewedMessage` What the ONE request would become, message by message (URL promotion can make several). - `parts: array of unknown` The message's post-degrade, post-promotion parts. A URL attachment remains in its source form because preview does not fetch it or mint the real `att_id` that an accepted send returns. Typed as an opaque array rather than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is. - `type: string` The part type of this message's FIRST part: the same value the send's `200` reports per message. - `wire: optional unknown` The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when `wire_unavailable` says why. - `wire_unavailable: optional string` Present only when this message has no wire preview (a plain attachment message), and why. - `wire_fidelity: "representative"` Always `representative`: the wire bytes are an approximation of what delivery sends, never the exact payload. - `"representative"` - `capture: optional object { collection, expects, page_id, 4 more }` The reply capture this send WOULD open, identical to the send `200`'s object except `group_id`, which is ABSENT here (nothing was minted). Absent entirely when no capture would open. - `collection: string` `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved. - `expects: string` The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots). - `page_id: string` Your form page's own id: the key the eventual `reply.values` uses. - `reasks: number` How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1. - `timeout_s: number` How long the collection stays open, in seconds. Fixed at 1800. - `group_id: optional string` ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back. - `options: optional array of object { index, item_id, label }` Present for the choice shapes only: what the customer can answer with, by number or label. - `index: number` - `item_id: string` - `label: string` - `fallbacks: optional array of Fallback` The capability rewrites `degrade` would apply: the same array the send's `200` would carry. Present only when something would be rewritten. - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Previewed Message - `PreviewedMessage object { parts, type, wire, wire_unavailable }` One message of a previewed send, in send order (URL promotion can make several). - `parts: array of unknown` The message's post-degrade, post-promotion parts. A URL attachment remains in its source form because preview does not fetch it or mint the real `att_id` that an accepted send returns. Typed as an opaque array rather than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is. - `type: string` The part type of this message's FIRST part: the same value the send's `200` reports per message. - `wire: optional unknown` The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when `wire_unavailable` says why. - `wire_unavailable: optional string` Present only when this message has no wire preview (a plain attachment message), and why. ### Rich Link Part - `RichLinkPart object { title, type, url, 5 more }` A rich link card. Omit image_att_id to get the default card. The image must be a real PNG. - `title: string` - `type: "rich_link"` - `"rich_link"` - `url: string` - `app_clip: optional boolean` Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card. - `image_att_id: optional string` - `store_region: optional string` The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored. - `video_mime_type: optional string` MIME type of the video at video_url. video/mp4 is the recommended container. - `video_url: optional string` Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id. ### Sent Message - `SentMessage object { id, type, parts }` One accepted message of a send (URL promotion can make several). - `id: string` Message id (`msg_…`). - `type: string` The part type of that message's FIRST part. - `parts: optional array of unknown` The message's canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason `TranscriptRow.parts` is. ### Text Part - `TextPart object { body, type, subject }` A plain text message. subject renders bold on AMB. - `body: string` - `type: "text"` - `"text"` - `subject: optional string` ### Time Slots Part - `TimeSlotsPart object { event, slots, type, 5 more }` AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes. - `event: object { title, image_id, location }` - `title: string` - `image_id: optional string` - `location: optional object { latitude, longitude, radius_m, title }` - `latitude: optional number` - `longitude: optional number` - `radius_m: optional number` - `title: optional string` - `slots: array of object { id, duration_s, start }` - `id: string` - `duration_s: number` - `start: string` - `type: "time_slots"` - `"time_slots"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `timezone_offset_min: optional number` ### Message Send Response - `MessageSendResponse object { id, chat_id, delivery, 3 more }` The accepted send. A replay of the same Idempotency-Key within 24h returns these exact bytes; past that window the key is forgotten and the request executes again. - `id: string` The FIRST message's id: the send's primary id. - `chat_id: string` - `delivery: "queued"` - `"queued"` - `messages: array of SentMessage` Every message this request was accepted as, in delivery order; ALWAYS present, one element for an ordinary send. Longer when an `https` URL in your text was promoted to its own rich-link message: each element then has its own `message.sent`/`message.failed` lifecycle, correlated by `group_id`. More than 10 messages returns HTTP 422 `code` 1075. - `id: string` Message id (`msg_…`). - `type: string` The part type of that message's FIRST part. - `parts: optional array of unknown` The message's canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason `TranscriptRow.parts` is. - `capture: optional object { collection, expects, page_id, 4 more }` The reply capture this send opened; see `CaptureObject`. Absent when no capture opened. - `collection: string` `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved. - `expects: string` The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots). - `page_id: string` Your form page's own id: the key the eventual `reply.values` uses. - `reasks: number` How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1. - `timeout_s: number` How long the collection stays open, in seconds. Fixed at 1800. - `group_id: optional string` ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back. - `options: optional array of object { index, item_id, label }` Present for the choice shapes only: what the customer can answer with, by number or label. - `index: number` - `item_id: string` - `label: string` - `fallbacks: optional array of Fallback` Present ONLY when `degrade` (`"auto"` or `"acknowledged"`) actually rewrote a part: its presence is the signal that the customer saw something other than what you composed, and an acknowledgement does not suppress it. Absent otherwise (never an empty array). - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` # Typing ## Set the typing indicator **post** `/v1/chats/{chat}/typing` Sets or clears the chat's typing indicator. **Behavior** - Live only: the customer sees it as it happens; nothing is recorded. - Not needed around your own sends: put `typing_lead_ms` on the send instead, which shows the indicator for exactly as long as you ask. This endpoint is for showing activity while you are still working on a reply. - No `Idempotency-Key`: the server neither requires nor consults one here. ### Path Parameters - `chat: string` ### Body Parameters - `state: "start" or "end"` `start` shows the customer the typing indicator; `end` clears it. Any other value is a 422 `code` 1066. - `"start"` - `"end"` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/typing \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "state": "start" }' ``` #### Response ```json {} ``` ## Domain Types ### Typing Set Response - `TypingSetResponse = unknown` Typing succeeds with an empty JSON object; we do not wait for Apple to confirm it. # Events ## List chat events **get** `/v1/chats/{chat}/events` Lists the chat's event log: the ordered record you rebuild your own state from. Each entry carries a `seq` that only ever counts up, with no gaps; page with `after_seq`. ### Path Parameters - `chat: string` ### Query Parameters - `after_seq: optional number` Return entries with `seq` greater than this; omitted (or non-numeric) reads from the start. - `limit: optional number` Page size; absent or outside 1–100 reads as 100. ### Returns - `data: array of JournalEntry` - `actor: string` Who wrote it: `customer`, `partner`, `human`, `system` or `brand`. - `created_at: string` - `payload: unknown` The event's own JSON payload; shape depends on `type`. - `seq: number` Gapless per-chat sequence number: the events cursor. - `type: string` Event type (`message_received`, `message_sent`, `owner_changed`, …). - `operator: optional string` WHICH human, for the events that have one: the operator name the acting request carried (attributed through our agent console). Present ONLY beside `actor: "human"`, and only when the caller named an operator; absent everywhere else, including on every event recorded before an operator id was ever sent. So its presence is the signal that this row can be rendered as "Dana accepted this" rather than "someone accepted this"; its absence is never "no human", only "unattributed". ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/events \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "seq": 13, "type": "message_received", "actor": "customer", "payload": { "body": "where is my driver?" }, "created_at": "2026-08-06T14:01:58Z" } ] } ``` ## Domain Types ### Journal Entry - `JournalEntry object { actor, created_at, payload, 3 more }` One entry in the chat's event log. - `actor: string` Who wrote it: `customer`, `partner`, `human`, `system` or `brand`. - `created_at: string` - `payload: unknown` The event's own JSON payload; shape depends on `type`. - `seq: number` Gapless per-chat sequence number: the events cursor. - `type: string` Event type (`message_received`, `message_sent`, `owner_changed`, …). - `operator: optional string` WHICH human, for the events that have one: the operator name the acting request carried (attributed through our agent console). Present ONLY beside `actor: "human"`, and only when the caller named an operator; absent everywhere else, including on every event recorded before an operator id was ever sent. So its presence is the signal that this row can be rendered as "Dana accepted this" rather than "someone accepted this"; its absence is never "no human", only "unattributed". # Transcript ## List chat transcript **get** `/v1/chats/{chat}/transcript` Lists the chat as a conversation to display: what the customer saw, in order. **Behavior** - The same rows `/events` pages, rebuilt for rendering: each carries a `kind` to switch on, the message's canonical `parts`, and the delivery state of each outbound message. - Same paging (`after_seq`, `limit`) and same bounds as `/events`. Use this to draw a chat; use `/events` to rebuild state. ### Path Parameters - `chat: string` ### Query Parameters - `after_seq: optional number` Return rows with `seq` greater than this; omitted (or non-numeric) reads from the start. - `limit: optional number` Page size; absent or outside 1–100 reads as 100. ### Returns - `data: array of TranscriptRow` - `actor: string` Who wrote it: `customer`, `partner`, `human`, `flow`, `system` or `brand`. On a row carrying a `message_id` this is who COMPOSED that message: a human agent replying from a console reads `human`, a deterministic flow reads `flow`, and NOT the actor of the underlying event, which is always `partner` on a send because that event means we delivered it. Everywhere else it is the underlying event's own actor. - `created_at: string` - `event_type: string` The underlying event type (`message_received`, `owner_changed`, `consent_send_refused`, …); render detail from it, but branch on `kind`. - `kind: "customer_message" or "business_message" or "system"` What KIND of thing happened; switch on this. The set is CLOSED: an event type added later renders as `system`, never as a fourth value. - `"customer_message"` - `"business_message"` - `"system"` - `seq: number` The `seq` this row is built from; same numbering as the events endpoint, and the transcript's own cursor. - `attachments: optional array of object { att_id, mime_type, name, 2 more }` Files the CUSTOMER sent with this message; absent when they sent none. - `att_id: optional string` The retained plaintext's id (`att_…`); absent when nothing was retained. - `mime_type: optional string` - `name: optional string` The file's name as the customer's device sent it. - `reason: optional string` Present exactly when `att_id` is absent, and a CLOSED set: `too_large` | `brand_unresolved` | `store_unavailable` | `disabled` | `unavailable` (any failure earlier than the retention decision). Never the underlying error text, that is free-form and can carry the channel's own temporary download URL, so it stays on `/events`. - `size: optional number` Size in bytes. - `delivery: optional string` That message's delivery state, read from the message row: `queued` | `sending` | `sent` | `retry` | `failed` | `undelivered` | `suppressed`. Two of those are terminal failures, not one: `undelivered` is the "resubmitting later may land" outcome and `failed` the one that will not. There is NO delivered or read receipt here or anywhere on this API; AMB produces neither, and `sent` means only that Apple accepted the send. - `form_response: optional unknown` A `form_response` row's FULL record, first-class: `values` keyed by your original page ids, `item_ids`, `status`, `sources`, `opened_at`, `closed_at`. Present only on `event_type: "form_response"` rows. - `group_id: optional string` Ties a fan-out member to its group, and a `form_response` row to the degraded prompt messages that asked, so grouping needs no payload spelunking. Present on rows that belong to a group. - `message_id: optional string` The outbound message this row is about; pass it to `GET /v1/messages/{msg}`. - `operator: optional string` WHICH human. On a row carrying a `message_id` this is the operator who composed that send, where one did and where the send named them. On every other row it is the events endpoint's `operator`: the human we recorded, with the same rules. **On message rows this deliberately does NOT match `/events`**, which shows no `operator` for the same `seq`: a send is recorded by the worker that delivered it, with actor `partner` and no operator, so the composer is only knowable from the message row. Absence never means "no human"; it means this row cannot say which. - `parts: optional array of unknown` The message's canonical parts (the same array a send takes), absent on system rows and on a message that carried none: an invitation delivery records its invitation identity rather than parts. Typed as an opaque array rather than `Part[]` on purpose: the generated Go transport must never contain a second decoder for the part union (`content.ParseParts` is the one decode door), and a response `$ref` to `Part` would put one there. - `reaction: optional object { action, correlation, kind, 7 more }` The reaction this customer text row IS, when the text is a tapback. The row keeps its text verbatim and `kind` stays `customer_message`, so a client ignoring this key sees exactly the transcript it saw before. - `action: "added" or "removed"` Whether the customer APPLIED this reaction or TOOK IT AWAY. CLOSED: `added` or `removed`, and always present. Apple sends a removal as its own message, so a removal is its own transcript fact rather than the absence of one. Note what this does NOT tell you: replacing one reaction with another sends the new `added` and no `removed` beside it, so state is last-one-wins per (message, customer) and a client that appends will accumulate reactions the customer no longer has. - `"added"` - `"removed"` - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"` HOW the target was reached. CLOSED: `inferred` (exactly one message in the recent window carried that text, and both target keys name it), `ambiguous` (several did; `candidate_count` says how many, and no winner is picked for you), `unmatched` (none did), `unavailable` (Apple named no target at all, which is what a reaction to a non-text message looks like). There is no `exact`, because no reaction on this wire carries an id. - `"inferred"` - `"ambiguous"` - `"unmatched"` - `"unavailable"` - `kind: "like" or "love" or "dislike" or 5 more` What the customer did. CLOSED: `like`, `love`, `dislike`, `laugh`, `emphasize`, `question`, `sticker` (a sticker or Memoji), or `emoji` (any other character, which rides `emoji`). - `"like"` - `"love"` - `"dislike"` - `"laugh"` - `"emphasize"` - `"question"` - `"sticker"` - `"emoji"` - `attachment_id: optional string` The sticker's image, on a sticker whose image we retained: pass it to `GET /v1/attachments/{att}/content`. Present ONLY when `kind` is `sticker` and `action` is `added`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker: no handle is better than the wrong one. Do not assume a format. Captures show both `image/png` and `image/heic`; read the response's own content type. A PRESENT ID IS NOT A PROMISE OF BYTES, exactly as for `att_id`: retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`. Handle that; it is the ordinary outcome for anything old enough. - `candidate_count: optional number` How many messages carried that exact text, on `ambiguous`. - `emoji: optional string` The character the customer picked, present only when `kind` is `emoji`, where the character IS the reaction and no named one exists for it. - `quoted_text: optional string` The original message's text as Apple quoted it back: the only handle this wire offers, and what the correlation is made against. Absent when Apple named no target. - `target_msg_id: optional string` The message reacted to, on `inferred`. Pass it to `GET /v1/messages/{msg}`. - `target_seq: optional number` That message's `seq`, on `inferred`: the transcript row to scroll to. - `target_side: optional "business" or "customer"` WHOSE message was reacted to, on `inferred`: `business` or `customer`. A customer can react to their own message, so this is not derivable from the reaction being a customer event. Unlike the correlation it is not an inference; it is the matched row's own type. - `"business"` - `"customer"` - `reason: optional string` The reason the event states, where it states one: the failure discriminator on a failed or suppressed send, the handoff reason on an ownership change, the refusal reason on a consent row. - `reply: optional unknown` A customer's normalized interactive selection (quick reply, list pick, time slot); absent when they sent plain text. A customer who taps a Quick Reply sends no text, so this is the row's only content. - `request_id: optional string` The `requestIdentifier` this message was delivered under: the exact value an interactive reply names in its own `request_id`, so an answer can be matched to the message it answers instead of inferred from item ids (the same card sent twice defines the same item ids, while each interactive send is delivered under a fresh identifier). Present only on a row naming a message that recorded one: interactive sends mint one, plain sends do not, and rows from before this field was served carry none. ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/transcript \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "seq": 13, "kind": "customer_message", "event_type": "message_received", "actor": "customer", "created_at": "2026-08-06T14:01:58Z", "parts": [ { "type": "text", "body": "where is my driver?" } ] } ] } ``` ## Domain Types ### Transcript Row - `TranscriptRow object { actor, created_at, event_type, 13 more }` One transcript row, ready to render: an event-log entry rebuilt for display. It is not a `JournalEntry`: that shape has an open set of `type` values and a `payload` whose shape depends on it, so a client has nothing to switch on. This row leads with a closed `kind` and carries the message's canonical parts and delivery state. - `actor: string` Who wrote it: `customer`, `partner`, `human`, `flow`, `system` or `brand`. On a row carrying a `message_id` this is who COMPOSED that message: a human agent replying from a console reads `human`, a deterministic flow reads `flow`, and NOT the actor of the underlying event, which is always `partner` on a send because that event means we delivered it. Everywhere else it is the underlying event's own actor. - `created_at: string` - `event_type: string` The underlying event type (`message_received`, `owner_changed`, `consent_send_refused`, …); render detail from it, but branch on `kind`. - `kind: "customer_message" or "business_message" or "system"` What KIND of thing happened; switch on this. The set is CLOSED: an event type added later renders as `system`, never as a fourth value. - `"customer_message"` - `"business_message"` - `"system"` - `seq: number` The `seq` this row is built from; same numbering as the events endpoint, and the transcript's own cursor. - `attachments: optional array of object { att_id, mime_type, name, 2 more }` Files the CUSTOMER sent with this message; absent when they sent none. - `att_id: optional string` The retained plaintext's id (`att_…`); absent when nothing was retained. - `mime_type: optional string` - `name: optional string` The file's name as the customer's device sent it. - `reason: optional string` Present exactly when `att_id` is absent, and a CLOSED set: `too_large` | `brand_unresolved` | `store_unavailable` | `disabled` | `unavailable` (any failure earlier than the retention decision). Never the underlying error text, that is free-form and can carry the channel's own temporary download URL, so it stays on `/events`. - `size: optional number` Size in bytes. - `delivery: optional string` That message's delivery state, read from the message row: `queued` | `sending` | `sent` | `retry` | `failed` | `undelivered` | `suppressed`. Two of those are terminal failures, not one: `undelivered` is the "resubmitting later may land" outcome and `failed` the one that will not. There is NO delivered or read receipt here or anywhere on this API; AMB produces neither, and `sent` means only that Apple accepted the send. - `form_response: optional unknown` A `form_response` row's FULL record, first-class: `values` keyed by your original page ids, `item_ids`, `status`, `sources`, `opened_at`, `closed_at`. Present only on `event_type: "form_response"` rows. - `group_id: optional string` Ties a fan-out member to its group, and a `form_response` row to the degraded prompt messages that asked, so grouping needs no payload spelunking. Present on rows that belong to a group. - `message_id: optional string` The outbound message this row is about; pass it to `GET /v1/messages/{msg}`. - `operator: optional string` WHICH human. On a row carrying a `message_id` this is the operator who composed that send, where one did and where the send named them. On every other row it is the events endpoint's `operator`: the human we recorded, with the same rules. **On message rows this deliberately does NOT match `/events`**, which shows no `operator` for the same `seq`: a send is recorded by the worker that delivered it, with actor `partner` and no operator, so the composer is only knowable from the message row. Absence never means "no human"; it means this row cannot say which. - `parts: optional array of unknown` The message's canonical parts (the same array a send takes), absent on system rows and on a message that carried none: an invitation delivery records its invitation identity rather than parts. Typed as an opaque array rather than `Part[]` on purpose: the generated Go transport must never contain a second decoder for the part union (`content.ParseParts` is the one decode door), and a response `$ref` to `Part` would put one there. - `reaction: optional object { action, correlation, kind, 7 more }` The reaction this customer text row IS, when the text is a tapback. The row keeps its text verbatim and `kind` stays `customer_message`, so a client ignoring this key sees exactly the transcript it saw before. - `action: "added" or "removed"` Whether the customer APPLIED this reaction or TOOK IT AWAY. CLOSED: `added` or `removed`, and always present. Apple sends a removal as its own message, so a removal is its own transcript fact rather than the absence of one. Note what this does NOT tell you: replacing one reaction with another sends the new `added` and no `removed` beside it, so state is last-one-wins per (message, customer) and a client that appends will accumulate reactions the customer no longer has. - `"added"` - `"removed"` - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"` HOW the target was reached. CLOSED: `inferred` (exactly one message in the recent window carried that text, and both target keys name it), `ambiguous` (several did; `candidate_count` says how many, and no winner is picked for you), `unmatched` (none did), `unavailable` (Apple named no target at all, which is what a reaction to a non-text message looks like). There is no `exact`, because no reaction on this wire carries an id. - `"inferred"` - `"ambiguous"` - `"unmatched"` - `"unavailable"` - `kind: "like" or "love" or "dislike" or 5 more` What the customer did. CLOSED: `like`, `love`, `dislike`, `laugh`, `emphasize`, `question`, `sticker` (a sticker or Memoji), or `emoji` (any other character, which rides `emoji`). - `"like"` - `"love"` - `"dislike"` - `"laugh"` - `"emphasize"` - `"question"` - `"sticker"` - `"emoji"` - `attachment_id: optional string` The sticker's image, on a sticker whose image we retained: pass it to `GET /v1/attachments/{att}/content`. Present ONLY when `kind` is `sticker` and `action` is `added`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker: no handle is better than the wrong one. Do not assume a format. Captures show both `image/png` and `image/heic`; read the response's own content type. A PRESENT ID IS NOT A PROMISE OF BYTES, exactly as for `att_id`: retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`. Handle that; it is the ordinary outcome for anything old enough. - `candidate_count: optional number` How many messages carried that exact text, on `ambiguous`. - `emoji: optional string` The character the customer picked, present only when `kind` is `emoji`, where the character IS the reaction and no named one exists for it. - `quoted_text: optional string` The original message's text as Apple quoted it back: the only handle this wire offers, and what the correlation is made against. Absent when Apple named no target. - `target_msg_id: optional string` The message reacted to, on `inferred`. Pass it to `GET /v1/messages/{msg}`. - `target_seq: optional number` That message's `seq`, on `inferred`: the transcript row to scroll to. - `target_side: optional "business" or "customer"` WHOSE message was reacted to, on `inferred`: `business` or `customer`. A customer can react to their own message, so this is not derivable from the reaction being a customer event. Unlike the correlation it is not an inference; it is the matched row's own type. - `"business"` - `"customer"` - `reason: optional string` The reason the event states, where it states one: the failure discriminator on a failed or suppressed send, the handoff reason on an ownership change, the refusal reason on a consent row. - `reply: optional unknown` A customer's normalized interactive selection (quick reply, list pick, time slot); absent when they sent plain text. A customer who taps a Quick Reply sends no text, so this is the row's only content. - `request_id: optional string` The `requestIdentifier` this message was delivered under: the exact value an interactive reply names in its own `request_id`, so an answer can be matched to the message it answers instead of inferred from item ids (the same card sent twice defines the same item ids, while each interactive send is delivered under a fresh identifier). Present only on a row naming a message that recorded one: interactive sends mint one, plain sends do not, and rows from before this field was served carry none. # Activity ## Get a chat's activity timeline **get** `/v1/chats/{chat}/activity` The chat's activity timeline: the governance story, distinct from the transcript: how contact began, what consent stands, who controlled the conversation when. Read-only, composed at read time. **Behavior** - `activity[]` carries the same event-log rows `/events` serves, narrowed to governance events (ownership, handoffs, flows, entry-point routing decisions, close/reopen, invitation responses, enforcement audit rows), never messages, typing, or reactions. Each item's `seq` is the event log's own, so an activity item and its `/events` row are the same fact under the same id. `class` is a closed enum the server promises: `contact | consent | control`. `actor` is one of the closed set `customer|partner|human|flow|system|brand`; an operator's identity, where one was recorded, is `detail.operator`. - `entry_routed` (class `control`) is one entry-point routing decision, recorded for every inbound your routing document routed, whatever it decided. Its `detail` carries `intent` and `group` (the `biz-intent-id` and `biz-group-id` your entry-point link delivered), `matched` (the 0-based index of the rule that fired in your routing document, or `default`), `action` (`flow` | `human` | `none` | `entry_flow`), `flow` (the flow that was started, empty when none was), `outcome` (`applied` | `already_applied` | `entry_flow_unset`), and `routed_seq`, the `seq` of the inbound the decision was about. A customer can open a second entry point part way through a conversation, so a chat can carry several, each at its own `seq`. - `origin`, `consent`, and `consent_events` are returned on the FIRST page only (a request with no `after_seq`); a resumed request answers `activity` alone. Page `activity` with `after_seq` exactly as `/events`. - `consent` is a FOUR-STATE headline per category (`granted | revoked | expired | none`) over the categories that apply to the chat's handle kind: `expired` is a grant whose `expires_at` has passed, reported the way the send gate already treats it, and `none` means no decision on record. `consent_events` is the append-only consent history (`granted | revoked | expired`), complete for the subject; each category's `history_recorded_since` is its earliest recorded event. **Errors** - HTTP 404: unknown chat, or one belonging to another brand. ### Path Parameters - `chat: string` ### Query Parameters - `after_seq: optional number` Resume the `activity` page after this `seq`; omitted (or non-numeric) reads from the start and includes the `origin`/`consent`/`consent_events` blocks. - `limit: optional number` Page size for `activity`; absent or outside 1-100 reads as 100. ### Returns - `ActivityResponse object { activity, consent, consent_events, origin }` The activity timeline: the chat's governance story as a read-time composition. `origin`, `consent`, and `consent_events` appear on the first page only; a resumed page (`after_seq` present) carries `activity` alone. - `activity: array of object { actor, at, class, 3 more }` The governance events, `seq`-paged exactly as `/events`. - `actor: string` Who did this: `customer`, `partner`, `human`, `flow`, `system`, or `brand`. - `at: string` - `class: "contact" or "consent" or "control"` Server-promised closed class; render by class for any type you do not map. - `"contact"` - `"consent"` - `"control"` - `seq: number` The event log's own `seq`: the same id the `/events` row carries. - `type: string` The event type (`owner_changed`, `handoff_accepted`, `flow_entered`, …). - `detail: optional unknown` Per-type detail from the event's payload. OPEN AND ADDITIVE by contract: keys may be added over time, never renamed or removed; `operator`, `reason` and the per-type ids are present whenever their event recorded them. - `consent: optional unknown` Four-state consent snapshot per category applying to this chat's handle kind: `granted`, `revoked`, `expired`, or `none`. First page only. - `consent_events: optional array of object { action, at, category, 2 more }` The subject's append-only consent history, time-ordered and complete. First page only. - `action: "granted" or "revoked" or "expired"` `granted`, `revoked`, or `expired`, an expiry lapse recorded by the sweep. - `"granted"` - `"revoked"` - `"expired"` - `at: string` - `category: string` - `capture_mode: optional string` - `source: optional string` - `origin: optional object { kind, at, invitation }` Contact provenance. First page only. - `kind: "customer_initiated" or "invitation"` `customer_initiated` (the customer's first message created the chat) or `invitation` (business-initiated). - `"customer_initiated"` - `"invitation"` - `at: optional string` First-contact instant for a customer-initiated chat: the chat's own creation stamp. - `invitation: optional object { id, delivery, sent_at, 3 more }` The originating invitation, for `kind: invitation`. - `id: string` - `delivery: string` Its delivery state. - `sent_at: string` When the invitation was sent. - `response: optional string` The customer's response, when one was recorded. - `response_source: optional string` How the response was resolved: `tap`, or the literal-text fallback. - `superseded_by_chat_id: optional string` Set when a later invitation superseded this conversation. ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/activity \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "origin": { "kind": "invitation", "invitation": { "id": "inv_7f81c2", "sent_at": "2026-08-14T17:55:34Z", "delivery": "sent", "response": "accepted", "response_source": "tap" } }, "consent": { "marketing": { "state": "none", "history_recorded_since": null }, "account_notification": { "state": "granted", "source": "inbound_keyword", "capture_mode": "linq_managed", "granted_at": "2026-08-14T18:01:02Z", "history_recorded_since": "2026-08-14T18:01:02Z" } }, "consent_events": [ { "category": "account_notification", "action": "granted", "at": "2026-08-14T18:01:02Z", "source": "inbound_keyword", "capture_mode": "linq_managed" } ], "activity": [ { "seq": 9, "type": "handoff_accepted", "class": "control", "at": "2026-08-14T18:02:40Z", "actor": "human", "detail": { "operator": "op_becca", "reason": "customer asked" } } ] } ``` ## Domain Types ### Activity Response - `ActivityResponse object { activity, consent, consent_events, origin }` The activity timeline: the chat's governance story as a read-time composition. `origin`, `consent`, and `consent_events` appear on the first page only; a resumed page (`after_seq` present) carries `activity` alone. - `activity: array of object { actor, at, class, 3 more }` The governance events, `seq`-paged exactly as `/events`. - `actor: string` Who did this: `customer`, `partner`, `human`, `flow`, `system`, or `brand`. - `at: string` - `class: "contact" or "consent" or "control"` Server-promised closed class; render by class for any type you do not map. - `"contact"` - `"consent"` - `"control"` - `seq: number` The event log's own `seq`: the same id the `/events` row carries. - `type: string` The event type (`owner_changed`, `handoff_accepted`, `flow_entered`, …). - `detail: optional unknown` Per-type detail from the event's payload. OPEN AND ADDITIVE by contract: keys may be added over time, never renamed or removed; `operator`, `reason` and the per-type ids are present whenever their event recorded them. - `consent: optional unknown` Four-state consent snapshot per category applying to this chat's handle kind: `granted`, `revoked`, `expired`, or `none`. First page only. - `consent_events: optional array of object { action, at, category, 2 more }` The subject's append-only consent history, time-ordered and complete. First page only. - `action: "granted" or "revoked" or "expired"` `granted`, `revoked`, or `expired`, an expiry lapse recorded by the sweep. - `"granted"` - `"revoked"` - `"expired"` - `at: string` - `category: string` - `capture_mode: optional string` - `source: optional string` - `origin: optional object { kind, at, invitation }` Contact provenance. First page only. - `kind: "customer_initiated" or "invitation"` `customer_initiated` (the customer's first message created the chat) or `invitation` (business-initiated). - `"customer_initiated"` - `"invitation"` - `at: optional string` First-contact instant for a customer-initiated chat: the chat's own creation stamp. - `invitation: optional object { id, delivery, sent_at, 3 more }` The originating invitation, for `kind: invitation`. - `id: string` - `delivery: string` Its delivery state. - `sent_at: string` When the invitation was sent. - `response: optional string` The customer's response, when one was recorded. - `response_source: optional string` How the response was resolved: `tap`, or the literal-text fallback. - `superseded_by_chat_id: optional string` Set when a later invitation superseded this conversation. # Handoff ## Request or return a handoff **post** `/v1/chats/{chat}/handoff` Requests a human takeover, or returns control to your system. **Behavior** - `to: "human"` moves the chat to `human_pending`; closed to your sends until a human accepts or control returns. - `to: "partner"` returns control to your system. - The event log records who asked: a request carries actor `partner`, a return carries actor `human`, and an operator name appears only beside a `human` actor. ### Path Parameters - `chat: string` ### Body Parameters - `to: "human" or "partner"` `human` requests a human (owner → `human_pending`); `partner` returns control. Anything else returns HTTP 422 `code` 1071. - `"human"` - `"partner"` - `reason: optional string` Free-text reason, recorded on the ownership event (absent → empty). ### Returns - `ok: true` - `true` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/handoff \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "to": "human" }' ``` #### Response ```json { "ok": true } ``` ## Accept a pending handoff **post** `/v1/chats/{chat}/handoff/accept` Take a pending handoff live (`human_pending` → `human`). Auth stays the partner's API key: the `human` actor is honest about the role, not an authenticated person. When the accept is made through our agent console with a named operator, the event log records WHICH human accepted it (`operator` on the events read). **Behavior** - Legal only from `human_pending`. Once applied, the chat is `human`, and a second accept returns HTTP 409 `code` 2025. - Two accepts arriving together resolve to one; first writer wins, and the loser records nothing, so the transcript never shows an accept that did not take effect. - When our console attributes the accept to a named operator, that operator claims the chat (`assigned_operator`); a different operator accepting the same entry returns HTTP 409 `code` 2009, naming the holder. The claim is released when the chat returns to your system (`POST …/handoff` with `to: "partner"`), which is also how a claim held by someone unreachable is cleared: return it, then accept it again. - A claim does not restrict sending; the desk is shared at the brand level. ### Path Parameters - `chat: string` ### Body Parameters - `reason: optional string` Free-text reason, recorded on the ownership event. ### Returns - `ok: true` - `true` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/handoff/accept \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "ok": true } ``` ## Domain Types ### Handoff Request Response - `HandoffRequestResponse object { ok }` The bare `{"ok":true}` acknowledgement; failures ride the error envelope instead. - `ok: true` - `true` ### Handoff Accept Response - `HandoffAcceptResponse object { ok }` The bare `{"ok":true}` acknowledgement; failures ride the error envelope instead. - `ok: true` - `true` --- # Clarify an unclassifiable turn URL: https://docs.linqapp.com/channel/amb/api/resources/chats/methods/clarify/ **post** `/v1/chats/{chat}/clarify` Asks the customer one clarification question on your behalf, or escalates when this chat has already used it. **Behavior** - No request body. - Each chat gets one clarification question. A second call escalates instead of asking again, and the response's `action` says which happened. - Returns HTTP 202: the question is applied a moment after the response. - The question runs as a platform flow: the chat's `owner` becomes `flow` while it awaits the customer, and your sends during that window return HTTP 409 `code` 2011. A rephrase hands the turn straight back to you to classify; a reply asking for a person, or five minutes of silence, escalates instead. Every ownership movement is recorded by `chat.owner_changed`, and the flow's own steps appear on `GET /v1/chats/{chat}/events`. **Errors** - HTTP 409 `code` 2011: you no longer hold the send turn. ### Path Parameters - `chat: string` ### Returns - `action: "clarified" or "escalated"` `clarified`: the one bounded question was asked; `escalated`: this chat had already used its question, so we escalated instead. - `"clarified"` - `"escalated"` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/clarify \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "action": "clarified" } ``` --- # List chats URL: https://docs.linqapp.com/channel/amb/api/resources/chats/methods/list/ **get** `/v1/chats` Lists your brand's chats, newest first, one page at a time. **Behavior** - Scoped to your API key's brand by construction: no parameter names a brand, and another brand's chats are invisible rather than forbidden. - Page with `cursor`: pass the previous response's `next_cursor` back verbatim, and stop when it comes back empty. ### Query Parameters - `assigned_operator: optional string` Filter by the operator holding the chat; absent means every row, assigned or not. - `cursor: optional string` The previous page's `next_cursor`, verbatim; absent starts from the first page. Not a cursor this API issued returns HTTP 400 `code` 1013. - `limit: optional number` Page size; absent (or non-numeric) reads as 25, above 100 reads as 100. - `owner: optional string` Filter by turn owner (`partner`, `flow`, `human_pending`, `human`); absent means every owner. - `state: optional string` Filter by lifecycle phase (`open`, `closed`); absent means both. ### Returns - `data: array of object { capabilities, chat_id, customer_handle, 10 more }` - `capabilities: array of string` The device's most recently announced capability tokens. Empty means unknown, never "supports nothing". - `chat_id: string` Chat id (`chat_…`); pass it to `GET /v1/chats/{chat}` for the full view. - `customer_handle: string` The customer identifier: an Apple Opaque ID, or canonical `tel:+E164`, per `handle_kind`. - `handle_kind: "opaque" or "tel"` Which identifier kind `customer_handle` holds; branch on this, never read the string itself. - `"opaque"` - `"tel"` - `origin: "customer" or "invitation"` How the chat began. Not derivable from `handle_kind`: an accepted invitation arrives under a brand-new opaque id. - `"customer"` - `"invitation"` - `owner: string` Turn owner: `partner`, `flow`, `human_pending` or `human`. `human_pending` is the queue. - `owner_since: string` When the chat entered its CURRENT owner state: the queue clock, NOT `updated_at` (which any progress bumps). Stamped only when the owner actually changes; see `GET /v1/chats/{chat}` for the one self-clearing exception. - `state: string` Lifecycle phase: `open` or `closed`. - `updated_at: string` Last change of any kind: the list's sort key, and the first half of the cursor. - `assigned_operator: optional string` The operator holding this chat; absent when nobody does (the common case). - `business_id: optional string` The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. The same value `GET /v1/chats/{chat}` serves. - `last_inbound_at: optional string` When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet). - `last_message: optional object { actor, at, has_attachment, 2 more }` The newest message on this chat, so the row has a subject line instead of only a handle and a wait age. ABSENT when the chat has nothing previewable: an invitation nobody has answered, a chat whose only events are ownership changes, or a message that decoded to nothing. Render nothing in that case; never invent a preview. - `actor: string` Who spoke: `customer` on an inbound, `partner` on an outbound. It is the event log's actor, not the composer: a send is recorded by the worker that delivered it, so a reply a human agent typed still reads `partner` here. `GET /v1/chats/{chat}/transcript` is where a human is named. - `at: string` When that message was recorded. Distinct from both of its neighbours on this row: `last_inbound_at` moves only on inbound, and `updated_at` moves on any machine progress. Do not treat the three as one clock. - `has_attachment: boolean` Whether the message carried files. Deliberately ORTHOGONAL to `kind`, because the two answer different questions: `kind` says what to write on the row and this says whether to badge it. `kind: "text"` with this `true` is words PLUS a file: the one multi-part combination Apple admits. - `kind: "text" or "attachment" or "interactive" or "reply"` What kind of thing it was: a CLOSED set. `text`: there were words. `attachment`: there were none, only files. `reply`: the customer answered an interactive message instead of typing. `interactive`: the business sent one. Words win when a message has both. - `"text"` - `"attachment"` - `"interactive"` - `"reply"` - `preview: optional string` The opening words, with attachment placeholders (``) removed and a single `…` where it was cut. ABSENT rather than empty when no words survived: an attachment-only message has a body of exactly ``, and a preview built from it verbatim would put an invisible character on the row. Say "sent a file" in your own words instead; this API does not ship desk copy. - `next_cursor: string` Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the list, that, not an empty `data`, is how paging ends. ### Example ```http curl https://messages.api.linqapp.com/v1/chats \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "chat_id": "chat_4f81b2", "state": "open", "owner": "human_pending", "customer_handle": "urn:mbid:AQAAY7c1", "handle_kind": "opaque", "origin": "customer", "capabilities": [ "TEXT", "LIST", "TIME", "QUICK" ], "owner_since": "2026-08-06T14:02:11Z", "last_inbound_at": "2026-08-06T14:01:58Z", "updated_at": "2026-08-06T14:02:11Z", "last_message": { "actor": "customer", "at": "2026-08-06T14:01:58Z", "kind": "text", "has_attachment": false, "preview": "I need a human agent" } } ], "next_cursor": "MjAyNi0wOC0wNlQxNDowMTo1OFogY2hhdF80ZjgxYjI" } ``` --- # Resolve a chat URL: https://docs.linqapp.com/channel/amb/api/resources/chats/methods/resolve/ **post** `/v1/chats/{chat}/resolve` Resolves the chat and starts the satisfaction survey Apple asks for at resolution. **Errors** - HTTP 404 `code` 2001 when the chat does not exist for your brand: the same answer `/clarify` gives: both resolve the chat before they touch a flow. **Behavior** - No request body. - Returns HTTP 202: the resolution is applied a moment after the response. - Idempotent for 10 minutes: a second resolve inside that window returns HTTP 202 with `action: "already_resolved"` and does nothing, so a customer who answers the survey late is never surveyed twice. Past the window, a chat that genuinely finishes again can be resolved again. - The survey runs as a platform flow: the chat's `owner` becomes `flow` while the satisfaction card awaits an answer, and your sends during that window return HTTP 409 `code` 2011. The flow ends on the customer's reply (the next message is consumed as the survey answer; one asking for a person escalates instead) or after 120 seconds of silence, and the turn then returns, recorded by `chat.owner_changed`. So resolve when the conversation is finished: a customer who writes back inside that window is answering the survey, not restarting the thread with you. ### Path Parameters - `chat: string` ### Returns - `action: "resolved" or "already_resolved"` `resolved`: the CSAT flow was started; `already_resolved`: this chat was resolved within the last 10 minutes, so the call was a no-op; never retry it. - `"resolved"` - `"already_resolved"` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/resolve \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "action": "resolved" } ``` --- # Get a chat URL: https://docs.linqapp.com/channel/amb/api/resources/chats/methods/retrieve/ **get** `/v1/chats/{chat}` Retrieves a chat, including the customer's per-category consent and whether a send would currently be refused. ### Path Parameters - `chat: string` ### Returns - `ChatView object { id, brand, capabilities, 14 more }` The partner view of a chat. - `id: string` Chat id (`chat_…`). The LIST endpoint spells the same fact `chat_id`; neither is renamed to match the other. - `brand: object { id, business_hours, display_name, hours_timezone }` This chat's brand and its configured display/hours. - `id: string` The brand this chat belongs to; always your own. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a split shift is expressible. Absent when no hours are configured. - `display_name: optional string` The brand's display name; absent when it has none. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Hours are never configured without it; it may be configured without hours. - `capabilities: array of string` The device's most recently announced capability tokens. Empty means unknown, never "supports nothing". - `consent: unknown` Per-category consent for this chat's subject, scoped to the caller's brand (empty when none is recorded). - `customer_handle: string` The customer identifier: an Apple Opaque ID, or canonical `tel:+E164`, per `handle_kind`. - `handle_kind: "opaque" or "tel"` Which identifier kind `customer_handle` holds; branch on this, never read the string itself. A `tel` chat is not addressable for sends or typing. - `"opaque"` - `"tel"` - `origin: "customer" or "invitation"` How the chat began. Not derivable from `handle_kind`: an accepted invitation arrives under a brand-new opaque id. - `"customer"` - `"invitation"` - `owner: string` Turn owner: `partner`, `flow`, `human_pending` or `human`. Sends while not `partner` return HTTP 409 `code` 2011. `flow` is a platform flow holding the turn, typically the survey `/resolve` starts or the question `/clarify` asks: it ends on the customer's answer or its own timeout, and ownership then returns, recorded by `chat.owner_changed`. - `owner_since: string` When the chat entered its CURRENT owner state: the queue clock, NOT `updated_at` (which any progress bumps). Stamped only when the owner actually changes. One exception, and it is self-clearing: a chat that has not changed owner since the column was deployed reports the deploy time instead, which is a CEILING: deploy time is later than the true entry, so a wait derived from it reads SHORTER than the real one until that chat's next owner move. Don't page on it alone. - `send_blocked: object { blocked, code, message }` Whether a send would be refused right now, and why; read it BEFORE composing. - `blocked: boolean` - `code: number` The code the send would return, byte-identical to the `error.code` a real send would answer with; `0` when `blocked` is false. - `message: string` The message the send would return, verbatim; empty when `blocked` is false. - `state: string` Lifecycle phase: `open` or `closed`. Sends to a closed chat returns HTTP 409 `code` 2010. - `updated_at: string` Last change of any kind, machine progress included. - `assigned_operator: optional string` The operator holding this chat; absent when nobody does (the common case). - `business_id: optional string` The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. This is the current binding, not a per-message historical value, so it is the id you can act on today. Distinct from `brand.id`, which is your logical brand on this platform. - `capabilities_announced_at: optional string` When a device last ANNOUNCED a `capability-list`; NOT when we last heard from them (an inbound carrying no capability header moves `last_inbound_at` and leaves this alone). Absent means no device has ever announced. - `capture: optional object { expires_at, group_id, opened_at, page_id }` The chat's open reply capture (a degraded form mid-collection), so a takeover desk can warn before ending it: the group id `form.response` will carry, your page id, and the open/expiry instants. Absent when none is open; an expired capture is filtered out here, never closed by a read. - `expires_at: string` - `group_id: string` The degraded fan's correlation id: the primary prompt message's own id. - `opened_at: string` - `page_id: optional string` Your form page's id. - `last_inbound_at: optional string` When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet). A typing indicator is not recorded and does not move it; neither does anything the business sends. ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "chat_4f81b2", "state": "open", "owner": "partner", "capabilities": [ "TEXT", "LIST", "TIME", "QUICK", "FORM" ], "customer_handle": "urn:mbid:AQAAY7c1", "handle_kind": "opaque", "origin": "customer", "owner_since": "2026-08-06T14:02:11Z", "last_inbound_at": "2026-08-06T14:01:58Z", "updated_at": "2026-08-06T14:02:11Z", "capabilities_announced_at": "2026-08-06T14:01:58Z", "consent": { "marketing": { "state": "granted" }, "account_notification": { "state": "granted" } }, "brand": { "id": "biz_9f2c1a", "display_name": "CurbFare" }, "send_blocked": { "blocked": false, "code": 0, "message": "" } } ``` --- # Activity URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/activity/ ## Get a chat's activity timeline **get** `/v1/chats/{chat}/activity` The chat's activity timeline: the governance story, distinct from the transcript: how contact began, what consent stands, who controlled the conversation when. Read-only, composed at read time. **Behavior** - `activity[]` carries the same event-log rows `/events` serves, narrowed to governance events (ownership, handoffs, flows, entry-point routing decisions, close/reopen, invitation responses, enforcement audit rows), never messages, typing, or reactions. Each item's `seq` is the event log's own, so an activity item and its `/events` row are the same fact under the same id. `class` is a closed enum the server promises: `contact | consent | control`. `actor` is one of the closed set `customer|partner|human|flow|system|brand`; an operator's identity, where one was recorded, is `detail.operator`. - `entry_routed` (class `control`) is one entry-point routing decision, recorded for every inbound your routing document routed, whatever it decided. Its `detail` carries `intent` and `group` (the `biz-intent-id` and `biz-group-id` your entry-point link delivered), `matched` (the 0-based index of the rule that fired in your routing document, or `default`), `action` (`flow` | `human` | `none` | `entry_flow`), `flow` (the flow that was started, empty when none was), `outcome` (`applied` | `already_applied` | `entry_flow_unset`), and `routed_seq`, the `seq` of the inbound the decision was about. A customer can open a second entry point part way through a conversation, so a chat can carry several, each at its own `seq`. - `origin`, `consent`, and `consent_events` are returned on the FIRST page only (a request with no `after_seq`); a resumed request answers `activity` alone. Page `activity` with `after_seq` exactly as `/events`. - `consent` is a FOUR-STATE headline per category (`granted | revoked | expired | none`) over the categories that apply to the chat's handle kind: `expired` is a grant whose `expires_at` has passed, reported the way the send gate already treats it, and `none` means no decision on record. `consent_events` is the append-only consent history (`granted | revoked | expired`), complete for the subject; each category's `history_recorded_since` is its earliest recorded event. **Errors** - HTTP 404: unknown chat, or one belonging to another brand. ### Path Parameters - `chat: string` ### Query Parameters - `after_seq: optional number` Resume the `activity` page after this `seq`; omitted (or non-numeric) reads from the start and includes the `origin`/`consent`/`consent_events` blocks. - `limit: optional number` Page size for `activity`; absent or outside 1-100 reads as 100. ### Returns - `ActivityResponse object { activity, consent, consent_events, origin }` The activity timeline: the chat's governance story as a read-time composition. `origin`, `consent`, and `consent_events` appear on the first page only; a resumed page (`after_seq` present) carries `activity` alone. - `activity: array of object { actor, at, class, 3 more }` The governance events, `seq`-paged exactly as `/events`. - `actor: string` Who did this: `customer`, `partner`, `human`, `flow`, `system`, or `brand`. - `at: string` - `class: "contact" or "consent" or "control"` Server-promised closed class; render by class for any type you do not map. - `"contact"` - `"consent"` - `"control"` - `seq: number` The event log's own `seq`: the same id the `/events` row carries. - `type: string` The event type (`owner_changed`, `handoff_accepted`, `flow_entered`, …). - `detail: optional unknown` Per-type detail from the event's payload. OPEN AND ADDITIVE by contract: keys may be added over time, never renamed or removed; `operator`, `reason` and the per-type ids are present whenever their event recorded them. - `consent: optional unknown` Four-state consent snapshot per category applying to this chat's handle kind: `granted`, `revoked`, `expired`, or `none`. First page only. - `consent_events: optional array of object { action, at, category, 2 more }` The subject's append-only consent history, time-ordered and complete. First page only. - `action: "granted" or "revoked" or "expired"` `granted`, `revoked`, or `expired`, an expiry lapse recorded by the sweep. - `"granted"` - `"revoked"` - `"expired"` - `at: string` - `category: string` - `capture_mode: optional string` - `source: optional string` - `origin: optional object { kind, at, invitation }` Contact provenance. First page only. - `kind: "customer_initiated" or "invitation"` `customer_initiated` (the customer's first message created the chat) or `invitation` (business-initiated). - `"customer_initiated"` - `"invitation"` - `at: optional string` First-contact instant for a customer-initiated chat: the chat's own creation stamp. - `invitation: optional object { id, delivery, sent_at, 3 more }` The originating invitation, for `kind: invitation`. - `id: string` - `delivery: string` Its delivery state. - `sent_at: string` When the invitation was sent. - `response: optional string` The customer's response, when one was recorded. - `response_source: optional string` How the response was resolved: `tap`, or the literal-text fallback. - `superseded_by_chat_id: optional string` Set when a later invitation superseded this conversation. ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/activity \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "origin": { "kind": "invitation", "invitation": { "id": "inv_7f81c2", "sent_at": "2026-08-14T17:55:34Z", "delivery": "sent", "response": "accepted", "response_source": "tap" } }, "consent": { "marketing": { "state": "none", "history_recorded_since": null }, "account_notification": { "state": "granted", "source": "inbound_keyword", "capture_mode": "linq_managed", "granted_at": "2026-08-14T18:01:02Z", "history_recorded_since": "2026-08-14T18:01:02Z" } }, "consent_events": [ { "category": "account_notification", "action": "granted", "at": "2026-08-14T18:01:02Z", "source": "inbound_keyword", "capture_mode": "linq_managed" } ], "activity": [ { "seq": 9, "type": "handoff_accepted", "class": "control", "at": "2026-08-14T18:02:40Z", "actor": "human", "detail": { "operator": "op_becca", "reason": "customer asked" } } ] } ``` ## Domain Types ### Activity Response - `ActivityResponse object { activity, consent, consent_events, origin }` The activity timeline: the chat's governance story as a read-time composition. `origin`, `consent`, and `consent_events` appear on the first page only; a resumed page (`after_seq` present) carries `activity` alone. - `activity: array of object { actor, at, class, 3 more }` The governance events, `seq`-paged exactly as `/events`. - `actor: string` Who did this: `customer`, `partner`, `human`, `flow`, `system`, or `brand`. - `at: string` - `class: "contact" or "consent" or "control"` Server-promised closed class; render by class for any type you do not map. - `"contact"` - `"consent"` - `"control"` - `seq: number` The event log's own `seq`: the same id the `/events` row carries. - `type: string` The event type (`owner_changed`, `handoff_accepted`, `flow_entered`, …). - `detail: optional unknown` Per-type detail from the event's payload. OPEN AND ADDITIVE by contract: keys may be added over time, never renamed or removed; `operator`, `reason` and the per-type ids are present whenever their event recorded them. - `consent: optional unknown` Four-state consent snapshot per category applying to this chat's handle kind: `granted`, `revoked`, `expired`, or `none`. First page only. - `consent_events: optional array of object { action, at, category, 2 more }` The subject's append-only consent history, time-ordered and complete. First page only. - `action: "granted" or "revoked" or "expired"` `granted`, `revoked`, or `expired`, an expiry lapse recorded by the sweep. - `"granted"` - `"revoked"` - `"expired"` - `at: string` - `category: string` - `capture_mode: optional string` - `source: optional string` - `origin: optional object { kind, at, invitation }` Contact provenance. First page only. - `kind: "customer_initiated" or "invitation"` `customer_initiated` (the customer's first message created the chat) or `invitation` (business-initiated). - `"customer_initiated"` - `"invitation"` - `at: optional string` First-contact instant for a customer-initiated chat: the chat's own creation stamp. - `invitation: optional object { id, delivery, sent_at, 3 more }` The originating invitation, for `kind: invitation`. - `id: string` - `delivery: string` Its delivery state. - `sent_at: string` When the invitation was sent. - `response: optional string` The customer's response, when one was recorded. - `response_source: optional string` How the response was resolved: `tap`, or the literal-text fallback. - `superseded_by_chat_id: optional string` Set when a later invitation superseded this conversation. --- # Get a chat's activity timeline URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/activity/methods/retrieve/ **get** `/v1/chats/{chat}/activity` The chat's activity timeline: the governance story, distinct from the transcript: how contact began, what consent stands, who controlled the conversation when. Read-only, composed at read time. **Behavior** - `activity[]` carries the same event-log rows `/events` serves, narrowed to governance events (ownership, handoffs, flows, entry-point routing decisions, close/reopen, invitation responses, enforcement audit rows), never messages, typing, or reactions. Each item's `seq` is the event log's own, so an activity item and its `/events` row are the same fact under the same id. `class` is a closed enum the server promises: `contact | consent | control`. `actor` is one of the closed set `customer|partner|human|flow|system|brand`; an operator's identity, where one was recorded, is `detail.operator`. - `entry_routed` (class `control`) is one entry-point routing decision, recorded for every inbound your routing document routed, whatever it decided. Its `detail` carries `intent` and `group` (the `biz-intent-id` and `biz-group-id` your entry-point link delivered), `matched` (the 0-based index of the rule that fired in your routing document, or `default`), `action` (`flow` | `human` | `none` | `entry_flow`), `flow` (the flow that was started, empty when none was), `outcome` (`applied` | `already_applied` | `entry_flow_unset`), and `routed_seq`, the `seq` of the inbound the decision was about. A customer can open a second entry point part way through a conversation, so a chat can carry several, each at its own `seq`. - `origin`, `consent`, and `consent_events` are returned on the FIRST page only (a request with no `after_seq`); a resumed request answers `activity` alone. Page `activity` with `after_seq` exactly as `/events`. - `consent` is a FOUR-STATE headline per category (`granted | revoked | expired | none`) over the categories that apply to the chat's handle kind: `expired` is a grant whose `expires_at` has passed, reported the way the send gate already treats it, and `none` means no decision on record. `consent_events` is the append-only consent history (`granted | revoked | expired`), complete for the subject; each category's `history_recorded_since` is its earliest recorded event. **Errors** - HTTP 404: unknown chat, or one belonging to another brand. ### Path Parameters - `chat: string` ### Query Parameters - `after_seq: optional number` Resume the `activity` page after this `seq`; omitted (or non-numeric) reads from the start and includes the `origin`/`consent`/`consent_events` blocks. - `limit: optional number` Page size for `activity`; absent or outside 1-100 reads as 100. ### Returns - `ActivityResponse object { activity, consent, consent_events, origin }` The activity timeline: the chat's governance story as a read-time composition. `origin`, `consent`, and `consent_events` appear on the first page only; a resumed page (`after_seq` present) carries `activity` alone. - `activity: array of object { actor, at, class, 3 more }` The governance events, `seq`-paged exactly as `/events`. - `actor: string` Who did this: `customer`, `partner`, `human`, `flow`, `system`, or `brand`. - `at: string` - `class: "contact" or "consent" or "control"` Server-promised closed class; render by class for any type you do not map. - `"contact"` - `"consent"` - `"control"` - `seq: number` The event log's own `seq`: the same id the `/events` row carries. - `type: string` The event type (`owner_changed`, `handoff_accepted`, `flow_entered`, …). - `detail: optional unknown` Per-type detail from the event's payload. OPEN AND ADDITIVE by contract: keys may be added over time, never renamed or removed; `operator`, `reason` and the per-type ids are present whenever their event recorded them. - `consent: optional unknown` Four-state consent snapshot per category applying to this chat's handle kind: `granted`, `revoked`, `expired`, or `none`. First page only. - `consent_events: optional array of object { action, at, category, 2 more }` The subject's append-only consent history, time-ordered and complete. First page only. - `action: "granted" or "revoked" or "expired"` `granted`, `revoked`, or `expired`, an expiry lapse recorded by the sweep. - `"granted"` - `"revoked"` - `"expired"` - `at: string` - `category: string` - `capture_mode: optional string` - `source: optional string` - `origin: optional object { kind, at, invitation }` Contact provenance. First page only. - `kind: "customer_initiated" or "invitation"` `customer_initiated` (the customer's first message created the chat) or `invitation` (business-initiated). - `"customer_initiated"` - `"invitation"` - `at: optional string` First-contact instant for a customer-initiated chat: the chat's own creation stamp. - `invitation: optional object { id, delivery, sent_at, 3 more }` The originating invitation, for `kind: invitation`. - `id: string` - `delivery: string` Its delivery state. - `sent_at: string` When the invitation was sent. - `response: optional string` The customer's response, when one was recorded. - `response_source: optional string` How the response was resolved: `tap`, or the literal-text fallback. - `superseded_by_chat_id: optional string` Set when a later invitation superseded this conversation. ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/activity \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "origin": { "kind": "invitation", "invitation": { "id": "inv_7f81c2", "sent_at": "2026-08-14T17:55:34Z", "delivery": "sent", "response": "accepted", "response_source": "tap" } }, "consent": { "marketing": { "state": "none", "history_recorded_since": null }, "account_notification": { "state": "granted", "source": "inbound_keyword", "capture_mode": "linq_managed", "granted_at": "2026-08-14T18:01:02Z", "history_recorded_since": "2026-08-14T18:01:02Z" } }, "consent_events": [ { "category": "account_notification", "action": "granted", "at": "2026-08-14T18:01:02Z", "source": "inbound_keyword", "capture_mode": "linq_managed" } ], "activity": [ { "seq": 9, "type": "handoff_accepted", "class": "control", "at": "2026-08-14T18:02:40Z", "actor": "human", "detail": { "operator": "op_becca", "reason": "customer asked" } } ] } ``` --- # Events URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/events/ ## List chat events **get** `/v1/chats/{chat}/events` Lists the chat's event log: the ordered record you rebuild your own state from. Each entry carries a `seq` that only ever counts up, with no gaps; page with `after_seq`. ### Path Parameters - `chat: string` ### Query Parameters - `after_seq: optional number` Return entries with `seq` greater than this; omitted (or non-numeric) reads from the start. - `limit: optional number` Page size; absent or outside 1–100 reads as 100. ### Returns - `data: array of JournalEntry` - `actor: string` Who wrote it: `customer`, `partner`, `human`, `system` or `brand`. - `created_at: string` - `payload: unknown` The event's own JSON payload; shape depends on `type`. - `seq: number` Gapless per-chat sequence number: the events cursor. - `type: string` Event type (`message_received`, `message_sent`, `owner_changed`, …). - `operator: optional string` WHICH human, for the events that have one: the operator name the acting request carried (attributed through our agent console). Present ONLY beside `actor: "human"`, and only when the caller named an operator; absent everywhere else, including on every event recorded before an operator id was ever sent. So its presence is the signal that this row can be rendered as "Dana accepted this" rather than "someone accepted this"; its absence is never "no human", only "unattributed". ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/events \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "seq": 13, "type": "message_received", "actor": "customer", "payload": { "body": "where is my driver?" }, "created_at": "2026-08-06T14:01:58Z" } ] } ``` ## Domain Types ### Journal Entry - `JournalEntry object { actor, created_at, payload, 3 more }` One entry in the chat's event log. - `actor: string` Who wrote it: `customer`, `partner`, `human`, `system` or `brand`. - `created_at: string` - `payload: unknown` The event's own JSON payload; shape depends on `type`. - `seq: number` Gapless per-chat sequence number: the events cursor. - `type: string` Event type (`message_received`, `message_sent`, `owner_changed`, …). - `operator: optional string` WHICH human, for the events that have one: the operator name the acting request carried (attributed through our agent console). Present ONLY beside `actor: "human"`, and only when the caller named an operator; absent everywhere else, including on every event recorded before an operator id was ever sent. So its presence is the signal that this row can be rendered as "Dana accepted this" rather than "someone accepted this"; its absence is never "no human", only "unattributed". --- # List chat events URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/events/methods/list/ **get** `/v1/chats/{chat}/events` Lists the chat's event log: the ordered record you rebuild your own state from. Each entry carries a `seq` that only ever counts up, with no gaps; page with `after_seq`. ### Path Parameters - `chat: string` ### Query Parameters - `after_seq: optional number` Return entries with `seq` greater than this; omitted (or non-numeric) reads from the start. - `limit: optional number` Page size; absent or outside 1–100 reads as 100. ### Returns - `data: array of JournalEntry` - `actor: string` Who wrote it: `customer`, `partner`, `human`, `system` or `brand`. - `created_at: string` - `payload: unknown` The event's own JSON payload; shape depends on `type`. - `seq: number` Gapless per-chat sequence number: the events cursor. - `type: string` Event type (`message_received`, `message_sent`, `owner_changed`, …). - `operator: optional string` WHICH human, for the events that have one: the operator name the acting request carried (attributed through our agent console). Present ONLY beside `actor: "human"`, and only when the caller named an operator; absent everywhere else, including on every event recorded before an operator id was ever sent. So its presence is the signal that this row can be rendered as "Dana accepted this" rather than "someone accepted this"; its absence is never "no human", only "unattributed". ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/events \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "seq": 13, "type": "message_received", "actor": "customer", "payload": { "body": "where is my driver?" }, "created_at": "2026-08-06T14:01:58Z" } ] } ``` --- # Handoff URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/handoff/ ## Request or return a handoff **post** `/v1/chats/{chat}/handoff` Requests a human takeover, or returns control to your system. **Behavior** - `to: "human"` moves the chat to `human_pending`; closed to your sends until a human accepts or control returns. - `to: "partner"` returns control to your system. - The event log records who asked: a request carries actor `partner`, a return carries actor `human`, and an operator name appears only beside a `human` actor. ### Path Parameters - `chat: string` ### Body Parameters - `to: "human" or "partner"` `human` requests a human (owner → `human_pending`); `partner` returns control. Anything else returns HTTP 422 `code` 1071. - `"human"` - `"partner"` - `reason: optional string` Free-text reason, recorded on the ownership event (absent → empty). ### Returns - `ok: true` - `true` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/handoff \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "to": "human" }' ``` #### Response ```json { "ok": true } ``` ## Accept a pending handoff **post** `/v1/chats/{chat}/handoff/accept` Take a pending handoff live (`human_pending` → `human`). Auth stays the partner's API key: the `human` actor is honest about the role, not an authenticated person. When the accept is made through our agent console with a named operator, the event log records WHICH human accepted it (`operator` on the events read). **Behavior** - Legal only from `human_pending`. Once applied, the chat is `human`, and a second accept returns HTTP 409 `code` 2025. - Two accepts arriving together resolve to one; first writer wins, and the loser records nothing, so the transcript never shows an accept that did not take effect. - When our console attributes the accept to a named operator, that operator claims the chat (`assigned_operator`); a different operator accepting the same entry returns HTTP 409 `code` 2009, naming the holder. The claim is released when the chat returns to your system (`POST …/handoff` with `to: "partner"`), which is also how a claim held by someone unreachable is cleared: return it, then accept it again. - A claim does not restrict sending; the desk is shared at the brand level. ### Path Parameters - `chat: string` ### Body Parameters - `reason: optional string` Free-text reason, recorded on the ownership event. ### Returns - `ok: true` - `true` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/handoff/accept \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "ok": true } ``` ## Domain Types ### Handoff Request Response - `HandoffRequestResponse object { ok }` The bare `{"ok":true}` acknowledgement; failures ride the error envelope instead. - `ok: true` - `true` ### Handoff Accept Response - `HandoffAcceptResponse object { ok }` The bare `{"ok":true}` acknowledgement; failures ride the error envelope instead. - `ok: true` - `true` --- # Accept a pending handoff URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/handoff/methods/accept/ **post** `/v1/chats/{chat}/handoff/accept` Take a pending handoff live (`human_pending` → `human`). Auth stays the partner's API key: the `human` actor is honest about the role, not an authenticated person. When the accept is made through our agent console with a named operator, the event log records WHICH human accepted it (`operator` on the events read). **Behavior** - Legal only from `human_pending`. Once applied, the chat is `human`, and a second accept returns HTTP 409 `code` 2025. - Two accepts arriving together resolve to one; first writer wins, and the loser records nothing, so the transcript never shows an accept that did not take effect. - When our console attributes the accept to a named operator, that operator claims the chat (`assigned_operator`); a different operator accepting the same entry returns HTTP 409 `code` 2009, naming the holder. The claim is released when the chat returns to your system (`POST …/handoff` with `to: "partner"`), which is also how a claim held by someone unreachable is cleared: return it, then accept it again. - A claim does not restrict sending; the desk is shared at the brand level. ### Path Parameters - `chat: string` ### Body Parameters - `reason: optional string` Free-text reason, recorded on the ownership event. ### Returns - `ok: true` - `true` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/handoff/accept \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "ok": true } ``` --- # Request or return a handoff URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/handoff/methods/request/ **post** `/v1/chats/{chat}/handoff` Requests a human takeover, or returns control to your system. **Behavior** - `to: "human"` moves the chat to `human_pending`; closed to your sends until a human accepts or control returns. - `to: "partner"` returns control to your system. - The event log records who asked: a request carries actor `partner`, a return carries actor `human`, and an operator name appears only beside a `human` actor. ### Path Parameters - `chat: string` ### Body Parameters - `to: "human" or "partner"` `human` requests a human (owner → `human_pending`); `partner` returns control. Anything else returns HTTP 422 `code` 1071. - `"human"` - `"partner"` - `reason: optional string` Free-text reason, recorded on the ownership event (absent → empty). ### Returns - `ok: true` - `true` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/handoff \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "to": "human" }' ``` #### Response ```json { "ok": true } ``` --- # Messages URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/messages/ ## Send a message **post** `/v1/chats/{chat}/messages` Sends a message into a chat. **Requirements** - `Idempotency-Key` header: required. A UUID you mint per logical send. - The chat must be open, and you must hold the send turn (see Chat control). **Behavior** - Replaying the same `Idempotency-Key` within 24 hours returns the original response. A refused request records no key, so the same key succeeds on retry. - An `https` URL in message text may be promoted to its own rich-link message. Each resulting message has its own `message.sent` or `message.failed` lifecycle, correlated by `group_id`. - Rate limiting is per brand: only your own traffic can produce it, and `Retry-After` is computed from your brand's own refill rate. A rate-limited request is not validated: an invalid send still fails with its own error once you are back within rate. A request that fans out into several messages costs one rate credit per message. **Errors** - HTTP 400: `idempotency_key_required`, `body_invalid` - HTTP 403: `consent_required` - HTTP 409: `chat_closed`, `chat_owned`, `capability_unsupported`, `idempotency_key_reused`, `idempotency_key_processing` while another request with the same key is still in flight - HTTP 413: `attachment_source_too_large` - HTTP 422: field-level validation (`category_invalid`, `degrade_invalid`, `attachment_source_invalid`, `attachment_source_empty`, and per-part codes), each naming the exact field at fault - HTTP 429: `rate_limited`, with `Retry-After` - HTTP 502: `attachment_source_unavailable` - HTTP 503: `send_paused`, `attachment_source_busy` (with `Retry-After`), or `attachment_storage_unavailable` ### Path Parameters - `chat: string` ### Query Parameters - `force: optional boolean` `true` bypasses the exclusive-owner gate (sends while `owner != partner`). A real override of a non-partner owner is audited (`chat.send_forced`). Does NOT override consent policy. ### Header Parameters - `"Idempotency-Key": string` ### Body Parameters - `parts: array of TextPart or ChoicesPart or ListPickerPart or 8 more` The message content, ≤20 parts. Structural rule (adapter L3): at most one interactive part per message, and `text` may not be combined with an interactive part; send two messages. An empty array returns HTTP 422 `code` 1052. - `TextPart object { body, type, subject }` A plain text message. subject renders bold on AMB. - `body: string` - `type: "text"` - `"text"` - `subject: optional string` - `ChoicesPart object { items, summary_text, type }` AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message. - `items: array of object { id, label }` - `id: string` - `label: string` - `summary_text: string` - `type: "choices"` - `"choices"` - `ListPickerPart object { sections, summary_text, type, 4 more }` AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id. - `sections: array of object { items, title, multi_select }` - `items: array of object { id, label, detail, image_id }` - `id: string` - `label: string` - `detail: optional string` - `image_id: optional string` - `title: string` - `multi_select: optional boolean` - `summary_text: string` - `type: "list_picker"` - `"list_picker"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `TimeSlotsPart object { event, slots, type, 5 more }` AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes. - `event: object { title, image_id, location }` - `title: string` - `image_id: optional string` - `location: optional object { latitude, longitude, radius_m, title }` - `latitude: optional number` - `longitude: optional number` - `radius_m: optional number` - `title: optional string` - `slots: array of object { id, duration_s, start }` - `id: string` - `duration_s: number` - `start: string` - `type: "time_slots"` - `"time_slots"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `timezone_offset_min: optional number` - `FormPart object { header, pages, type, 7 more }` AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true. - `header: string` - `pages: array of object { id, kind, subtitle, 15 more }` - `id: string` - `kind: "select" or "picker" or "datePicker" or "input"` - `"select"` - `"picker"` - `"datePicker"` - `"input"` - `subtitle: string` The question shown to the user. - `date_format: optional string` datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings. - `hint_text: optional string` datePicker only. - `items: optional array of object { id, label, image_id }` select and picker only. - `id: string` - `label: string` - `image_id: optional string` select items only. - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more` input only. - `"default"` - `"asciiCapable"` - `"numbersAndPunctuation"` - `"URL"` - `"numberPad"` - `"phonePad"` - `"namePhonePad"` - `"emailAddress"` - `"decimalPad"` - `"webSearch"` - `label_text: optional string` datePicker only; defaults to "Date". - `max_chars: optional number` input only. - `maximum_date: optional string` datePicker only, in date_format. - `minimum_date: optional string` datePicker only, in date_format. - `multiple: optional boolean` select only. - `next_page_id: optional string` - `picker_title: optional string` picker only; empty centers the field. - `required: optional boolean` input only. - `selected_item_index: optional number` picker only. - `start_date: optional string` datePicker only, in date_format. - `title: optional string` - `type: "form"` - `"form"` - `bubble_image_id: optional string` Icon on the received/reply message bubble. - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `show_summary: optional boolean` - `splash_button_title: optional string` Defaults to "Start". The splash view shows when any splash field is set. - `splash_image_id: optional string` - `splash_text: optional string` - `RichLinkPart object { title, type, url, 5 more }` A rich link card. Omit image_att_id to get the default card. The image must be a real PNG. - `title: string` - `type: "rich_link"` - `"rich_link"` - `url: string` - `app_clip: optional boolean` Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card. - `image_att_id: optional string` - `store_region: optional string` The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored. - `video_mime_type: optional string` MIME type of the video at video_url. video/mp4 is the recommended container. - `video_url: optional string` Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id. - `AttachmentPart object { att_id, type }` An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64. - `att_id: string` - `type: "attachment"` - `"attachment"` - `ApplePayPart object { payment_request, received_message, type }` AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings. - `payment_request: object { country_code, currency_code, line_items, 7 more }` - `country_code: string` - `currency_code: string` - `line_items: array of object { amount, label, type }` - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"` - `"supports3DS"` - `"supportsCredit"` - `"supportsDebit"` - `"supportsEMV"` - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more` - `"amex"` - `"discover"` - `"jcb"` - `"masterCard"` - `"privateLabel"` - `"visa"` - `total: object { amount, label, type }` The grand total. - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `shipping_methods: optional array of object { amount, detail, identifier, label }` - `amount: string` - `detail: string` - `identifier: string` - `label: string` - `supported_countries: optional array of string` - `received_message: object { title, image_att_id, subtitle }` The bubble the customer sees before authorizing. - `title: string` - `image_att_id: optional string` - `subtitle: optional string` - `type: "apple_pay"` - `"apple_pay"` - `AuthenticatePart object { received_title, reply_title, scope, 3 more }` Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts. - `received_title: string` The bubble the customer sees. - `reply_title: string` The bubble after completion. - `scope: array of string` - `type: "authenticate"` - `"authenticate"` - `redirect_uri: optional string` - `state: optional string` - `AppExtensionPart object { app, received_message, reply_message, 4 more }` A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it. - `app: string` Which of the brand's registered apps to send under. - `received_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `reply_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `type: "app_extension"` - `"app_extension"` - `url: string` The payload the Messages app hands to the extension. Opaque: never parsed or rewritten. - `session_id: optional string` Reuse to address a card already sent; omit and Apple generates one. - `use_live_layout: optional boolean` Defaults to true. - `SourceAttachmentPart object { name, source_url, type }` A partner-hosted attachment imported synchronously before the send is accepted. Linq stores a frozen copy; later source changes cannot alter delivery. The URL must be public HTTPS on port 443 and require no headers, cookies or authentication. Redirects are accepted only when every hop passes the same secure URL policy. - `name: string` Filename delivered to the customer. - `source_url: string` Public HTTPS URL fetched before acceptance. - `type: "attachment"` - `"attachment"` - `category: optional "transactional" or "account_notification" or "marketing"` Apple notification category. OPTIONAL by contract: absent means `transactional` (an in-chat reply): a bare `{"parts":[…]}` must keep working bit-for-bit. Unknown value → 422 `code` 1010. A non-transactional send with no valid opt-in on file returns HTTP 403 `code` 2015 (`force=true` does NOT override consent). - `"transactional"` - `"account_notification"` - `"marketing"` - `degrade: optional "reject" or "auto" or "acknowledged"` Capability fallback opt-in. Absent or `reject` (the default): an unsupported part type is refused 409 `code` 4005. `auto`: the part is rewritten to the best representation the device renders, and the response reports each swap it made in `fallbacks`. `acknowledged`: `auto`, plus the statement that a HUMAN has seen what the downgrade produces and accepted it; identical to `auto` for an automated caller, and the ONLY value that lets a human-attributed send be rewritten (a send attributed to a named operator that `auto` would rewrite is refused 409 `code` 4005, outright rather than warned about). Any other value → 422 `code` 1091. - `"reject"` - `"auto"` - `"acknowledged"` - `typing_lead_ms: optional number` How long the typing indicator runs before this message, in MILLISECONDS. THE INDICATOR IS YOURS TO ASK FOR. We show one for exactly as long as you request and never on our own, so this field is the whole control: absent or `0` shows no indicator at all, and a positive value shows one for precisely that long before the message reaches the customer. OPTIONAL by contract, and absent means the same as `0`: no indicator. We recommend asking for about 1000 ms before each message you send, and longer when the customer is about to wait on a lookup or a handoff, so the pause reads as thinking rather than as a stall. Bounded at 15000 (422 `code` 1096, which is also what a negative value gets). The cap is derived from the budget one send attempt gets: a longer indicator eats the time the message send itself needs, so the send would be cut short and retried and the customer would watch a long indicator followed by a late message or none. ### Returns - `id: string` The FIRST message's id: the send's primary id. - `chat_id: string` - `delivery: "queued"` - `"queued"` - `messages: array of SentMessage` Every message this request was accepted as, in delivery order; ALWAYS present, one element for an ordinary send. Longer when an `https` URL in your text was promoted to its own rich-link message: each element then has its own `message.sent`/`message.failed` lifecycle, correlated by `group_id`. More than 10 messages returns HTTP 422 `code` 1075. - `id: string` Message id (`msg_…`). - `type: string` The part type of that message's FIRST part. - `parts: optional array of unknown` The message's canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason `TranscriptRow.parts` is. - `capture: optional object { collection, expects, page_id, 4 more }` The reply capture this send opened; see `CaptureObject`. Absent when no capture opened. - `collection: string` `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved. - `expects: string` The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots). - `page_id: string` Your form page's own id: the key the eventual `reply.values` uses. - `reasks: number` How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1. - `timeout_s: number` How long the collection stays open, in seconds. Fixed at 1800. - `group_id: optional string` ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back. - `options: optional array of object { index, item_id, label }` Present for the choice shapes only: what the customer can answer with, by number or label. - `index: number` - `item_id: string` - `label: string` - `fallbacks: optional array of Fallback` Present ONLY when `degrade` (`"auto"` or `"acknowledged"`) actually rewrote a part: its presence is the signal that the customer saw something other than what you composed, and an acknowledgement does not suppress it. Absent otherwise (never an empty array). - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/messages \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "parts": [ { "body": "Your driver Marta is two minutes away in a blue Prius.", "type": "text" } ] }' ``` #### Response ```json { "id": "msg_2c7d90", "chat_id": "chat_4f81b2", "delivery": "queued", "messages": [ { "id": "msg_2c7d90", "type": "text" } ] } ``` ## Preview a message **post** `/v1/chats/{chat}/messages/preview` Previews a send without sending it. Use it to surface field-level errors and capability rewrites in a composer before anything reaches the customer. **Behavior** - Runs the identical accept pipeline as the send: the same validation, chat gates, `degrade` decision, and consent policy, but records nothing: no message id is minted, no idempotency claim is made, and no `Idempotency-Key` is required. - A body this endpoint refuses, the send refuses with the same `code` and `param`, except that a `source_url` attachment is not fetched during preview. Its reachability, MIME, byte size and storage readiness are therefore send-time facts, and the preview returns the unresolved source form rather than an `att_id`. Otherwise a body preview accepts is accepted by send, chat state permitting, since a preview cannot freeze the chat between the two calls. **Limits** - The per-message `wire` field is representative, never exact; `wire_fidelity` states this in the response. Values resolved only at delivery are substituted or omitted: payment sessions and app identities appear as placeholders, image bytes are omitted, and a plain attachment message has no `wire` at all. Do not present it as the exact payload Apple will receive. ### Path Parameters - `chat: string` ### Query Parameters - `force: optional boolean` Same meaning as on the send: `true` previews the send as an override of the exclusive-owner gate. ### Body Parameters - `parts: array of TextPart or ChoicesPart or ListPickerPart or 8 more` The message content, ≤20 parts. Structural rule (adapter L3): at most one interactive part per message, and `text` may not be combined with an interactive part; send two messages. An empty array returns HTTP 422 `code` 1052. - `TextPart object { body, type, subject }` A plain text message. subject renders bold on AMB. - `body: string` - `type: "text"` - `"text"` - `subject: optional string` - `ChoicesPart object { items, summary_text, type }` AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message. - `items: array of object { id, label }` - `id: string` - `label: string` - `summary_text: string` - `type: "choices"` - `"choices"` - `ListPickerPart object { sections, summary_text, type, 4 more }` AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id. - `sections: array of object { items, title, multi_select }` - `items: array of object { id, label, detail, image_id }` - `id: string` - `label: string` - `detail: optional string` - `image_id: optional string` - `title: string` - `multi_select: optional boolean` - `summary_text: string` - `type: "list_picker"` - `"list_picker"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `TimeSlotsPart object { event, slots, type, 5 more }` AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes. - `event: object { title, image_id, location }` - `title: string` - `image_id: optional string` - `location: optional object { latitude, longitude, radius_m, title }` - `latitude: optional number` - `longitude: optional number` - `radius_m: optional number` - `title: optional string` - `slots: array of object { id, duration_s, start }` - `id: string` - `duration_s: number` - `start: string` - `type: "time_slots"` - `"time_slots"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `timezone_offset_min: optional number` - `FormPart object { header, pages, type, 7 more }` AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true. - `header: string` - `pages: array of object { id, kind, subtitle, 15 more }` - `id: string` - `kind: "select" or "picker" or "datePicker" or "input"` - `"select"` - `"picker"` - `"datePicker"` - `"input"` - `subtitle: string` The question shown to the user. - `date_format: optional string` datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings. - `hint_text: optional string` datePicker only. - `items: optional array of object { id, label, image_id }` select and picker only. - `id: string` - `label: string` - `image_id: optional string` select items only. - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more` input only. - `"default"` - `"asciiCapable"` - `"numbersAndPunctuation"` - `"URL"` - `"numberPad"` - `"phonePad"` - `"namePhonePad"` - `"emailAddress"` - `"decimalPad"` - `"webSearch"` - `label_text: optional string` datePicker only; defaults to "Date". - `max_chars: optional number` input only. - `maximum_date: optional string` datePicker only, in date_format. - `minimum_date: optional string` datePicker only, in date_format. - `multiple: optional boolean` select only. - `next_page_id: optional string` - `picker_title: optional string` picker only; empty centers the field. - `required: optional boolean` input only. - `selected_item_index: optional number` picker only. - `start_date: optional string` datePicker only, in date_format. - `title: optional string` - `type: "form"` - `"form"` - `bubble_image_id: optional string` Icon on the received/reply message bubble. - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `show_summary: optional boolean` - `splash_button_title: optional string` Defaults to "Start". The splash view shows when any splash field is set. - `splash_image_id: optional string` - `splash_text: optional string` - `RichLinkPart object { title, type, url, 5 more }` A rich link card. Omit image_att_id to get the default card. The image must be a real PNG. - `title: string` - `type: "rich_link"` - `"rich_link"` - `url: string` - `app_clip: optional boolean` Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card. - `image_att_id: optional string` - `store_region: optional string` The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored. - `video_mime_type: optional string` MIME type of the video at video_url. video/mp4 is the recommended container. - `video_url: optional string` Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id. - `AttachmentPart object { att_id, type }` An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64. - `att_id: string` - `type: "attachment"` - `"attachment"` - `ApplePayPart object { payment_request, received_message, type }` AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings. - `payment_request: object { country_code, currency_code, line_items, 7 more }` - `country_code: string` - `currency_code: string` - `line_items: array of object { amount, label, type }` - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"` - `"supports3DS"` - `"supportsCredit"` - `"supportsDebit"` - `"supportsEMV"` - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more` - `"amex"` - `"discover"` - `"jcb"` - `"masterCard"` - `"privateLabel"` - `"visa"` - `total: object { amount, label, type }` The grand total. - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `shipping_methods: optional array of object { amount, detail, identifier, label }` - `amount: string` - `detail: string` - `identifier: string` - `label: string` - `supported_countries: optional array of string` - `received_message: object { title, image_att_id, subtitle }` The bubble the customer sees before authorizing. - `title: string` - `image_att_id: optional string` - `subtitle: optional string` - `type: "apple_pay"` - `"apple_pay"` - `AuthenticatePart object { received_title, reply_title, scope, 3 more }` Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts. - `received_title: string` The bubble the customer sees. - `reply_title: string` The bubble after completion. - `scope: array of string` - `type: "authenticate"` - `"authenticate"` - `redirect_uri: optional string` - `state: optional string` - `AppExtensionPart object { app, received_message, reply_message, 4 more }` A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it. - `app: string` Which of the brand's registered apps to send under. - `received_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `reply_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `type: "app_extension"` - `"app_extension"` - `url: string` The payload the Messages app hands to the extension. Opaque: never parsed or rewritten. - `session_id: optional string` Reuse to address a card already sent; omit and Apple generates one. - `use_live_layout: optional boolean` Defaults to true. - `SourceAttachmentPart object { name, source_url, type }` A partner-hosted attachment imported synchronously before the send is accepted. Linq stores a frozen copy; later source changes cannot alter delivery. The URL must be public HTTPS on port 443 and require no headers, cookies or authentication. Redirects are accepted only when every hop passes the same secure URL policy. - `name: string` Filename delivered to the customer. - `source_url: string` Public HTTPS URL fetched before acceptance. - `type: "attachment"` - `"attachment"` - `category: optional "transactional" or "account_notification" or "marketing"` Apple notification category. OPTIONAL by contract: absent means `transactional` (an in-chat reply): a bare `{"parts":[…]}` must keep working bit-for-bit. Unknown value → 422 `code` 1010. A non-transactional send with no valid opt-in on file returns HTTP 403 `code` 2015 (`force=true` does NOT override consent). - `"transactional"` - `"account_notification"` - `"marketing"` - `degrade: optional "reject" or "auto" or "acknowledged"` Capability fallback opt-in. Absent or `reject` (the default): an unsupported part type is refused 409 `code` 4005. `auto`: the part is rewritten to the best representation the device renders, and the response reports each swap it made in `fallbacks`. `acknowledged`: `auto`, plus the statement that a HUMAN has seen what the downgrade produces and accepted it; identical to `auto` for an automated caller, and the ONLY value that lets a human-attributed send be rewritten (a send attributed to a named operator that `auto` would rewrite is refused 409 `code` 4005, outright rather than warned about). Any other value → 422 `code` 1091. - `"reject"` - `"auto"` - `"acknowledged"` - `typing_lead_ms: optional number` How long the typing indicator runs before this message, in MILLISECONDS. THE INDICATOR IS YOURS TO ASK FOR. We show one for exactly as long as you request and never on our own, so this field is the whole control: absent or `0` shows no indicator at all, and a positive value shows one for precisely that long before the message reaches the customer. OPTIONAL by contract, and absent means the same as `0`: no indicator. We recommend asking for about 1000 ms before each message you send, and longer when the customer is about to wait on a lookup or a handoff, so the pause reads as thinking rather than as a stall. Bounded at 15000 (422 `code` 1096, which is also what a negative value gets). The cap is derived from the budget one send attempt gets: a longer indicator eats the time the message send itself needs, so the send would be cut short and retried and the customer would watch a long indicator followed by a late message or none. ### Returns - `PreviewMessageResponse object { chat_id, messages, wire_fidelity, 2 more }` The previewed send. Nothing was written; nothing will be sent. - `chat_id: string` - `messages: array of PreviewedMessage` What the ONE request would become, message by message (URL promotion can make several). - `parts: array of unknown` The message's post-degrade, post-promotion parts. A URL attachment remains in its source form because preview does not fetch it or mint the real `att_id` that an accepted send returns. Typed as an opaque array rather than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is. - `type: string` The part type of this message's FIRST part: the same value the send's `200` reports per message. - `wire: optional unknown` The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when `wire_unavailable` says why. - `wire_unavailable: optional string` Present only when this message has no wire preview (a plain attachment message), and why. - `wire_fidelity: "representative"` Always `representative`: the wire bytes are an approximation of what delivery sends, never the exact payload. - `"representative"` - `capture: optional object { collection, expects, page_id, 4 more }` The reply capture this send WOULD open, identical to the send `200`'s object except `group_id`, which is ABSENT here (nothing was minted). Absent entirely when no capture would open. - `collection: string` `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved. - `expects: string` The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots). - `page_id: string` Your form page's own id: the key the eventual `reply.values` uses. - `reasks: number` How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1. - `timeout_s: number` How long the collection stays open, in seconds. Fixed at 1800. - `group_id: optional string` ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back. - `options: optional array of object { index, item_id, label }` Present for the choice shapes only: what the customer can answer with, by number or label. - `index: number` - `item_id: string` - `label: string` - `fallbacks: optional array of Fallback` The capability rewrites `degrade` would apply: the same array the send's `200` would carry. Present only when something would be rewritten. - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/messages/preview \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "parts": [ { "items": [ { "id": "curb", "label": "Curbside" }, { "id": "garage", "label": "Garage level 2" } ], "summary_text": "Pickup options", "type": "choices" } ] }' ``` #### Response ```json { "chat_id": "chat_4f81b2", "wire_fidelity": "representative", "messages": [ { "type": "choices", "parts": [ { "type": "choices", "summary_text": "Pickup options", "items": [ { "id": "curb", "label": "Curbside" }, { "id": "garage", "label": "Garage level 2" } ] } ] } ] } ``` ## Domain Types ### App Extension Part - `AppExtensionPart object { app, received_message, reply_message, 4 more }` A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it. - `app: string` Which of the brand's registered apps to send under. - `received_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `reply_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `type: "app_extension"` - `"app_extension"` - `url: string` The payload the Messages app hands to the extension. Opaque: never parsed or rewritten. - `session_id: optional string` Reuse to address a card already sent; omit and Apple generates one. - `use_live_layout: optional boolean` Defaults to true. ### Apple Pay Part - `ApplePayPart object { payment_request, received_message, type }` AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings. - `payment_request: object { country_code, currency_code, line_items, 7 more }` - `country_code: string` - `currency_code: string` - `line_items: array of object { amount, label, type }` - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"` - `"supports3DS"` - `"supportsCredit"` - `"supportsDebit"` - `"supportsEMV"` - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more` - `"amex"` - `"discover"` - `"jcb"` - `"masterCard"` - `"privateLabel"` - `"visa"` - `total: object { amount, label, type }` The grand total. - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `shipping_methods: optional array of object { amount, detail, identifier, label }` - `amount: string` - `detail: string` - `identifier: string` - `label: string` - `supported_countries: optional array of string` - `received_message: object { title, image_att_id, subtitle }` The bubble the customer sees before authorizing. - `title: string` - `image_att_id: optional string` - `subtitle: optional string` - `type: "apple_pay"` - `"apple_pay"` ### Attachment Part - `AttachmentPart object { att_id, type }` An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64. - `att_id: string` - `type: "attachment"` - `"attachment"` ### Authenticate Part - `AuthenticatePart object { received_title, reply_title, scope, 3 more }` Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts. - `received_title: string` The bubble the customer sees. - `reply_title: string` The bubble after completion. - `scope: array of string` - `type: "authenticate"` - `"authenticate"` - `redirect_uri: optional string` - `state: optional string` ### Choices Part - `ChoicesPart object { items, summary_text, type }` AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message. - `items: array of object { id, label }` - `id: string` - `label: string` - `summary_text: string` - `type: "choices"` - `"choices"` ### Fallback - `Fallback object { token, from, rule, 2 more }` One capability rewrite performed under `degrade: "auto"`, or `"acknowledged"`, which rewrites on exactly the same rules and records the swap the same way. - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Fallback Option - `FallbackOption object { index, item_id, label }` One entry of a menu a degrade rewrite flattened into words. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Form Part - `FormPart object { header, pages, type, 7 more }` AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true. - `header: string` - `pages: array of object { id, kind, subtitle, 15 more }` - `id: string` - `kind: "select" or "picker" or "datePicker" or "input"` - `"select"` - `"picker"` - `"datePicker"` - `"input"` - `subtitle: string` The question shown to the user. - `date_format: optional string` datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings. - `hint_text: optional string` datePicker only. - `items: optional array of object { id, label, image_id }` select and picker only. - `id: string` - `label: string` - `image_id: optional string` select items only. - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more` input only. - `"default"` - `"asciiCapable"` - `"numbersAndPunctuation"` - `"URL"` - `"numberPad"` - `"phonePad"` - `"namePhonePad"` - `"emailAddress"` - `"decimalPad"` - `"webSearch"` - `label_text: optional string` datePicker only; defaults to "Date". - `max_chars: optional number` input only. - `maximum_date: optional string` datePicker only, in date_format. - `minimum_date: optional string` datePicker only, in date_format. - `multiple: optional boolean` select only. - `next_page_id: optional string` - `picker_title: optional string` picker only; empty centers the field. - `required: optional boolean` input only. - `selected_item_index: optional number` picker only. - `start_date: optional string` datePicker only, in date_format. - `title: optional string` - `type: "form"` - `"form"` - `bubble_image_id: optional string` Icon on the received/reply message bubble. - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `show_summary: optional boolean` - `splash_button_title: optional string` Defaults to "Start". The splash view shows when any splash field is set. - `splash_image_id: optional string` - `splash_text: optional string` ### List Picker Part - `ListPickerPart object { sections, summary_text, type, 4 more }` AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id. - `sections: array of object { items, title, multi_select }` - `items: array of object { id, label, detail, image_id }` - `id: string` - `label: string` - `detail: optional string` - `image_id: optional string` - `title: string` - `multi_select: optional boolean` - `summary_text: string` - `type: "list_picker"` - `"list_picker"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` ### Part - `Part = TextPart or ChoicesPart or ListPickerPart or 7 more` The sealed part union: one variant per member of the canonical content model (10 types). Discriminated on the wire by each part's `type` const. - `TextPart object { body, type, subject }` A plain text message. subject renders bold on AMB. - `body: string` - `type: "text"` - `"text"` - `subject: optional string` - `ChoicesPart object { items, summary_text, type }` AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message. - `items: array of object { id, label }` - `id: string` - `label: string` - `summary_text: string` - `type: "choices"` - `"choices"` - `ListPickerPart object { sections, summary_text, type, 4 more }` AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id. - `sections: array of object { items, title, multi_select }` - `items: array of object { id, label, detail, image_id }` - `id: string` - `label: string` - `detail: optional string` - `image_id: optional string` - `title: string` - `multi_select: optional boolean` - `summary_text: string` - `type: "list_picker"` - `"list_picker"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `TimeSlotsPart object { event, slots, type, 5 more }` AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes. - `event: object { title, image_id, location }` - `title: string` - `image_id: optional string` - `location: optional object { latitude, longitude, radius_m, title }` - `latitude: optional number` - `longitude: optional number` - `radius_m: optional number` - `title: optional string` - `slots: array of object { id, duration_s, start }` - `id: string` - `duration_s: number` - `start: string` - `type: "time_slots"` - `"time_slots"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `timezone_offset_min: optional number` - `FormPart object { header, pages, type, 7 more }` AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true. - `header: string` - `pages: array of object { id, kind, subtitle, 15 more }` - `id: string` - `kind: "select" or "picker" or "datePicker" or "input"` - `"select"` - `"picker"` - `"datePicker"` - `"input"` - `subtitle: string` The question shown to the user. - `date_format: optional string` datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings. - `hint_text: optional string` datePicker only. - `items: optional array of object { id, label, image_id }` select and picker only. - `id: string` - `label: string` - `image_id: optional string` select items only. - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more` input only. - `"default"` - `"asciiCapable"` - `"numbersAndPunctuation"` - `"URL"` - `"numberPad"` - `"phonePad"` - `"namePhonePad"` - `"emailAddress"` - `"decimalPad"` - `"webSearch"` - `label_text: optional string` datePicker only; defaults to "Date". - `max_chars: optional number` input only. - `maximum_date: optional string` datePicker only, in date_format. - `minimum_date: optional string` datePicker only, in date_format. - `multiple: optional boolean` select only. - `next_page_id: optional string` - `picker_title: optional string` picker only; empty centers the field. - `required: optional boolean` input only. - `selected_item_index: optional number` picker only. - `start_date: optional string` datePicker only, in date_format. - `title: optional string` - `type: "form"` - `"form"` - `bubble_image_id: optional string` Icon on the received/reply message bubble. - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `show_summary: optional boolean` - `splash_button_title: optional string` Defaults to "Start". The splash view shows when any splash field is set. - `splash_image_id: optional string` - `splash_text: optional string` - `RichLinkPart object { title, type, url, 5 more }` A rich link card. Omit image_att_id to get the default card. The image must be a real PNG. - `title: string` - `type: "rich_link"` - `"rich_link"` - `url: string` - `app_clip: optional boolean` Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card. - `image_att_id: optional string` - `store_region: optional string` The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored. - `video_mime_type: optional string` MIME type of the video at video_url. video/mp4 is the recommended container. - `video_url: optional string` Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id. - `AttachmentPart object { att_id, type }` An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64. - `att_id: string` - `type: "attachment"` - `"attachment"` - `ApplePayPart object { payment_request, received_message, type }` AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings. - `payment_request: object { country_code, currency_code, line_items, 7 more }` - `country_code: string` - `currency_code: string` - `line_items: array of object { amount, label, type }` - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"` - `"supports3DS"` - `"supportsCredit"` - `"supportsDebit"` - `"supportsEMV"` - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more` - `"amex"` - `"discover"` - `"jcb"` - `"masterCard"` - `"privateLabel"` - `"visa"` - `total: object { amount, label, type }` The grand total. - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `shipping_methods: optional array of object { amount, detail, identifier, label }` - `amount: string` - `detail: string` - `identifier: string` - `label: string` - `supported_countries: optional array of string` - `received_message: object { title, image_att_id, subtitle }` The bubble the customer sees before authorizing. - `title: string` - `image_att_id: optional string` - `subtitle: optional string` - `type: "apple_pay"` - `"apple_pay"` - `AuthenticatePart object { received_title, reply_title, scope, 3 more }` Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts. - `received_title: string` The bubble the customer sees. - `reply_title: string` The bubble after completion. - `scope: array of string` - `type: "authenticate"` - `"authenticate"` - `redirect_uri: optional string` - `state: optional string` - `AppExtensionPart object { app, received_message, reply_message, 4 more }` A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it. - `app: string` Which of the brand's registered apps to send under. - `received_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `reply_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `type: "app_extension"` - `"app_extension"` - `url: string` The payload the Messages app hands to the extension. Opaque: never parsed or rewritten. - `session_id: optional string` Reuse to address a card already sent; omit and Apple generates one. - `use_live_layout: optional boolean` Defaults to true. ### Preview Message Response - `PreviewMessageResponse object { chat_id, messages, wire_fidelity, 2 more }` The previewed send. Nothing was written; nothing will be sent. - `chat_id: string` - `messages: array of PreviewedMessage` What the ONE request would become, message by message (URL promotion can make several). - `parts: array of unknown` The message's post-degrade, post-promotion parts. A URL attachment remains in its source form because preview does not fetch it or mint the real `att_id` that an accepted send returns. Typed as an opaque array rather than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is. - `type: string` The part type of this message's FIRST part: the same value the send's `200` reports per message. - `wire: optional unknown` The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when `wire_unavailable` says why. - `wire_unavailable: optional string` Present only when this message has no wire preview (a plain attachment message), and why. - `wire_fidelity: "representative"` Always `representative`: the wire bytes are an approximation of what delivery sends, never the exact payload. - `"representative"` - `capture: optional object { collection, expects, page_id, 4 more }` The reply capture this send WOULD open, identical to the send `200`'s object except `group_id`, which is ABSENT here (nothing was minted). Absent entirely when no capture would open. - `collection: string` `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved. - `expects: string` The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots). - `page_id: string` Your form page's own id: the key the eventual `reply.values` uses. - `reasks: number` How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1. - `timeout_s: number` How long the collection stays open, in seconds. Fixed at 1800. - `group_id: optional string` ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back. - `options: optional array of object { index, item_id, label }` Present for the choice shapes only: what the customer can answer with, by number or label. - `index: number` - `item_id: string` - `label: string` - `fallbacks: optional array of Fallback` The capability rewrites `degrade` would apply: the same array the send's `200` would carry. Present only when something would be rewritten. - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Previewed Message - `PreviewedMessage object { parts, type, wire, wire_unavailable }` One message of a previewed send, in send order (URL promotion can make several). - `parts: array of unknown` The message's post-degrade, post-promotion parts. A URL attachment remains in its source form because preview does not fetch it or mint the real `att_id` that an accepted send returns. Typed as an opaque array rather than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is. - `type: string` The part type of this message's FIRST part: the same value the send's `200` reports per message. - `wire: optional unknown` The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when `wire_unavailable` says why. - `wire_unavailable: optional string` Present only when this message has no wire preview (a plain attachment message), and why. ### Rich Link Part - `RichLinkPart object { title, type, url, 5 more }` A rich link card. Omit image_att_id to get the default card. The image must be a real PNG. - `title: string` - `type: "rich_link"` - `"rich_link"` - `url: string` - `app_clip: optional boolean` Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card. - `image_att_id: optional string` - `store_region: optional string` The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored. - `video_mime_type: optional string` MIME type of the video at video_url. video/mp4 is the recommended container. - `video_url: optional string` Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id. ### Sent Message - `SentMessage object { id, type, parts }` One accepted message of a send (URL promotion can make several). - `id: string` Message id (`msg_…`). - `type: string` The part type of that message's FIRST part. - `parts: optional array of unknown` The message's canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason `TranscriptRow.parts` is. ### Text Part - `TextPart object { body, type, subject }` A plain text message. subject renders bold on AMB. - `body: string` - `type: "text"` - `"text"` - `subject: optional string` ### Time Slots Part - `TimeSlotsPart object { event, slots, type, 5 more }` AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes. - `event: object { title, image_id, location }` - `title: string` - `image_id: optional string` - `location: optional object { latitude, longitude, radius_m, title }` - `latitude: optional number` - `longitude: optional number` - `radius_m: optional number` - `title: optional string` - `slots: array of object { id, duration_s, start }` - `id: string` - `duration_s: number` - `start: string` - `type: "time_slots"` - `"time_slots"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `timezone_offset_min: optional number` ### Message Send Response - `MessageSendResponse object { id, chat_id, delivery, 3 more }` The accepted send. A replay of the same Idempotency-Key within 24h returns these exact bytes; past that window the key is forgotten and the request executes again. - `id: string` The FIRST message's id: the send's primary id. - `chat_id: string` - `delivery: "queued"` - `"queued"` - `messages: array of SentMessage` Every message this request was accepted as, in delivery order; ALWAYS present, one element for an ordinary send. Longer when an `https` URL in your text was promoted to its own rich-link message: each element then has its own `message.sent`/`message.failed` lifecycle, correlated by `group_id`. More than 10 messages returns HTTP 422 `code` 1075. - `id: string` Message id (`msg_…`). - `type: string` The part type of that message's FIRST part. - `parts: optional array of unknown` The message's canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason `TranscriptRow.parts` is. - `capture: optional object { collection, expects, page_id, 4 more }` The reply capture this send opened; see `CaptureObject`. Absent when no capture opened. - `collection: string` `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved. - `expects: string` The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots). - `page_id: string` Your form page's own id: the key the eventual `reply.values` uses. - `reasks: number` How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1. - `timeout_s: number` How long the collection stays open, in seconds. Fixed at 1800. - `group_id: optional string` ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back. - `options: optional array of object { index, item_id, label }` Present for the choice shapes only: what the customer can answer with, by number or label. - `index: number` - `item_id: string` - `label: string` - `fallbacks: optional array of Fallback` Present ONLY when `degrade` (`"auto"` or `"acknowledged"`) actually rewrote a part: its presence is the signal that the customer saw something other than what you composed, and an acknowledgement does not suppress it. Absent otherwise (never an empty array). - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` --- # Preview a message URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/messages/methods/preview/ **post** `/v1/chats/{chat}/messages/preview` Previews a send without sending it. Use it to surface field-level errors and capability rewrites in a composer before anything reaches the customer. **Behavior** - Runs the identical accept pipeline as the send: the same validation, chat gates, `degrade` decision, and consent policy, but records nothing: no message id is minted, no idempotency claim is made, and no `Idempotency-Key` is required. - A body this endpoint refuses, the send refuses with the same `code` and `param`, except that a `source_url` attachment is not fetched during preview. Its reachability, MIME, byte size and storage readiness are therefore send-time facts, and the preview returns the unresolved source form rather than an `att_id`. Otherwise a body preview accepts is accepted by send, chat state permitting, since a preview cannot freeze the chat between the two calls. **Limits** - The per-message `wire` field is representative, never exact; `wire_fidelity` states this in the response. Values resolved only at delivery are substituted or omitted: payment sessions and app identities appear as placeholders, image bytes are omitted, and a plain attachment message has no `wire` at all. Do not present it as the exact payload Apple will receive. ### Path Parameters - `chat: string` ### Query Parameters - `force: optional boolean` Same meaning as on the send: `true` previews the send as an override of the exclusive-owner gate. ### Body Parameters - `parts: array of TextPart or ChoicesPart or ListPickerPart or 8 more` The message content, ≤20 parts. Structural rule (adapter L3): at most one interactive part per message, and `text` may not be combined with an interactive part; send two messages. An empty array returns HTTP 422 `code` 1052. - `TextPart object { body, type, subject }` A plain text message. subject renders bold on AMB. - `body: string` - `type: "text"` - `"text"` - `subject: optional string` - `ChoicesPart object { items, summary_text, type }` AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message. - `items: array of object { id, label }` - `id: string` - `label: string` - `summary_text: string` - `type: "choices"` - `"choices"` - `ListPickerPart object { sections, summary_text, type, 4 more }` AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id. - `sections: array of object { items, title, multi_select }` - `items: array of object { id, label, detail, image_id }` - `id: string` - `label: string` - `detail: optional string` - `image_id: optional string` - `title: string` - `multi_select: optional boolean` - `summary_text: string` - `type: "list_picker"` - `"list_picker"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `TimeSlotsPart object { event, slots, type, 5 more }` AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes. - `event: object { title, image_id, location }` - `title: string` - `image_id: optional string` - `location: optional object { latitude, longitude, radius_m, title }` - `latitude: optional number` - `longitude: optional number` - `radius_m: optional number` - `title: optional string` - `slots: array of object { id, duration_s, start }` - `id: string` - `duration_s: number` - `start: string` - `type: "time_slots"` - `"time_slots"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `timezone_offset_min: optional number` - `FormPart object { header, pages, type, 7 more }` AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true. - `header: string` - `pages: array of object { id, kind, subtitle, 15 more }` - `id: string` - `kind: "select" or "picker" or "datePicker" or "input"` - `"select"` - `"picker"` - `"datePicker"` - `"input"` - `subtitle: string` The question shown to the user. - `date_format: optional string` datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings. - `hint_text: optional string` datePicker only. - `items: optional array of object { id, label, image_id }` select and picker only. - `id: string` - `label: string` - `image_id: optional string` select items only. - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more` input only. - `"default"` - `"asciiCapable"` - `"numbersAndPunctuation"` - `"URL"` - `"numberPad"` - `"phonePad"` - `"namePhonePad"` - `"emailAddress"` - `"decimalPad"` - `"webSearch"` - `label_text: optional string` datePicker only; defaults to "Date". - `max_chars: optional number` input only. - `maximum_date: optional string` datePicker only, in date_format. - `minimum_date: optional string` datePicker only, in date_format. - `multiple: optional boolean` select only. - `next_page_id: optional string` - `picker_title: optional string` picker only; empty centers the field. - `required: optional boolean` input only. - `selected_item_index: optional number` picker only. - `start_date: optional string` datePicker only, in date_format. - `title: optional string` - `type: "form"` - `"form"` - `bubble_image_id: optional string` Icon on the received/reply message bubble. - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `show_summary: optional boolean` - `splash_button_title: optional string` Defaults to "Start". The splash view shows when any splash field is set. - `splash_image_id: optional string` - `splash_text: optional string` - `RichLinkPart object { title, type, url, 5 more }` A rich link card. Omit image_att_id to get the default card. The image must be a real PNG. - `title: string` - `type: "rich_link"` - `"rich_link"` - `url: string` - `app_clip: optional boolean` Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card. - `image_att_id: optional string` - `store_region: optional string` The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored. - `video_mime_type: optional string` MIME type of the video at video_url. video/mp4 is the recommended container. - `video_url: optional string` Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id. - `AttachmentPart object { att_id, type }` An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64. - `att_id: string` - `type: "attachment"` - `"attachment"` - `ApplePayPart object { payment_request, received_message, type }` AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings. - `payment_request: object { country_code, currency_code, line_items, 7 more }` - `country_code: string` - `currency_code: string` - `line_items: array of object { amount, label, type }` - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"` - `"supports3DS"` - `"supportsCredit"` - `"supportsDebit"` - `"supportsEMV"` - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more` - `"amex"` - `"discover"` - `"jcb"` - `"masterCard"` - `"privateLabel"` - `"visa"` - `total: object { amount, label, type }` The grand total. - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `shipping_methods: optional array of object { amount, detail, identifier, label }` - `amount: string` - `detail: string` - `identifier: string` - `label: string` - `supported_countries: optional array of string` - `received_message: object { title, image_att_id, subtitle }` The bubble the customer sees before authorizing. - `title: string` - `image_att_id: optional string` - `subtitle: optional string` - `type: "apple_pay"` - `"apple_pay"` - `AuthenticatePart object { received_title, reply_title, scope, 3 more }` Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts. - `received_title: string` The bubble the customer sees. - `reply_title: string` The bubble after completion. - `scope: array of string` - `type: "authenticate"` - `"authenticate"` - `redirect_uri: optional string` - `state: optional string` - `AppExtensionPart object { app, received_message, reply_message, 4 more }` A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it. - `app: string` Which of the brand's registered apps to send under. - `received_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `reply_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `type: "app_extension"` - `"app_extension"` - `url: string` The payload the Messages app hands to the extension. Opaque: never parsed or rewritten. - `session_id: optional string` Reuse to address a card already sent; omit and Apple generates one. - `use_live_layout: optional boolean` Defaults to true. - `SourceAttachmentPart object { name, source_url, type }` A partner-hosted attachment imported synchronously before the send is accepted. Linq stores a frozen copy; later source changes cannot alter delivery. The URL must be public HTTPS on port 443 and require no headers, cookies or authentication. Redirects are accepted only when every hop passes the same secure URL policy. - `name: string` Filename delivered to the customer. - `source_url: string` Public HTTPS URL fetched before acceptance. - `type: "attachment"` - `"attachment"` - `category: optional "transactional" or "account_notification" or "marketing"` Apple notification category. OPTIONAL by contract: absent means `transactional` (an in-chat reply): a bare `{"parts":[…]}` must keep working bit-for-bit. Unknown value → 422 `code` 1010. A non-transactional send with no valid opt-in on file returns HTTP 403 `code` 2015 (`force=true` does NOT override consent). - `"transactional"` - `"account_notification"` - `"marketing"` - `degrade: optional "reject" or "auto" or "acknowledged"` Capability fallback opt-in. Absent or `reject` (the default): an unsupported part type is refused 409 `code` 4005. `auto`: the part is rewritten to the best representation the device renders, and the response reports each swap it made in `fallbacks`. `acknowledged`: `auto`, plus the statement that a HUMAN has seen what the downgrade produces and accepted it; identical to `auto` for an automated caller, and the ONLY value that lets a human-attributed send be rewritten (a send attributed to a named operator that `auto` would rewrite is refused 409 `code` 4005, outright rather than warned about). Any other value → 422 `code` 1091. - `"reject"` - `"auto"` - `"acknowledged"` - `typing_lead_ms: optional number` How long the typing indicator runs before this message, in MILLISECONDS. THE INDICATOR IS YOURS TO ASK FOR. We show one for exactly as long as you request and never on our own, so this field is the whole control: absent or `0` shows no indicator at all, and a positive value shows one for precisely that long before the message reaches the customer. OPTIONAL by contract, and absent means the same as `0`: no indicator. We recommend asking for about 1000 ms before each message you send, and longer when the customer is about to wait on a lookup or a handoff, so the pause reads as thinking rather than as a stall. Bounded at 15000 (422 `code` 1096, which is also what a negative value gets). The cap is derived from the budget one send attempt gets: a longer indicator eats the time the message send itself needs, so the send would be cut short and retried and the customer would watch a long indicator followed by a late message or none. ### Returns - `PreviewMessageResponse object { chat_id, messages, wire_fidelity, 2 more }` The previewed send. Nothing was written; nothing will be sent. - `chat_id: string` - `messages: array of PreviewedMessage` What the ONE request would become, message by message (URL promotion can make several). - `parts: array of unknown` The message's post-degrade, post-promotion parts. A URL attachment remains in its source form because preview does not fetch it or mint the real `att_id` that an accepted send returns. Typed as an opaque array rather than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is. - `type: string` The part type of this message's FIRST part: the same value the send's `200` reports per message. - `wire: optional unknown` The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when `wire_unavailable` says why. - `wire_unavailable: optional string` Present only when this message has no wire preview (a plain attachment message), and why. - `wire_fidelity: "representative"` Always `representative`: the wire bytes are an approximation of what delivery sends, never the exact payload. - `"representative"` - `capture: optional object { collection, expects, page_id, 4 more }` The reply capture this send WOULD open, identical to the send `200`'s object except `group_id`, which is ABSENT here (nothing was minted). Absent entirely when no capture would open. - `collection: string` `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved. - `expects: string` The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots). - `page_id: string` Your form page's own id: the key the eventual `reply.values` uses. - `reasks: number` How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1. - `timeout_s: number` How long the collection stays open, in seconds. Fixed at 1800. - `group_id: optional string` ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back. - `options: optional array of object { index, item_id, label }` Present for the choice shapes only: what the customer can answer with, by number or label. - `index: number` - `item_id: string` - `label: string` - `fallbacks: optional array of Fallback` The capability rewrites `degrade` would apply: the same array the send's `200` would carry. Present only when something would be rewritten. - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/messages/preview \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "parts": [ { "items": [ { "id": "curb", "label": "Curbside" }, { "id": "garage", "label": "Garage level 2" } ], "summary_text": "Pickup options", "type": "choices" } ] }' ``` #### Response ```json { "chat_id": "chat_4f81b2", "wire_fidelity": "representative", "messages": [ { "type": "choices", "parts": [ { "type": "choices", "summary_text": "Pickup options", "items": [ { "id": "curb", "label": "Curbside" }, { "id": "garage", "label": "Garage level 2" } ] } ] } ] } ``` --- # Send a message URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/messages/methods/send/ **post** `/v1/chats/{chat}/messages` Sends a message into a chat. **Requirements** - `Idempotency-Key` header: required. A UUID you mint per logical send. - The chat must be open, and you must hold the send turn (see Chat control). **Behavior** - Replaying the same `Idempotency-Key` within 24 hours returns the original response. A refused request records no key, so the same key succeeds on retry. - An `https` URL in message text may be promoted to its own rich-link message. Each resulting message has its own `message.sent` or `message.failed` lifecycle, correlated by `group_id`. - Rate limiting is per brand: only your own traffic can produce it, and `Retry-After` is computed from your brand's own refill rate. A rate-limited request is not validated: an invalid send still fails with its own error once you are back within rate. A request that fans out into several messages costs one rate credit per message. **Errors** - HTTP 400: `idempotency_key_required`, `body_invalid` - HTTP 403: `consent_required` - HTTP 409: `chat_closed`, `chat_owned`, `capability_unsupported`, `idempotency_key_reused`, `idempotency_key_processing` while another request with the same key is still in flight - HTTP 413: `attachment_source_too_large` - HTTP 422: field-level validation (`category_invalid`, `degrade_invalid`, `attachment_source_invalid`, `attachment_source_empty`, and per-part codes), each naming the exact field at fault - HTTP 429: `rate_limited`, with `Retry-After` - HTTP 502: `attachment_source_unavailable` - HTTP 503: `send_paused`, `attachment_source_busy` (with `Retry-After`), or `attachment_storage_unavailable` ### Path Parameters - `chat: string` ### Query Parameters - `force: optional boolean` `true` bypasses the exclusive-owner gate (sends while `owner != partner`). A real override of a non-partner owner is audited (`chat.send_forced`). Does NOT override consent policy. ### Header Parameters - `"Idempotency-Key": string` ### Body Parameters - `parts: array of TextPart or ChoicesPart or ListPickerPart or 8 more` The message content, ≤20 parts. Structural rule (adapter L3): at most one interactive part per message, and `text` may not be combined with an interactive part; send two messages. An empty array returns HTTP 422 `code` 1052. - `TextPart object { body, type, subject }` A plain text message. subject renders bold on AMB. - `body: string` - `type: "text"` - `"text"` - `subject: optional string` - `ChoicesPart object { items, summary_text, type }` AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message. - `items: array of object { id, label }` - `id: string` - `label: string` - `summary_text: string` - `type: "choices"` - `"choices"` - `ListPickerPart object { sections, summary_text, type, 4 more }` AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id. - `sections: array of object { items, title, multi_select }` - `items: array of object { id, label, detail, image_id }` - `id: string` - `label: string` - `detail: optional string` - `image_id: optional string` - `title: string` - `multi_select: optional boolean` - `summary_text: string` - `type: "list_picker"` - `"list_picker"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `TimeSlotsPart object { event, slots, type, 5 more }` AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes. - `event: object { title, image_id, location }` - `title: string` - `image_id: optional string` - `location: optional object { latitude, longitude, radius_m, title }` - `latitude: optional number` - `longitude: optional number` - `radius_m: optional number` - `title: optional string` - `slots: array of object { id, duration_s, start }` - `id: string` - `duration_s: number` - `start: string` - `type: "time_slots"` - `"time_slots"` - `bubble_image_id: optional string` - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `subtitle: optional string` - `timezone_offset_min: optional number` - `FormPart object { header, pages, type, 7 more }` AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true. - `header: string` - `pages: array of object { id, kind, subtitle, 15 more }` - `id: string` - `kind: "select" or "picker" or "datePicker" or "input"` - `"select"` - `"picker"` - `"datePicker"` - `"input"` - `subtitle: string` The question shown to the user. - `date_format: optional string` datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings. - `hint_text: optional string` datePicker only. - `items: optional array of object { id, label, image_id }` select and picker only. - `id: string` - `label: string` - `image_id: optional string` select items only. - `keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more` input only. - `"default"` - `"asciiCapable"` - `"numbersAndPunctuation"` - `"URL"` - `"numberPad"` - `"phonePad"` - `"namePhonePad"` - `"emailAddress"` - `"decimalPad"` - `"webSearch"` - `label_text: optional string` datePicker only; defaults to "Date". - `max_chars: optional number` input only. - `maximum_date: optional string` datePicker only, in date_format. - `minimum_date: optional string` datePicker only, in date_format. - `multiple: optional boolean` select only. - `next_page_id: optional string` - `picker_title: optional string` picker only; empty centers the field. - `required: optional boolean` input only. - `selected_item_index: optional number` picker only. - `start_date: optional string` datePicker only, in date_format. - `title: optional string` - `type: "form"` - `"form"` - `bubble_image_id: optional string` Icon on the received/reply message bubble. - `bubble_style: optional "icon" or "small" or "large"` Size of the transcript bubble this part renders as, and therefore the size of `bubble_image_id` inside it. `icon` is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). `small` is 280x85 pt with 60x60 pt (180x180 px). `large` is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part's existing bubble. - `"icon"` - `"small"` - `"large"` - `images: optional array of object { id, att_id, description }` - `id: string` - `att_id: string` A stored attachment's att_id; never inline base64. - `description: optional string` Read aloud by VoiceOver. - `show_summary: optional boolean` - `splash_button_title: optional string` Defaults to "Start". The splash view shows when any splash field is set. - `splash_image_id: optional string` - `splash_text: optional string` - `RichLinkPart object { title, type, url, 5 more }` A rich link card. Omit image_att_id to get the default card. The image must be a real PNG. - `title: string` - `type: "rich_link"` - `"rich_link"` - `url: string` - `app_clip: optional boolean` Set true when url is an App Clip page, so the App Clip payload is constructed server side (POST /v1/constructPayload). Apple Music and Apple Maps URLs need no flag; they are detected by host. The page must carry App Clip elements and its image must be under 4 MB; if construction fails, the send fails rather than delivering a plain card. - `image_att_id: optional string` - `store_region: optional string` The App Store region Apple resolves the App Clip in, as a 2-letter uppercase ISO-3166 code. Omit for the US, which is Apple's default. Requires app_clip: true; Apple scopes this to App Clips, so setting it on any other rich link is rejected rather than ignored. - `video_mime_type: optional string` MIME type of the video at video_url. video/mp4 is the recommended container. - `video_url: optional string` Public https URL of a video asset to play inline in the card. Apple's device fetches this URL client-side when the customer taps play; it must serve the video bytes from the open internet. We never fetch, validate, or re-host them. Requires video_mime_type and image_att_id. - `AttachmentPart object { att_id, type }` An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part's source_url form instead; never inline base64. - `att_id: string` - `type: "attachment"` - `"attachment"` - `ApplePayPart object { payment_request, received_message, type }` AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand's configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings. - `payment_request: object { country_code, currency_code, line_items, 7 more }` - `country_code: string` - `currency_code: string` - `line_items: array of object { amount, label, type }` - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"` - `"supports3DS"` - `"supportsCredit"` - `"supportsDebit"` - `"supportsEMV"` - `supported_networks: array of "amex" or "discover" or "jcb" or 3 more` - `"amex"` - `"discover"` - `"jcb"` - `"masterCard"` - `"privateLabel"` - `"visa"` - `total: object { amount, label, type }` The grand total. - `amount: string` - `label: string` - `type: optional "final" or "pending" or ""` Empty means unset. - `"final"` - `"pending"` - `""` - `required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more` - `"email"` - `"name"` - `"phone"` - `"phoneticName"` - `"post"` - `shipping_methods: optional array of object { amount, detail, identifier, label }` - `amount: string` - `detail: string` - `identifier: string` - `label: string` - `supported_countries: optional array of string` - `received_message: object { title, image_att_id, subtitle }` The bubble the customer sees before authorizing. - `title: string` - `image_att_id: optional string` - `subtitle: optional string` - `type: "apple_pay"` - `"apple_pay"` - `AuthenticatePart object { received_title, reply_title, scope, 3 more }` Opens Apple's sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts. - `received_title: string` The bubble the customer sees. - `reply_title: string` The bubble after completion. - `scope: array of string` - `type: "authenticate"` - `"authenticate"` - `redirect_uri: optional string` - `state: optional string` - `AppExtensionPart object { app, received_message, reply_message, 4 more }` A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand's registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it. - `app: string` Which of the brand's registered apps to send under. - `received_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `reply_message: object { title, subtitle }` Shown on a device without the extension installed. - `title: string` - `subtitle: optional string` - `type: "app_extension"` - `"app_extension"` - `url: string` The payload the Messages app hands to the extension. Opaque: never parsed or rewritten. - `session_id: optional string` Reuse to address a card already sent; omit and Apple generates one. - `use_live_layout: optional boolean` Defaults to true. - `SourceAttachmentPart object { name, source_url, type }` A partner-hosted attachment imported synchronously before the send is accepted. Linq stores a frozen copy; later source changes cannot alter delivery. The URL must be public HTTPS on port 443 and require no headers, cookies or authentication. Redirects are accepted only when every hop passes the same secure URL policy. - `name: string` Filename delivered to the customer. - `source_url: string` Public HTTPS URL fetched before acceptance. - `type: "attachment"` - `"attachment"` - `category: optional "transactional" or "account_notification" or "marketing"` Apple notification category. OPTIONAL by contract: absent means `transactional` (an in-chat reply): a bare `{"parts":[…]}` must keep working bit-for-bit. Unknown value → 422 `code` 1010. A non-transactional send with no valid opt-in on file returns HTTP 403 `code` 2015 (`force=true` does NOT override consent). - `"transactional"` - `"account_notification"` - `"marketing"` - `degrade: optional "reject" or "auto" or "acknowledged"` Capability fallback opt-in. Absent or `reject` (the default): an unsupported part type is refused 409 `code` 4005. `auto`: the part is rewritten to the best representation the device renders, and the response reports each swap it made in `fallbacks`. `acknowledged`: `auto`, plus the statement that a HUMAN has seen what the downgrade produces and accepted it; identical to `auto` for an automated caller, and the ONLY value that lets a human-attributed send be rewritten (a send attributed to a named operator that `auto` would rewrite is refused 409 `code` 4005, outright rather than warned about). Any other value → 422 `code` 1091. - `"reject"` - `"auto"` - `"acknowledged"` - `typing_lead_ms: optional number` How long the typing indicator runs before this message, in MILLISECONDS. THE INDICATOR IS YOURS TO ASK FOR. We show one for exactly as long as you request and never on our own, so this field is the whole control: absent or `0` shows no indicator at all, and a positive value shows one for precisely that long before the message reaches the customer. OPTIONAL by contract, and absent means the same as `0`: no indicator. We recommend asking for about 1000 ms before each message you send, and longer when the customer is about to wait on a lookup or a handoff, so the pause reads as thinking rather than as a stall. Bounded at 15000 (422 `code` 1096, which is also what a negative value gets). The cap is derived from the budget one send attempt gets: a longer indicator eats the time the message send itself needs, so the send would be cut short and retried and the customer would watch a long indicator followed by a late message or none. ### Returns - `id: string` The FIRST message's id: the send's primary id. - `chat_id: string` - `delivery: "queued"` - `"queued"` - `messages: array of SentMessage` Every message this request was accepted as, in delivery order; ALWAYS present, one element for an ordinary send. Longer when an `https` URL in your text was promoted to its own rich-link message: each element then has its own `message.sent`/`message.failed` lifecycle, correlated by `group_id`. More than 10 messages returns HTTP 422 `code` 1075. - `id: string` Message id (`msg_…`). - `type: string` The part type of that message's FIRST part. - `parts: optional array of unknown` The message's canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason `TranscriptRow.parts` is. - `capture: optional object { collection, expects, page_id, 4 more }` The reply capture this send opened; see `CaptureObject`. Absent when no capture opened. - `collection: string` `single_shot`: the whole prompt sequence goes at once. `progressive` is reserved. - `expects: string` The reply shape we will match: `choice`, `multi_choice`, `text`, `phone`, `email`, or `date_text` (a date question at the text floor records the customer's words verbatim; `date_choice` is reserved for a future variant that offers concrete slots). - `page_id: string` Your form page's own id: the key the eventual `reply.values` uses. - `reasks: number` How many re-asks a mismatched reply earns before the collection ends `abandoned`. Fixed at 1. - `timeout_s: number` How long the collection stays open, in seconds. Fixed at 1800. - `group_id: optional string` ABSENT on the preview (nothing was minted); on the send `200`, the fan's correlation id: the primary message's own id, the value `form.response` reports back. - `options: optional array of object { index, item_id, label }` Present for the choice shapes only: what the customer can answer with, by number or label. - `index: number` - `item_id: string` - `label: string` - `fallbacks: optional array of Fallback` Present ONLY when `degrade` (`"auto"` or `"acknowledged"`) actually rewrote a part: its presence is the signal that the customer saw something other than what you composed, and an acknowledgement does not suppress it. Absent otherwise (never an empty array). - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/messages \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "parts": [ { "body": "Your driver Marta is two minutes away in a blue Prius.", "type": "text" } ] }' ``` #### Response ```json { "id": "msg_2c7d90", "chat_id": "chat_4f81b2", "delivery": "queued", "messages": [ { "id": "msg_2c7d90", "type": "text" } ] } ``` --- # Transcript URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/transcript/ ## List chat transcript **get** `/v1/chats/{chat}/transcript` Lists the chat as a conversation to display: what the customer saw, in order. **Behavior** - The same rows `/events` pages, rebuilt for rendering: each carries a `kind` to switch on, the message's canonical `parts`, and the delivery state of each outbound message. - Same paging (`after_seq`, `limit`) and same bounds as `/events`. Use this to draw a chat; use `/events` to rebuild state. ### Path Parameters - `chat: string` ### Query Parameters - `after_seq: optional number` Return rows with `seq` greater than this; omitted (or non-numeric) reads from the start. - `limit: optional number` Page size; absent or outside 1–100 reads as 100. ### Returns - `data: array of TranscriptRow` - `actor: string` Who wrote it: `customer`, `partner`, `human`, `flow`, `system` or `brand`. On a row carrying a `message_id` this is who COMPOSED that message: a human agent replying from a console reads `human`, a deterministic flow reads `flow`, and NOT the actor of the underlying event, which is always `partner` on a send because that event means we delivered it. Everywhere else it is the underlying event's own actor. - `created_at: string` - `event_type: string` The underlying event type (`message_received`, `owner_changed`, `consent_send_refused`, …); render detail from it, but branch on `kind`. - `kind: "customer_message" or "business_message" or "system"` What KIND of thing happened; switch on this. The set is CLOSED: an event type added later renders as `system`, never as a fourth value. - `"customer_message"` - `"business_message"` - `"system"` - `seq: number` The `seq` this row is built from; same numbering as the events endpoint, and the transcript's own cursor. - `attachments: optional array of object { att_id, mime_type, name, 2 more }` Files the CUSTOMER sent with this message; absent when they sent none. - `att_id: optional string` The retained plaintext's id (`att_…`); absent when nothing was retained. - `mime_type: optional string` - `name: optional string` The file's name as the customer's device sent it. - `reason: optional string` Present exactly when `att_id` is absent, and a CLOSED set: `too_large` | `brand_unresolved` | `store_unavailable` | `disabled` | `unavailable` (any failure earlier than the retention decision). Never the underlying error text, that is free-form and can carry the channel's own temporary download URL, so it stays on `/events`. - `size: optional number` Size in bytes. - `delivery: optional string` That message's delivery state, read from the message row: `queued` | `sending` | `sent` | `retry` | `failed` | `undelivered` | `suppressed`. Two of those are terminal failures, not one: `undelivered` is the "resubmitting later may land" outcome and `failed` the one that will not. There is NO delivered or read receipt here or anywhere on this API; AMB produces neither, and `sent` means only that Apple accepted the send. - `form_response: optional unknown` A `form_response` row's FULL record, first-class: `values` keyed by your original page ids, `item_ids`, `status`, `sources`, `opened_at`, `closed_at`. Present only on `event_type: "form_response"` rows. - `group_id: optional string` Ties a fan-out member to its group, and a `form_response` row to the degraded prompt messages that asked, so grouping needs no payload spelunking. Present on rows that belong to a group. - `message_id: optional string` The outbound message this row is about; pass it to `GET /v1/messages/{msg}`. - `operator: optional string` WHICH human. On a row carrying a `message_id` this is the operator who composed that send, where one did and where the send named them. On every other row it is the events endpoint's `operator`: the human we recorded, with the same rules. **On message rows this deliberately does NOT match `/events`**, which shows no `operator` for the same `seq`: a send is recorded by the worker that delivered it, with actor `partner` and no operator, so the composer is only knowable from the message row. Absence never means "no human"; it means this row cannot say which. - `parts: optional array of unknown` The message's canonical parts (the same array a send takes), absent on system rows and on a message that carried none: an invitation delivery records its invitation identity rather than parts. Typed as an opaque array rather than `Part[]` on purpose: the generated Go transport must never contain a second decoder for the part union (`content.ParseParts` is the one decode door), and a response `$ref` to `Part` would put one there. - `reaction: optional object { action, correlation, kind, 7 more }` The reaction this customer text row IS, when the text is a tapback. The row keeps its text verbatim and `kind` stays `customer_message`, so a client ignoring this key sees exactly the transcript it saw before. - `action: "added" or "removed"` Whether the customer APPLIED this reaction or TOOK IT AWAY. CLOSED: `added` or `removed`, and always present. Apple sends a removal as its own message, so a removal is its own transcript fact rather than the absence of one. Note what this does NOT tell you: replacing one reaction with another sends the new `added` and no `removed` beside it, so state is last-one-wins per (message, customer) and a client that appends will accumulate reactions the customer no longer has. - `"added"` - `"removed"` - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"` HOW the target was reached. CLOSED: `inferred` (exactly one message in the recent window carried that text, and both target keys name it), `ambiguous` (several did; `candidate_count` says how many, and no winner is picked for you), `unmatched` (none did), `unavailable` (Apple named no target at all, which is what a reaction to a non-text message looks like). There is no `exact`, because no reaction on this wire carries an id. - `"inferred"` - `"ambiguous"` - `"unmatched"` - `"unavailable"` - `kind: "like" or "love" or "dislike" or 5 more` What the customer did. CLOSED: `like`, `love`, `dislike`, `laugh`, `emphasize`, `question`, `sticker` (a sticker or Memoji), or `emoji` (any other character, which rides `emoji`). - `"like"` - `"love"` - `"dislike"` - `"laugh"` - `"emphasize"` - `"question"` - `"sticker"` - `"emoji"` - `attachment_id: optional string` The sticker's image, on a sticker whose image we retained: pass it to `GET /v1/attachments/{att}/content`. Present ONLY when `kind` is `sticker` and `action` is `added`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker: no handle is better than the wrong one. Do not assume a format. Captures show both `image/png` and `image/heic`; read the response's own content type. A PRESENT ID IS NOT A PROMISE OF BYTES, exactly as for `att_id`: retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`. Handle that; it is the ordinary outcome for anything old enough. - `candidate_count: optional number` How many messages carried that exact text, on `ambiguous`. - `emoji: optional string` The character the customer picked, present only when `kind` is `emoji`, where the character IS the reaction and no named one exists for it. - `quoted_text: optional string` The original message's text as Apple quoted it back: the only handle this wire offers, and what the correlation is made against. Absent when Apple named no target. - `target_msg_id: optional string` The message reacted to, on `inferred`. Pass it to `GET /v1/messages/{msg}`. - `target_seq: optional number` That message's `seq`, on `inferred`: the transcript row to scroll to. - `target_side: optional "business" or "customer"` WHOSE message was reacted to, on `inferred`: `business` or `customer`. A customer can react to their own message, so this is not derivable from the reaction being a customer event. Unlike the correlation it is not an inference; it is the matched row's own type. - `"business"` - `"customer"` - `reason: optional string` The reason the event states, where it states one: the failure discriminator on a failed or suppressed send, the handoff reason on an ownership change, the refusal reason on a consent row. - `reply: optional unknown` A customer's normalized interactive selection (quick reply, list pick, time slot); absent when they sent plain text. A customer who taps a Quick Reply sends no text, so this is the row's only content. - `request_id: optional string` The `requestIdentifier` this message was delivered under: the exact value an interactive reply names in its own `request_id`, so an answer can be matched to the message it answers instead of inferred from item ids (the same card sent twice defines the same item ids, while each interactive send is delivered under a fresh identifier). Present only on a row naming a message that recorded one: interactive sends mint one, plain sends do not, and rows from before this field was served carry none. ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/transcript \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "seq": 13, "kind": "customer_message", "event_type": "message_received", "actor": "customer", "created_at": "2026-08-06T14:01:58Z", "parts": [ { "type": "text", "body": "where is my driver?" } ] } ] } ``` ## Domain Types ### Transcript Row - `TranscriptRow object { actor, created_at, event_type, 13 more }` One transcript row, ready to render: an event-log entry rebuilt for display. It is not a `JournalEntry`: that shape has an open set of `type` values and a `payload` whose shape depends on it, so a client has nothing to switch on. This row leads with a closed `kind` and carries the message's canonical parts and delivery state. - `actor: string` Who wrote it: `customer`, `partner`, `human`, `flow`, `system` or `brand`. On a row carrying a `message_id` this is who COMPOSED that message: a human agent replying from a console reads `human`, a deterministic flow reads `flow`, and NOT the actor of the underlying event, which is always `partner` on a send because that event means we delivered it. Everywhere else it is the underlying event's own actor. - `created_at: string` - `event_type: string` The underlying event type (`message_received`, `owner_changed`, `consent_send_refused`, …); render detail from it, but branch on `kind`. - `kind: "customer_message" or "business_message" or "system"` What KIND of thing happened; switch on this. The set is CLOSED: an event type added later renders as `system`, never as a fourth value. - `"customer_message"` - `"business_message"` - `"system"` - `seq: number` The `seq` this row is built from; same numbering as the events endpoint, and the transcript's own cursor. - `attachments: optional array of object { att_id, mime_type, name, 2 more }` Files the CUSTOMER sent with this message; absent when they sent none. - `att_id: optional string` The retained plaintext's id (`att_…`); absent when nothing was retained. - `mime_type: optional string` - `name: optional string` The file's name as the customer's device sent it. - `reason: optional string` Present exactly when `att_id` is absent, and a CLOSED set: `too_large` | `brand_unresolved` | `store_unavailable` | `disabled` | `unavailable` (any failure earlier than the retention decision). Never the underlying error text, that is free-form and can carry the channel's own temporary download URL, so it stays on `/events`. - `size: optional number` Size in bytes. - `delivery: optional string` That message's delivery state, read from the message row: `queued` | `sending` | `sent` | `retry` | `failed` | `undelivered` | `suppressed`. Two of those are terminal failures, not one: `undelivered` is the "resubmitting later may land" outcome and `failed` the one that will not. There is NO delivered or read receipt here or anywhere on this API; AMB produces neither, and `sent` means only that Apple accepted the send. - `form_response: optional unknown` A `form_response` row's FULL record, first-class: `values` keyed by your original page ids, `item_ids`, `status`, `sources`, `opened_at`, `closed_at`. Present only on `event_type: "form_response"` rows. - `group_id: optional string` Ties a fan-out member to its group, and a `form_response` row to the degraded prompt messages that asked, so grouping needs no payload spelunking. Present on rows that belong to a group. - `message_id: optional string` The outbound message this row is about; pass it to `GET /v1/messages/{msg}`. - `operator: optional string` WHICH human. On a row carrying a `message_id` this is the operator who composed that send, where one did and where the send named them. On every other row it is the events endpoint's `operator`: the human we recorded, with the same rules. **On message rows this deliberately does NOT match `/events`**, which shows no `operator` for the same `seq`: a send is recorded by the worker that delivered it, with actor `partner` and no operator, so the composer is only knowable from the message row. Absence never means "no human"; it means this row cannot say which. - `parts: optional array of unknown` The message's canonical parts (the same array a send takes), absent on system rows and on a message that carried none: an invitation delivery records its invitation identity rather than parts. Typed as an opaque array rather than `Part[]` on purpose: the generated Go transport must never contain a second decoder for the part union (`content.ParseParts` is the one decode door), and a response `$ref` to `Part` would put one there. - `reaction: optional object { action, correlation, kind, 7 more }` The reaction this customer text row IS, when the text is a tapback. The row keeps its text verbatim and `kind` stays `customer_message`, so a client ignoring this key sees exactly the transcript it saw before. - `action: "added" or "removed"` Whether the customer APPLIED this reaction or TOOK IT AWAY. CLOSED: `added` or `removed`, and always present. Apple sends a removal as its own message, so a removal is its own transcript fact rather than the absence of one. Note what this does NOT tell you: replacing one reaction with another sends the new `added` and no `removed` beside it, so state is last-one-wins per (message, customer) and a client that appends will accumulate reactions the customer no longer has. - `"added"` - `"removed"` - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"` HOW the target was reached. CLOSED: `inferred` (exactly one message in the recent window carried that text, and both target keys name it), `ambiguous` (several did; `candidate_count` says how many, and no winner is picked for you), `unmatched` (none did), `unavailable` (Apple named no target at all, which is what a reaction to a non-text message looks like). There is no `exact`, because no reaction on this wire carries an id. - `"inferred"` - `"ambiguous"` - `"unmatched"` - `"unavailable"` - `kind: "like" or "love" or "dislike" or 5 more` What the customer did. CLOSED: `like`, `love`, `dislike`, `laugh`, `emphasize`, `question`, `sticker` (a sticker or Memoji), or `emoji` (any other character, which rides `emoji`). - `"like"` - `"love"` - `"dislike"` - `"laugh"` - `"emphasize"` - `"question"` - `"sticker"` - `"emoji"` - `attachment_id: optional string` The sticker's image, on a sticker whose image we retained: pass it to `GET /v1/attachments/{att}/content`. Present ONLY when `kind` is `sticker` and `action` is `added`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker: no handle is better than the wrong one. Do not assume a format. Captures show both `image/png` and `image/heic`; read the response's own content type. A PRESENT ID IS NOT A PROMISE OF BYTES, exactly as for `att_id`: retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`. Handle that; it is the ordinary outcome for anything old enough. - `candidate_count: optional number` How many messages carried that exact text, on `ambiguous`. - `emoji: optional string` The character the customer picked, present only when `kind` is `emoji`, where the character IS the reaction and no named one exists for it. - `quoted_text: optional string` The original message's text as Apple quoted it back: the only handle this wire offers, and what the correlation is made against. Absent when Apple named no target. - `target_msg_id: optional string` The message reacted to, on `inferred`. Pass it to `GET /v1/messages/{msg}`. - `target_seq: optional number` That message's `seq`, on `inferred`: the transcript row to scroll to. - `target_side: optional "business" or "customer"` WHOSE message was reacted to, on `inferred`: `business` or `customer`. A customer can react to their own message, so this is not derivable from the reaction being a customer event. Unlike the correlation it is not an inference; it is the matched row's own type. - `"business"` - `"customer"` - `reason: optional string` The reason the event states, where it states one: the failure discriminator on a failed or suppressed send, the handoff reason on an ownership change, the refusal reason on a consent row. - `reply: optional unknown` A customer's normalized interactive selection (quick reply, list pick, time slot); absent when they sent plain text. A customer who taps a Quick Reply sends no text, so this is the row's only content. - `request_id: optional string` The `requestIdentifier` this message was delivered under: the exact value an interactive reply names in its own `request_id`, so an answer can be matched to the message it answers instead of inferred from item ids (the same card sent twice defines the same item ids, while each interactive send is delivered under a fresh identifier). Present only on a row naming a message that recorded one: interactive sends mint one, plain sends do not, and rows from before this field was served carry none. --- # List chat transcript URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/transcript/methods/list/ **get** `/v1/chats/{chat}/transcript` Lists the chat as a conversation to display: what the customer saw, in order. **Behavior** - The same rows `/events` pages, rebuilt for rendering: each carries a `kind` to switch on, the message's canonical `parts`, and the delivery state of each outbound message. - Same paging (`after_seq`, `limit`) and same bounds as `/events`. Use this to draw a chat; use `/events` to rebuild state. ### Path Parameters - `chat: string` ### Query Parameters - `after_seq: optional number` Return rows with `seq` greater than this; omitted (or non-numeric) reads from the start. - `limit: optional number` Page size; absent or outside 1–100 reads as 100. ### Returns - `data: array of TranscriptRow` - `actor: string` Who wrote it: `customer`, `partner`, `human`, `flow`, `system` or `brand`. On a row carrying a `message_id` this is who COMPOSED that message: a human agent replying from a console reads `human`, a deterministic flow reads `flow`, and NOT the actor of the underlying event, which is always `partner` on a send because that event means we delivered it. Everywhere else it is the underlying event's own actor. - `created_at: string` - `event_type: string` The underlying event type (`message_received`, `owner_changed`, `consent_send_refused`, …); render detail from it, but branch on `kind`. - `kind: "customer_message" or "business_message" or "system"` What KIND of thing happened; switch on this. The set is CLOSED: an event type added later renders as `system`, never as a fourth value. - `"customer_message"` - `"business_message"` - `"system"` - `seq: number` The `seq` this row is built from; same numbering as the events endpoint, and the transcript's own cursor. - `attachments: optional array of object { att_id, mime_type, name, 2 more }` Files the CUSTOMER sent with this message; absent when they sent none. - `att_id: optional string` The retained plaintext's id (`att_…`); absent when nothing was retained. - `mime_type: optional string` - `name: optional string` The file's name as the customer's device sent it. - `reason: optional string` Present exactly when `att_id` is absent, and a CLOSED set: `too_large` | `brand_unresolved` | `store_unavailable` | `disabled` | `unavailable` (any failure earlier than the retention decision). Never the underlying error text, that is free-form and can carry the channel's own temporary download URL, so it stays on `/events`. - `size: optional number` Size in bytes. - `delivery: optional string` That message's delivery state, read from the message row: `queued` | `sending` | `sent` | `retry` | `failed` | `undelivered` | `suppressed`. Two of those are terminal failures, not one: `undelivered` is the "resubmitting later may land" outcome and `failed` the one that will not. There is NO delivered or read receipt here or anywhere on this API; AMB produces neither, and `sent` means only that Apple accepted the send. - `form_response: optional unknown` A `form_response` row's FULL record, first-class: `values` keyed by your original page ids, `item_ids`, `status`, `sources`, `opened_at`, `closed_at`. Present only on `event_type: "form_response"` rows. - `group_id: optional string` Ties a fan-out member to its group, and a `form_response` row to the degraded prompt messages that asked, so grouping needs no payload spelunking. Present on rows that belong to a group. - `message_id: optional string` The outbound message this row is about; pass it to `GET /v1/messages/{msg}`. - `operator: optional string` WHICH human. On a row carrying a `message_id` this is the operator who composed that send, where one did and where the send named them. On every other row it is the events endpoint's `operator`: the human we recorded, with the same rules. **On message rows this deliberately does NOT match `/events`**, which shows no `operator` for the same `seq`: a send is recorded by the worker that delivered it, with actor `partner` and no operator, so the composer is only knowable from the message row. Absence never means "no human"; it means this row cannot say which. - `parts: optional array of unknown` The message's canonical parts (the same array a send takes), absent on system rows and on a message that carried none: an invitation delivery records its invitation identity rather than parts. Typed as an opaque array rather than `Part[]` on purpose: the generated Go transport must never contain a second decoder for the part union (`content.ParseParts` is the one decode door), and a response `$ref` to `Part` would put one there. - `reaction: optional object { action, correlation, kind, 7 more }` The reaction this customer text row IS, when the text is a tapback. The row keeps its text verbatim and `kind` stays `customer_message`, so a client ignoring this key sees exactly the transcript it saw before. - `action: "added" or "removed"` Whether the customer APPLIED this reaction or TOOK IT AWAY. CLOSED: `added` or `removed`, and always present. Apple sends a removal as its own message, so a removal is its own transcript fact rather than the absence of one. Note what this does NOT tell you: replacing one reaction with another sends the new `added` and no `removed` beside it, so state is last-one-wins per (message, customer) and a client that appends will accumulate reactions the customer no longer has. - `"added"` - `"removed"` - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"` HOW the target was reached. CLOSED: `inferred` (exactly one message in the recent window carried that text, and both target keys name it), `ambiguous` (several did; `candidate_count` says how many, and no winner is picked for you), `unmatched` (none did), `unavailable` (Apple named no target at all, which is what a reaction to a non-text message looks like). There is no `exact`, because no reaction on this wire carries an id. - `"inferred"` - `"ambiguous"` - `"unmatched"` - `"unavailable"` - `kind: "like" or "love" or "dislike" or 5 more` What the customer did. CLOSED: `like`, `love`, `dislike`, `laugh`, `emphasize`, `question`, `sticker` (a sticker or Memoji), or `emoji` (any other character, which rides `emoji`). - `"like"` - `"love"` - `"dislike"` - `"laugh"` - `"emphasize"` - `"question"` - `"sticker"` - `"emoji"` - `attachment_id: optional string` The sticker's image, on a sticker whose image we retained: pass it to `GET /v1/attachments/{att}/content`. Present ONLY when `kind` is `sticker` and `action` is `added`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker: no handle is better than the wrong one. Do not assume a format. Captures show both `image/png` and `image/heic`; read the response's own content type. A PRESENT ID IS NOT A PROMISE OF BYTES, exactly as for `att_id`: retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`. Handle that; it is the ordinary outcome for anything old enough. - `candidate_count: optional number` How many messages carried that exact text, on `ambiguous`. - `emoji: optional string` The character the customer picked, present only when `kind` is `emoji`, where the character IS the reaction and no named one exists for it. - `quoted_text: optional string` The original message's text as Apple quoted it back: the only handle this wire offers, and what the correlation is made against. Absent when Apple named no target. - `target_msg_id: optional string` The message reacted to, on `inferred`. Pass it to `GET /v1/messages/{msg}`. - `target_seq: optional number` That message's `seq`, on `inferred`: the transcript row to scroll to. - `target_side: optional "business" or "customer"` WHOSE message was reacted to, on `inferred`: `business` or `customer`. A customer can react to their own message, so this is not derivable from the reaction being a customer event. Unlike the correlation it is not an inference; it is the matched row's own type. - `"business"` - `"customer"` - `reason: optional string` The reason the event states, where it states one: the failure discriminator on a failed or suppressed send, the handoff reason on an ownership change, the refusal reason on a consent row. - `reply: optional unknown` A customer's normalized interactive selection (quick reply, list pick, time slot); absent when they sent plain text. A customer who taps a Quick Reply sends no text, so this is the row's only content. - `request_id: optional string` The `requestIdentifier` this message was delivered under: the exact value an interactive reply names in its own `request_id`, so an answer can be matched to the message it answers instead of inferred from item ids (the same card sent twice defines the same item ids, while each interactive send is delivered under a fresh identifier). Present only on a row naming a message that recorded one: interactive sends mint one, plain sends do not, and rows from before this field was served carry none. ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/transcript \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "seq": 13, "kind": "customer_message", "event_type": "message_received", "actor": "customer", "created_at": "2026-08-06T14:01:58Z", "parts": [ { "type": "text", "body": "where is my driver?" } ] } ] } ``` --- # Typing URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/typing/ ## Set the typing indicator **post** `/v1/chats/{chat}/typing` Sets or clears the chat's typing indicator. **Behavior** - Live only: the customer sees it as it happens; nothing is recorded. - Not needed around your own sends: put `typing_lead_ms` on the send instead, which shows the indicator for exactly as long as you ask. This endpoint is for showing activity while you are still working on a reply. - No `Idempotency-Key`: the server neither requires nor consults one here. ### Path Parameters - `chat: string` ### Body Parameters - `state: "start" or "end"` `start` shows the customer the typing indicator; `end` clears it. Any other value is a 422 `code` 1066. - `"start"` - `"end"` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/typing \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "state": "start" }' ``` #### Response ```json {} ``` ## Domain Types ### Typing Set Response - `TypingSetResponse = unknown` Typing succeeds with an empty JSON object; we do not wait for Apple to confirm it. --- # Set the typing indicator URL: https://docs.linqapp.com/channel/amb/api/resources/chats/subresources/typing/methods/set/ **post** `/v1/chats/{chat}/typing` Sets or clears the chat's typing indicator. **Behavior** - Live only: the customer sees it as it happens; nothing is recorded. - Not needed around your own sends: put `typing_lead_ms` on the send instead, which shows the indicator for exactly as long as you ask. This endpoint is for showing activity while you are still working on a reply. - No `Idempotency-Key`: the server neither requires nor consults one here. ### Path Parameters - `chat: string` ### Body Parameters - `state: "start" or "end"` `start` shows the customer the typing indicator; `end` clears it. Any other value is a 422 `code` 1066. - `"start"` - `"end"` ### Example ```http curl https://messages.api.linqapp.com/v1/chats/$CHAT/typing \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "state": "start" }' ``` #### Response ```json {} ``` --- # Consent URL: https://docs.linqapp.com/channel/amb/api/resources/consent/ ## Attest a consent grant **post** `/v1/consent` Records a consent grant your brand attests to; for example, an opt-in collected on your website. **Requirements** - Your brand's `consent_mode` must be `brand_attested`. Otherwise the call returns HTTP 409 `code` 2014. Read your mode before building against this endpoint: `GET /v1/settings` reports it as `consent_mode`. - No `Idempotency-Key`: the server neither requires nor consults one here. **Behavior** - Grants only. Revocations are never attested through this endpoint: an opt-out is honored in the channel where the customer expressed it. ### Body Parameters - `category: "account_notification" or "marketing" or "invitation"` The category being granted (`transactional` is not grantable returns HTTP 422 `code` 1010). - `"account_notification"` - `"marketing"` - `"invitation"` - `source: "website" or "app" or "phone" or "in_person"` Where the brand captured the opt-in. - `"website"` - `"app"` - `"phone"` - `"in_person"` - `subject: string` The subject: an opaque customer handle, or a bare E.164 when `subject_kind` is `tel` (normalized on record). - `expires_at: optional string` Expiry, clamped to `granted_at` + 2 years, which is Apple's ceiling; absent → that ceiling. - `granted_at: optional string` When the customer opted in; absent → now. - `subject_kind: optional "opaque" or "tel"` Defaults to `opaque`. `invitation` requires `tel`; the organic categories require `opaque`; cross-kind returns HTTP 422 `code` 1068. - `"opaque"` - `"tel"` ### Returns - `ok: true` - `true` - `subject: string` The normalized subject; for `tel`, the `tel:+E164` handle form that equals a tel chat's `customer_handle`. - `subject_kind: "opaque" or "tel"` The namespace the grant was written under (`opaque` when the request omitted it). - `"opaque"` - `"tel"` ### Example ```http curl https://messages.api.linqapp.com/v1/consent \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "category": "marketing", "source": "website", "subject": "urn:mbid:AQAAY7c1" }' ``` #### Response ```json { "ok": true, "subject": "urn:mbid:AQAAY7c1", "subject_kind": "opaque" } ``` ## Domain Types ### Consent Attest Response - `ConsentAttestResponse object { ok, subject, subject_kind }` The recorded grant, echoing the normalized subject. - `ok: true` - `true` - `subject: string` The normalized subject; for `tel`, the `tel:+E164` handle form that equals a tel chat's `customer_handle`. - `subject_kind: "opaque" or "tel"` The namespace the grant was written under (`opaque` when the request omitted it). - `"opaque"` - `"tel"` --- # Attest a consent grant URL: https://docs.linqapp.com/channel/amb/api/resources/consent/methods/attest/ **post** `/v1/consent` Records a consent grant your brand attests to; for example, an opt-in collected on your website. **Requirements** - Your brand's `consent_mode` must be `brand_attested`. Otherwise the call returns HTTP 409 `code` 2014. Read your mode before building against this endpoint: `GET /v1/settings` reports it as `consent_mode`. - No `Idempotency-Key`: the server neither requires nor consults one here. **Behavior** - Grants only. Revocations are never attested through this endpoint: an opt-out is honored in the channel where the customer expressed it. ### Body Parameters - `category: "account_notification" or "marketing" or "invitation"` The category being granted (`transactional` is not grantable returns HTTP 422 `code` 1010). - `"account_notification"` - `"marketing"` - `"invitation"` - `source: "website" or "app" or "phone" or "in_person"` Where the brand captured the opt-in. - `"website"` - `"app"` - `"phone"` - `"in_person"` - `subject: string` The subject: an opaque customer handle, or a bare E.164 when `subject_kind` is `tel` (normalized on record). - `expires_at: optional string` Expiry, clamped to `granted_at` + 2 years, which is Apple's ceiling; absent → that ceiling. - `granted_at: optional string` When the customer opted in; absent → now. - `subject_kind: optional "opaque" or "tel"` Defaults to `opaque`. `invitation` requires `tel`; the organic categories require `opaque`; cross-kind returns HTTP 422 `code` 1068. - `"opaque"` - `"tel"` ### Returns - `ok: true` - `true` - `subject: string` The normalized subject; for `tel`, the `tel:+E164` handle form that equals a tel chat's `customer_handle`. - `subject_kind: "opaque" or "tel"` The namespace the grant was written under (`opaque` when the request omitted it). - `"opaque"` - `"tel"` ### Example ```http curl https://messages.api.linqapp.com/v1/consent \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "category": "marketing", "source": "website", "subject": "urn:mbid:AQAAY7c1" }' ``` #### Response ```json { "ok": true, "subject": "urn:mbid:AQAAY7c1", "subject_kind": "opaque" } ``` --- # Contract URL: https://docs.linqapp.com/channel/amb/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 error codes; the guide carries the semantics a spec cannot express, such as consent gating and turn-taking. ### Example ```http curl https://messages.api.linqapp.com/v1/openapi.yaml \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` ## Get a part type's JSON Schema **get** `/v1/parts/{type}` Fetch one part type's JSON Schema (2020-12) BYTE-VERBATIM: the same Retrieves one part type's JSON Schema (2020-12), served as `application/schema+json`; ready to hand to a structured-output model as a tool schema. **Behavior** - The schema is deliberately wider than the API: rules JSON Schema cannot express (length caps, item counts, casing) are enforced when you send, and a violation returns HTTP 422 naming the exact field. **Errors** - HTTP 404 `code` 2037: unknown part type. ### Path Parameters - `type: string` ### Example ```http curl https://messages.api.linqapp.com/v1/parts/$TYPE \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` ## Domain Types ### Contract OpenAPI Response - `ContractOpenAPIResponse = string` ### Contract Part Schema Response - `ContractPartSchemaResponse = string` --- # Download the OpenAPI definition URL: https://docs.linqapp.com/channel/amb/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 error codes; the guide carries the semantics a spec cannot express, such as consent gating and turn-taking. ### Example ```http curl https://messages.api.linqapp.com/v1/openapi.yaml \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` --- # Get a part type's JSON Schema URL: https://docs.linqapp.com/channel/amb/api/resources/contract/methods/part_schema/ **get** `/v1/parts/{type}` Fetch one part type's JSON Schema (2020-12) BYTE-VERBATIM: the same Retrieves one part type's JSON Schema (2020-12), served as `application/schema+json`; ready to hand to a structured-output model as a tool schema. **Behavior** - The schema is deliberately wider than the API: rules JSON Schema cannot express (length caps, item counts, casing) are enforced when you send, and a violation returns HTTP 422 naming the exact field. **Errors** - HTTP 404 `code` 2037: unknown part type. ### Path Parameters - `type: string` ### Example ```http curl https://messages.api.linqapp.com/v1/parts/$TYPE \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` --- # Events URL: https://docs.linqapp.com/channel/amb/api/resources/events/ ## List events **get** `/v1/events` Lists your brand's events, oldest first. **Behavior** - An event is recorded when it HAPPENS, not when it is delivered. A brand with no webhook endpoint still accumulates events, which is what makes this readable after you register one. - Ordering is a stable total order: the event's instant paired with its unique id. Paging twice returns the same rows in the same places. - Scoped to your key's brand by construction: no parameter names a brand. ### Query Parameters - `chat_id: optional string` Only events for this chat. Chat events only, since a batch run is not a chat. - `cursor: optional string` Resume from a previous page's `next_cursor`. - `limit: optional number` Page size; absent or outside 1–100 reads as 100. - `since: optional string` Only events at or after this RFC3339 instant. A value that is not a timestamp is refused, never ignored. - `type: optional string` Only this event type. ### Returns - `data: array of Event` - `id: string` Stable id, `evt_…`. Unique for the life of the event. - `created_at: string` When the event happened. For a chat event this is the instant the chat recorded it. - `data: unknown` The event body, exactly what a webhook delivery for this event carries, so the same parser reads both. - `seq_from: number` First chat `seq` this event covers. - `seq_to: number` Last chat `seq` this event covers. - `type: string` What happened, e.g. `message.received`. The same names an endpoint subscribes to. - `batch_id: optional string` The invitation-batch run this event belongs to. Present on `invitation_batch.*` events only. - `chat_id: optional string` The chat this event belongs to, on chat events. A brand-scoped event (`brand.escalation_handling_changed`) carries its synthetic feed id here instead (`brand:`), which is load-bearing for ordering and dedup exactly as a chat id is. - `next_cursor: string` Pass back verbatim to read the next page. Empty string when the log is exhausted. ### Example ```http curl https://messages.api.linqapp.com/v1/events \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "id": "evt_9c2f4a1b", "type": "message.received", "created_at": "2026-08-14T05:12:44Z", "data": { "body": "where is my driver?" }, "chat_id": "chat_4f81b2", "seq_from": 13, "seq_to": 13 } ], "next_cursor": "" } ``` ## Get event **get** `/v1/events/{evt}` Reads one event. **Behavior** - An id belonging to another brand answers `404`, never `403`. The two are the same answer here, so this route cannot confirm an id exists elsewhere. ### Path Parameters - `evt: string` ### Returns - `Event object { id, created_at, data, 5 more }` One event in your brand's log. The attribution is a typed pair: a chat event carries `chat_id`, an `invitation_batch.*` event carries `batch_id`, and exactly one of the two is present on any event. They are never both set and never interchangeable. - `id: string` Stable id, `evt_…`. Unique for the life of the event. - `created_at: string` When the event happened. For a chat event this is the instant the chat recorded it. - `data: unknown` The event body, exactly what a webhook delivery for this event carries, so the same parser reads both. - `seq_from: number` First chat `seq` this event covers. - `seq_to: number` Last chat `seq` this event covers. - `type: string` What happened, e.g. `message.received`. The same names an endpoint subscribes to. - `batch_id: optional string` The invitation-batch run this event belongs to. Present on `invitation_batch.*` events only. - `chat_id: optional string` The chat this event belongs to, on chat events. A brand-scoped event (`brand.escalation_handling_changed`) carries its synthetic feed id here instead (`brand:`), which is load-bearing for ordering and dedup exactly as a chat id is. ### Example ```http curl https://messages.api.linqapp.com/v1/events/$EVT \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "evt_9c2f4a1b", "type": "message.received", "created_at": "2026-08-14T05:12:44Z", "data": { "body": "where is my driver?" }, "chat_id": "chat_4f81b2", "seq_from": 13, "seq_to": 13 } ``` ## Domain Types ### Event - `Event object { id, created_at, data, 5 more }` One event in your brand's log. The attribution is a typed pair: a chat event carries `chat_id`, an `invitation_batch.*` event carries `batch_id`, and exactly one of the two is present on any event. They are never both set and never interchangeable. - `id: string` Stable id, `evt_…`. Unique for the life of the event. - `created_at: string` When the event happened. For a chat event this is the instant the chat recorded it. - `data: unknown` The event body, exactly what a webhook delivery for this event carries, so the same parser reads both. - `seq_from: number` First chat `seq` this event covers. - `seq_to: number` Last chat `seq` this event covers. - `type: string` What happened, e.g. `message.received`. The same names an endpoint subscribes to. - `batch_id: optional string` The invitation-batch run this event belongs to. Present on `invitation_batch.*` events only. - `chat_id: optional string` The chat this event belongs to, on chat events. A brand-scoped event (`brand.escalation_handling_changed`) carries its synthetic feed id here instead (`brand:`), which is load-bearing for ordering and dedup exactly as a chat id is. ### Event List Response - `EventListResponse object { data, next_cursor }` - `data: array of Event` - `id: string` Stable id, `evt_…`. Unique for the life of the event. - `created_at: string` When the event happened. For a chat event this is the instant the chat recorded it. - `data: unknown` The event body, exactly what a webhook delivery for this event carries, so the same parser reads both. - `seq_from: number` First chat `seq` this event covers. - `seq_to: number` Last chat `seq` this event covers. - `type: string` What happened, e.g. `message.received`. The same names an endpoint subscribes to. - `batch_id: optional string` The invitation-batch run this event belongs to. Present on `invitation_batch.*` events only. - `chat_id: optional string` The chat this event belongs to, on chat events. A brand-scoped event (`brand.escalation_handling_changed`) carries its synthetic feed id here instead (`brand:`), which is load-bearing for ordering and dedup exactly as a chat id is. - `next_cursor: string` Pass back verbatim to read the next page. Empty string when the log is exhausted. --- # List events URL: https://docs.linqapp.com/channel/amb/api/resources/events/methods/list/ **get** `/v1/events` Lists your brand's events, oldest first. **Behavior** - An event is recorded when it HAPPENS, not when it is delivered. A brand with no webhook endpoint still accumulates events, which is what makes this readable after you register one. - Ordering is a stable total order: the event's instant paired with its unique id. Paging twice returns the same rows in the same places. - Scoped to your key's brand by construction: no parameter names a brand. ### Query Parameters - `chat_id: optional string` Only events for this chat. Chat events only, since a batch run is not a chat. - `cursor: optional string` Resume from a previous page's `next_cursor`. - `limit: optional number` Page size; absent or outside 1–100 reads as 100. - `since: optional string` Only events at or after this RFC3339 instant. A value that is not a timestamp is refused, never ignored. - `type: optional string` Only this event type. ### Returns - `data: array of Event` - `id: string` Stable id, `evt_…`. Unique for the life of the event. - `created_at: string` When the event happened. For a chat event this is the instant the chat recorded it. - `data: unknown` The event body, exactly what a webhook delivery for this event carries, so the same parser reads both. - `seq_from: number` First chat `seq` this event covers. - `seq_to: number` Last chat `seq` this event covers. - `type: string` What happened, e.g. `message.received`. The same names an endpoint subscribes to. - `batch_id: optional string` The invitation-batch run this event belongs to. Present on `invitation_batch.*` events only. - `chat_id: optional string` The chat this event belongs to, on chat events. A brand-scoped event (`brand.escalation_handling_changed`) carries its synthetic feed id here instead (`brand:`), which is load-bearing for ordering and dedup exactly as a chat id is. - `next_cursor: string` Pass back verbatim to read the next page. Empty string when the log is exhausted. ### Example ```http curl https://messages.api.linqapp.com/v1/events \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "id": "evt_9c2f4a1b", "type": "message.received", "created_at": "2026-08-14T05:12:44Z", "data": { "body": "where is my driver?" }, "chat_id": "chat_4f81b2", "seq_from": 13, "seq_to": 13 } ], "next_cursor": "" } ``` --- # Get event URL: https://docs.linqapp.com/channel/amb/api/resources/events/methods/retrieve/ **get** `/v1/events/{evt}` Reads one event. **Behavior** - An id belonging to another brand answers `404`, never `403`. The two are the same answer here, so this route cannot confirm an id exists elsewhere. ### Path Parameters - `evt: string` ### Returns - `Event object { id, created_at, data, 5 more }` One event in your brand's log. The attribution is a typed pair: a chat event carries `chat_id`, an `invitation_batch.*` event carries `batch_id`, and exactly one of the two is present on any event. They are never both set and never interchangeable. - `id: string` Stable id, `evt_…`. Unique for the life of the event. - `created_at: string` When the event happened. For a chat event this is the instant the chat recorded it. - `data: unknown` The event body, exactly what a webhook delivery for this event carries, so the same parser reads both. - `seq_from: number` First chat `seq` this event covers. - `seq_to: number` Last chat `seq` this event covers. - `type: string` What happened, e.g. `message.received`. The same names an endpoint subscribes to. - `batch_id: optional string` The invitation-batch run this event belongs to. Present on `invitation_batch.*` events only. - `chat_id: optional string` The chat this event belongs to, on chat events. A brand-scoped event (`brand.escalation_handling_changed`) carries its synthetic feed id here instead (`brand:`), which is load-bearing for ordering and dedup exactly as a chat id is. ### Example ```http curl https://messages.api.linqapp.com/v1/events/$EVT \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "evt_9c2f4a1b", "type": "message.received", "created_at": "2026-08-14T05:12:44Z", "data": { "body": "where is my driver?" }, "chat_id": "chat_4f81b2", "seq_from": 13, "seq_to": 13 } ``` --- # Invitation Batches URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_batches/ ## Submit a batch of invitations **post** `/v1/invitation_batches` Submits a batch of invitations as one run. Returns HTTP 202 with a run id immediately: the fan-out, pacing, and retries are handled for you, and none of it happens inside your request. **Behavior** - Validation covers the whole batch and names every row: each unusable row is reported at once, with your row index and field (`/recipients/7/to`), so the number of entries is exactly the number of rows to fix. `on_invalid` decides whether bad rows refuse the whole submit (HTTP 422 `code` 1005, nothing created) or are dropped while the rest proceed (HTTP 202 with a `skipped` array). - `validate_only: true` runs every screen and creates nothing: no run, no recipients, and the `Idempotency-Key` stays unclaimed. Success is HTTP 200 with a report instead of HTTP 202 with a run. - Consent is checked at submit, so you learn which numbers lack an `invitation` opt-in immediately instead of reading delivery failures for hours. It is also re-decided before every attempt: a customer who opts out after you submit is suppressed then. - The run is admitted against the delivery backlog. HTTP 429 `backlog_horizon_exceeded` (with `Retry-After`, legitimately hours) means your queue plus this run would exceed the delivery horizon; retry later. HTTP 422 `code` 1004 means this run alone can never fit; the message names a run size that will, rounded down to a round number, and the remedy is to split it. In one uncommon configuration we name no size and ask you to send fewer per run. Both create nothing, and the same `Idempotency-Key` succeeds on retry. - A scheduled (`not_before`) or smoothed (`spread_over_seconds`) run is admitted as it materializes instead of at submit: it waits rather than failing when your queue is full, its `eta` says so, and it can be cancelled like any other. - Per-row problems arrive inside `error.details` (or `skipped`), never as the response status: a batch's answer is about the list, and no single status is truthful when row 3 has a bad number and row 40 has no consent. **Errors (run-level)** - HTTP 400: `body_invalid`, `idempotency_key_required` - HTTP 403: `invitation_card_not_granted`: your brand is no longer approved for the card shape the template renders - HTTP 413: `too_large`: the body exceeds 8 MiB; split the batch - HTTP 422: `template_id_required`, `unknown_template`, `recipients_required`, `too_many_recipients`, `batch_exceeds_horizon`; `field_removed` if the request still carries the retired `brand_logo_att_id` field or a recipient still carries `parameters` - HTTP 429: `backlog_horizon_exceeded`, with `Retry-After` ### Header Parameters - `"Idempotency-Key": string` ### Body Parameters - `recipients: array of object { reference_id, to, locale }` The recipient set, in your order. 1–10,000 rows per submit: the ceiling is what one request can carry and still be validated, stored and reported on as a unit, and a larger campaign is more than one batch (the run id makes each one trackable). An empty array is `422 recipients_required`; over the ceiling is `422 too_many_recipients`, both naming `/recipients`. - `reference_id: string` REQUIRED per recipient: an order number, case id or similar, echoed on that invitation's delivery webhooks. A batch does not relax it: a run of 10,000 sends with one shared correlation handle is not reconcilable. - `to: string` The customer's phone number, bare E.164 (partner formatting tolerated). Normalized to a canonical `tel:+E164` handle at submit: an unparseable one is a `to_invalid` naming that row, and two rows that normalize alike are a `duplicate_recipient`. Your own string is stored as sent and echoed back in the run's per-recipient results. - `locale: optional string` Overrides the run's `locale` for this recipient. - `template_id: string` One of YOUR invitation template ids (`invt_…`). ONE template per run: a run is a campaign, and two templates are two batches. The card's name and logo come from the template, resolved ONCE at submit and frozen into the run, so editing the template mid-run cannot change what queued recipients receive. - `locale: optional string` The run's default locale; a recipient may override it. Absent renders the template in its own default. - `not_before: optional string` SCHEDULE THE RUN: do not start before this instant (RFC 3339, with an offset). Absent: the default; starts as soon as the run is accepted. A value in the PAST is accepted and starts the run immediately; it is not an error, because a retry of a submit whose start time has since elapsed must give you the run you asked for rather than a refusal you cannot fix. At most 7 days ahead (`422 not_before_too_far`): every recipient's opt-in is read at SUBMIT, so a run scheduled further out would send against consent nobody has re-checked. A scheduled run sits at `status: "queued"` until its time comes, costs you nothing meanwhile, and can be cancelled like any other. If our workers are down across the boundary the run starts LATE, never skipped: the start is a stored instant, not an event anyone has to be awake for. - `on_invalid: optional "reject_batch" or "skip_invalid"` What to do when some rows cannot be sent to. Defaults to `reject_batch`: partial acceptance is something you ask for, never something that happens to you, because "half my campaign went out" is not a state to reach by omission. - `"reject_batch"` - `"skip_invalid"` - `spread_over_seconds: optional number` SMOOTH THE RUN: spread it evenly over this many seconds, starting at `not_before` (or at acceptance). `0`: the default; hands every recipient to the delivery machinery as fast as it will take them, which is what every run did before this field existed. Capped at 7 days (`422 spread_over_seconds_invalid`). It can only ever make a run SLOWER. Your delivery rate is enforced independently and is unaffected by this field: a window longer than your rate needs gets you the window, a shorter one gets you your rate. Use it when the thing that cannot absorb a burst is on your side: a support queue, a fulfilment pipeline, a landing page. - `validate_only: optional boolean` DRY RUN. Every screen runs: the template grant, the parameter schemas, the phone numbers, the duplicates, the consent records, and NOTHING is created: no run, no recipients, and the `Idempotency-Key` is not claimed, so the real submit can use the same one. The answer is the answer the identical live request would give, except that success is `200` with a report instead of `202` with a run: so a `422` here is exactly the `422` you would have got. ### Returns - `accepted_count: number` How many rows a live submit would have accepted. - `recipient_count: number` The length of the recipient array you submitted. - `validate_only: true` Always `true`. Echoed rather than implied, so a response read out of a log cannot be mistaken for an accepted campaign. - `true` - `skipped: optional array of object { code, message, param }` The rows a live `skip_invalid` submit would have dropped. Absent when nothing would be. - `code: string` `to_required` | `to_invalid` | `duplicate_recipient` | `consent_required` | or a reference/locale code (`required`, `too_long`, `forbidden_character`). - `message: string` - `param: string` JSON pointer at the offending row AND field of your own request (`/recipients/7/to`). ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_batches \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "recipients": [ { "reference_id": "ride-88214", "to": "+15555550142" }, { "reference_id": "ride-88215", "to": "+15555550188" } ], "template_id": "invt_a4f2c718d0" }' ``` #### Response ```json { "id": "invb_0d5c19", "status": "queued", "template_id": "invt_a4f2c718d0", "recipient_count": 2, "accepted_count": 2, "created_at": "2026-08-06T14:00:00Z" } ``` ## Get a batch run's progress **get** `/v1/invitation_batches/{batch}` Retrieves a run's progress: its status, how many recipients stand in each state, and when the rest are projected to finish. **Behavior** - One call regardless of run size: the cost does not grow with the recipient count. - `counts` always carries all five states, zeros included, and always sums to `recipient_count`. - `eta` is an estimate from a rate that moves, and is absent rather than zero when none can honestly be quoted. - Unknown ids, and another brand's; return HTTP 404, the same answer. ### Path Parameters - `batch: string` ### Returns - `id: string` Run id (`invb_…`). - `accepted_count: number` How many recipients the run will attempt: `recipient_count` minus the rows the submit screen skipped. The same number the submit response returned, and frozen with it. - `counts: object { cancelled, failed, pending, 2 more }` How many of a run's recipients stand in each state. EVERY key is always present, zero included: a client reads five numbers instead of branching on which keys exist, and a state that has not happened yet is `0` rather than missing. - `cancelled: number` Stopped by a cancel before it was attempted. - `failed: number` Accepted at submit, then permanently refused before sending; consent withdrawn in between, a superseded or closed chat, a revoked template grant. The row's `reason` says which. - `pending: number` Not yet attempted. This is the number the ETA projects. - `sent: number` Handed to the invitation pipeline: an `invitation_id` exists on that row. Delivery to Apple is reported by that invitation's own status and webhooks, not here. - `skipped: number` Dropped by the SUBMIT screen under `on_invalid: skip_invalid`, with the reason the submit response gave. Frozen at creation; nothing later moves a row into or out of it. - `created_at: string` - `recipient_count: number` How many recipients the run was SUBMITTED with, frozen at creation; dropped rows included. Always the length of the array you sent, and always `counts`' total. - `status: "queued" or "running" or "completed" or "cancelled"` `queued` (created, nothing attempted yet) → `running` (at least one recipient attempted, some still pending) → `completed` (no recipient is pending; every one is `sent`, `failed`, `cancelled` or `skipped`), or `cancelled` when you stopped it. `completed` does NOT mean every invitation was delivered: it means the run has nothing left to attempt, and per-row outcomes are in `counts` and the recipient results. - `"queued"` - `"running"` - `"completed"` - `"cancelled"` - `template_id: string` - `updated_at: string` Last change to the run row itself (its `status`). NOT bumped by a per-recipient transition, so it is a run-lifecycle timestamp and not a progress heartbeat; read `counts` for progress. - `eta: optional object { at, queued_ahead, rate_per_second, 2 more }` The completion estimate. Absent when none can honestly be quoted; see the model. - `at: string` The same estimate as a timestamp on OUR clock; `seconds` from when this response was composed, so a client does not have to correct for its own skew. - `queued_ahead: number` How many other invitations of yours are queued ahead of this run's remaining rows in the same brand queue. A run does not clear in isolation; another campaign submitted first is counted here, and so are this run's own recipients that have already been handed to the delivery queue. - `rate_per_second: number` Recipients per second the estimate was taken at: `min(your brand's effective rate, our overall rate / active brands)`. - `remaining: number` How many of this run's recipients the estimate covers; `counts.pending`. - `seconds: number` Estimated seconds until the last remaining recipient is released. `0` when nothing is pending. - `not_before: optional string` The run's scheduled start, if you gave it one. A `queued` run with a `not_before` in the future is waiting for its own schedule, not for us. - `spread_over_seconds: optional number` The run's smoothing window, if you gave it one. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_batches/$BATCH \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "invb_0d5c19", "status": "running", "template_id": "invt_a4f2c718d0", "recipient_count": 2, "accepted_count": 2, "counts": { "pending": 1, "sent": 1, "failed": 0, "cancelled": 0, "skipped": 0 }, "created_at": "2026-08-06T14:00:00Z", "updated_at": "2026-08-06T14:00:07Z" } ``` ## List a batch run's recipients **get** `/v1/invitation_batches/{batch}/recipients` Lists a run's per-recipient results, in the order you submitted them, one page at a time. **Behavior** - Page with `cursor`: pass the previous page's `next_cursor` back verbatim, and stop when it comes back empty. Rows are ordered by your own array index, which never changes, so a page cannot skip or repeat a row while the run progresses underneath you. - Filter with `state` to avoid paging 10,000 rows to find 40 problems; `failed` or `skipped` and read one page. An unknown value is refused, never silently answered with an empty page. - Unknown runs, and another brand's; return HTTP 404. ### Path Parameters - `batch: string` ### Query Parameters - `cursor: optional string` The previous page's `next_cursor`, verbatim; absent starts at your first recipient. Not a cursor this API issued returns HTTP 400 `code` 1013. - `limit: optional number` Page size; absent (or non-numeric) reads as 25, above 100 reads as 100: the same page bounds every list on this API applies. - `state: optional string` Only recipients in this state (`pending`, `sent`, `failed`, `cancelled`, `skipped`); absent means every row. Anything else returns HTTP 400 `code` 1066. ### Returns - `data: array of object { idx, reference_id, state, 3 more }` - `idx: number` This row's position in the array you submitted, zero-based: the same index each refusal names (`/recipients/7/to`), and this run's identity for that row. Never renumbered: skipped rows stay in place, so the index cannot develop holes. - `reference_id: string` Your correlation handle for this recipient, echoed back. - `state: "pending" or "sent" or "failed" or 2 more` - `"pending"` - `"sent"` - `"failed"` - `"cancelled"` - `"skipped"` - `to: string` The recipient string AS YOU SENT IT, not the canonical form, so a result matches a row of your own list without you re-normalizing anything. - `invitation_id: optional string` The invitation this recipient became, once it has been attempted; read `GET /v1/invitations/{inv}` for its delivery state, the customer's response and its webhooks. Absent while the row is `pending`, and for any row that never became one (`skipped`, `cancelled`, and a `failed` row refused before an invitation existed). - `reason: optional object { code, message }` Present exactly on the rows that will not send (`skipped`, `failed`), absent on every other. - `code: string` `to_required` | `to_invalid` | `duplicate_recipient` | `consent_required` | a `reference_id` code (`too_long`, `forbidden_character`), or, for a row that failed after acceptance, the send-time refusal: `consent_required`, `chat_superseded`, `chat_closed`, or `invitation_template_not_authorized` (only here: your brand's approval was withdrawn while the run was in flight; at submit the same condition is the run-level `403 invitation_card_not_granted`). - `message: string` - `next_cursor: string` Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the run, that, not an empty `data`, is how paging ends. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_batches/$BATCH/recipients \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "idx": 1, "to": "tel:+15555550188", "reference_id": "ride-88215", "state": "failed", "reason": { "code": "consent_required", "message": "this recipient withdrew consent after the batch was accepted" } } ], "next_cursor": "" } ``` ## Cancel a batch run **post** `/v1/invitation_batches/{batch}/cancel` Cancels a run: every recipient not yet attempted is stopped before any attempt. **Behavior** - Stops both the recipients still waiting to become invitations and the ones already sitting in the delivery queue; cancelling seconds into a large run does not let the hundreds already queued go out. - Cannot stop a request already on Apple's wire or anything Apple has accepted. The `cancellation` object reports those as `in_flight` and `already_sent` rather than counting them as cancelled. - Exact, not best-effort: a cancel and the run's own fan-out serialize on the run, so there is no window where a recipient is both cancelled and sent. - Safe to repeat, never refused. Cancelling a cancelled run returns HTTP 200 with the same body. Cancelling a `completed` run also works; `completed` means every recipient became an invitation, not that all were delivered, and a large run is routinely `completed` with thousands of sends still queued; this call still stops everything unattempted. If nothing was left, `cancellation` is all zeros. Read the numbers, not the status code. - Stopped recipients appear as `cancelled` in `counts` and on the recipients page (`?state=cancelled`). No per-recipient webhook fires for them; the run's one `invitation_batch.completed` event fires with `status: "cancelled"` and the final counts. - Unknown runs, and another brand's; return HTTP 404. ### Path Parameters - `batch: string` ### Returns - `id: string` Run id (`invb_…`). - `accepted_count: number` How many recipients the run would have attempted: `recipient_count` minus the rows the submit screen skipped. - `cancellation: object { already_sent, in_flight, queued, unreleased }` What a cancel actually stopped, split by how far each recipient had got. The four numbers exist because two of them are NOT cancelled and you have to know which. Cancel is EXACT for everything that has not been attempted and cannot recall anything that has: a request already on Apple's wire is on Apple's wire. `unreleased + queued` is what this call stopped. `in_flight + already_sent` is what went out anyway. The four PARTITION the run; every recipient that became an invitation is in exactly one of them, and so is every recipient we stopped before it became one, so nothing falls between them. All four are recomputed from the run's rows on every call, so cancelling twice reports the same split rather than a set of zeros. - `already_sent: number` The send was already ATTEMPTED before your cancel landed. NOT stopped, and not recallable; by us or by anyone. Whether Apple accepted it is that invitation's own delivery state; this counts the attempt, so a send that was attempted and refused is here rather than nowhere. - `in_flight: number` An attempt was on the wire when your cancel landed. NOT stopped: it either reached Apple or did not, and we report rather than guess. - `queued: number` Released to the delivery queue but never attempted. STOPPED; this is the set that makes a cancel three seconds into a 50,000-recipient run mean something. - `unreleased: number` Never handed to the delivery queue at all; recipients we had not reached yet, plus invitations the pacer had not released. STOPPED. - `counts: object { cancelled, failed, pending, 2 more }` The run's recipients by state, the same object the progress read returns. Rows this cancel stopped before they were attempted are now `cancelled`; a row that had already become an invitation stays `sent`: the invitation exists, and whether it went out is that invitation's own delivery state. - `cancelled: number` Stopped by a cancel before it was attempted. - `failed: number` Accepted at submit, then permanently refused before sending; consent withdrawn in between, a superseded or closed chat, a revoked template grant. The row's `reason` says which. - `pending: number` Not yet attempted. This is the number the ETA projects. - `sent: number` Handed to the invitation pipeline: an `invitation_id` exists on that row. Delivery to Apple is reported by that invitation's own status and webhooks, not here. - `skipped: number` Dropped by the SUBMIT screen under `on_invalid: skip_invalid`, with the reason the submit response gave. Frozen at creation; nothing later moves a row into or out of it. - `created_at: string` - `recipient_count: number` How many recipients the run was SUBMITTED with, frozen at creation. Always `counts`' total. - `status: "cancelled" or "completed"` `cancelled` when this call (or an earlier one) stopped a live run. `completed` when the run's own fan-out had ALREADY finished; every recipient was materialized before you cancelled; in which case the run keeps that status and `cancellation` reports what the cancel still caught in the delivery queue. It is never any other value: those two are the only states a run can be in after this call. - `"cancelled"` - `"completed"` - `template_id: string` - `updated_at: string` When the run reached `cancelled`. Unchanged by a repeated cancel. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_batches/$BATCH/cancel \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "invb_0d5c19", "status": "cancelled", "template_id": "invt_a4f2c718d0", "recipient_count": 2, "accepted_count": 2, "counts": { "pending": 0, "sent": 1, "failed": 0, "cancelled": 1, "skipped": 0 }, "cancellation": { "unreleased": 1, "queued": 0, "in_flight": 0, "already_sent": 1 }, "created_at": "2026-08-06T14:00:00Z", "updated_at": "2026-08-06T14:00:31Z" } ``` ## Domain Types ### Invitation Batch Create Response - `InvitationBatchCreateResponse object { accepted_count, recipient_count, validate_only, skipped }` `validate_only`'s report: what a live submit of the same body would have accepted, with no run created. - `accepted_count: number` How many rows a live submit would have accepted. - `recipient_count: number` The length of the recipient array you submitted. - `validate_only: true` Always `true`. Echoed rather than implied, so a response read out of a log cannot be mistaken for an accepted campaign. - `true` - `skipped: optional array of object { code, message, param }` The rows a live `skip_invalid` submit would have dropped. Absent when nothing would be. - `code: string` `to_required` | `to_invalid` | `duplicate_recipient` | `consent_required` | or a reference/locale code (`required`, `too_long`, `forbidden_character`). - `message: string` - `param: string` JSON pointer at the offending row AND field of your own request (`/recipients/7/to`). ### Invitation Batch Retrieve Response - `InvitationBatchRetrieveResponse object { id, accepted_count, counts, 8 more }` A run's progress. Poll this, not the recipients. - `id: string` Run id (`invb_…`). - `accepted_count: number` How many recipients the run will attempt: `recipient_count` minus the rows the submit screen skipped. The same number the submit response returned, and frozen with it. - `counts: object { cancelled, failed, pending, 2 more }` How many of a run's recipients stand in each state. EVERY key is always present, zero included: a client reads five numbers instead of branching on which keys exist, and a state that has not happened yet is `0` rather than missing. - `cancelled: number` Stopped by a cancel before it was attempted. - `failed: number` Accepted at submit, then permanently refused before sending; consent withdrawn in between, a superseded or closed chat, a revoked template grant. The row's `reason` says which. - `pending: number` Not yet attempted. This is the number the ETA projects. - `sent: number` Handed to the invitation pipeline: an `invitation_id` exists on that row. Delivery to Apple is reported by that invitation's own status and webhooks, not here. - `skipped: number` Dropped by the SUBMIT screen under `on_invalid: skip_invalid`, with the reason the submit response gave. Frozen at creation; nothing later moves a row into or out of it. - `created_at: string` - `recipient_count: number` How many recipients the run was SUBMITTED with, frozen at creation; dropped rows included. Always the length of the array you sent, and always `counts`' total. - `status: "queued" or "running" or "completed" or "cancelled"` `queued` (created, nothing attempted yet) → `running` (at least one recipient attempted, some still pending) → `completed` (no recipient is pending; every one is `sent`, `failed`, `cancelled` or `skipped`), or `cancelled` when you stopped it. `completed` does NOT mean every invitation was delivered: it means the run has nothing left to attempt, and per-row outcomes are in `counts` and the recipient results. - `"queued"` - `"running"` - `"completed"` - `"cancelled"` - `template_id: string` - `updated_at: string` Last change to the run row itself (its `status`). NOT bumped by a per-recipient transition, so it is a run-lifecycle timestamp and not a progress heartbeat; read `counts` for progress. - `eta: optional object { at, queued_ahead, rate_per_second, 2 more }` The completion estimate. Absent when none can honestly be quoted; see the model. - `at: string` The same estimate as a timestamp on OUR clock; `seconds` from when this response was composed, so a client does not have to correct for its own skew. - `queued_ahead: number` How many other invitations of yours are queued ahead of this run's remaining rows in the same brand queue. A run does not clear in isolation; another campaign submitted first is counted here, and so are this run's own recipients that have already been handed to the delivery queue. - `rate_per_second: number` Recipients per second the estimate was taken at: `min(your brand's effective rate, our overall rate / active brands)`. - `remaining: number` How many of this run's recipients the estimate covers; `counts.pending`. - `seconds: number` Estimated seconds until the last remaining recipient is released. `0` when nothing is pending. - `not_before: optional string` The run's scheduled start, if you gave it one. A `queued` run with a `not_before` in the future is waiting for its own schedule, not for us. - `spread_over_seconds: optional number` The run's smoothing window, if you gave it one. ### Invitation Batch List Recipients Response - `InvitationBatchListRecipientsResponse object { data, next_cursor }` One page of a run's recipients, in your submitted order. - `data: array of object { idx, reference_id, state, 3 more }` - `idx: number` This row's position in the array you submitted, zero-based: the same index each refusal names (`/recipients/7/to`), and this run's identity for that row. Never renumbered: skipped rows stay in place, so the index cannot develop holes. - `reference_id: string` Your correlation handle for this recipient, echoed back. - `state: "pending" or "sent" or "failed" or 2 more` - `"pending"` - `"sent"` - `"failed"` - `"cancelled"` - `"skipped"` - `to: string` The recipient string AS YOU SENT IT, not the canonical form, so a result matches a row of your own list without you re-normalizing anything. - `invitation_id: optional string` The invitation this recipient became, once it has been attempted; read `GET /v1/invitations/{inv}` for its delivery state, the customer's response and its webhooks. Absent while the row is `pending`, and for any row that never became one (`skipped`, `cancelled`, and a `failed` row refused before an invitation existed). - `reason: optional object { code, message }` Present exactly on the rows that will not send (`skipped`, `failed`), absent on every other. - `code: string` `to_required` | `to_invalid` | `duplicate_recipient` | `consent_required` | a `reference_id` code (`too_long`, `forbidden_character`), or, for a row that failed after acceptance, the send-time refusal: `consent_required`, `chat_superseded`, `chat_closed`, or `invitation_template_not_authorized` (only here: your brand's approval was withdrawn while the run was in flight; at submit the same condition is the run-level `403 invitation_card_not_granted`). - `message: string` - `next_cursor: string` Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the run, that, not an empty `data`, is how paging ends. ### Invitation Batch Cancel Response - `InvitationBatchCancelResponse object { id, accepted_count, cancellation, 6 more }` A stopped run: where it now stands, and exactly what the cancel caught. - `id: string` Run id (`invb_…`). - `accepted_count: number` How many recipients the run would have attempted: `recipient_count` minus the rows the submit screen skipped. - `cancellation: object { already_sent, in_flight, queued, unreleased }` What a cancel actually stopped, split by how far each recipient had got. The four numbers exist because two of them are NOT cancelled and you have to know which. Cancel is EXACT for everything that has not been attempted and cannot recall anything that has: a request already on Apple's wire is on Apple's wire. `unreleased + queued` is what this call stopped. `in_flight + already_sent` is what went out anyway. The four PARTITION the run; every recipient that became an invitation is in exactly one of them, and so is every recipient we stopped before it became one, so nothing falls between them. All four are recomputed from the run's rows on every call, so cancelling twice reports the same split rather than a set of zeros. - `already_sent: number` The send was already ATTEMPTED before your cancel landed. NOT stopped, and not recallable; by us or by anyone. Whether Apple accepted it is that invitation's own delivery state; this counts the attempt, so a send that was attempted and refused is here rather than nowhere. - `in_flight: number` An attempt was on the wire when your cancel landed. NOT stopped: it either reached Apple or did not, and we report rather than guess. - `queued: number` Released to the delivery queue but never attempted. STOPPED; this is the set that makes a cancel three seconds into a 50,000-recipient run mean something. - `unreleased: number` Never handed to the delivery queue at all; recipients we had not reached yet, plus invitations the pacer had not released. STOPPED. - `counts: object { cancelled, failed, pending, 2 more }` The run's recipients by state, the same object the progress read returns. Rows this cancel stopped before they were attempted are now `cancelled`; a row that had already become an invitation stays `sent`: the invitation exists, and whether it went out is that invitation's own delivery state. - `cancelled: number` Stopped by a cancel before it was attempted. - `failed: number` Accepted at submit, then permanently refused before sending; consent withdrawn in between, a superseded or closed chat, a revoked template grant. The row's `reason` says which. - `pending: number` Not yet attempted. This is the number the ETA projects. - `sent: number` Handed to the invitation pipeline: an `invitation_id` exists on that row. Delivery to Apple is reported by that invitation's own status and webhooks, not here. - `skipped: number` Dropped by the SUBMIT screen under `on_invalid: skip_invalid`, with the reason the submit response gave. Frozen at creation; nothing later moves a row into or out of it. - `created_at: string` - `recipient_count: number` How many recipients the run was SUBMITTED with, frozen at creation. Always `counts`' total. - `status: "cancelled" or "completed"` `cancelled` when this call (or an earlier one) stopped a live run. `completed` when the run's own fan-out had ALREADY finished; every recipient was materialized before you cancelled; in which case the run keeps that status and `cancellation` reports what the cancel still caught in the delivery queue. It is never any other value: those two are the only states a run can be in after this call. - `"cancelled"` - `"completed"` - `template_id: string` - `updated_at: string` When the run reached `cancelled`. Unchanged by a repeated cancel. --- # Cancel a batch run URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_batches/methods/cancel/ **post** `/v1/invitation_batches/{batch}/cancel` Cancels a run: every recipient not yet attempted is stopped before any attempt. **Behavior** - Stops both the recipients still waiting to become invitations and the ones already sitting in the delivery queue; cancelling seconds into a large run does not let the hundreds already queued go out. - Cannot stop a request already on Apple's wire or anything Apple has accepted. The `cancellation` object reports those as `in_flight` and `already_sent` rather than counting them as cancelled. - Exact, not best-effort: a cancel and the run's own fan-out serialize on the run, so there is no window where a recipient is both cancelled and sent. - Safe to repeat, never refused. Cancelling a cancelled run returns HTTP 200 with the same body. Cancelling a `completed` run also works; `completed` means every recipient became an invitation, not that all were delivered, and a large run is routinely `completed` with thousands of sends still queued; this call still stops everything unattempted. If nothing was left, `cancellation` is all zeros. Read the numbers, not the status code. - Stopped recipients appear as `cancelled` in `counts` and on the recipients page (`?state=cancelled`). No per-recipient webhook fires for them; the run's one `invitation_batch.completed` event fires with `status: "cancelled"` and the final counts. - Unknown runs, and another brand's; return HTTP 404. ### Path Parameters - `batch: string` ### Returns - `id: string` Run id (`invb_…`). - `accepted_count: number` How many recipients the run would have attempted: `recipient_count` minus the rows the submit screen skipped. - `cancellation: object { already_sent, in_flight, queued, unreleased }` What a cancel actually stopped, split by how far each recipient had got. The four numbers exist because two of them are NOT cancelled and you have to know which. Cancel is EXACT for everything that has not been attempted and cannot recall anything that has: a request already on Apple's wire is on Apple's wire. `unreleased + queued` is what this call stopped. `in_flight + already_sent` is what went out anyway. The four PARTITION the run; every recipient that became an invitation is in exactly one of them, and so is every recipient we stopped before it became one, so nothing falls between them. All four are recomputed from the run's rows on every call, so cancelling twice reports the same split rather than a set of zeros. - `already_sent: number` The send was already ATTEMPTED before your cancel landed. NOT stopped, and not recallable; by us or by anyone. Whether Apple accepted it is that invitation's own delivery state; this counts the attempt, so a send that was attempted and refused is here rather than nowhere. - `in_flight: number` An attempt was on the wire when your cancel landed. NOT stopped: it either reached Apple or did not, and we report rather than guess. - `queued: number` Released to the delivery queue but never attempted. STOPPED; this is the set that makes a cancel three seconds into a 50,000-recipient run mean something. - `unreleased: number` Never handed to the delivery queue at all; recipients we had not reached yet, plus invitations the pacer had not released. STOPPED. - `counts: object { cancelled, failed, pending, 2 more }` The run's recipients by state, the same object the progress read returns. Rows this cancel stopped before they were attempted are now `cancelled`; a row that had already become an invitation stays `sent`: the invitation exists, and whether it went out is that invitation's own delivery state. - `cancelled: number` Stopped by a cancel before it was attempted. - `failed: number` Accepted at submit, then permanently refused before sending; consent withdrawn in between, a superseded or closed chat, a revoked template grant. The row's `reason` says which. - `pending: number` Not yet attempted. This is the number the ETA projects. - `sent: number` Handed to the invitation pipeline: an `invitation_id` exists on that row. Delivery to Apple is reported by that invitation's own status and webhooks, not here. - `skipped: number` Dropped by the SUBMIT screen under `on_invalid: skip_invalid`, with the reason the submit response gave. Frozen at creation; nothing later moves a row into or out of it. - `created_at: string` - `recipient_count: number` How many recipients the run was SUBMITTED with, frozen at creation. Always `counts`' total. - `status: "cancelled" or "completed"` `cancelled` when this call (or an earlier one) stopped a live run. `completed` when the run's own fan-out had ALREADY finished; every recipient was materialized before you cancelled; in which case the run keeps that status and `cancellation` reports what the cancel still caught in the delivery queue. It is never any other value: those two are the only states a run can be in after this call. - `"cancelled"` - `"completed"` - `template_id: string` - `updated_at: string` When the run reached `cancelled`. Unchanged by a repeated cancel. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_batches/$BATCH/cancel \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "invb_0d5c19", "status": "cancelled", "template_id": "invt_a4f2c718d0", "recipient_count": 2, "accepted_count": 2, "counts": { "pending": 0, "sent": 1, "failed": 0, "cancelled": 1, "skipped": 0 }, "cancellation": { "unreleased": 1, "queued": 0, "in_flight": 0, "already_sent": 1 }, "created_at": "2026-08-06T14:00:00Z", "updated_at": "2026-08-06T14:00:31Z" } ``` --- # Submit a batch of invitations URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_batches/methods/create/ **post** `/v1/invitation_batches` Submits a batch of invitations as one run. Returns HTTP 202 with a run id immediately: the fan-out, pacing, and retries are handled for you, and none of it happens inside your request. **Behavior** - Validation covers the whole batch and names every row: each unusable row is reported at once, with your row index and field (`/recipients/7/to`), so the number of entries is exactly the number of rows to fix. `on_invalid` decides whether bad rows refuse the whole submit (HTTP 422 `code` 1005, nothing created) or are dropped while the rest proceed (HTTP 202 with a `skipped` array). - `validate_only: true` runs every screen and creates nothing: no run, no recipients, and the `Idempotency-Key` stays unclaimed. Success is HTTP 200 with a report instead of HTTP 202 with a run. - Consent is checked at submit, so you learn which numbers lack an `invitation` opt-in immediately instead of reading delivery failures for hours. It is also re-decided before every attempt: a customer who opts out after you submit is suppressed then. - The run is admitted against the delivery backlog. HTTP 429 `backlog_horizon_exceeded` (with `Retry-After`, legitimately hours) means your queue plus this run would exceed the delivery horizon; retry later. HTTP 422 `code` 1004 means this run alone can never fit; the message names a run size that will, rounded down to a round number, and the remedy is to split it. In one uncommon configuration we name no size and ask you to send fewer per run. Both create nothing, and the same `Idempotency-Key` succeeds on retry. - A scheduled (`not_before`) or smoothed (`spread_over_seconds`) run is admitted as it materializes instead of at submit: it waits rather than failing when your queue is full, its `eta` says so, and it can be cancelled like any other. - Per-row problems arrive inside `error.details` (or `skipped`), never as the response status: a batch's answer is about the list, and no single status is truthful when row 3 has a bad number and row 40 has no consent. **Errors (run-level)** - HTTP 400: `body_invalid`, `idempotency_key_required` - HTTP 403: `invitation_card_not_granted`: your brand is no longer approved for the card shape the template renders - HTTP 413: `too_large`: the body exceeds 8 MiB; split the batch - HTTP 422: `template_id_required`, `unknown_template`, `recipients_required`, `too_many_recipients`, `batch_exceeds_horizon`; `field_removed` if the request still carries the retired `brand_logo_att_id` field or a recipient still carries `parameters` - HTTP 429: `backlog_horizon_exceeded`, with `Retry-After` ### Header Parameters - `"Idempotency-Key": string` ### Body Parameters - `recipients: array of object { reference_id, to, locale }` The recipient set, in your order. 1–10,000 rows per submit: the ceiling is what one request can carry and still be validated, stored and reported on as a unit, and a larger campaign is more than one batch (the run id makes each one trackable). An empty array is `422 recipients_required`; over the ceiling is `422 too_many_recipients`, both naming `/recipients`. - `reference_id: string` REQUIRED per recipient: an order number, case id or similar, echoed on that invitation's delivery webhooks. A batch does not relax it: a run of 10,000 sends with one shared correlation handle is not reconcilable. - `to: string` The customer's phone number, bare E.164 (partner formatting tolerated). Normalized to a canonical `tel:+E164` handle at submit: an unparseable one is a `to_invalid` naming that row, and two rows that normalize alike are a `duplicate_recipient`. Your own string is stored as sent and echoed back in the run's per-recipient results. - `locale: optional string` Overrides the run's `locale` for this recipient. - `template_id: string` One of YOUR invitation template ids (`invt_…`). ONE template per run: a run is a campaign, and two templates are two batches. The card's name and logo come from the template, resolved ONCE at submit and frozen into the run, so editing the template mid-run cannot change what queued recipients receive. - `locale: optional string` The run's default locale; a recipient may override it. Absent renders the template in its own default. - `not_before: optional string` SCHEDULE THE RUN: do not start before this instant (RFC 3339, with an offset). Absent: the default; starts as soon as the run is accepted. A value in the PAST is accepted and starts the run immediately; it is not an error, because a retry of a submit whose start time has since elapsed must give you the run you asked for rather than a refusal you cannot fix. At most 7 days ahead (`422 not_before_too_far`): every recipient's opt-in is read at SUBMIT, so a run scheduled further out would send against consent nobody has re-checked. A scheduled run sits at `status: "queued"` until its time comes, costs you nothing meanwhile, and can be cancelled like any other. If our workers are down across the boundary the run starts LATE, never skipped: the start is a stored instant, not an event anyone has to be awake for. - `on_invalid: optional "reject_batch" or "skip_invalid"` What to do when some rows cannot be sent to. Defaults to `reject_batch`: partial acceptance is something you ask for, never something that happens to you, because "half my campaign went out" is not a state to reach by omission. - `"reject_batch"` - `"skip_invalid"` - `spread_over_seconds: optional number` SMOOTH THE RUN: spread it evenly over this many seconds, starting at `not_before` (or at acceptance). `0`: the default; hands every recipient to the delivery machinery as fast as it will take them, which is what every run did before this field existed. Capped at 7 days (`422 spread_over_seconds_invalid`). It can only ever make a run SLOWER. Your delivery rate is enforced independently and is unaffected by this field: a window longer than your rate needs gets you the window, a shorter one gets you your rate. Use it when the thing that cannot absorb a burst is on your side: a support queue, a fulfilment pipeline, a landing page. - `validate_only: optional boolean` DRY RUN. Every screen runs: the template grant, the parameter schemas, the phone numbers, the duplicates, the consent records, and NOTHING is created: no run, no recipients, and the `Idempotency-Key` is not claimed, so the real submit can use the same one. The answer is the answer the identical live request would give, except that success is `200` with a report instead of `202` with a run: so a `422` here is exactly the `422` you would have got. ### Returns - `accepted_count: number` How many rows a live submit would have accepted. - `recipient_count: number` The length of the recipient array you submitted. - `validate_only: true` Always `true`. Echoed rather than implied, so a response read out of a log cannot be mistaken for an accepted campaign. - `true` - `skipped: optional array of object { code, message, param }` The rows a live `skip_invalid` submit would have dropped. Absent when nothing would be. - `code: string` `to_required` | `to_invalid` | `duplicate_recipient` | `consent_required` | or a reference/locale code (`required`, `too_long`, `forbidden_character`). - `message: string` - `param: string` JSON pointer at the offending row AND field of your own request (`/recipients/7/to`). ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_batches \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "recipients": [ { "reference_id": "ride-88214", "to": "+15555550142" }, { "reference_id": "ride-88215", "to": "+15555550188" } ], "template_id": "invt_a4f2c718d0" }' ``` #### Response ```json { "id": "invb_0d5c19", "status": "queued", "template_id": "invt_a4f2c718d0", "recipient_count": 2, "accepted_count": 2, "created_at": "2026-08-06T14:00:00Z" } ``` --- # List a batch run's recipients URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_batches/methods/list_recipients/ **get** `/v1/invitation_batches/{batch}/recipients` Lists a run's per-recipient results, in the order you submitted them, one page at a time. **Behavior** - Page with `cursor`: pass the previous page's `next_cursor` back verbatim, and stop when it comes back empty. Rows are ordered by your own array index, which never changes, so a page cannot skip or repeat a row while the run progresses underneath you. - Filter with `state` to avoid paging 10,000 rows to find 40 problems; `failed` or `skipped` and read one page. An unknown value is refused, never silently answered with an empty page. - Unknown runs, and another brand's; return HTTP 404. ### Path Parameters - `batch: string` ### Query Parameters - `cursor: optional string` The previous page's `next_cursor`, verbatim; absent starts at your first recipient. Not a cursor this API issued returns HTTP 400 `code` 1013. - `limit: optional number` Page size; absent (or non-numeric) reads as 25, above 100 reads as 100: the same page bounds every list on this API applies. - `state: optional string` Only recipients in this state (`pending`, `sent`, `failed`, `cancelled`, `skipped`); absent means every row. Anything else returns HTTP 400 `code` 1066. ### Returns - `data: array of object { idx, reference_id, state, 3 more }` - `idx: number` This row's position in the array you submitted, zero-based: the same index each refusal names (`/recipients/7/to`), and this run's identity for that row. Never renumbered: skipped rows stay in place, so the index cannot develop holes. - `reference_id: string` Your correlation handle for this recipient, echoed back. - `state: "pending" or "sent" or "failed" or 2 more` - `"pending"` - `"sent"` - `"failed"` - `"cancelled"` - `"skipped"` - `to: string` The recipient string AS YOU SENT IT, not the canonical form, so a result matches a row of your own list without you re-normalizing anything. - `invitation_id: optional string` The invitation this recipient became, once it has been attempted; read `GET /v1/invitations/{inv}` for its delivery state, the customer's response and its webhooks. Absent while the row is `pending`, and for any row that never became one (`skipped`, `cancelled`, and a `failed` row refused before an invitation existed). - `reason: optional object { code, message }` Present exactly on the rows that will not send (`skipped`, `failed`), absent on every other. - `code: string` `to_required` | `to_invalid` | `duplicate_recipient` | `consent_required` | a `reference_id` code (`too_long`, `forbidden_character`), or, for a row that failed after acceptance, the send-time refusal: `consent_required`, `chat_superseded`, `chat_closed`, or `invitation_template_not_authorized` (only here: your brand's approval was withdrawn while the run was in flight; at submit the same condition is the run-level `403 invitation_card_not_granted`). - `message: string` - `next_cursor: string` Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the run, that, not an empty `data`, is how paging ends. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_batches/$BATCH/recipients \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "idx": 1, "to": "tel:+15555550188", "reference_id": "ride-88215", "state": "failed", "reason": { "code": "consent_required", "message": "this recipient withdrew consent after the batch was accepted" } } ], "next_cursor": "" } ``` --- # Get a batch run's progress URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_batches/methods/retrieve/ **get** `/v1/invitation_batches/{batch}` Retrieves a run's progress: its status, how many recipients stand in each state, and when the rest are projected to finish. **Behavior** - One call regardless of run size: the cost does not grow with the recipient count. - `counts` always carries all five states, zeros included, and always sums to `recipient_count`. - `eta` is an estimate from a rate that moves, and is absent rather than zero when none can honestly be quoted. - Unknown ids, and another brand's; return HTTP 404, the same answer. ### Path Parameters - `batch: string` ### Returns - `id: string` Run id (`invb_…`). - `accepted_count: number` How many recipients the run will attempt: `recipient_count` minus the rows the submit screen skipped. The same number the submit response returned, and frozen with it. - `counts: object { cancelled, failed, pending, 2 more }` How many of a run's recipients stand in each state. EVERY key is always present, zero included: a client reads five numbers instead of branching on which keys exist, and a state that has not happened yet is `0` rather than missing. - `cancelled: number` Stopped by a cancel before it was attempted. - `failed: number` Accepted at submit, then permanently refused before sending; consent withdrawn in between, a superseded or closed chat, a revoked template grant. The row's `reason` says which. - `pending: number` Not yet attempted. This is the number the ETA projects. - `sent: number` Handed to the invitation pipeline: an `invitation_id` exists on that row. Delivery to Apple is reported by that invitation's own status and webhooks, not here. - `skipped: number` Dropped by the SUBMIT screen under `on_invalid: skip_invalid`, with the reason the submit response gave. Frozen at creation; nothing later moves a row into or out of it. - `created_at: string` - `recipient_count: number` How many recipients the run was SUBMITTED with, frozen at creation; dropped rows included. Always the length of the array you sent, and always `counts`' total. - `status: "queued" or "running" or "completed" or "cancelled"` `queued` (created, nothing attempted yet) → `running` (at least one recipient attempted, some still pending) → `completed` (no recipient is pending; every one is `sent`, `failed`, `cancelled` or `skipped`), or `cancelled` when you stopped it. `completed` does NOT mean every invitation was delivered: it means the run has nothing left to attempt, and per-row outcomes are in `counts` and the recipient results. - `"queued"` - `"running"` - `"completed"` - `"cancelled"` - `template_id: string` - `updated_at: string` Last change to the run row itself (its `status`). NOT bumped by a per-recipient transition, so it is a run-lifecycle timestamp and not a progress heartbeat; read `counts` for progress. - `eta: optional object { at, queued_ahead, rate_per_second, 2 more }` The completion estimate. Absent when none can honestly be quoted; see the model. - `at: string` The same estimate as a timestamp on OUR clock; `seconds` from when this response was composed, so a client does not have to correct for its own skew. - `queued_ahead: number` How many other invitations of yours are queued ahead of this run's remaining rows in the same brand queue. A run does not clear in isolation; another campaign submitted first is counted here, and so are this run's own recipients that have already been handed to the delivery queue. - `rate_per_second: number` Recipients per second the estimate was taken at: `min(your brand's effective rate, our overall rate / active brands)`. - `remaining: number` How many of this run's recipients the estimate covers; `counts.pending`. - `seconds: number` Estimated seconds until the last remaining recipient is released. `0` when nothing is pending. - `not_before: optional string` The run's scheduled start, if you gave it one. A `queued` run with a `not_before` in the future is waiting for its own schedule, not for us. - `spread_over_seconds: optional number` The run's smoothing window, if you gave it one. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_batches/$BATCH \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "invb_0d5c19", "status": "running", "template_id": "invt_a4f2c718d0", "recipient_count": 2, "accepted_count": 2, "counts": { "pending": 1, "sent": 1, "failed": 0, "cancelled": 0, "skipped": 0 }, "created_at": "2026-08-06T14:00:00Z", "updated_at": "2026-08-06T14:00:07Z" } ``` --- # Invitation Templates URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_templates/ ## List invitation templates **get** `/v1/invitation_templates` Lists your invitation templates, newest-first, one page at a time. An empty list on a fresh brand means none created yet; create one and send; if creation is refused `403 invitation_card_not_granted`, your brand is not yet approved for invitations of that shape: approval is per-brand, raised with us, not self-service. ### Query Parameters - `cursor: optional string` The previous page's `next_cursor`, verbatim; absent starts from the first page. Not a cursor this API issued returns HTTP 400 `code` 1013. - `limit: optional number` Page size; absent (or non-numeric) reads as 25, above 100 reads as 100. ### Returns - `data: array of object { id, created_at, has_logo, 3 more }` - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. - `next_cursor: string` Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the list, that, not an empty `data`, is how paging ends. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "id": "invt_a4f2c718d0", "name": "Acme Rides", "has_logo": true, "version": 3, "created_at": "2026-08-10T09:00:00Z", "updated_at": "2026-08-12T15:30:00Z" } ], "next_cursor": "" } ``` ## Create an invitation template **post** `/v1/invitation_templates` Creates an invitation template: the name (and optionally the logo file) that fill the card, saved once, referenced by id on every send. The response's `ETag` carries the version `PUT` will want in `If-Match`. **Errors** - HTTP 403 `code` 2029: your brand is not approved for invitations of this shape (with/without a logo); raised with us, not self-service. - HTTP 422: field-level validation (`name_required`, `too_long`, `brand_logo_not_png`, `logo_too_large`). - HTTP 413 `code` 1073: the whole request body exceeds the ceiling: the logo may be at most 143,360 bytes raw. ### Returns - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates \ -H 'Content-Type: multipart/form-data' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -F name=name ``` #### Response ```json { "id": "invt_a4f2c718d0", "name": "Acme Rides", "has_logo": true, "version": 1, "created_at": "2026-08-10T09:00:00Z", "updated_at": "2026-08-10T09:00:00Z" } ``` ## Get an invitation template **get** `/v1/invitation_templates/{invt}` Fetch one template. Unknown ids, and another brand's; return HTTP 404. ### Path Parameters - `invt: string` ### Returns - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates/$INVT \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "invt_a4f2c718d0", "name": "Acme Rides", "has_logo": true, "version": 3, "created_at": "2026-08-10T09:00:00Z", "updated_at": "2026-08-12T15:30:00Z" } ``` ## Download an invitation template's logo **get** `/v1/invitation_templates/{invt}/logo` Downloads a template's stored logo, verbatim, under its stored MIME type (`image/png`: the logo is checked to be a PNG when it is saved). This is the image the card carries when the template has one; `has_logo` on the template says whether there is one to fetch. **Errors** - HTTP 404 `code` 2056: the template is yours and carries no logo. - HTTP 404 `code` 2033: unknown ids, and another brand's, exactly as the template read answers them. ### Path Parameters - `invt: string` ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates/$INVT/logo \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` ## Update an invitation template **put** `/v1/invitation_templates/{invt}` Replaces a template: a full replacement, never a patch, under the `If-Match` contract: missing → `428`, stale → `412` naming the current version. The logo is three-valued: an absent `logo` part KEEPS the stored logo, a present one replaces it, and `remove_logo=true` clears it. A change that flips the template between with-logo and without is re-checked against your brand's approval and refused `403 invitation_card_not_granted` at the edit rather than discovered at the next send. Size refusals match the create: `422 logo_too_large` for the logo, `413 too_large` past the whole-body ceiling. ### Path Parameters - `invt: string` ### Header Parameters - `"If-Match": string` ### Returns - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates/$INVT \ -X PUT \ -H 'Content-Type: multipart/form-data' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -F name=name ``` #### Response ```json { "id": "invt_a4f2c718d0", "name": "Acme Rides Support", "has_logo": true, "version": 4, "created_at": "2026-08-10T09:00:00Z", "updated_at": "2026-08-12T16:00:00Z" } ``` ## Delete an invitation template **delete** `/v1/invitation_templates/{invt}` Deletes a template. Invitations already sent from it are unaffected; what went out is recorded on each invitation. No precondition: deletion is terminal, and a delete-vs-edit race resolves visibly either way. ### Path Parameters - `invt: string` ### Returns - `ok: true` - `true` ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates/$INVT \ -X DELETE \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "ok": true } ``` ## Domain Types ### Invitation Template List Response - `InvitationTemplateListResponse object { data, next_cursor }` One page of your invitation templates, newest-first. - `data: array of object { id, created_at, has_logo, 3 more }` - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. - `next_cursor: string` Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the list, that, not an empty `data`, is how paging ends. ### Invitation Template Create Response - `InvitationTemplateCreateResponse object { id, created_at, has_logo, 3 more }` One of YOUR invitation templates: the brand name and optional logo that fill the card, saved once under an id we mint, referenced on every send. You never see or supply Apple's internal card identifiers: the platform picks the right card from whether the template carries a logo. - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. ### Invitation Template Retrieve Response - `InvitationTemplateRetrieveResponse object { id, created_at, has_logo, 3 more }` One of YOUR invitation templates: the brand name and optional logo that fill the card, saved once under an id we mint, referenced on every send. You never see or supply Apple's internal card identifiers: the platform picks the right card from whether the template carries a logo. - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. ### Invitation Template Update Response - `InvitationTemplateUpdateResponse object { id, created_at, has_logo, 3 more }` One of YOUR invitation templates: the brand name and optional logo that fill the card, saved once under an id we mint, referenced on every send. You never see or supply Apple's internal card identifiers: the platform picks the right card from whether the template carries a logo. - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. ### Invitation Template Delete Response - `InvitationTemplateDeleteResponse object { ok }` The bare `{"ok":true}` acknowledgement; failures ride the error envelope instead. - `ok: true` - `true` --- # Create an invitation template URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_templates/methods/create/ **post** `/v1/invitation_templates` Creates an invitation template: the name (and optionally the logo file) that fill the card, saved once, referenced by id on every send. The response's `ETag` carries the version `PUT` will want in `If-Match`. **Errors** - HTTP 403 `code` 2029: your brand is not approved for invitations of this shape (with/without a logo); raised with us, not self-service. - HTTP 422: field-level validation (`name_required`, `too_long`, `brand_logo_not_png`, `logo_too_large`). - HTTP 413 `code` 1073: the whole request body exceeds the ceiling: the logo may be at most 143,360 bytes raw. ### Returns - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates \ -H 'Content-Type: multipart/form-data' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -F name=name ``` #### Response ```json { "id": "invt_a4f2c718d0", "name": "Acme Rides", "has_logo": true, "version": 1, "created_at": "2026-08-10T09:00:00Z", "updated_at": "2026-08-10T09:00:00Z" } ``` --- # Delete an invitation template URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_templates/methods/delete/ **delete** `/v1/invitation_templates/{invt}` Deletes a template. Invitations already sent from it are unaffected; what went out is recorded on each invitation. No precondition: deletion is terminal, and a delete-vs-edit race resolves visibly either way. ### Path Parameters - `invt: string` ### Returns - `ok: true` - `true` ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates/$INVT \ -X DELETE \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "ok": true } ``` --- # List invitation templates URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_templates/methods/list/ **get** `/v1/invitation_templates` Lists your invitation templates, newest-first, one page at a time. An empty list on a fresh brand means none created yet; create one and send; if creation is refused `403 invitation_card_not_granted`, your brand is not yet approved for invitations of that shape: approval is per-brand, raised with us, not self-service. ### Query Parameters - `cursor: optional string` The previous page's `next_cursor`, verbatim; absent starts from the first page. Not a cursor this API issued returns HTTP 400 `code` 1013. - `limit: optional number` Page size; absent (or non-numeric) reads as 25, above 100 reads as 100. ### Returns - `data: array of object { id, created_at, has_logo, 3 more }` - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. - `next_cursor: string` Pass back as `?cursor=` for the next page. EMPTY when this page exhausted the list, that, not an empty `data`, is how paging ends. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "id": "invt_a4f2c718d0", "name": "Acme Rides", "has_logo": true, "version": 3, "created_at": "2026-08-10T09:00:00Z", "updated_at": "2026-08-12T15:30:00Z" } ], "next_cursor": "" } ``` --- # Download an invitation template's logo URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_templates/methods/logo/ **get** `/v1/invitation_templates/{invt}/logo` Downloads a template's stored logo, verbatim, under its stored MIME type (`image/png`: the logo is checked to be a PNG when it is saved). This is the image the card carries when the template has one; `has_logo` on the template says whether there is one to fetch. **Errors** - HTTP 404 `code` 2056: the template is yours and carries no logo. - HTTP 404 `code` 2033: unknown ids, and another brand's, exactly as the template read answers them. ### Path Parameters - `invt: string` ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates/$INVT/logo \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` --- # Get an invitation template URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_templates/methods/retrieve/ **get** `/v1/invitation_templates/{invt}` Fetch one template. Unknown ids, and another brand's; return HTTP 404. ### Path Parameters - `invt: string` ### Returns - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates/$INVT \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "invt_a4f2c718d0", "name": "Acme Rides", "has_logo": true, "version": 3, "created_at": "2026-08-10T09:00:00Z", "updated_at": "2026-08-12T15:30:00Z" } ``` --- # Update an invitation template URL: https://docs.linqapp.com/channel/amb/api/resources/invitation_templates/methods/update/ **put** `/v1/invitation_templates/{invt}` Replaces a template: a full replacement, never a patch, under the `If-Match` contract: missing → `428`, stale → `412` naming the current version. The logo is three-valued: an absent `logo` part KEEPS the stored logo, a present one replaces it, and `remove_logo=true` clears it. A change that flips the template between with-logo and without is re-checked against your brand's approval and refused `403 invitation_card_not_granted` at the edit rather than discovered at the next send. Size refusals match the create: `422 logo_too_large` for the logo, `413 too_large` past the whole-body ceiling. ### Path Parameters - `invt: string` ### Header Parameters - `"If-Match": string` ### Returns - `id: string` The template id (`invt_…`); what a send's `template_id` references. - `created_at: string` - `has_logo: boolean` Whether a logo is stored. A template with a logo renders the image card; one without renders the plain card. - `name: string` The brand name printed on the invitation card. - `updated_at: string` - `version: number` Edit counter that only ever counts up, also returned as this resource's strong `ETag`. Echo it in `If-Match` on the next `PUT`; a stale one is refused `412` rather than overwriting someone else's edit. ### Example ```http curl https://messages.api.linqapp.com/v1/invitation_templates/$INVT \ -X PUT \ -H 'Content-Type: multipart/form-data' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -F name=name ``` #### Response ```json { "id": "invt_a4f2c718d0", "name": "Acme Rides Support", "has_logo": true, "version": 4, "created_at": "2026-08-10T09:00:00Z", "updated_at": "2026-08-12T16:00:00Z" } ``` --- # Invitations URL: https://docs.linqapp.com/channel/amb/api/resources/invitations/ ## Send an invitation **post** `/v1/invitations` Sends a templated invitation to a phone number: the one way to start a conversation with a customer who has not messaged you first. **Requirements** - `Idempotency-Key` header: required. This is business-initiated contact, and a blind retry must never send two. **Behavior** - Refusals are checked in a fixed order, most actionable first: validation, then idempotency, then template authorization, then backlog admission, then chat state, then consent. - HTTP 429 `code` 1002 is admission control on your backlog, not a rate limit: your queued-but-unsent invitations plus this one would take longer to deliver than the delivery horizon allows, so the request is refused rather than queued: an invitation delivered hours late could go out against consent revoked in the meantime. `Retry-After` reflects when your backlog will actually clear and can legitimately be hours; retrying sooner is refused again. Nothing is queued and no key is recorded, so the same key succeeds on retry. - A backlog refusal is checked before the chat-state and consent gates (validation still runs first): a recipient who has also opted out still fails with the consent error once there is room. **Errors** - HTTP 403: `invitation_card_not_granted` (your brand is no longer approved for the card shape the template renders), `consent_required` - HTTP 409: `idempotency_key_reused`, `idempotency_key_processing`, `chat_superseded`, `chat_closed` - HTTP 422: field-level validation; `unknown_template`; `field_removed` if the request still carries the retired `parameters` or `brand_logo_att_id` fields - HTTP 429: `backlog_horizon_exceeded`, with `Retry-After` ### Header Parameters - `"Idempotency-Key": string` ### Body Parameters - `reference_id: string` REQUIRED correlation handle: an order number, case id or similar. Apple bounds its length and does not allow quotes. Echoed on the delivery webhooks. - `template_id: string` One of YOUR invitation template ids (`invt_…`, from `POST /v1/invitation_templates`): the card's name and logo come from the template, and the platform picks the right card from whether it carries a logo. Unknown returns HTTP 422 `code` 1081. - `to: string` The customer's phone number, bare E.164 (partner formatting tolerated); malformed returns HTTP 422 `code` 1071. - `locale: optional string` Optional locale for the card rendering. ### Returns - `id: string` Invitation id (`inv_…`). - `chat_id: string` The `tel`-handled chat carrying the invitation delivery; NOT addressable for organic sends. - `delivery: "queued"` - `"queued"` - `message_id: string` The invitation delivery's message id; poll it via GET /v1/messages/{msg}. - `to: string` The canonical `tel:+E164` handle form of the request's `to`. ### Example ```http curl https://messages.api.linqapp.com/v1/invitations \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "reference_id": "ride-88214", "template_id": "invt_a4f2c718d0", "to": "+15555550142" }' ``` #### Response ```json { "id": "inv_6b2e04", "chat_id": "chat_4f81b2", "message_id": "msg_2c7d90", "to": "tel:+15555550142", "delivery": "queued" } ``` ## Get an invitation **get** `/v1/invitations/{inv}` Retrieves an invitation's status: delivery, the customer's response, and the chat it opened. Unknown ids, and another brand's; return HTTP 404. ### Path Parameters - `inv: string` ### Returns - `id: string` Invitation id (`inv_…`). - `chat_id: string` - `created_at: string` - `delivery: string` The underlying message's delivery state; same values as GET /v1/messages/{msg}. - `opted_out: boolean` Derived from the consent record: a later tel STOP flips it. Independent of `response`. - `parameters: unknown` The parameters as sent (JSON `null` when none). - `reference_id: string` - `response: string` `none`, `accepted`, `declined`, or `superseded` (an outstanding card on a thread a sibling's accept superseded). - `response_source: string` How `response` was resolved: `tap`; Apple forwarded the customer's own interaction and we correlated it by the card's `requestIdentifier`; `inference`; we concluded it from message ordering (a reply on the thread while the card was outstanding), which a later tap may correct; `none`; unresolved, and for the derived `superseded` response, which is a fact about the chat rather than an answer from the customer. Reconcile on this when a deduced decline must not be treated like an answered one. - `template_id: string` - `accepted_chat_id: optional string` The opaque chat the customer landed in; this card's accept, or the sibling accept that superseded it. - `locale: optional string` Omitted when the send carried none. ### Example ```http curl https://messages.api.linqapp.com/v1/invitations/$INV \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "inv_6b2e04", "chat_id": "chat_4f81b2", "template_id": "invt_a4f2c718d0", "reference_id": "ride-88214", "locale": "en-US", "parameters": { "brandName": "Acme Rides" }, "delivery": "sent", "response": "accepted", "response_source": "customer", "accepted_chat_id": "chat_4f81b2", "opted_out": false, "created_at": "2026-08-06T13:58:02Z" } ``` ## Domain Types ### Invitation Create Response - `InvitationCreateResponse object { id, chat_id, delivery, 2 more }` The accepted invitation. A replay of the same Idempotency-Key within 24h returns these exact bytes; past that window the key is forgotten and the request executes again. - `id: string` Invitation id (`inv_…`). - `chat_id: string` The `tel`-handled chat carrying the invitation delivery; NOT addressable for organic sends. - `delivery: "queued"` - `"queued"` - `message_id: string` The invitation delivery's message id; poll it via GET /v1/messages/{msg}. - `to: string` The canonical `tel:+E164` handle form of the request's `to`. ### Invitation Retrieve Response - `InvitationRetrieveResponse object { id, chat_id, created_at, 9 more }` The composed invitation status; every field derived at read time from the table that owns it, so nothing can drift. - `id: string` Invitation id (`inv_…`). - `chat_id: string` - `created_at: string` - `delivery: string` The underlying message's delivery state; same values as GET /v1/messages/{msg}. - `opted_out: boolean` Derived from the consent record: a later tel STOP flips it. Independent of `response`. - `parameters: unknown` The parameters as sent (JSON `null` when none). - `reference_id: string` - `response: string` `none`, `accepted`, `declined`, or `superseded` (an outstanding card on a thread a sibling's accept superseded). - `response_source: string` How `response` was resolved: `tap`; Apple forwarded the customer's own interaction and we correlated it by the card's `requestIdentifier`; `inference`; we concluded it from message ordering (a reply on the thread while the card was outstanding), which a later tap may correct; `none`; unresolved, and for the derived `superseded` response, which is a fact about the chat rather than an answer from the customer. Reconcile on this when a deduced decline must not be treated like an answered one. - `template_id: string` - `accepted_chat_id: optional string` The opaque chat the customer landed in; this card's accept, or the sibling accept that superseded it. - `locale: optional string` Omitted when the send carried none. --- # Send an invitation URL: https://docs.linqapp.com/channel/amb/api/resources/invitations/methods/create/ **post** `/v1/invitations` Sends a templated invitation to a phone number: the one way to start a conversation with a customer who has not messaged you first. **Requirements** - `Idempotency-Key` header: required. This is business-initiated contact, and a blind retry must never send two. **Behavior** - Refusals are checked in a fixed order, most actionable first: validation, then idempotency, then template authorization, then backlog admission, then chat state, then consent. - HTTP 429 `code` 1002 is admission control on your backlog, not a rate limit: your queued-but-unsent invitations plus this one would take longer to deliver than the delivery horizon allows, so the request is refused rather than queued: an invitation delivered hours late could go out against consent revoked in the meantime. `Retry-After` reflects when your backlog will actually clear and can legitimately be hours; retrying sooner is refused again. Nothing is queued and no key is recorded, so the same key succeeds on retry. - A backlog refusal is checked before the chat-state and consent gates (validation still runs first): a recipient who has also opted out still fails with the consent error once there is room. **Errors** - HTTP 403: `invitation_card_not_granted` (your brand is no longer approved for the card shape the template renders), `consent_required` - HTTP 409: `idempotency_key_reused`, `idempotency_key_processing`, `chat_superseded`, `chat_closed` - HTTP 422: field-level validation; `unknown_template`; `field_removed` if the request still carries the retired `parameters` or `brand_logo_att_id` fields - HTTP 429: `backlog_horizon_exceeded`, with `Retry-After` ### Header Parameters - `"Idempotency-Key": string` ### Body Parameters - `reference_id: string` REQUIRED correlation handle: an order number, case id or similar. Apple bounds its length and does not allow quotes. Echoed on the delivery webhooks. - `template_id: string` One of YOUR invitation template ids (`invt_…`, from `POST /v1/invitation_templates`): the card's name and logo come from the template, and the platform picks the right card from whether it carries a logo. Unknown returns HTTP 422 `code` 1081. - `to: string` The customer's phone number, bare E.164 (partner formatting tolerated); malformed returns HTTP 422 `code` 1071. - `locale: optional string` Optional locale for the card rendering. ### Returns - `id: string` Invitation id (`inv_…`). - `chat_id: string` The `tel`-handled chat carrying the invitation delivery; NOT addressable for organic sends. - `delivery: "queued"` - `"queued"` - `message_id: string` The invitation delivery's message id; poll it via GET /v1/messages/{msg}. - `to: string` The canonical `tel:+E164` handle form of the request's `to`. ### Example ```http curl https://messages.api.linqapp.com/v1/invitations \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "reference_id": "ride-88214", "template_id": "invt_a4f2c718d0", "to": "+15555550142" }' ``` #### Response ```json { "id": "inv_6b2e04", "chat_id": "chat_4f81b2", "message_id": "msg_2c7d90", "to": "tel:+15555550142", "delivery": "queued" } ``` --- # Get an invitation URL: https://docs.linqapp.com/channel/amb/api/resources/invitations/methods/retrieve/ **get** `/v1/invitations/{inv}` Retrieves an invitation's status: delivery, the customer's response, and the chat it opened. Unknown ids, and another brand's; return HTTP 404. ### Path Parameters - `inv: string` ### Returns - `id: string` Invitation id (`inv_…`). - `chat_id: string` - `created_at: string` - `delivery: string` The underlying message's delivery state; same values as GET /v1/messages/{msg}. - `opted_out: boolean` Derived from the consent record: a later tel STOP flips it. Independent of `response`. - `parameters: unknown` The parameters as sent (JSON `null` when none). - `reference_id: string` - `response: string` `none`, `accepted`, `declined`, or `superseded` (an outstanding card on a thread a sibling's accept superseded). - `response_source: string` How `response` was resolved: `tap`; Apple forwarded the customer's own interaction and we correlated it by the card's `requestIdentifier`; `inference`; we concluded it from message ordering (a reply on the thread while the card was outstanding), which a later tap may correct; `none`; unresolved, and for the derived `superseded` response, which is a fact about the chat rather than an answer from the customer. Reconcile on this when a deduced decline must not be treated like an answered one. - `template_id: string` - `accepted_chat_id: optional string` The opaque chat the customer landed in; this card's accept, or the sibling accept that superseded it. - `locale: optional string` Omitted when the send carried none. ### Example ```http curl https://messages.api.linqapp.com/v1/invitations/$INV \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "inv_6b2e04", "chat_id": "chat_4f81b2", "template_id": "invt_a4f2c718d0", "reference_id": "ride-88214", "locale": "en-US", "parameters": { "brandName": "Acme Rides" }, "delivery": "sent", "response": "accepted", "response_source": "customer", "accepted_chat_id": "chat_4f81b2", "opted_out": false, "created_at": "2026-08-06T13:58:02Z" } ``` --- # Me URL: https://docs.linqapp.com/channel/amb/api/resources/me/ ## Get the authenticated brand **get** `/v1/me` Retrieves the brand this API key is scoped to. **Behavior** - Check it at boot: every other endpoint scopes itself to your brand implicitly, and a key pointed at the wrong brand is invisible until it returns someone else's chats. - Returns the key's id, never the key. Cheap and safe to call on startup. - Carries `webhook_delivery`, so polling it at a human interval tells you whether webhook delivery to any of your endpoints is paused. ### Returns - `IdentityResponse object { brand_id, key_id, webhook_delivery }` Who this API key is. - `brand_id: string` The brand every request on this key is scoped to. - `key_id: string` The key's own id: the `key_id` that appears in support chats and logs, never the secret. - `webhook_delivery: optional object { parked_feeds, next_recovery_attempt_at, oldest_parked_at }` Your webhook delivery health. Present whenever it could be computed, which is every ordinary request; ABSENT means we could not read it, and never that you are healthy. - `parked_feeds: number` How many of your (endpoint, chat) feeds are parked. Always present; `0` is a measurement. - `next_recovery_attempt_at: optional string` At or after this instant, the next recovery attempt is made for one of the endpoints holding your parked feeds. It may be in the past: a due endpoint is probed on the next sweep tick. Absent when nothing is parked. - `oldest_parked_at: optional string` The event time of the oldest event now stuck, which is EARLIER than the moment the feed parked: a feed parks after ten attempts, roughly seventeen minutes after the first. Absent when nothing is parked. ### Example ```http curl https://messages.api.linqapp.com/v1/me \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "brand_id": "biz_9f2c1a", "key_id": "key_7bd41e", "webhook_delivery": { "parked_feeds": 0 } } ``` ## Domain Types ### Identity Response - `IdentityResponse object { brand_id, key_id, webhook_delivery }` Who this API key is. - `brand_id: string` The brand every request on this key is scoped to. - `key_id: string` The key's own id: the `key_id` that appears in support chats and logs, never the secret. - `webhook_delivery: optional object { parked_feeds, next_recovery_attempt_at, oldest_parked_at }` Your webhook delivery health. Present whenever it could be computed, which is every ordinary request; ABSENT means we could not read it, and never that you are healthy. - `parked_feeds: number` How many of your (endpoint, chat) feeds are parked. Always present; `0` is a measurement. - `next_recovery_attempt_at: optional string` At or after this instant, the next recovery attempt is made for one of the endpoints holding your parked feeds. It may be in the past: a due endpoint is probed on the next sweep tick. Absent when nothing is parked. - `oldest_parked_at: optional string` The event time of the oldest event now stuck, which is EARLIER than the moment the feed parked: a feed parks after ten attempts, roughly seventeen minutes after the first. Absent when nothing is parked. --- # Get the authenticated brand URL: https://docs.linqapp.com/channel/amb/api/resources/me/methods/retrieve/ **get** `/v1/me` Retrieves the brand this API key is scoped to. **Behavior** - Check it at boot: every other endpoint scopes itself to your brand implicitly, and a key pointed at the wrong brand is invisible until it returns someone else's chats. - Returns the key's id, never the key. Cheap and safe to call on startup. - Carries `webhook_delivery`, so polling it at a human interval tells you whether webhook delivery to any of your endpoints is paused. ### Returns - `IdentityResponse object { brand_id, key_id, webhook_delivery }` Who this API key is. - `brand_id: string` The brand every request on this key is scoped to. - `key_id: string` The key's own id: the `key_id` that appears in support chats and logs, never the secret. - `webhook_delivery: optional object { parked_feeds, next_recovery_attempt_at, oldest_parked_at }` Your webhook delivery health. Present whenever it could be computed, which is every ordinary request; ABSENT means we could not read it, and never that you are healthy. - `parked_feeds: number` How many of your (endpoint, chat) feeds are parked. Always present; `0` is a measurement. - `next_recovery_attempt_at: optional string` At or after this instant, the next recovery attempt is made for one of the endpoints holding your parked feeds. It may be in the past: a due endpoint is probed on the next sweep tick. Absent when nothing is parked. - `oldest_parked_at: optional string` The event time of the oldest event now stuck, which is EARLIER than the moment the feed parked: a feed parks after ten attempts, roughly seventeen minutes after the first. Absent when nothing is parked. ### Example ```http curl https://messages.api.linqapp.com/v1/me \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "brand_id": "biz_9f2c1a", "key_id": "key_7bd41e", "webhook_delivery": { "parked_feeds": 0 } } ``` --- # Messages URL: https://docs.linqapp.com/channel/amb/api/resources/messages/ ## Get a message's status **get** `/v1/messages/{msg}` Retrieves a sent message's delivery outcome. Use it when you missed, or do not subscribe to: the `message.sent` and `message.failed` webhooks. ### Path Parameters - `msg: string` ### Returns - `id: string` Message id (`msg_…`). - `chat_id: string` - `created_at: string` - `state: string` Delivery state: `queued` until a terminal outcome (`sent`, `failed`, `suppressed`, …). - `updated_at: string` - `fallbacks: optional array of Fallback` Capability rewrites applied at accept under `degrade` (`"auto"` or `"acknowledged"`); present only when something was rewritten (the same array the send's 200 carried). - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` - `group_id: optional string` Correlation id present ONLY when this message was one of several a single request fanned out into (URL promotion): it is the first message's id, carried on every member, the same value the send response and the `message.sent`/`message.failed` webhooks report. Use it to identify which fan-out group a message belongs to; it is a correlation key, not a member list. Absent on an ordinary single-message send. - `last_error: optional string` Gateway error of the last attempt; rides only on a gateway-verdict failure. - `last_status: optional number` Gateway HTTP status of the last attempt; rides only on a gateway-verdict failure. - `parts: optional array of unknown` The message's canonical parts AS STORED at accept; post-degrade, post-promotion, i.e. what the customer's device was (or will be) sent, not necessarily what you composed (`fallbacks` says when they differ). Typed as an opaque array rather than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is. Absent only when the stored content cannot be rendered canonically. - `reason: optional string` Failure-only: whether to resubmit (`permanent`, `undelivered`, `auth_error`, `suppressed`, `consent_revoked`, …): the same value the `message.failed` webhook carried. ### Example ```http curl https://messages.api.linqapp.com/v1/messages/$MSG \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "msg_2c7d90", "chat_id": "chat_4f81b2", "state": "sent", "created_at": "2026-08-06T14:02:12Z", "updated_at": "2026-08-06T14:02:13Z" } ``` ## Get a message's timeline **get** `/v1/messages/{msg}/timeline` Retrieves the full story of one outbound send: when it was accepted, its current delivery state, the terminal events it produced, and the webhook deliveries that carried those events to your endpoints. `GET /v1/messages/{msg}` answers "what happened"; this answers "why does it say that". **Requirements** - `msg` is an outbound send id: the ids `POST /v1/chats/{chat}/messages` returns. Inbound customer messages have no message id and return HTTP 404, as does another brand's id or an unknown one. **Limits** - No per-attempt history: entries carry counters and a current state. - No intermediate delivery states: you get the current state plus the immutable terminal events. - Entries past the retention horizons are marked by `retention` and `truncated` rather than silently absent. ### Path Parameters - `msg: string` ### Returns - `chat_id: string` - `entries: array of object { at, kind, attempt, 13 more }` Every entry we hold, oldest first. - `at: string` When the fact was recorded: accept time on `accepted`, when the delivery row last moved on `delivery` (it is mutable), the journal row's time on a terminal event, and the outbox row's creation on `webhook`. - `kind: string` What this entry is. Documented-open; skip values you do not know. - `attempt: optional number` The row's attempt counter. Present-and-zero is a real answer ("accepted, never attempted"), so read absence and zero differently. - `attempt_404: optional number` `delivery` only: attempts that answered `404`. - `attempt_auth: optional number` `delivery` only: attempts that failed authentication. - `endpoint_id: optional string` `webhook` only: YOUR endpoint id (`wh_…`) the row is bound for. - `event_type: optional string` `webhook` only: the partner event kind the row carries (`message.sent` | `message.failed`). - `last_error: optional string` Failure-only: the gateway's (or your endpoint's) error text on a FAILED row. - `last_status: optional number` Failure-only, exactly as on `GET /v1/messages/{msg}`: the gateway's (or your endpoint's) HTTP status on a FAILED row, and absent otherwise: a success does not publish its status here. - `message_id: optional string` `webhook` only: the message the row's own payload names: the finer attribution key on the window-spanning row above. - `next_attempt_at: optional string` When the next attempt is due. Rides only while the row is NON-terminal, so a settled entry never advertises a retry that will not happen. - `reason: optional string` `message_failed` only: the failure discriminator (`permanent`, `undelivered`, `auth_error`, `suppressed`, …), the same value that webhook carried. - `seq: optional number` Journal entries only: the event's `seq`: the same watermark `GET /v1/chats/{chat}/events` pages on. - `seq_from: optional number` `webhook` only: the row's journal-seq window (inclusive). Every per-message emit produces a single-seq window today, so a webhook entry names exactly one message; the schema permits a wider one, and such a row appears on EVERY covered message's timeline. - `seq_to: optional number` - `state: optional string` Current state of the mutable row behind this entry: the delivery state (`queued` | `sending` | `retry` | `sent` | `failed` | `undelivered` | `suppressed` | `cancelled`) on `delivery`, the outbox state (`pending` | `sending` | `delivered` | `failed`) on `webhook`. - `message_id: string` The message this timeline is about (`msg_…`). - `retention: object { delivery_from, webhook_from }` The two retention horizons this response was computed against: the database clock's `now()` minus each sweep's own window. They differ, which is the whole reason `truncated` exists. - `delivery_from: string` Delivery rows that settled before this may have been swept (90 days). - `webhook_from: string` Webhook outbox rows created before this may have been swept (30 days). - `truncated: array of string` Each section whose retention horizon this message has outlived; `"delivery"`, `"webhook"`, or both. Always present; an EMPTY array means an empty section is a fact about the world rather than a sweep. It exists because the horizons differ: a 45-day-old send that delivered and whose webhook fired shows a delivery entry and zero webhook entries, which would otherwise be byte-identical to "the webhook was never created" on the one endpoint built to answer that question. "MAY have been swept", never "was": the 30-day webhook sweep takes `delivered` rows ONLY, so a pending or failed outbox row survives past its horizon and truncation can hide only a SUCCESS. ### Example ```http curl https://messages.api.linqapp.com/v1/messages/$MSG/timeline \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "message_id": "msg_2c7d90", "chat_id": "chat_4f81b2", "entries": [ { "kind": "accepted", "at": "2026-08-06T14:02:12Z" }, { "kind": "delivery", "at": "2026-08-06T14:02:13Z", "state": "sent", "attempt": 1 }, { "kind": "message_sent", "at": "2026-08-06T14:02:13Z", "seq": 14 }, { "kind": "webhook", "at": "2026-08-06T14:02:13Z", "state": "delivered", "attempt": 1, "endpoint_id": "wh_7c31a8", "event_type": "message.sent", "seq_from": 14, "seq_to": 14, "message_id": "msg_2c7d90" } ], "retention": { "delivery_from": "2026-05-08T14:02:20Z", "webhook_from": "2026-07-07T14:02:20Z" }, "truncated": [] } ``` ## Domain Types ### Message Retrieve Response - `MessageRetrieveResponse object { id, chat_id, created_at, 8 more }` A send's delivery outcome. - `id: string` Message id (`msg_…`). - `chat_id: string` - `created_at: string` - `state: string` Delivery state: `queued` until a terminal outcome (`sent`, `failed`, `suppressed`, …). - `updated_at: string` - `fallbacks: optional array of Fallback` Capability rewrites applied at accept under `degrade` (`"auto"` or `"acknowledged"`); present only when something was rewritten (the same array the send's 200 carried). - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` - `group_id: optional string` Correlation id present ONLY when this message was one of several a single request fanned out into (URL promotion): it is the first message's id, carried on every member, the same value the send response and the `message.sent`/`message.failed` webhooks report. Use it to identify which fan-out group a message belongs to; it is a correlation key, not a member list. Absent on an ordinary single-message send. - `last_error: optional string` Gateway error of the last attempt; rides only on a gateway-verdict failure. - `last_status: optional number` Gateway HTTP status of the last attempt; rides only on a gateway-verdict failure. - `parts: optional array of unknown` The message's canonical parts AS STORED at accept; post-degrade, post-promotion, i.e. what the customer's device was (or will be) sent, not necessarily what you composed (`fallbacks` says when they differ). Typed as an opaque array rather than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is. Absent only when the stored content cannot be rendered canonically. - `reason: optional string` Failure-only: whether to resubmit (`permanent`, `undelivered`, `auth_error`, `suppressed`, `consent_revoked`, …): the same value the `message.failed` webhook carried. ### Message Timeline Response - `MessageTimelineResponse object { chat_id, entries, message_id, 2 more }` The row-level story of one outbound send. - `chat_id: string` - `entries: array of object { at, kind, attempt, 13 more }` Every entry we hold, oldest first. - `at: string` When the fact was recorded: accept time on `accepted`, when the delivery row last moved on `delivery` (it is mutable), the journal row's time on a terminal event, and the outbox row's creation on `webhook`. - `kind: string` What this entry is. Documented-open; skip values you do not know. - `attempt: optional number` The row's attempt counter. Present-and-zero is a real answer ("accepted, never attempted"), so read absence and zero differently. - `attempt_404: optional number` `delivery` only: attempts that answered `404`. - `attempt_auth: optional number` `delivery` only: attempts that failed authentication. - `endpoint_id: optional string` `webhook` only: YOUR endpoint id (`wh_…`) the row is bound for. - `event_type: optional string` `webhook` only: the partner event kind the row carries (`message.sent` | `message.failed`). - `last_error: optional string` Failure-only: the gateway's (or your endpoint's) error text on a FAILED row. - `last_status: optional number` Failure-only, exactly as on `GET /v1/messages/{msg}`: the gateway's (or your endpoint's) HTTP status on a FAILED row, and absent otherwise: a success does not publish its status here. - `message_id: optional string` `webhook` only: the message the row's own payload names: the finer attribution key on the window-spanning row above. - `next_attempt_at: optional string` When the next attempt is due. Rides only while the row is NON-terminal, so a settled entry never advertises a retry that will not happen. - `reason: optional string` `message_failed` only: the failure discriminator (`permanent`, `undelivered`, `auth_error`, `suppressed`, …), the same value that webhook carried. - `seq: optional number` Journal entries only: the event's `seq`: the same watermark `GET /v1/chats/{chat}/events` pages on. - `seq_from: optional number` `webhook` only: the row's journal-seq window (inclusive). Every per-message emit produces a single-seq window today, so a webhook entry names exactly one message; the schema permits a wider one, and such a row appears on EVERY covered message's timeline. - `seq_to: optional number` - `state: optional string` Current state of the mutable row behind this entry: the delivery state (`queued` | `sending` | `retry` | `sent` | `failed` | `undelivered` | `suppressed` | `cancelled`) on `delivery`, the outbox state (`pending` | `sending` | `delivered` | `failed`) on `webhook`. - `message_id: string` The message this timeline is about (`msg_…`). - `retention: object { delivery_from, webhook_from }` The two retention horizons this response was computed against: the database clock's `now()` minus each sweep's own window. They differ, which is the whole reason `truncated` exists. - `delivery_from: string` Delivery rows that settled before this may have been swept (90 days). - `webhook_from: string` Webhook outbox rows created before this may have been swept (30 days). - `truncated: array of string` Each section whose retention horizon this message has outlived; `"delivery"`, `"webhook"`, or both. Always present; an EMPTY array means an empty section is a fact about the world rather than a sweep. It exists because the horizons differ: a 45-day-old send that delivered and whose webhook fired shows a delivery entry and zero webhook entries, which would otherwise be byte-identical to "the webhook was never created" on the one endpoint built to answer that question. "MAY have been swept", never "was": the 30-day webhook sweep takes `delivered` rows ONLY, so a pending or failed outbox row survives past its horizon and truncation can hide only a SUCCESS. --- # Get a message's status URL: https://docs.linqapp.com/channel/amb/api/resources/messages/methods/retrieve/ **get** `/v1/messages/{msg}` Retrieves a sent message's delivery outcome. Use it when you missed, or do not subscribe to: the `message.sent` and `message.failed` webhooks. ### Path Parameters - `msg: string` ### Returns - `id: string` Message id (`msg_…`). - `chat_id: string` - `created_at: string` - `state: string` Delivery state: `queued` until a terminal outcome (`sent`, `failed`, `suppressed`, …). - `updated_at: string` - `fallbacks: optional array of Fallback` Capability rewrites applied at accept under `degrade` (`"auto"` or `"acknowledged"`); present only when something was rewritten (the same array the send's 200 carried). - `token: string` The missing capability token that forced the rewrite. - `from: string` Canonical part type in. - `rule: string` The fallback-tree rule applied (e.g. `guide-17.5-select-le5`). - `to: string` Canonical part type out. For a rewritten form this is `text_sequence`: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response's own `messages[]`. - `options: optional array of FallbackOption` Present only when the rewrite flattened a menu. - `index: number` 1-based, matching the numbering the degraded copy emits. - `item_id: string` The original item id, so a customer's "2" maps back to your routing. - `label: string` - `group_id: optional string` Correlation id present ONLY when this message was one of several a single request fanned out into (URL promotion): it is the first message's id, carried on every member, the same value the send response and the `message.sent`/`message.failed` webhooks report. Use it to identify which fan-out group a message belongs to; it is a correlation key, not a member list. Absent on an ordinary single-message send. - `last_error: optional string` Gateway error of the last attempt; rides only on a gateway-verdict failure. - `last_status: optional number` Gateway HTTP status of the last attempt; rides only on a gateway-verdict failure. - `parts: optional array of unknown` The message's canonical parts AS STORED at accept; post-degrade, post-promotion, i.e. what the customer's device was (or will be) sent, not necessarily what you composed (`fallbacks` says when they differ). Typed as an opaque array rather than `Part[]` for the same one-decode-door reason `TranscriptRow.parts` is. Absent only when the stored content cannot be rendered canonically. - `reason: optional string` Failure-only: whether to resubmit (`permanent`, `undelivered`, `auth_error`, `suppressed`, `consent_revoked`, …): the same value the `message.failed` webhook carried. ### Example ```http curl https://messages.api.linqapp.com/v1/messages/$MSG \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "msg_2c7d90", "chat_id": "chat_4f81b2", "state": "sent", "created_at": "2026-08-06T14:02:12Z", "updated_at": "2026-08-06T14:02:13Z" } ``` --- # Get a message's timeline URL: https://docs.linqapp.com/channel/amb/api/resources/messages/methods/timeline/ **get** `/v1/messages/{msg}/timeline` Retrieves the full story of one outbound send: when it was accepted, its current delivery state, the terminal events it produced, and the webhook deliveries that carried those events to your endpoints. `GET /v1/messages/{msg}` answers "what happened"; this answers "why does it say that". **Requirements** - `msg` is an outbound send id: the ids `POST /v1/chats/{chat}/messages` returns. Inbound customer messages have no message id and return HTTP 404, as does another brand's id or an unknown one. **Limits** - No per-attempt history: entries carry counters and a current state. - No intermediate delivery states: you get the current state plus the immutable terminal events. - Entries past the retention horizons are marked by `retention` and `truncated` rather than silently absent. ### Path Parameters - `msg: string` ### Returns - `chat_id: string` - `entries: array of object { at, kind, attempt, 13 more }` Every entry we hold, oldest first. - `at: string` When the fact was recorded: accept time on `accepted`, when the delivery row last moved on `delivery` (it is mutable), the journal row's time on a terminal event, and the outbox row's creation on `webhook`. - `kind: string` What this entry is. Documented-open; skip values you do not know. - `attempt: optional number` The row's attempt counter. Present-and-zero is a real answer ("accepted, never attempted"), so read absence and zero differently. - `attempt_404: optional number` `delivery` only: attempts that answered `404`. - `attempt_auth: optional number` `delivery` only: attempts that failed authentication. - `endpoint_id: optional string` `webhook` only: YOUR endpoint id (`wh_…`) the row is bound for. - `event_type: optional string` `webhook` only: the partner event kind the row carries (`message.sent` | `message.failed`). - `last_error: optional string` Failure-only: the gateway's (or your endpoint's) error text on a FAILED row. - `last_status: optional number` Failure-only, exactly as on `GET /v1/messages/{msg}`: the gateway's (or your endpoint's) HTTP status on a FAILED row, and absent otherwise: a success does not publish its status here. - `message_id: optional string` `webhook` only: the message the row's own payload names: the finer attribution key on the window-spanning row above. - `next_attempt_at: optional string` When the next attempt is due. Rides only while the row is NON-terminal, so a settled entry never advertises a retry that will not happen. - `reason: optional string` `message_failed` only: the failure discriminator (`permanent`, `undelivered`, `auth_error`, `suppressed`, …), the same value that webhook carried. - `seq: optional number` Journal entries only: the event's `seq`: the same watermark `GET /v1/chats/{chat}/events` pages on. - `seq_from: optional number` `webhook` only: the row's journal-seq window (inclusive). Every per-message emit produces a single-seq window today, so a webhook entry names exactly one message; the schema permits a wider one, and such a row appears on EVERY covered message's timeline. - `seq_to: optional number` - `state: optional string` Current state of the mutable row behind this entry: the delivery state (`queued` | `sending` | `retry` | `sent` | `failed` | `undelivered` | `suppressed` | `cancelled`) on `delivery`, the outbox state (`pending` | `sending` | `delivered` | `failed`) on `webhook`. - `message_id: string` The message this timeline is about (`msg_…`). - `retention: object { delivery_from, webhook_from }` The two retention horizons this response was computed against: the database clock's `now()` minus each sweep's own window. They differ, which is the whole reason `truncated` exists. - `delivery_from: string` Delivery rows that settled before this may have been swept (90 days). - `webhook_from: string` Webhook outbox rows created before this may have been swept (30 days). - `truncated: array of string` Each section whose retention horizon this message has outlived; `"delivery"`, `"webhook"`, or both. Always present; an EMPTY array means an empty section is a fact about the world rather than a sweep. It exists because the horizons differ: a 45-day-old send that delivered and whose webhook fired shows a delivery entry and zero webhook entries, which would otherwise be byte-identical to "the webhook was never created" on the one endpoint built to answer that question. "MAY have been swept", never "was": the 30-day webhook sweep takes `delivered` rows ONLY, so a pending or failed outbox row survives past its horizon and truncation can hide only a SUCCESS. ### Example ```http curl https://messages.api.linqapp.com/v1/messages/$MSG/timeline \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "message_id": "msg_2c7d90", "chat_id": "chat_4f81b2", "entries": [ { "kind": "accepted", "at": "2026-08-06T14:02:12Z" }, { "kind": "delivery", "at": "2026-08-06T14:02:13Z", "state": "sent", "attempt": 1 }, { "kind": "message_sent", "at": "2026-08-06T14:02:13Z", "seq": 14 }, { "kind": "webhook", "at": "2026-08-06T14:02:13Z", "state": "delivered", "attempt": 1, "endpoint_id": "wh_7c31a8", "event_type": "message.sent", "seq_from": 14, "seq_to": 14, "message_id": "msg_2c7d90" } ], "retention": { "delivery_from": "2026-05-08T14:02:20Z", "webhook_from": "2026-07-07T14:02:20Z" }, "truncated": [] } ``` --- # Meta URL: https://docs.linqapp.com/channel/amb/api/resources/meta/ ## Get the part registry **get** `/v1/meta/parts` Retrieves the part registry: every part type with its capability token, interactivity, and schema, plus every server-enforced rule by name. **Behavior** - Static; it describes this API, not any chat. - Intersect each part's `token` with a chat's announced `capabilities` (from `GET /v1/chats/{chat}`) to know which part types the customer's device can render. A composer must not offer payloads the device cannot support. ### Returns - `PartRegistryResponse object { parts, rules }` The part registry: the union, its capability gates, and every enforced rule. - `parts: array of PartInfo` - `token: string` The capability-list token a device must announce, same value as `capability_token`. EMPTY means universal; not "unknown" and not "blocked": disable a type only when this is non-empty and the token is missing from the chat's `capabilities`. - `interactive: boolean` Whether the part renders as an interactive message (Apple's `interactiveData` family plus the payment, auth and app balloons). - `label: string` What a composer calls this type in front of a human. Served rather than hardcoded client-side so the name has one home. - `schema: unknown` This type's JSON Schema 2020-12: the same authority document `GET /v1/parts/{type}` publishes byte-verbatim, embedded here as JSON so one request equips a composer for every type. - `summary: string` One line describing what the customer sees, for a composer's type picker. - `type: string` The wire discriminator; `text`, `choices`, `list_picker`, … - `capability_token: optional string` The capability-list token a device must announce for this part to render. ABSENT means universal: disable a type only when this field is present and the token is missing from the chat's `capabilities`. Kept alongside `token`, which carries the same value: the long name shipped first and a client may already read it. - `degrade_note: optional string` What a customer sees when this part degrades, or why it never does. Present for every capability-gated part, and read together with `degrades_to`: a note WITHOUT `degrades_to` means the part is never substituted and the note says why (an unsupported send is refused, not rewritten); a note alongside `degrades_to` describes the substitution. An empty target list is never served: absence of `degrades_to` is the signal. - `degrades_to: optional array of string` The POSSIBLE part types this one can become under `degrade:"auto"` when its token is not announced, richest first. Informational, not device-resolved: which one a given customer gets depends on the rest of their announced capabilities, so preview the message against the chat for the answer a specific device would produce. Absent for universal parts, and for gated parts that are never substituted; `degrade_note` tells that story either way. - `device_scope: optional string` Which devices render this type, as prose from the OS-capability matrix; e.g. `iPhone, iPad` or `iPhone, iPad, Mac`. - `kit: optional string` Where this row is sourced in Apple's canonical documentation, as line references. - `min_ios: optional string` Minimum iOS/iPadOS version, e.g. `18.4`. Every type has one; AMB is an iOS-first channel, so this is always present. - `min_macos: optional string` Minimum macOS version. ABSENT means the type does not render on macOS AT ALL (the capability matrix's N/A), which is exactly what a lock reason needs to say; it is not "unknown". - `rules: optional array of string` Names of the entries in `rules` this type's validator applies, so a composer can show the bounds it is about to enforce without deciding which ones matter. - `rules: unknown` Every registered rule by its `x-cerebro-rule` name: the same names the part schemas carry. ### Example ```http curl https://messages.api.linqapp.com/v1/meta/parts \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "parts": [ { "type": "text", "capability_token": "", "token": "", "label": "Text", "summary": "A plain message; `subject` renders bold.", "min_ios": "13.0", "min_macos": "10.15", "device_scope": "iPhone, iPad, Mac", "kit": "L1035", "rules": [ "max_text_body_runes", "text_body_bmp_only" ], "interactive": false, "schema": { "title": "text", "type": "object" } } ], "rules": { "max_text_body_runes": { "kind": "bound", "limit": 2000 }, "text_body_bmp_only": { "kind": "semantic", "statement": "must contain only Basic Multilingual Plane characters" } } } ``` ## Domain Types ### Part Info - `PartInfo object { token, interactive, label, 11 more }` One member of the sealed part union, described for a composer. - `token: string` The capability-list token a device must announce, same value as `capability_token`. EMPTY means universal; not "unknown" and not "blocked": disable a type only when this is non-empty and the token is missing from the chat's `capabilities`. - `interactive: boolean` Whether the part renders as an interactive message (Apple's `interactiveData` family plus the payment, auth and app balloons). - `label: string` What a composer calls this type in front of a human. Served rather than hardcoded client-side so the name has one home. - `schema: unknown` This type's JSON Schema 2020-12: the same authority document `GET /v1/parts/{type}` publishes byte-verbatim, embedded here as JSON so one request equips a composer for every type. - `summary: string` One line describing what the customer sees, for a composer's type picker. - `type: string` The wire discriminator; `text`, `choices`, `list_picker`, … - `capability_token: optional string` The capability-list token a device must announce for this part to render. ABSENT means universal: disable a type only when this field is present and the token is missing from the chat's `capabilities`. Kept alongside `token`, which carries the same value: the long name shipped first and a client may already read it. - `degrade_note: optional string` What a customer sees when this part degrades, or why it never does. Present for every capability-gated part, and read together with `degrades_to`: a note WITHOUT `degrades_to` means the part is never substituted and the note says why (an unsupported send is refused, not rewritten); a note alongside `degrades_to` describes the substitution. An empty target list is never served: absence of `degrades_to` is the signal. - `degrades_to: optional array of string` The POSSIBLE part types this one can become under `degrade:"auto"` when its token is not announced, richest first. Informational, not device-resolved: which one a given customer gets depends on the rest of their announced capabilities, so preview the message against the chat for the answer a specific device would produce. Absent for universal parts, and for gated parts that are never substituted; `degrade_note` tells that story either way. - `device_scope: optional string` Which devices render this type, as prose from the OS-capability matrix; e.g. `iPhone, iPad` or `iPhone, iPad, Mac`. - `kit: optional string` Where this row is sourced in Apple's canonical documentation, as line references. - `min_ios: optional string` Minimum iOS/iPadOS version, e.g. `18.4`. Every type has one; AMB is an iOS-first channel, so this is always present. - `min_macos: optional string` Minimum macOS version. ABSENT means the type does not render on macOS AT ALL (the capability matrix's N/A), which is exactly what a lock reason needs to say; it is not "unknown". - `rules: optional array of string` Names of the entries in `rules` this type's validator applies, so a composer can show the bounds it is about to enforce without deciding which ones matter. ### Part Registry Response - `PartRegistryResponse object { parts, rules }` The part registry: the union, its capability gates, and every enforced rule. - `parts: array of PartInfo` - `token: string` The capability-list token a device must announce, same value as `capability_token`. EMPTY means universal; not "unknown" and not "blocked": disable a type only when this is non-empty and the token is missing from the chat's `capabilities`. - `interactive: boolean` Whether the part renders as an interactive message (Apple's `interactiveData` family plus the payment, auth and app balloons). - `label: string` What a composer calls this type in front of a human. Served rather than hardcoded client-side so the name has one home. - `schema: unknown` This type's JSON Schema 2020-12: the same authority document `GET /v1/parts/{type}` publishes byte-verbatim, embedded here as JSON so one request equips a composer for every type. - `summary: string` One line describing what the customer sees, for a composer's type picker. - `type: string` The wire discriminator; `text`, `choices`, `list_picker`, … - `capability_token: optional string` The capability-list token a device must announce for this part to render. ABSENT means universal: disable a type only when this field is present and the token is missing from the chat's `capabilities`. Kept alongside `token`, which carries the same value: the long name shipped first and a client may already read it. - `degrade_note: optional string` What a customer sees when this part degrades, or why it never does. Present for every capability-gated part, and read together with `degrades_to`: a note WITHOUT `degrades_to` means the part is never substituted and the note says why (an unsupported send is refused, not rewritten); a note alongside `degrades_to` describes the substitution. An empty target list is never served: absence of `degrades_to` is the signal. - `degrades_to: optional array of string` The POSSIBLE part types this one can become under `degrade:"auto"` when its token is not announced, richest first. Informational, not device-resolved: which one a given customer gets depends on the rest of their announced capabilities, so preview the message against the chat for the answer a specific device would produce. Absent for universal parts, and for gated parts that are never substituted; `degrade_note` tells that story either way. - `device_scope: optional string` Which devices render this type, as prose from the OS-capability matrix; e.g. `iPhone, iPad` or `iPhone, iPad, Mac`. - `kit: optional string` Where this row is sourced in Apple's canonical documentation, as line references. - `min_ios: optional string` Minimum iOS/iPadOS version, e.g. `18.4`. Every type has one; AMB is an iOS-first channel, so this is always present. - `min_macos: optional string` Minimum macOS version. ABSENT means the type does not render on macOS AT ALL (the capability matrix's N/A), which is exactly what a lock reason needs to say; it is not "unknown". - `rules: optional array of string` Names of the entries in `rules` this type's validator applies, so a composer can show the bounds it is about to enforce without deciding which ones matter. - `rules: unknown` Every registered rule by its `x-cerebro-rule` name: the same names the part schemas carry. ### Part Rule - `PartRule object { kind, limit, statement }` One registered constraint the strict JSON-Schema subset cannot express. - `kind: "bound" or "semantic"` `bound` carries `limit`; `semantic` carries `statement`. - `"bound"` - `"semantic"` - `limit: optional number` The enforced numeric limit, on a `bound` rule. - `statement: optional string` The human-readable rule, on a `semantic` rule. --- # Get the part registry URL: https://docs.linqapp.com/channel/amb/api/resources/meta/methods/parts/ **get** `/v1/meta/parts` Retrieves the part registry: every part type with its capability token, interactivity, and schema, plus every server-enforced rule by name. **Behavior** - Static; it describes this API, not any chat. - Intersect each part's `token` with a chat's announced `capabilities` (from `GET /v1/chats/{chat}`) to know which part types the customer's device can render. A composer must not offer payloads the device cannot support. ### Returns - `PartRegistryResponse object { parts, rules }` The part registry: the union, its capability gates, and every enforced rule. - `parts: array of PartInfo` - `token: string` The capability-list token a device must announce, same value as `capability_token`. EMPTY means universal; not "unknown" and not "blocked": disable a type only when this is non-empty and the token is missing from the chat's `capabilities`. - `interactive: boolean` Whether the part renders as an interactive message (Apple's `interactiveData` family plus the payment, auth and app balloons). - `label: string` What a composer calls this type in front of a human. Served rather than hardcoded client-side so the name has one home. - `schema: unknown` This type's JSON Schema 2020-12: the same authority document `GET /v1/parts/{type}` publishes byte-verbatim, embedded here as JSON so one request equips a composer for every type. - `summary: string` One line describing what the customer sees, for a composer's type picker. - `type: string` The wire discriminator; `text`, `choices`, `list_picker`, … - `capability_token: optional string` The capability-list token a device must announce for this part to render. ABSENT means universal: disable a type only when this field is present and the token is missing from the chat's `capabilities`. Kept alongside `token`, which carries the same value: the long name shipped first and a client may already read it. - `degrade_note: optional string` What a customer sees when this part degrades, or why it never does. Present for every capability-gated part, and read together with `degrades_to`: a note WITHOUT `degrades_to` means the part is never substituted and the note says why (an unsupported send is refused, not rewritten); a note alongside `degrades_to` describes the substitution. An empty target list is never served: absence of `degrades_to` is the signal. - `degrades_to: optional array of string` The POSSIBLE part types this one can become under `degrade:"auto"` when its token is not announced, richest first. Informational, not device-resolved: which one a given customer gets depends on the rest of their announced capabilities, so preview the message against the chat for the answer a specific device would produce. Absent for universal parts, and for gated parts that are never substituted; `degrade_note` tells that story either way. - `device_scope: optional string` Which devices render this type, as prose from the OS-capability matrix; e.g. `iPhone, iPad` or `iPhone, iPad, Mac`. - `kit: optional string` Where this row is sourced in Apple's canonical documentation, as line references. - `min_ios: optional string` Minimum iOS/iPadOS version, e.g. `18.4`. Every type has one; AMB is an iOS-first channel, so this is always present. - `min_macos: optional string` Minimum macOS version. ABSENT means the type does not render on macOS AT ALL (the capability matrix's N/A), which is exactly what a lock reason needs to say; it is not "unknown". - `rules: optional array of string` Names of the entries in `rules` this type's validator applies, so a composer can show the bounds it is about to enforce without deciding which ones matter. - `rules: unknown` Every registered rule by its `x-cerebro-rule` name: the same names the part schemas carry. ### Example ```http curl https://messages.api.linqapp.com/v1/meta/parts \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "parts": [ { "type": "text", "capability_token": "", "token": "", "label": "Text", "summary": "A plain message; `subject` renders bold.", "min_ios": "13.0", "min_macos": "10.15", "device_scope": "iPhone, iPad, Mac", "kit": "L1035", "rules": [ "max_text_body_runes", "text_body_bmp_only" ], "interactive": false, "schema": { "title": "text", "type": "object" } } ], "rules": { "max_text_body_runes": { "kind": "bound", "limit": 2000 }, "text_body_bmp_only": { "kind": "semantic", "statement": "must contain only Basic Multilingual Plane characters" } } } ``` --- # Settings URL: https://docs.linqapp.com/channel/amb/api/resources/settings/ ## Get channel settings **get** `/v1/settings` Retrieves your brand's channel settings: the editable fields, the link state, and the managed integration groups as display strings. **Behavior** - Nothing here is secret: no client secret, no certificate material. - `business_hours` and `hours_timezone` are absent when the brand publishes no hours. Absence means "no hours model": the brand is treated as always open; never a default week. ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_hours": { "mon": [ [ "09:00", "12:00" ], [ "13:00", "17:00" ] ], "sat": [ [ "10:00", "14:00" ] ] }, "hours_timezone": "America/New_York", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "configured", "managed_by": "linq", "set": true, "source": "brand" } ], "app_extensions": { "apps": {} } } ``` ## Update channel settings **put** `/v1/settings` Updates your brand's editable settings. **Behavior** - An absent field keeps its current value; send only what you mean to change. - `bump_policy_version` is the one explicit act: a version bump re-notifies customers and never happens as a side effect. - Business hours are one setting spelled in two keys: send either `business_hours` or `hours_timezone` and the other falls back to what is stored; the merged pair is validated whole and written whole, so a refusal leaves the stored week untouched. Clear both together with `"business_hours": {}` and `"hours_timezone": ""`. - Returns the updated view, exactly as `GET /v1/settings` would. - `escalation_handling` is the optional opt-in for having us run escalation (`managed`); every brand starts as `self_managed`. The field's own documentation carries the full contract and what the `chat.trigger_word` event looks like in each mode. **Errors** - HTTP 422 `code` 1021: `escalation_handling` is neither `managed` nor `self_managed`. - HTTP 409 `code` 2019: we hold your brand on `managed` escalation and the update named a differing value; re-sending the stored value still succeeds as a no-op. - HTTP 413 `code` 1073: the hours document exceeds 4,096 bytes. - HTTP 422: `business_hours_invalid` (wrong shape: an unknown weekday key, a range that is not `["HH:MM","HH:MM"]`, a time outside `00:00`–`24:00`), `business_hours_end_not_after_start`, `business_hours_overlap`, `business_hours_never_open`, `hours_timezone_invalid`, and `required` when hours arrive without a zone or a zone without hours. ### Body Parameters - `agent_enabled: optional boolean` Switch your AI agent on or off; see `agent_enabled` on the response for what the pause does and does not cover. Absent leaves it as it is; there is no other way to spell "unchanged" for a boolean, so a settings update that omits this can never switch your agent off by accident. - `bump_policy_version: optional boolean` `true` increments `policy_version`: the explicit act that re-notifies customers of a policy change. NEVER implicit: editing a URL alone does not bump, because an accidental bump re-messages every customer. - `business_hours: optional unknown` Your opening times: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key. Absent keeps the stored document; `{}` together with `hours_timezone: ""` CLEARS the hours, which is how "we do not publish hours" is spelled. `business_hours` and `hours_timezone` are ONE SETTING: send either and the other falls back to what is stored, and the merged pair is validated whole. Hours without a zone are refused (`422 required` on `/hours_timezone`) and a zone without hours is refused the same way on `/business_hours`, so clearing means sending BOTH empty, not one. THE WRITER IS STRICTER THAN THE READER, in three ways worth knowing before you compose a week (each has its own code, listed on the operation): `end` must come AFTER `start`: a range that reads back as crossing midnight is refused on write, and the message names the split-shift spelling to use instead; two ranges on one weekday may not share a minute (they may touch); and the week must open somewhere. - `escalation_handling: optional "managed" or "self_managed"` Who runs escalation for this brand. OPTIONAL and an OPT-IN: every brand starts as `self_managed`, absent leaves your stored choice unchanged, and re-sending your stored value is a no-op, never an error. To opt into our desk, `PUT /v1/settings` with `{"escalation_handling": "managed"}`; the response is the full settings view with the new value. To take escalation back, send `"self_managed"` the same way. `self_managed` (the default): escalation is yours. We detect the request for a person and deliver the `chat.trigger_word` event with `handled_by: "partner"` and NO delegation fields: no `queued_for_human`, no `ack_by`. Nothing is queued, the chat's owner does not move, and your agent keeps the floor; your ordinary sends keep answering exactly as on any partner-owned chat, and none of them meet `409 chat_owned`, because the ownership that refusal guards never moved. The ABSENCE of `queued_for_human` on that event is how your webhook handler knows this escalation is yours; from there you decide whether to answer in place, hand the chat to your own human desk and tell us with `/handoff`, or call `/clarify` to opt that turn back into our escalation. The way to a person is your escalation experience, the one your Experience Review approved, and the live-human obligation in that review is yours. The event's own documentation shows a worked payload for each shape. `managed` (the opt-in): when a customer asks for a person, we queue them for a human at that moment and you enrich the acknowledgement, by `ack_by`. We may force a brand onto `managed` if monitoring shows its human path is not answering; while forced, a differing value is refused `409 escalation_handling_locked` and the response shows `escalation_handling_locked: true`. A value other than the two above is refused `422 escalation_handling_invalid`. - `"managed"` - `"self_managed"` - `hours_timezone: optional string` The IANA zone your hours are stated in (`America/New_York`). Absent keeps the stored zone; `""` alongside an empty `business_hours` clears the pair. `Local` is refused (`422 hours_timezone_invalid`); it resolves to the serving container's zone, not yours. - `privacy_policy_url: optional string` Absolute `https` URL, at most 2048 characters, or `""` to unset. `http` is refused (`400 invalid_url`): these URLs are rendered to customers. - `richlink_default_image_att_id: optional string` An `att_…` id your brand uploaded (a PNG ≤ 200 KB), or `""` to clear back to the platform default. Validated at set time, same rules as a rich link's image. - `support_contact: optional string` Free text, trimmed; stored to 320 characters. - `terms_of_use_url: optional string` Same rules as `privacy_policy_url`. ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{}' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_hours": { "mon": [ [ "09:00", "12:00" ], [ "13:00", "17:00" ] ], "sat": [ [ "10:00", "14:00" ] ] }, "hours_timezone": "America/New_York", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "configured", "managed_by": "linq", "set": true, "source": "brand" } ], "app_extensions": { "apps": {} } } ``` ## Domain Types ### Brand Settings - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Managed Setting - `ManagedSetting object { managed_by, name, set, 2 more }` One channel-specific setting managed WITH Linq rather than edited here; reported as a display string so a settings screen can show the truth about what exists without pretending a brand can change it on this API. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. # Integrations ## Set the Apple Pay integration **put** `/v1/settings/integrations/apple_pay` Sets your brand's Apple Pay integration: the full group in one write. **Behavior** - A present group always replaces the stored one wholly. - The certificate is write-only: stored by reference, never returned. Omit `merchant_identity_cert_pem` to keep the stored certificate; HTTP 400 `code` 1061 when there is none to keep. ### Body Parameters - `display_name: string` The merchant display name shown while acquiring the payment session. - `domain: string` The verified Apple Pay merchant domain, host only, no scheme. - `merchant_id: string` The TEXT-form Apple Pay Merchant ID (e.g. `merchant.com.example`), character-for-character as registered. - `merchant_identity_cert_pem: optional string` WRITE-ONLY. The Merchant Identity credential as PEM text: at least one `CERTIFICATE` block and exactly one private-key block, concatenated. Stored by reference in the secret store: no read ever returns it. ABSENT keeps the certificate the stored group already holds; on a first write it is required (`400 secret_value_required`). ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/apple_pay \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "display_name": "CurbFare", "domain": "pay.example.com", "merchant_id": "merchant.com.example" }' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "merchant.com.example", "managed_by": "linq", "set": true, "source": "brand" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" } ], "app_extensions": { "apps": {} } } ``` ## Set the OAuth integration **put** `/v1/settings/integrations/oauth` Sets your brand's customer-authentication (OAuth) integration: the full group in one write. **Behavior** - A present group always replaces the stored one wholly. - The client secret is write-only: stored by reference, never returned. Omit `client_secret` to keep the stored one; HTTP 400 `secret_value_required` when there is none to keep. - A write that changes `token_url` or `issuer_url` must carry `client_secret`: the stored secret is only ever sent to the token endpoint it was supplied for. HTTP 400 `code` 1061 at `/client_secret` when the field is omitted; the message names the endpoint that moved. `authorize_url`, `redirect_uri`, `client_id` and `scope` may change with the secret omitted. - An operator-attributed write (`X-Cerebro-Operator` honoured) may not change `issuer_url`, `authorize_url`, `token_url` or `redirect_uri` once a group is stored: HTTP 403 `code` 2057 with `param` naming the first changed field, in that order. Make the write with the brand key alone, carrying the secret. An operator's first write, and an operator write that changes only `client_id` or `scope`, is allowed. ### Body Parameters - `authorize_url: string` The provider's authorization endpoint. - `client_id: string` The OAuth client id registered for this brand. - `issuer_url: string` The provider's issuer URL. - `redirect_uri: string` The redirect URI, character-for-character as registered with the provider. - `scope: string` The scope string sent on the authorize request. - `token_url: string` The provider's token endpoint. - `client_secret: optional string` WRITE-ONLY. The OAuth client secret. Stored by reference in the secret store: no read ever returns it. ABSENT keeps the secret the stored group already holds; on a first write it is required (`400 secret_value_required`), and so is it on any write that changes `token_url` or `issuer_url`: the stored secret is never sent to a token endpoint chosen after it was supplied. ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/oauth \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "authorize_url": "https://id.example.com/authorize", "client_id": "curbfare-messages", "issuer_url": "https://id.example.com", "redirect_uri": "https://auth.example.com/auth/callback", "scope": "openid", "token_url": "https://id.example.com/token" }' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "configured", "managed_by": "linq", "set": true, "source": "brand" } ], "app_extensions": { "apps": {} } } ``` ## Set the iMessage-app integration **put** `/v1/settings/integrations/app_extensions` Sets your brand's iMessage-app registry: the full registry in one write. **Behavior** - A present registry replaces the stored one wholly: send the complete app set every time, never a partial update. `{"apps": {}}` is a legal write meaning "this brand registers no apps". - Icons are write-only: stored by reference, never returned. Omit an entry's `app_icon_b64` to keep that `app_key`'s stored icon; HTTP 400 `secret_value_required` when there is none to keep. ### Body Parameters - `apps: unknown` The brand's complete registry. `{}` registers no apps (and masks any deploy default). ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/app_extensions \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "apps": { "payments": { "bid": "com.apple.messages.MSMessageExtensionBalloonPlugin:ABCDE12345:com.example.app.MessagesExtension", "app_id": "6794029060", "app_name": "CurbFare Payments", "app_icon_b64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=" } } }' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "1 registered", "managed_by": "linq", "set": true, "source": "brand" }, { "name": "oauth", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" } ], "app_extensions": { "apps": { "ride": { "bid": "com.apple.messages.MSMessageExtensionBalloonPlugin:9BC1AB2D3E:com.curbfare.ride.imsg", "app_id": "6440000001", "app_name": "CurbFare Ride", "app_icon_set": true } } } } ``` ## Clear an integration group **delete** `/v1/settings/integrations/{group}` Clears one stored integration group, returning that group to the platform default when one exists; `source` returns to `platform`. **Behavior** - Clearing a group that is not stored is a no-op, not an error. **Errors** - HTTP 404 `code` 2026: unknown group name. ### Path Parameters - `group: "apple_pay" or "oauth" or "app_extensions"` - `"apple_pay"` - `"oauth"` - `"app_extensions"` ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/$GROUP \ -X DELETE \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" } ], "app_extensions": { "apps": {} } } ``` --- # Get channel settings URL: https://docs.linqapp.com/channel/amb/api/resources/settings/methods/retrieve/ **get** `/v1/settings` Retrieves your brand's channel settings: the editable fields, the link state, and the managed integration groups as display strings. **Behavior** - Nothing here is secret: no client secret, no certificate material. - `business_hours` and `hours_timezone` are absent when the brand publishes no hours. Absence means "no hours model": the brand is treated as always open; never a default week. ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_hours": { "mon": [ [ "09:00", "12:00" ], [ "13:00", "17:00" ] ], "sat": [ [ "10:00", "14:00" ] ] }, "hours_timezone": "America/New_York", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "configured", "managed_by": "linq", "set": true, "source": "brand" } ], "app_extensions": { "apps": {} } } ``` --- # Update channel settings URL: https://docs.linqapp.com/channel/amb/api/resources/settings/methods/update/ **put** `/v1/settings` Updates your brand's editable settings. **Behavior** - An absent field keeps its current value; send only what you mean to change. - `bump_policy_version` is the one explicit act: a version bump re-notifies customers and never happens as a side effect. - Business hours are one setting spelled in two keys: send either `business_hours` or `hours_timezone` and the other falls back to what is stored; the merged pair is validated whole and written whole, so a refusal leaves the stored week untouched. Clear both together with `"business_hours": {}` and `"hours_timezone": ""`. - Returns the updated view, exactly as `GET /v1/settings` would. - `escalation_handling` is the optional opt-in for having us run escalation (`managed`); every brand starts as `self_managed`. The field's own documentation carries the full contract and what the `chat.trigger_word` event looks like in each mode. **Errors** - HTTP 422 `code` 1021: `escalation_handling` is neither `managed` nor `self_managed`. - HTTP 409 `code` 2019: we hold your brand on `managed` escalation and the update named a differing value; re-sending the stored value still succeeds as a no-op. - HTTP 413 `code` 1073: the hours document exceeds 4,096 bytes. - HTTP 422: `business_hours_invalid` (wrong shape: an unknown weekday key, a range that is not `["HH:MM","HH:MM"]`, a time outside `00:00`–`24:00`), `business_hours_end_not_after_start`, `business_hours_overlap`, `business_hours_never_open`, `hours_timezone_invalid`, and `required` when hours arrive without a zone or a zone without hours. ### Body Parameters - `agent_enabled: optional boolean` Switch your AI agent on or off; see `agent_enabled` on the response for what the pause does and does not cover. Absent leaves it as it is; there is no other way to spell "unchanged" for a boolean, so a settings update that omits this can never switch your agent off by accident. - `bump_policy_version: optional boolean` `true` increments `policy_version`: the explicit act that re-notifies customers of a policy change. NEVER implicit: editing a URL alone does not bump, because an accidental bump re-messages every customer. - `business_hours: optional unknown` Your opening times: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key. Absent keeps the stored document; `{}` together with `hours_timezone: ""` CLEARS the hours, which is how "we do not publish hours" is spelled. `business_hours` and `hours_timezone` are ONE SETTING: send either and the other falls back to what is stored, and the merged pair is validated whole. Hours without a zone are refused (`422 required` on `/hours_timezone`) and a zone without hours is refused the same way on `/business_hours`, so clearing means sending BOTH empty, not one. THE WRITER IS STRICTER THAN THE READER, in three ways worth knowing before you compose a week (each has its own code, listed on the operation): `end` must come AFTER `start`: a range that reads back as crossing midnight is refused on write, and the message names the split-shift spelling to use instead; two ranges on one weekday may not share a minute (they may touch); and the week must open somewhere. - `escalation_handling: optional "managed" or "self_managed"` Who runs escalation for this brand. OPTIONAL and an OPT-IN: every brand starts as `self_managed`, absent leaves your stored choice unchanged, and re-sending your stored value is a no-op, never an error. To opt into our desk, `PUT /v1/settings` with `{"escalation_handling": "managed"}`; the response is the full settings view with the new value. To take escalation back, send `"self_managed"` the same way. `self_managed` (the default): escalation is yours. We detect the request for a person and deliver the `chat.trigger_word` event with `handled_by: "partner"` and NO delegation fields: no `queued_for_human`, no `ack_by`. Nothing is queued, the chat's owner does not move, and your agent keeps the floor; your ordinary sends keep answering exactly as on any partner-owned chat, and none of them meet `409 chat_owned`, because the ownership that refusal guards never moved. The ABSENCE of `queued_for_human` on that event is how your webhook handler knows this escalation is yours; from there you decide whether to answer in place, hand the chat to your own human desk and tell us with `/handoff`, or call `/clarify` to opt that turn back into our escalation. The way to a person is your escalation experience, the one your Experience Review approved, and the live-human obligation in that review is yours. The event's own documentation shows a worked payload for each shape. `managed` (the opt-in): when a customer asks for a person, we queue them for a human at that moment and you enrich the acknowledgement, by `ack_by`. We may force a brand onto `managed` if monitoring shows its human path is not answering; while forced, a differing value is refused `409 escalation_handling_locked` and the response shows `escalation_handling_locked: true`. A value other than the two above is refused `422 escalation_handling_invalid`. - `"managed"` - `"self_managed"` - `hours_timezone: optional string` The IANA zone your hours are stated in (`America/New_York`). Absent keeps the stored zone; `""` alongside an empty `business_hours` clears the pair. `Local` is refused (`422 hours_timezone_invalid`); it resolves to the serving container's zone, not yours. - `privacy_policy_url: optional string` Absolute `https` URL, at most 2048 characters, or `""` to unset. `http` is refused (`400 invalid_url`): these URLs are rendered to customers. - `richlink_default_image_att_id: optional string` An `att_…` id your brand uploaded (a PNG ≤ 200 KB), or `""` to clear back to the platform default. Validated at set time, same rules as a rich link's image. - `support_contact: optional string` Free text, trimmed; stored to 320 characters. - `terms_of_use_url: optional string` Same rules as `privacy_policy_url`. ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{}' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_hours": { "mon": [ [ "09:00", "12:00" ], [ "13:00", "17:00" ] ], "sat": [ [ "10:00", "14:00" ] ] }, "hours_timezone": "America/New_York", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "configured", "managed_by": "linq", "set": true, "source": "brand" } ], "app_extensions": { "apps": {} } } ``` --- # Integrations URL: https://docs.linqapp.com/channel/amb/api/resources/settings/subresources/integrations/ ## Set the Apple Pay integration **put** `/v1/settings/integrations/apple_pay` Sets your brand's Apple Pay integration: the full group in one write. **Behavior** - A present group always replaces the stored one wholly. - The certificate is write-only: stored by reference, never returned. Omit `merchant_identity_cert_pem` to keep the stored certificate; HTTP 400 `code` 1061 when there is none to keep. ### Body Parameters - `display_name: string` The merchant display name shown while acquiring the payment session. - `domain: string` The verified Apple Pay merchant domain, host only, no scheme. - `merchant_id: string` The TEXT-form Apple Pay Merchant ID (e.g. `merchant.com.example`), character-for-character as registered. - `merchant_identity_cert_pem: optional string` WRITE-ONLY. The Merchant Identity credential as PEM text: at least one `CERTIFICATE` block and exactly one private-key block, concatenated. Stored by reference in the secret store: no read ever returns it. ABSENT keeps the certificate the stored group already holds; on a first write it is required (`400 secret_value_required`). ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/apple_pay \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "display_name": "CurbFare", "domain": "pay.example.com", "merchant_id": "merchant.com.example" }' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "merchant.com.example", "managed_by": "linq", "set": true, "source": "brand" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" } ], "app_extensions": { "apps": {} } } ``` ## Set the OAuth integration **put** `/v1/settings/integrations/oauth` Sets your brand's customer-authentication (OAuth) integration: the full group in one write. **Behavior** - A present group always replaces the stored one wholly. - The client secret is write-only: stored by reference, never returned. Omit `client_secret` to keep the stored one; HTTP 400 `secret_value_required` when there is none to keep. - A write that changes `token_url` or `issuer_url` must carry `client_secret`: the stored secret is only ever sent to the token endpoint it was supplied for. HTTP 400 `code` 1061 at `/client_secret` when the field is omitted; the message names the endpoint that moved. `authorize_url`, `redirect_uri`, `client_id` and `scope` may change with the secret omitted. - An operator-attributed write (`X-Cerebro-Operator` honoured) may not change `issuer_url`, `authorize_url`, `token_url` or `redirect_uri` once a group is stored: HTTP 403 `code` 2057 with `param` naming the first changed field, in that order. Make the write with the brand key alone, carrying the secret. An operator's first write, and an operator write that changes only `client_id` or `scope`, is allowed. ### Body Parameters - `authorize_url: string` The provider's authorization endpoint. - `client_id: string` The OAuth client id registered for this brand. - `issuer_url: string` The provider's issuer URL. - `redirect_uri: string` The redirect URI, character-for-character as registered with the provider. - `scope: string` The scope string sent on the authorize request. - `token_url: string` The provider's token endpoint. - `client_secret: optional string` WRITE-ONLY. The OAuth client secret. Stored by reference in the secret store: no read ever returns it. ABSENT keeps the secret the stored group already holds; on a first write it is required (`400 secret_value_required`), and so is it on any write that changes `token_url` or `issuer_url`: the stored secret is never sent to a token endpoint chosen after it was supplied. ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/oauth \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "authorize_url": "https://id.example.com/authorize", "client_id": "curbfare-messages", "issuer_url": "https://id.example.com", "redirect_uri": "https://auth.example.com/auth/callback", "scope": "openid", "token_url": "https://id.example.com/token" }' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "configured", "managed_by": "linq", "set": true, "source": "brand" } ], "app_extensions": { "apps": {} } } ``` ## Set the iMessage-app integration **put** `/v1/settings/integrations/app_extensions` Sets your brand's iMessage-app registry: the full registry in one write. **Behavior** - A present registry replaces the stored one wholly: send the complete app set every time, never a partial update. `{"apps": {}}` is a legal write meaning "this brand registers no apps". - Icons are write-only: stored by reference, never returned. Omit an entry's `app_icon_b64` to keep that `app_key`'s stored icon; HTTP 400 `secret_value_required` when there is none to keep. ### Body Parameters - `apps: unknown` The brand's complete registry. `{}` registers no apps (and masks any deploy default). ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/app_extensions \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "apps": { "payments": { "bid": "com.apple.messages.MSMessageExtensionBalloonPlugin:ABCDE12345:com.example.app.MessagesExtension", "app_id": "6794029060", "app_name": "CurbFare Payments", "app_icon_b64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=" } } }' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "1 registered", "managed_by": "linq", "set": true, "source": "brand" }, { "name": "oauth", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" } ], "app_extensions": { "apps": { "ride": { "bid": "com.apple.messages.MSMessageExtensionBalloonPlugin:9BC1AB2D3E:com.curbfare.ride.imsg", "app_id": "6440000001", "app_name": "CurbFare Ride", "app_icon_set": true } } } } ``` ## Clear an integration group **delete** `/v1/settings/integrations/{group}` Clears one stored integration group, returning that group to the platform default when one exists; `source` returns to `platform`. **Behavior** - Clearing a group that is not stored is a no-op, not an error. **Errors** - HTTP 404 `code` 2026: unknown group name. ### Path Parameters - `group: "apple_pay" or "oauth" or "app_extensions"` - `"apple_pay"` - `"oauth"` - `"app_extensions"` ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/$GROUP \ -X DELETE \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" } ], "app_extensions": { "apps": {} } } ``` --- # Clear an integration group URL: https://docs.linqapp.com/channel/amb/api/resources/settings/subresources/integrations/methods/clear/ **delete** `/v1/settings/integrations/{group}` Clears one stored integration group, returning that group to the platform default when one exists; `source` returns to `platform`. **Behavior** - Clearing a group that is not stored is a no-op, not an error. **Errors** - HTTP 404 `code` 2026: unknown group name. ### Path Parameters - `group: "apple_pay" or "oauth" or "app_extensions"` - `"apple_pay"` - `"oauth"` - `"app_extensions"` ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/$GROUP \ -X DELETE \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" } ], "app_extensions": { "apps": {} } } ``` --- # Set the iMessage-app integration URL: https://docs.linqapp.com/channel/amb/api/resources/settings/subresources/integrations/methods/set_app_extensions/ **put** `/v1/settings/integrations/app_extensions` Sets your brand's iMessage-app registry: the full registry in one write. **Behavior** - A present registry replaces the stored one wholly: send the complete app set every time, never a partial update. `{"apps": {}}` is a legal write meaning "this brand registers no apps". - Icons are write-only: stored by reference, never returned. Omit an entry's `app_icon_b64` to keep that `app_key`'s stored icon; HTTP 400 `secret_value_required` when there is none to keep. ### Body Parameters - `apps: unknown` The brand's complete registry. `{}` registers no apps (and masks any deploy default). ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/app_extensions \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "apps": { "payments": { "bid": "com.apple.messages.MSMessageExtensionBalloonPlugin:ABCDE12345:com.example.app.MessagesExtension", "app_id": "6794029060", "app_name": "CurbFare Payments", "app_icon_b64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=" } } }' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "1 registered", "managed_by": "linq", "set": true, "source": "brand" }, { "name": "oauth", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" } ], "app_extensions": { "apps": { "ride": { "bid": "com.apple.messages.MSMessageExtensionBalloonPlugin:9BC1AB2D3E:com.curbfare.ride.imsg", "app_id": "6440000001", "app_name": "CurbFare Ride", "app_icon_set": true } } } } ``` --- # Set the Apple Pay integration URL: https://docs.linqapp.com/channel/amb/api/resources/settings/subresources/integrations/methods/set_apple_pay/ **put** `/v1/settings/integrations/apple_pay` Sets your brand's Apple Pay integration: the full group in one write. **Behavior** - A present group always replaces the stored one wholly. - The certificate is write-only: stored by reference, never returned. Omit `merchant_identity_cert_pem` to keep the stored certificate; HTTP 400 `code` 1061 when there is none to keep. ### Body Parameters - `display_name: string` The merchant display name shown while acquiring the payment session. - `domain: string` The verified Apple Pay merchant domain, host only, no scheme. - `merchant_id: string` The TEXT-form Apple Pay Merchant ID (e.g. `merchant.com.example`), character-for-character as registered. - `merchant_identity_cert_pem: optional string` WRITE-ONLY. The Merchant Identity credential as PEM text: at least one `CERTIFICATE` block and exactly one private-key block, concatenated. Stored by reference in the secret store: no read ever returns it. ABSENT keeps the certificate the stored group already holds; on a first write it is required (`400 secret_value_required`). ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/apple_pay \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "display_name": "CurbFare", "domain": "pay.example.com", "merchant_id": "merchant.com.example" }' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "merchant.com.example", "managed_by": "linq", "set": true, "source": "brand" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" } ], "app_extensions": { "apps": {} } } ``` --- # Set the OAuth integration URL: https://docs.linqapp.com/channel/amb/api/resources/settings/subresources/integrations/methods/set_oauth/ **put** `/v1/settings/integrations/oauth` Sets your brand's customer-authentication (OAuth) integration: the full group in one write. **Behavior** - A present group always replaces the stored one wholly. - The client secret is write-only: stored by reference, never returned. Omit `client_secret` to keep the stored one; HTTP 400 `secret_value_required` when there is none to keep. - A write that changes `token_url` or `issuer_url` must carry `client_secret`: the stored secret is only ever sent to the token endpoint it was supplied for. HTTP 400 `code` 1061 at `/client_secret` when the field is omitted; the message names the endpoint that moved. `authorize_url`, `redirect_uri`, `client_id` and `scope` may change with the secret omitted. - An operator-attributed write (`X-Cerebro-Operator` honoured) may not change `issuer_url`, `authorize_url`, `token_url` or `redirect_uri` once a group is stored: HTTP 403 `code` 2057 with `param` naming the first changed field, in that order. Make the write with the brand key alone, carrying the secret. An operator's first write, and an operator write that changes only `client_id` or `scope`, is allowed. ### Body Parameters - `authorize_url: string` The provider's authorization endpoint. - `client_id: string` The OAuth client id registered for this brand. - `issuer_url: string` The provider's issuer URL. - `redirect_uri: string` The redirect URI, character-for-character as registered with the provider. - `scope: string` The scope string sent on the authorize request. - `token_url: string` The provider's token endpoint. - `client_secret: optional string` WRITE-ONLY. The OAuth client secret. Stored by reference in the secret store: no read ever returns it. ABSENT keeps the secret the stored group already holds; on a first write it is required (`400 secret_value_required`), and so is it on any write that changes `token_url` or `issuer_url`: the stored secret is never sent to a token endpoint chosen after it was supplied. ### Returns - `BrandSettings object { agent_enabled, app_extensions, brand_id, 16 more }` The brand's channel settings: what a brand edits itself, plus the managed groups and the link state, read-only. - `agent_enabled: boolean` Whether your AI agent is switched on. `true` for a brand that has never changed it. While this is `false` we stop delivering webhooks to your brand's endpoints, so your agent is told nothing and answers nothing. Events that occur during the pause are **never** delivered; not held and replayed when you switch back on, which is what makes this safe to use during an incident: your agent will not wake up and answer an hour of stale messages. The mute covers your WHOLE webhook feed, not only the events an agent answers: `message.sent` / `message.failed`, `consent.expired` and the invitation-batch events travel the same fan-out, so delivery outcomes and invitation status go dark too and do not backfill. The read side is unaffected and is the during-mute channel; `GET /v1/messages/{msg}`, `GET /v1/invitations/{inv}` and `GET /v1/chats/{chat}/events` all still answer, so nothing is lost from the record, only from the push. It does **not** stop you sending. Your API key keeps working while the switch is off, which is what lets a human desk carry a conversation your agent is muted for. - `app_extensions: object { apps }` The registered apps that are OPERATIVE for your brand, keyed by the same `app_key` space `PUT /v1/settings/integrations/app_extensions` writes: your stored registry when you have one, the deploy default otherwise, and `managed[name=app_extensions].source` says which. **Read this before assembling a write.** The registry's precedence unit is the WHOLE set: a write replaces your stored registry outright, and a stored registry masks the deploy default entirely. So an app you leave out of a write stops resolving on the send path: a `200` and a silently unregistered app. This object is what makes a correct whole-set write possible: it is the complete operative registry, so send it back with your edits applied rather than assembling one from your own records. Unlike `apple_pay` and `oauth` above, this key is ALWAYS PRESENT: an empty `apps` is how "nothing is registered" is said, and it is safe to edit from. A registry is never "unconfigured" the way a merchant can be: one is always operative, so there is always an answer. The only way this key is absent is a platform build older than the field, and a client that cares about the difference should read an absent key as "cannot speak to the registry" and refuse to write the group; never as an empty one. App icons are never returned, on the same terms as the certificate and the client secret above: `app_icon_set` per entry is the whole of it. - `apps: unknown` The complete operative registry, `app_key` → app. Empty when nothing is registered. Every key here is a key `PUT /v1/settings/integrations/app_extensions` accepts, and that write replaces this whole map, so edit this object and send it back rather than composing one. - `brand_id: string` The brand this key is scoped to: the same id `GET /v1/me` reports. - `business_id: string` The linked business id, `""` when no channel is linked. The full row is `GET /v1/channels`. - `consent_mode: "linq_managed" or "brand_attested"` Who captures consent GRANTS for this brand. `linq_managed` (the default): we capture grants, and a grant attested through `POST /v1/consent` answers HTTP 409 `code` 2014. `brand_attested`: you attest grants yourself through that endpoint. Read-only here: the mode is operator-set; contact support to change it. Opt-outs are honored in both modes; the mode governs only who may record a GRANT. - `"linq_managed"` - `"brand_attested"` - `display: string` The brand's display name. - `escalation_handling: "managed" or "self_managed"` Who runs escalation for this brand: your stored choice, exactly as you set it, and `self_managed` until you set one. See `escalation_handling` on the update request for what each value means. While `escalation_handling_locked` is `true` this stored value is not operative: we have forced the brand to `managed` escalation. - `"managed"` - `"self_managed"` - `escalation_handling_locked: boolean` `true` while we hold this brand on `managed` escalation regardless of its stored choice. It is the state we use when monitoring shows a partner-run human path not answering. Updates to `escalation_handling` are refused while it is `true`; contact support to release it. - `link_origin: string` The link's origin (`seed` | `connect` | `control`), `""` when no channel is linked. - `managed: array of ManagedSetting` The read-only groups, as display strings with a managed-by marker. - `managed_by: "linq"` Who changes it. Always `linq` today: these are registered with the platform as well as with us, so they change together; write to your support contact. - `"linq"` - `name: "apple_pay_merchant" or "app_extensions" or "oauth"` Which setting: the Apple Pay merchant, the registered iMessage apps, or customer authentication (OAuth). - `"apple_pay_merchant"` - `"app_extensions"` - `"oauth"` - `set: boolean` Whether the group is operative for a send, evaluated on the RESOLVED view: your brand's stored configuration when present, the deploy default otherwise. `false` means a send needing this group is refused (`409`) until it is configured. - `source: "brand" or "platform"` Which copy is operative: `brand` when your brand's stored configuration answers, `platform` when the deploy default does. - `"brand"` - `"platform"` - `value: string` A display string; `merchant.com.example`, `2 registered`, `configured`, `not configured`. Never key material, secrets or certificate references; this field is safe to render verbatim. - `policy_version: number` Monotonic policy version. Bumping it (see the update's `bump_policy_version`) is what re-notifies customers of a policy change, so it only ever moves explicitly. - `privacy_policy_url: string` Absolute `https` URL of the privacy policy, `""` when unset. - `richlink_default_image_att_id: string` The brand's rich-link default card image (`att_…`), `""` when the platform default stands. - `support_contact: string` The brand's support contact, `""` when unset. - `terms_of_use_url: string` Absolute `https` URL of the terms of use, `""` when unset. - `apple_pay: optional object { display_name, domain, merchant_id, merchant_identity_cert_set }` The brand's Apple Pay configuration, non-secret fields only. ABSENT when the group is not configured; never an object of empty strings, the same absent-means-unconfigured promise `business_hours` makes. The merchant-identity certificate is NEVER returned, here or anywhere: it goes up write-only and lives in the secret store. `merchant_identity_cert_set` is the whole of what this contract says about it, so a client can offer "leave blank to keep the stored certificate" without learning anything. - `display_name: string` The business name Apple renders on the payment sheet. - `domain: string` The verified payment domain the merchant session is requested for. - `merchant_id: string` The Apple Pay merchant identifier (`merchant.example.brand`). - `merchant_identity_cert_set: boolean` Whether a merchant-identity certificate is on file. The certificate itself is never returned; omit `merchant_identity_cert_pem` on a write to keep the stored one. - `business_hours: optional unknown` Business hours as stored: a LIST of `["HH:MM","HH:MM"]` ranges per lowercase weekday key (`mon`…`sun`), so a lunch break or a split shift is expressible. ABSENT: the key is not present at all; when the brand publishes no hours, never `{}` and never an invented 9-5. That is the same promise `GET /v1/chats/{chat}`'s `brand` makes about the same document: an unconfigured brand has no hours model and the platform treats it as always open, which is a different fact from "open 24/7 by policy" and is not spelled the same way. - `hours_timezone: optional string` The IANA zone the hours are stated in (`America/New_York`). Absent when unset. Hours are never configured without it; the two keys are independently optional here only because a direct operator edit can leave a zone behind with no hours. - `oauth: optional object { authorize_url, client_id, client_secret_set, 4 more }` The brand's OAuth configuration, non-secret fields only. ABSENT when the group is not configured. The client secret is never returned, on the same terms as the certificate above. - `authorize_url: string` Where the customer is sent to authorize. - `client_id: string` The OAuth client id. - `client_secret_set: boolean` Whether a client secret is on file. The secret itself is never returned; omit `client_secret` on a write to keep the stored one. - `issuer_url: string` The identity provider's issuer URL. - `redirect_uri: string` The redirect the provider returns to. - `scope: string` The space-separated scopes requested. - `token_url: string` Where the authorization code is exchanged. ### Example ```http curl https://messages.api.linqapp.com/v1/settings/integrations/oauth \ -X PUT \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "authorize_url": "https://id.example.com/authorize", "client_id": "curbfare-messages", "issuer_url": "https://id.example.com", "redirect_uri": "https://auth.example.com/auth/callback", "scope": "openid", "token_url": "https://id.example.com/token" }' ``` #### Response ```json { "brand_id": "biz_9f2c1a", "display": "CurbFare", "privacy_policy_url": "https://example.com/privacy", "terms_of_use_url": "https://example.com/terms", "policy_version": 3, "support_contact": "support@example.com", "richlink_default_image_att_id": "", "agent_enabled": true, "escalation_handling": "self_managed", "escalation_handling_locked": false, "consent_mode": "linq_managed", "business_id": "a884eddf-b0ad-4be4-9c0e-071531638768", "link_origin": "connect", "managed": [ { "name": "apple_pay_merchant", "value": "not configured", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "app_extensions", "value": "none registered", "managed_by": "linq", "set": false, "source": "platform" }, { "name": "oauth", "value": "configured", "managed_by": "linq", "set": true, "source": "brand" } ], "app_extensions": { "apps": {} } } ``` --- # Webhook Endpoints URL: https://docs.linqapp.com/channel/amb/api/resources/webhook_endpoints/ ## List webhook endpoints **get** `/v1/webhook_endpoints` List this brand's endpoints, newest first. Scoped to your key's brand by construction: no parameter names a brand. ### Query Parameters - `cursor: optional string` `next_cursor` from the previous page. - `limit: optional number` Page size, 1–100. Defaults to 25. ### Returns - `data: array of WebhookEndpoint` - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. - `next_cursor: string` Empty when the list is exhausted. Always present. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "id": "wh_7f3a1c9e", "url": "https://hooks.example.com/messages", "description": "production receiver", "enabled_events": [ "message.received", "message.sent", "message.failed" ], "status": "enabled", "created_at": "2026-08-13T02:41:09Z" } ], "next_cursor": "" } ``` ## Create a webhook endpoint **post** `/v1/webhook_endpoints` Registers a URL and returns its signing secret. **The secret is returned once, here.** Nothing reads it back afterwards. A brand may hold up to 16 endpoints; a seventeenth is refused `409` `endpoint_limit_reached`. ### Body Parameters - `enabled_events: array of string` The events this endpoint should receive. Must be non-empty, and every name must be one this API emits: a name we do not emit is refused rather than accepted and then matching nothing. - `url: string` Absolute `https` URL. A private or loopback address is refused here rather than failing silently later. - `description: optional string` Your own label, ≤200 bytes. ### Returns - `WebhookEndpointWithSecret object { id, created_at, description, 4 more }` A newly created or newly rotated endpoint. **The `secret` is on this response and on no other**; store it now; it is never readable again, and a lost secret is replaced by rotating rather than by looking it up. - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `secret: string` `whsec_…`. Sign-verify every delivery with it. Shown once. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "enabled_events": [ "message.received", "message.sent", "message.failed" ], "url": "https://hooks.example.com/messages" }' ``` #### Response ```json { "id": "wh_7f3a1c9e", "url": "https://hooks.example.com/messages", "description": "production receiver", "enabled_events": [ "message.received", "message.sent", "message.failed" ], "status": "enabled", "created_at": "2026-08-13T02:41:09Z", "secret": "whsec_9Qm2ZK8vTn1xR4pL7bYcW0sHdJfA6eUg3iOtN5rXvBk=" } ``` ## Get a webhook endpoint **get** `/v1/webhook_endpoints/{wh}` Read one endpoint. Never returns the secret. ### Path Parameters - `wh: string` ### Returns - `WebhookEndpoint object { id, created_at, description, 3 more }` A URL your brand receives signed events on. - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints/$WH \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "wh_7f3a1c9e", "url": "https://hooks.example.com/messages", "description": "production receiver", "enabled_events": [ "message.received", "message.sent", "message.failed" ], "status": "enabled", "created_at": "2026-08-13T02:41:09Z" } ``` ## Update a webhook endpoint **patch** `/v1/webhook_endpoints/{wh}` Change the URL, the subscribed events, the label, or whether it is delivering. Omitted fields are left alone. ### Path Parameters - `wh: string` ### Body Parameters - `description: optional string` - `disabled: optional boolean` `true` stops deliveries without deleting the endpoint; `false` resumes them. - `enabled_events: optional array of string` - `url: optional string` ### Returns - `WebhookEndpoint object { id, created_at, description, 3 more }` A URL your brand receives signed events on. - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints/$WH \ -X PATCH \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{}' ``` #### Response ```json { "id": "wh_7f3a1c9e", "url": "https://hooks.example.com/messages", "description": "production receiver", "enabled_events": [ "message.received", "message.failed" ], "status": "enabled", "created_at": "2026-08-13T02:41:09Z" } ``` ## Delete a webhook endpoint **delete** `/v1/webhook_endpoints/{wh}` Stops deliveries permanently and removes the endpoint from your list. The record itself is retained so the deliveries it already received stay attributable; it stops receiving, stops being listed, and stops counting against your endpoint limit. ### Path Parameters - `wh: string` ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints/$WH \ -X DELETE \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` ## Rotate a webhook endpoint's secret **post** `/v1/webhook_endpoints/{wh}/rotate_secret` Issues a new signing secret and returns it; once. The old secret keeps verifying for an overlap window (default 24h, max 168h) so deliveries in flight and receivers mid-deploy do not fail. During the window `webhook-signature` carries BOTH values, space-separated, and a receiver that accepts either is correct throughout. **One rotation at a time.** While a previous overlap window is still open, this answers `409 rotation_in_flight`. A second window would push the OLDEST secret out while your receivers may still be verifying with it: the stranding the overlap exists to prevent, so the refusal is the guarantee rather than a limitation. ### Path Parameters - `wh: string` ### Query Parameters - `overlap_hours: optional number` How long the previous secret keeps verifying, in hours. 1–168, default 24. ### Returns - `WebhookEndpointWithSecret object { id, created_at, description, 4 more }` A newly created or newly rotated endpoint. **The `secret` is on this response and on no other**; store it now; it is never readable again, and a lost secret is replaced by rotating rather than by looking it up. - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `secret: string` `whsec_…`. Sign-verify every delivery with it. Shown once. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints/$WH/rotate_secret \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "wh_7f3a1c9e", "url": "https://hooks.example.com/messages", "description": "production receiver", "enabled_events": [ "message.received", "message.sent", "message.failed" ], "status": "enabled", "created_at": "2026-08-13T02:41:09Z", "secret": "whsec_2Ld8YpVx0QaZ6nMbT3wKcR9jEsHf1uGi7oPvN4rXyBk=" } ``` ## Replay past events to a webhook endpoint **post** `/v1/webhook_endpoints/{wh}/replay` Deliver past events to this endpoint. An endpoint receives events that happen while it exists. Register one on day 5 and days 1 to 4 are not waiting for it. This is how you fetch them: name the events, or a time range, and every matching event with no delivery to this endpoint gets one. **A replayed delivery arrives behind live traffic, not in front of it.** Within a chat, deliveries arrive in order and each waits for the one before it. A replay of an old event would otherwise sort ahead of everything current and hold up the traffic you are serving right now, so replays queue after live work instead. The in-order guarantee still holds for live traffic; a replay is the one thing that arrives out of its original place, by design. **Sending the same request twice creates nothing the second time.** Each call gets its own `request_id`, so two calls are two requests, but a delivery that already exists is not made again. The response counts `created` and `skipped` separately so a retry after a timeout tells you what actually happened. **A range larger than `max` is refused, not trimmed.** A trimmed replay looks like a finished one, and you would believe you had caught up. Narrow the range or raise `max`, then send it again. **It follows this endpoint's `enabled_events`.** An event type this endpoint does not subscribe to is not in scope for its replay, the same way it would not have been delivered live. Narrow or widen the subscription to change what a replay can reach. An endpoint that is disabled, or whose brand's agent is paused, answers `409 webhook_endpoint_not_receiving` rather than reporting nothing to do. Every delivery this creates carries `replay_of` set to the `request_id`, so you can tell a replay from live traffic on arrival. ### Path Parameters - `wh: string` ### Body Parameters - `event_ids: optional array of string` Replay exactly these events, at most 1000 per call. Send this or a range, not both. They ask two different questions, and answering the wrong one is worse than refusing. - `max: optional number` Most events one call may replay from a range. 1-1000, default 500. Send it with a range, not with `event_ids`: a list you named is already its own bound, so a `max` beside it would have nothing to do. - `since: optional string` Replay events at or after this instant. - `type: optional string` Narrow a range to one event type, e.g. `message.received`. It narrows; it does not select. Sent on its own it is refused, because a bare type would replay your whole history of that type. - `until: optional string` Replay events at or before this instant. ### Returns - `created: number` Deliveries created. These are queued behind whatever live traffic the chat has. - `not_found: number` Ids you named with no event of yours behind them: a typo, or an event old enough to have been swept. Always 0 for a range. - `not_subscribed: number` Events you named that this endpoint does not subscribe to, so they were never in scope for it. Always 0 for a range, where the subscription narrows the window rather than rejecting something you pointed at. - `request_id: string` This request's id, `rpl_…`. Every delivery it created carries it as `replay_of`. - `skipped: number` Events skipped because this endpoint already has a delivery for them. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints/$WH/replay \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{}' ``` #### Response ```json { "request_id": "rpl_9c2f1a7b", "created": 128, "skipped": 4, "not_subscribed": 0, "not_found": 0 } ``` ## Domain Types ### Webhook Endpoint - `WebhookEndpoint object { id, created_at, description, 3 more }` A URL your brand receives signed events on. - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. ### Webhook Endpoint With Secret - `WebhookEndpointWithSecret object { id, created_at, description, 4 more }` A newly created or newly rotated endpoint. **The `secret` is on this response and on no other**; store it now; it is never readable again, and a lost secret is replaced by rotating rather than by looking it up. - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `secret: string` `whsec_…`. Sign-verify every delivery with it. Shown once. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. ### Webhook Endpoint List Response - `WebhookEndpointListResponse object { data, next_cursor }` - `data: array of WebhookEndpoint` - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. - `next_cursor: string` Empty when the list is exhausted. Always present. ### Webhook Endpoint Replay Response - `WebhookEndpointReplayResponse object { created, not_found, not_subscribed, 2 more }` What a replay did. When you name `event_ids`, the four counts add up to the number of DISTINCT ids you sent, so nothing you pointed at goes unaccounted for. Naming the same id twice names one event. - `created: number` Deliveries created. These are queued behind whatever live traffic the chat has. - `not_found: number` Ids you named with no event of yours behind them: a typo, or an event old enough to have been swept. Always 0 for a range. - `not_subscribed: number` Events you named that this endpoint does not subscribe to, so they were never in scope for it. Always 0 for a range, where the subscription narrows the window rather than rejecting something you pointed at. - `request_id: string` This request's id, `rpl_…`. Every delivery it created carries it as `replay_of`. - `skipped: number` Events skipped because this endpoint already has a delivery for them. --- # Create a webhook endpoint URL: https://docs.linqapp.com/channel/amb/api/resources/webhook_endpoints/methods/create/ **post** `/v1/webhook_endpoints` Registers a URL and returns its signing secret. **The secret is returned once, here.** Nothing reads it back afterwards. A brand may hold up to 16 endpoints; a seventeenth is refused `409` `endpoint_limit_reached`. ### Body Parameters - `enabled_events: array of string` The events this endpoint should receive. Must be non-empty, and every name must be one this API emits: a name we do not emit is refused rather than accepted and then matching nothing. - `url: string` Absolute `https` URL. A private or loopback address is refused here rather than failing silently later. - `description: optional string` Your own label, ≤200 bytes. ### Returns - `WebhookEndpointWithSecret object { id, created_at, description, 4 more }` A newly created or newly rotated endpoint. **The `secret` is on this response and on no other**; store it now; it is never readable again, and a lost secret is replaced by rotating rather than by looking it up. - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `secret: string` `whsec_…`. Sign-verify every delivery with it. Shown once. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{ "enabled_events": [ "message.received", "message.sent", "message.failed" ], "url": "https://hooks.example.com/messages" }' ``` #### Response ```json { "id": "wh_7f3a1c9e", "url": "https://hooks.example.com/messages", "description": "production receiver", "enabled_events": [ "message.received", "message.sent", "message.failed" ], "status": "enabled", "created_at": "2026-08-13T02:41:09Z", "secret": "whsec_9Qm2ZK8vTn1xR4pL7bYcW0sHdJfA6eUg3iOtN5rXvBk=" } ``` --- # Delete a webhook endpoint URL: https://docs.linqapp.com/channel/amb/api/resources/webhook_endpoints/methods/delete/ **delete** `/v1/webhook_endpoints/{wh}` Stops deliveries permanently and removes the endpoint from your list. The record itself is retained so the deliveries it already received stay attributable; it stops receiving, stops being listed, and stops counting against your endpoint limit. ### Path Parameters - `wh: string` ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints/$WH \ -X DELETE \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` --- # List webhook endpoints URL: https://docs.linqapp.com/channel/amb/api/resources/webhook_endpoints/methods/list/ **get** `/v1/webhook_endpoints` List this brand's endpoints, newest first. Scoped to your key's brand by construction: no parameter names a brand. ### Query Parameters - `cursor: optional string` `next_cursor` from the previous page. - `limit: optional number` Page size, 1–100. Defaults to 25. ### Returns - `data: array of WebhookEndpoint` - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. - `next_cursor: string` Empty when the list is exhausted. Always present. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "data": [ { "id": "wh_7f3a1c9e", "url": "https://hooks.example.com/messages", "description": "production receiver", "enabled_events": [ "message.received", "message.sent", "message.failed" ], "status": "enabled", "created_at": "2026-08-13T02:41:09Z" } ], "next_cursor": "" } ``` --- # Replay past events to a webhook endpoint URL: https://docs.linqapp.com/channel/amb/api/resources/webhook_endpoints/methods/replay/ **post** `/v1/webhook_endpoints/{wh}/replay` Deliver past events to this endpoint. An endpoint receives events that happen while it exists. Register one on day 5 and days 1 to 4 are not waiting for it. This is how you fetch them: name the events, or a time range, and every matching event with no delivery to this endpoint gets one. **A replayed delivery arrives behind live traffic, not in front of it.** Within a chat, deliveries arrive in order and each waits for the one before it. A replay of an old event would otherwise sort ahead of everything current and hold up the traffic you are serving right now, so replays queue after live work instead. The in-order guarantee still holds for live traffic; a replay is the one thing that arrives out of its original place, by design. **Sending the same request twice creates nothing the second time.** Each call gets its own `request_id`, so two calls are two requests, but a delivery that already exists is not made again. The response counts `created` and `skipped` separately so a retry after a timeout tells you what actually happened. **A range larger than `max` is refused, not trimmed.** A trimmed replay looks like a finished one, and you would believe you had caught up. Narrow the range or raise `max`, then send it again. **It follows this endpoint's `enabled_events`.** An event type this endpoint does not subscribe to is not in scope for its replay, the same way it would not have been delivered live. Narrow or widen the subscription to change what a replay can reach. An endpoint that is disabled, or whose brand's agent is paused, answers `409 webhook_endpoint_not_receiving` rather than reporting nothing to do. Every delivery this creates carries `replay_of` set to the `request_id`, so you can tell a replay from live traffic on arrival. ### Path Parameters - `wh: string` ### Body Parameters - `event_ids: optional array of string` Replay exactly these events, at most 1000 per call. Send this or a range, not both. They ask two different questions, and answering the wrong one is worse than refusing. - `max: optional number` Most events one call may replay from a range. 1-1000, default 500. Send it with a range, not with `event_ids`: a list you named is already its own bound, so a `max` beside it would have nothing to do. - `since: optional string` Replay events at or after this instant. - `type: optional string` Narrow a range to one event type, e.g. `message.received`. It narrows; it does not select. Sent on its own it is refused, because a bare type would replay your whole history of that type. - `until: optional string` Replay events at or before this instant. ### Returns - `created: number` Deliveries created. These are queued behind whatever live traffic the chat has. - `not_found: number` Ids you named with no event of yours behind them: a typo, or an event old enough to have been swept. Always 0 for a range. - `not_subscribed: number` Events you named that this endpoint does not subscribe to, so they were never in scope for it. Always 0 for a range, where the subscription narrows the window rather than rejecting something you pointed at. - `request_id: string` This request's id, `rpl_…`. Every delivery it created carries it as `replay_of`. - `skipped: number` Events skipped because this endpoint already has a delivery for them. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints/$WH/replay \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{}' ``` #### Response ```json { "request_id": "rpl_9c2f1a7b", "created": 128, "skipped": 4, "not_subscribed": 0, "not_found": 0 } ``` --- # Get a webhook endpoint URL: https://docs.linqapp.com/channel/amb/api/resources/webhook_endpoints/methods/retrieve/ **get** `/v1/webhook_endpoints/{wh}` Read one endpoint. Never returns the secret. ### Path Parameters - `wh: string` ### Returns - `WebhookEndpoint object { id, created_at, description, 3 more }` A URL your brand receives signed events on. - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints/$WH \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "wh_7f3a1c9e", "url": "https://hooks.example.com/messages", "description": "production receiver", "enabled_events": [ "message.received", "message.sent", "message.failed" ], "status": "enabled", "created_at": "2026-08-13T02:41:09Z" } ``` --- # Rotate a webhook endpoint's secret URL: https://docs.linqapp.com/channel/amb/api/resources/webhook_endpoints/methods/rotate_secret/ **post** `/v1/webhook_endpoints/{wh}/rotate_secret` Issues a new signing secret and returns it; once. The old secret keeps verifying for an overlap window (default 24h, max 168h) so deliveries in flight and receivers mid-deploy do not fail. During the window `webhook-signature` carries BOTH values, space-separated, and a receiver that accepts either is correct throughout. **One rotation at a time.** While a previous overlap window is still open, this answers `409 rotation_in_flight`. A second window would push the OLDEST secret out while your receivers may still be verifying with it: the stranding the overlap exists to prevent, so the refusal is the guarantee rather than a limitation. ### Path Parameters - `wh: string` ### Query Parameters - `overlap_hours: optional number` How long the previous secret keeps verifying, in hours. 1–168, default 24. ### Returns - `WebhookEndpointWithSecret object { id, created_at, description, 4 more }` A newly created or newly rotated endpoint. **The `secret` is on this response and on no other**; store it now; it is never readable again, and a lost secret is replaced by rotating rather than by looking it up. - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `secret: string` `whsec_…`. Sign-verify every delivery with it. Shown once. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints/$WH/rotate_secret \ -X POST \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" ``` #### Response ```json { "id": "wh_7f3a1c9e", "url": "https://hooks.example.com/messages", "description": "production receiver", "enabled_events": [ "message.received", "message.sent", "message.failed" ], "status": "enabled", "created_at": "2026-08-13T02:41:09Z", "secret": "whsec_2Ld8YpVx0QaZ6nMbT3wKcR9jEsHf1uGi7oPvN4rXyBk=" } ``` --- # Update a webhook endpoint URL: https://docs.linqapp.com/channel/amb/api/resources/webhook_endpoints/methods/update/ **patch** `/v1/webhook_endpoints/{wh}` Change the URL, the subscribed events, the label, or whether it is delivering. Omitted fields are left alone. ### Path Parameters - `wh: string` ### Body Parameters - `description: optional string` - `disabled: optional boolean` `true` stops deliveries without deleting the endpoint; `false` resumes them. - `enabled_events: optional array of string` - `url: optional string` ### Returns - `WebhookEndpoint object { id, created_at, description, 3 more }` A URL your brand receives signed events on. - `id: string` `wh_…`. - `created_at: string` - `description: string` Your own label. Free text, ≤200 bytes, never interpreted. - `enabled_events: array of string` The event types this endpoint receives. Exact names: no wildcards. - `status: "enabled" or "disabled"` `enabled` while it receives deliveries, `disabled` once you turn it off. A disabled endpoint keeps its secret and its history and resumes on `PATCH {"disabled": false}`. - `"enabled"` - `"disabled"` - `url: string` Absolute `https` URL every delivery for this endpoint is POSTed to. ### Example ```http curl https://messages.api.linqapp.com/v1/webhook_endpoints/$WH \ -X PATCH \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $LINQ_AMB_API_KEY" \ -d '{}' ``` #### Response ```json { "id": "wh_7f3a1c9e", "url": "https://hooks.example.com/messages", "description": "production receiver", "enabled_events": [ "message.received", "message.failed" ], "status": "enabled", "created_at": "2026-08-13T02:41:09Z" } ``` --- # Webhooks URL: https://docs.linqapp.com/channel/amb/api/resources/webhooks/ ## Domain Types ### message.received - `MessageReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` Delivery id (`whd_…`), equal to the `webhook-id` header. - `created_at: string` - `data: object { brand_id, chat_id, message, 3 more }` - `brand_id: string` The logical brand this chat is scoped under, the value to answer as. Distinct from the Apple business UUID, which is served beside it as `business_id`; `brand_unknown:` for an unregistered business. - `chat_id: string` - `message: object { reply, text, attachments, 4 more }` - `reply: object { kind, answers, items, 3 more }` The normalized interactive response when the inbound was `type:interactive`; null otherwise (including a customer answering a DEGRADED prompt in plain text). - `kind: string` Normalized reply kind. One of `app_extension`, `apple_pay`, `choices`, `form`, `invitation_response`, `list_picker`, `time_slots`. Each kind carries its own shape. The six that answer a part you sent are shaped by that part's published schema, served at `GET /v1/parts/{type}`; `invitation_response` answers an invitation rather than a part, and reports the customer's verdict in `item_id`. This set describes replies classified on or after 2026-08-27. `app_extension` was called `imessage_app` before that, and a recorded event is never rewritten, so events recorded earlier keep the old spelling wherever you read them back: the event log, a chat's own events, the transcript, and a redelivered webhook. Accept `imessage_app` as a historical synonym when you read history. - `answers: optional array of object { field_id, item_ids, kind, 4 more }` One record per answered page, on `form` replies; a page whose submission carried no items emits no record. `question` is the page's title, the prompt the customer answered; `kind` says what shape of answer it is (`input`, `single_select`, `multi_select`, `picker`, `date`). A page answered with one item reports `field_id` and `value`; one answered with several reports `item_ids` and `values`, aligned entry for entry. Records carry the wire's raw fields, while the reply's own `values` map (unchanged) applies a display fallback per item: its value, else its identifier, else its title. So rebuild a page's `values` entry from a record by that fallback, never by joining the record's `values` alone. The one leg a record cannot rebuild is the title: records carry no titles, so when an item brought neither a value nor an identifier, read that page's answer from `values`, where the title already landed. Every field is present only when the submission carried its source. - `field_id: optional string` - `item_ids: optional array of string` - `kind: optional string` One of `input`, `single_select`, `multi_select`, `picker`, `date`. Present only when the submission said which. - `page_id: optional string` - `question: optional string` - `value: optional string` - `values: optional array of string` - `items: optional array of object { id, subtitle, title }` The chosen options with their display text, on `list_picker` replies: one entry per selected item, in the order the reply listed them. Display only: `item_ids` is unchanged and stays the set to match on. Present only when at least one chosen item carried display text; each entry's `title` and `subtitle` are themselves present only when set. - `id: optional string` - `subtitle: optional string` - `title: optional string` - `request_id: optional string` The identifier of the interactive message this reply answers: the same value the sending message's transcript row reports as its own `request_id`, so a tap is matched to the exact send that asked rather than inferred from item ids (the same card sent twice shares item ids, while each send delivers under a fresh identifier). Present when the send recorded one. - `timezone_offset_minutes: optional number` On `form` replies only: the customer's device timezone offset at submission, verbatim from the wire. Minutes, positive west of UTC (the JavaScript getTimezoneOffset convention, so a US Central device reads +300). Present exactly when the device reported one; never fabricated. Other reply kinds never carry it: the similarly named field on a time-slot reply is an echo of the event your own send defined, not customer data. - `title: optional string` The chosen option's display text, as the customer saw it, on `choices` replies. Display only: match on `item_id`, never on this text. Present only when the device sent it, so replies recorded before it was captured have no such key. - `text: string` Plain-text body; null when the inbound carried none. - `attachments: optional array of object { decrypted, id, decrypted_size, 6 more }` Present when the inbound carried files: one entry per attachment, with the outcome of our decrypt-and-retain pass. Absent when the message carried none, so an ordinary text inbound's shape is unchanged. - `decrypted: boolean` Whether we could decrypt the file. `false` means no bytes exist to serve and `error` says what happened. - `id: optional string` The retained file's `att_id`; `GET /v1/attachments/{att}/content` serves the bytes and this part sends them onward. Absent when nothing was retained, and `retention` then says why. - `decrypted_size: optional number` Plaintext size in bytes; present when decrypted. - `error: optional string` The decrypt failure, when `decrypted` is `false`. - `mime_type: optional string` - `name: optional string` Filename as the customer sent it. - `retention: optional "too_large" or "brand_unresolved" or "store_unavailable" or "disabled"` Why no `id` was minted for a file that DID decrypt; present exactly when that happened. `too_large` is yours to act on (the file exceeds what we retain); the other values are platform conditions. - `"too_large"` - `"brand_unresolved"` - `"store_unavailable"` - `"disabled"` - `sha256: optional string` Hex SHA-256 of the plaintext; present when decrypted. - `size: optional number` Transfer size in bytes, as delivered. - `group: optional string` The entry point's groupID, same delivery and same present-only-when-set rule as `intent`. - `intent: optional string` The entry point's intentID, exactly as Apple delivered it. The button, URL or QR code the customer started from sets this, not the message body. It is present only when the entry point set one, so an ordinary chat's shape is unchanged. Route your own skills on it — our brand routing matches the same representation. - `raw: optional unknown` The full inbound Apple envelope, verbatim, when present. A legacy escape hatch, not a surface to automate on: everything a workflow needs is in the normalized fields (`text`, `reply`, `attachments`, `business_id`), and this key is ABSENT on ordinary deliveries unless the platform is configured to send it. Two promises hold whatever that configuration says: a delivery carrying `reply_unresolved` always includes `raw` (the payload we could not normalize), and the verbatim envelope is always readable from `GET /v1/chats/{chat}/events`, which is the forensic record. - `reply_unresolved: optional object { reason, step }` Present only when Apple substituted an interactiveDataRef for a >10 KB reply and resolution failed permanently — `reply` is then null. - `reason: string` - `step: string` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The inbound's seq — the events cursor, and part of the dedupe key. - `business_id: optional string` The Apple business UUID this message arrived on. Present when the inbound envelope named one, which is every ordinary delivery; when Apple sends none the key is absent, never an empty string. Use this rather than reading the envelope inside `raw`. - `type: "message.received"` - `"message.received"` ### message.sent - `MessageSentWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, message_id, seq, 2 more }` - `chat_id: string` - `message_id: string` - `seq: number` - `group_id: optional string` The FIRST message's id of a fan-out (a promoted URL) — absent on an ordinary single-message send. - `invitation: optional object { id, reference_id, template_id }` Present only for an invitation delivery (POST /v1/invitations); `reference_id` is the partner's correlation handle. - `id: string` - `reference_id: string` - `template_id: string` - `type: "message.sent"` - `"message.sent"` ### message.failed - `MessageFailedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, message_id, reason, 4 more }` - `chat_id: string` - `message_id: string` - `reason: "permanent" or "undelivered" or "auth_error" or 5 more` `platform_error` and `invitation_undeliverable` occur only for invitation-class sends, so those two are worth resubmitting only there. - `"permanent"` - `"undelivered"` - `"auth_error"` - `"suppressed"` - `"consent_revoked"` - `"automation_paused"` - `"platform_error"` - `"invitation_undeliverable"` - `seq: number` - `group_id: optional string` Same meaning as on `message.sent` — ties a fan-out member's failure back to the one request. - `last_error: optional string` Gateway error of the last attempt; rides only on a gateway-verdict failure. - `last_status: optional number` Gateway HTTP status of the last attempt; rides only on a gateway-verdict failure. - `type: "message.failed"` - `"message.failed"` ### chat.closed - `ChatClosedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq }` - `chat_id: string` - `seq: number` - `type: "chat.closed"` - `"chat.closed"` ### chat.reopened - `ChatReopenedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq }` - `chat_id: string` - `seq: number` - `type: "chat.reopened"` - `"chat.reopened"` ### chat.owner_changed - `ChatOwnerChangedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, owner, seq, 2 more }` - `chat_id: string` - `owner: string` The NEW owner — `partner`, `flow`, `human_pending` or `human`. `flow` is a platform flow holding the turn (the survey `/resolve` starts, the question `/clarify` asks): it ends on the customer's answer or its own timeout, and ownership then returns through this same event. - `seq: number` - `reason: optional string` Free-text reason when the causing transition carried one (e.g. a partner handoff). - `summary: optional string` Partner-supplied summary when the causing transition carried one. - `type: "chat.owner_changed"` - `"chat.owner_changed"` ### chat.handoff_requested - `ChatHandoffRequestedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, owner, seq, 3 more }` - `chat_id: string` - `owner: string` Always `human_pending` for this kind. - `seq: number` - `reason: optional string` Why — your own free-text reason, or `trigger_word` for an escalation we matched. - `summary: optional string` Partner-supplied summary when the handoff carried one. - `term: optional string` The exact standardized word/phrase matched; only on `reason:"trigger_word"`. - `type: "chat.handoff_requested"` - `"chat.handoff_requested"` ### chat.send_forced - `ChatSendForcedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, message_id, overridden_owner, 2 more }` - `chat_id: string` - `message_id: string` Joins the event to the forced send. - `overridden_owner: string` The owner as it was at commit — who was spoken over. - `owner: string` - `seq: number` - `type: "chat.send_forced"` - `"chat.send_forced"` ### chat.authenticated - `ChatAuthenticatedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq, sub, 2 more }` - `chat_id: string` - `seq: number` - `sub: string` The provider's opaque subject identifier. This is the whole of what we pass through from the claims. - `message_id: optional string` The id of the authenticate message this sign-in completed, equal to the send response's `message_id` and the transcript business row's `message_id`. A correlation id, not a claim. Absent on a sign-in whose bubble was sent before this shipped. - `request_id: optional string` The requestIdentifier the authenticate message this sign-in completed was delivered under, equal to the transcript business row's `request_id`. A correlation id, not a claim. Every authenticate send has minted one since the flow shipped, so it is present on a sign-in whose bubble predates this field. - `type: "chat.authenticated"` - `"chat.authenticated"` ### chat.authentication_closed - `ChatAuthenticationClosedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, request_id, seq, 2 more }` - `chat_id: string` - `request_id: string` The requestIdentifier the authenticate message was delivered under, equal to the transcript business row's `request_id` and to `chat.authenticated`'s. A correlation id, not a claim; always present. - `seq: number` - `status: "cancel" or "failure" or "unknown" or "expired"` Why the bubble is dead. `cancel`: the customer dismissed it. `failure`: the sign-in did not complete. `unknown`: the device could not say, or reported a status outside this set. `expired`: the 30-minute sign-in window, counted from when the message was accepted, closed with no `chat.authenticated` and no earlier close. - `"cancel"` - `"failure"` - `"unknown"` - `"expired"` - `message_id: optional string` The id of the authenticate message that closed, equal to the send response's `message_id` and the transcript business row's. A correlation id, not a claim. Absent on a bubble that was sent before this shipped. - `type: "chat.authentication_closed"` - `"chat.authentication_closed"` ### chat.trigger_word - `ChatTriggerWordWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { action, chat_id, handled_by, 8 more }` - `action: "escalate" or "opt_out" or "opt_in" or 3 more` - `"escalate"` - `"opt_out"` - `"opt_in"` - `"menu"` - `"preferences"` - `"stop_automation"` - `chat_id: string` - `handled_by: "partner" or "platform"` Who the event asks something of. `platform`: informational, nothing owed. `partner`: yours, and the delegation fields say how much. With `queued_for_human` present, routing was already seized and only the acknowledgement is delegated to you, deadline `ack_by`. With `queued_for_human` ABSENT, your brand runs self-managed escalation and the whole escalation is yours: nothing was queued, and the chat moves only on your `/handoff` or `/clarify`. - `"partner"` - `"platform"` - `owner: string` - `seq: number` The seq of the `message.received` this rides with. - `term: string` The exact word/phrase matched — the deterministic floor to tune your NLU against. - `ack_by: optional string` The deadline for your acknowledgement, about 10 seconds out. Send anything before it and yours is what the customer reads; past it we send our own. It is not a routing fallback. - `agents_available: optional boolean` Honest reachability signal — never fabricated. - `escalation_channel: optional "offered"` The escalation entered the escalation-channel flow (out-of-hours/exempt brand) — no ack is delegated and no `ack_by` is promised. - `"offered"` - `queued_for_human: optional boolean` Routing was secured at t=0 regardless of partner behaviour; false on the escalation-channel path. ABSENT (not false) on a self-managed brand's escalation, and that absence is the field to branch on. - `wait_estimate_seconds: optional number` Rides only when a real estimate is configured — never invented. - `type: "chat.trigger_word"` - `"chat.trigger_word"` ### chat.escalation_channel_requested - `ChatEscalationChannelRequestedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { channel, chat_id, seq, 3 more }` - `channel: "phone" or "email"` - `"phone"` - `"email"` - `chat_id: string` - `seq: number` The seq of the customer turn that completed the request. - `email: optional string` The reply-to address — rides with `channel:"email"`. - `message: optional string` The customer's message body — rides with `channel:"email"`. - `phone: optional string` The callback number — rides with `channel:"phone"`. - `type: "chat.escalation_channel_requested"` - `"chat.escalation_channel_requested"` ### invitation.accepted - `InvitationAcceptedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { accepted_chat_id, chat_id, invitation_id, seq }` - `accepted_chat_id: string` The opaque chat the customer landed in — send there. - `chat_id: string` - `invitation_id: string` - `seq: number` - `type: "invitation.accepted"` - `"invitation.accepted"` ### invitation.declined - `InvitationDeclinedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, invitation_id, seq }` - `chat_id: string` - `invitation_id: string` - `seq: number` - `type: "invitation.declined"` - `"invitation.declined"` ### invitation.opted_out - `InvitationOptedOutWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq }` - `chat_id: string` - `seq: number` - `type: "invitation.opted_out"` - `"invitation.opted_out"` ### invitation_batch.recipient_failed - `InvitationBatchRecipientFailedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { batch_id, brand_id, idx, 4 more }` - `batch_id: string` The run this recipient belongs to (`invb_…`). - `brand_id: string` - `idx: number` This row's zero-based position in the array you submitted. It is the same index each validation error names, and the key the recipients page walks. - `reason: object { code, message }` Why it will never send. The same `{code, message}` pair the recipients page returns for this row. - `code: string` - `message: string` - `reference_id: string` Your correlation handle for this recipient, echoed back. - `state: string` Always `failed` — the recipient state this row now holds. - `to: string` The recipient string AS YOU SENT IT, not our canonical form. - `type: "invitation_batch.recipient_failed"` - `"invitation_batch.recipient_failed"` ### invitation_batch.completed - `InvitationBatchCompletedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { accepted_count, batch_id, brand_id, 3 more }` - `accepted_count: number` `recipient_count` minus the rows the submit screen skipped — the same number the submit returned. - `batch_id: string` - `brand_id: string` - `counts: object { cancelled, failed, pending, 2 more }` The run's recipients by state, all five keys present. `pending` is zero on a `completed` run by definition. - `cancelled: number` - `failed: number` - `pending: number` - `sent: number` - `skipped: number` - `recipient_count: number` The submitted size, frozen at creation. Always `counts`' total. - `status: "completed" or "cancelled"` How the run ended. - `"completed"` - `"cancelled"` - `type: "invitation_batch.completed"` - `"invitation_batch.completed"` ### brand.escalation_handling_changed - `BrandEscalationHandlingChangedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, new, old }` - `brand_id: string` - `new: "managed" or "self_managed"` - `"managed"` - `"self_managed"` - `old: "managed" or "self_managed"` - `"managed"` - `"self_managed"` - `type: "brand.escalation_handling_changed"` - `"brand.escalation_handling_changed"` ### consent.granted - `ConsentGrantedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { category, chat_id, seq, state }` - `category: string` Always `marketing` today — a keyword opt-in re-grants nothing else. - `chat_id: string` - `seq: number` - `state: "granted"` - `"granted"` - `type: "consent.granted"` - `"consent.granted"` ### consent.revoked - `ConsentRevokedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { category, chat_id, seq, state }` - `category: string` `marketing` or `account_notification` — or `invitation` for a tel-thread STOP. - `chat_id: string` - `seq: number` - `state: "revoked"` - `"revoked"` - `type: "consent.revoked"` - `"consent.revoked"` ### consent.expired - `ConsentExpiredWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { category, chat_id, state }` - `category: string` - `chat_id: string` - `state: "expired"` - `"expired"` - `type: "consent.expired"` - `"consent.expired"` ### chat.automation_paused - `ChatAutomationPausedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq }` - `chat_id: string` - `seq: number` The causing inbound message's seq. - `type: "chat.automation_paused"` - `"chat.automation_paused"` ### chat.automation_resumed - `ChatAutomationResumedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq }` - `chat_id: string` - `seq: number` The causing inbound message's seq. - `type: "chat.automation_resumed"` - `"chat.automation_resumed"` ### reaction.added - `ReactionAddedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, correlation, 12 more }` - `brand_id: string` - `chat_id: string` - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"` HOW the target was reached, and the same value `GET /v1/chats/{chat}/transcript` serves for this reaction. `inferred` means exactly one message in the recent window carried that text; `ambiguous` means several did and `candidate_count` says how many; `unmatched` means none did; `unavailable` means Apple named no target. There is no `exact`, because no reaction on this wire carries an id. - `"inferred"` - `"ambiguous"` - `"unmatched"` - `"unavailable"` - `kind: "like" or "love" or "dislike" or 5 more` What the customer did. The same closed set the transcript's `reaction.kind` uses, spelled the same way. - `"like"` - `"love"` - `"dislike"` - `"laugh"` - `"emphasize"` - `"question"` - `"sticker"` - `"emoji"` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). It rides every envelope so an orchestrator branches structurally instead of guessing. - `seq: number` This reaction's own `seq` in the chat. - `source_seq: number` The seq of the customer message that CARRIED the reaction. It is not the message reacted to; that is `target_seq`, and it is an inference. - `attachment_id: optional string` The sticker's image, when `kind` is `sticker` and we retained it. Pass it to `GET /v1/attachments/{att}/content`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker. Do not assume a format; read the response's own content type. A present id is NOT a promise of bytes, exactly as for `att_id`. Retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`. - `candidate_count: optional number` How many messages carried that exact text, on `ambiguous`. No winner is picked for you. - `emoji: optional string` The character the customer picked, present only when `kind` is `emoji`. Served verbatim. - `generic: optional boolean` Apple named no target at all, which is what a reaction to a non-text message looks like. `correlation` is then `unavailable`. - `quoted_text: optional string` The original message's text as Apple quoted it back — the only handle this wire offers, and what the correlation was made against. - `target_msg_id: optional string` The message reacted to, on `inferred`. - `target_seq: optional number` That message's seq, on `inferred`. - `target_side: optional "business" or "customer"` Whose message was reacted to, on `inferred`. A customer can react to their own, so it is not derivable from the reaction being a customer event. - `"business"` - `"customer"` - `type: "reaction.added"` - `"reaction.added"` ### reaction.removed - `ReactionRemovedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, correlation, 11 more }` - `brand_id: string` - `chat_id: string` - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"` HOW the target was reached, and the same value `GET /v1/chats/{chat}/transcript` serves for this reaction. `inferred` means exactly one message in the recent window carried that text; `ambiguous` means several did and `candidate_count` says how many; `unmatched` means none did; `unavailable` means Apple named no target. There is no `exact`, because no reaction on this wire carries an id. - `"inferred"` - `"ambiguous"` - `"unmatched"` - `"unavailable"` - `kind: "like" or "love" or "dislike" or 5 more` What the customer did. The same closed set the transcript's `reaction.kind` uses, spelled the same way. - `"like"` - `"love"` - `"dislike"` - `"laugh"` - `"emphasize"` - `"question"` - `"sticker"` - `"emoji"` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). It rides every envelope so an orchestrator branches structurally instead of guessing. - `seq: number` This reaction's own `seq` in the chat. - `source_seq: number` The seq of the customer message that CARRIED the reaction. It is not the message reacted to; that is `target_seq`, and it is an inference. - `candidate_count: optional number` How many messages carried that exact text, on `ambiguous`. No winner is picked for you. - `emoji: optional string` The character the customer picked, present only when `kind` is `emoji`. Served verbatim. - `generic: optional boolean` Apple named no target at all, which is what a reaction to a non-text message looks like. `correlation` is then `unavailable`. - `quoted_text: optional string` The original message's text as Apple quoted it back — the only handle this wire offers, and what the correlation was made against. - `target_msg_id: optional string` The message reacted to, on `inferred`. - `target_seq: optional number` That message's seq, on `inferred`. - `target_side: optional "business" or "customer"` Whose message was reacted to, on `inferred`. A customer can react to their own, so it is not derivable from the reaction being a customer event. - `"business"` - `"customer"` - `type: "reaction.removed"` - `"reaction.removed"` ### choice.received - `ChoiceReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, item_id, 5 more }` - `brand_id: string` - `chat_id: string` - `item_id: string` The chosen option's id, your own id from the sent part. The only match key. - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one. - `request_id: optional string` The identifier of the interactive message this reply answers; present when the send recorded one. - `title: optional string` The chosen option's display text as the reply carried it; present when it did. Display passthrough, never a match key. - `type: "choice.received"` - `"choice.received"` ### picker.received - `PickerReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, items, 4 more }` - `brand_id: string` - `chat_id: string` - `items: array of object { item_id, subtitle, title }` The chosen entries, in the reply's order. - `item_id: string` Your own item id from the sent part. The only match key. - `subtitle: optional string` The item's subtitle; present when the reply carried it. - `title: optional string` The item's display text; present when the reply carried it. - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one. - `request_id: optional string` The identifier of the interactive message this reply answers; present when the send recorded one. - `type: "picker.received"` - `"picker.received"` ### timeslot.received - `TimeslotReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, owner, 5 more }` - `brand_id: string` - `chat_id: string` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`. - `start: string` The chosen slot's start time, carried as the device sent it. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one. - `request_id: optional string` The identifier of the interactive message this reply answers; present when the send recorded one. - `slot_id: optional string` The chosen slot's identifier from your sent part; present when the reply carried it. - `type: "timeslot.received"` - `"timeslot.received"` ### form.completed - `FormCompletedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, owner, 11 more }` - `brand_id: string` - `chat_id: string` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The seq of the event that concluded the form, the join to `GET /v1/chats/{chat}/events`. - `transport: "native" or "paged"` How the form reached the customer. `native` is a single-page card submission; `paged` is the degraded multi-page collection. - `"native"` - `"paged"` - `values: map[string]` The flat answers, keyed by your original page ids, multi-select values comma-joined. Byte-congruent with the message-level reply's `values` and with `form.response`. - `answers: optional array of object { kind, page_id, field_id, 4 more }` The typed per-field record; present when captured at ingest. One entry per answered field, preserving page grouping and field kind. - `kind: "input" or "single_select" or "multi_select" or 2 more` The field kind, normalized. - `"input"` - `"single_select"` - `"multi_select"` - `"picker"` - `"date"` - `page_id: string` Your original page id. - `field_id: optional string` The field's own id, when the page distinguishes one. - `item_ids: optional array of string` The selected item ids for a choice field. - `question: optional string` The page's own prompt text, so the answer reads with its meaning; present when the submission carried it. - `value: optional string` The answer for a single-valued field. - `values: optional array of string` The answers for a multi-valued field. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one (native transport only). - `closed_at: optional string` Paged transport; when the collection concluded. - `group_id: optional string` Paged transport; the correlation id tying the record to the page messages that asked. - `item_ids: optional array of string` Every selected item id across the form's choice pages; present when any page was a choice and the ids were captured. - `opened_at: optional string` Paged transport; when the collection opened. - `request_id: optional string` Native transport; the identifier of the form message this submission answers, present when the send recorded one. - `timezone_offset_minutes: optional number` The customer device's UTC offset in minutes, positive west of UTC, exactly as the submission carried it; present when it did (native transport only). - `type: "form.completed"` - `"form.completed"` ### apple_pay.received - `ApplePayReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, owner, 4 more }` - `brand_id: string` - `chat_id: string` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `payment_state: string` The completion message's state, advisory only. NON-AUTHORITATIVE; reconcile against your own payment record, never this field. - `seq: number` The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one. - `request_id: optional string` The identifier of the payment message this completion answers; present when the send recorded one. - `type: "apple_pay.received"` - `"apple_pay.received"` ### app_extension.received - `AppExtensionReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { bid, brand_id, chat_id, 8 more }` - `bid: string` The extension bundle id the reply arrived under. - `brand_id: string` - `chat_id: string` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one. - `data: optional unknown` The raw dictionary your extension sent, verbatim; present when one exists. - `request_id: optional string` The identifier of the message this reply answers; present when the reply echoed one. - `session_id: optional string` The extension's session identifier; present when the reply carried one. - `url: optional string` The URL-encoded state your extension sent; present when the reply carried one. - `url_params: optional array of object { name, value }` The `url` query string parsed to pairs, in order, duplicates preserved; present when the URL carried a query. - `name: string` - `value: string` - `type: "app_extension.received"` - `"app_extension.received"` ### Unwrap Webhook Event - `UnwrapWebhookEvent = MessageReceivedWebhookEvent or MessageSentWebhookEvent or MessageFailedWebhookEvent or 28 more` - `MessageReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` Delivery id (`whd_…`), equal to the `webhook-id` header. - `created_at: string` - `data: object { brand_id, chat_id, message, 3 more }` - `brand_id: string` The logical brand this chat is scoped under, the value to answer as. Distinct from the Apple business UUID, which is served beside it as `business_id`; `brand_unknown:` for an unregistered business. - `chat_id: string` - `message: object { reply, text, attachments, 4 more }` - `reply: object { kind, answers, items, 3 more }` The normalized interactive response when the inbound was `type:interactive`; null otherwise (including a customer answering a DEGRADED prompt in plain text). - `kind: string` Normalized reply kind. One of `app_extension`, `apple_pay`, `choices`, `form`, `invitation_response`, `list_picker`, `time_slots`. Each kind carries its own shape. The six that answer a part you sent are shaped by that part's published schema, served at `GET /v1/parts/{type}`; `invitation_response` answers an invitation rather than a part, and reports the customer's verdict in `item_id`. This set describes replies classified on or after 2026-08-27. `app_extension` was called `imessage_app` before that, and a recorded event is never rewritten, so events recorded earlier keep the old spelling wherever you read them back: the event log, a chat's own events, the transcript, and a redelivered webhook. Accept `imessage_app` as a historical synonym when you read history. - `answers: optional array of object { field_id, item_ids, kind, 4 more }` One record per answered page, on `form` replies; a page whose submission carried no items emits no record. `question` is the page's title, the prompt the customer answered; `kind` says what shape of answer it is (`input`, `single_select`, `multi_select`, `picker`, `date`). A page answered with one item reports `field_id` and `value`; one answered with several reports `item_ids` and `values`, aligned entry for entry. Records carry the wire's raw fields, while the reply's own `values` map (unchanged) applies a display fallback per item: its value, else its identifier, else its title. So rebuild a page's `values` entry from a record by that fallback, never by joining the record's `values` alone. The one leg a record cannot rebuild is the title: records carry no titles, so when an item brought neither a value nor an identifier, read that page's answer from `values`, where the title already landed. Every field is present only when the submission carried its source. - `field_id: optional string` - `item_ids: optional array of string` - `kind: optional string` One of `input`, `single_select`, `multi_select`, `picker`, `date`. Present only when the submission said which. - `page_id: optional string` - `question: optional string` - `value: optional string` - `values: optional array of string` - `items: optional array of object { id, subtitle, title }` The chosen options with their display text, on `list_picker` replies: one entry per selected item, in the order the reply listed them. Display only: `item_ids` is unchanged and stays the set to match on. Present only when at least one chosen item carried display text; each entry's `title` and `subtitle` are themselves present only when set. - `id: optional string` - `subtitle: optional string` - `title: optional string` - `request_id: optional string` The identifier of the interactive message this reply answers: the same value the sending message's transcript row reports as its own `request_id`, so a tap is matched to the exact send that asked rather than inferred from item ids (the same card sent twice shares item ids, while each send delivers under a fresh identifier). Present when the send recorded one. - `timezone_offset_minutes: optional number` On `form` replies only: the customer's device timezone offset at submission, verbatim from the wire. Minutes, positive west of UTC (the JavaScript getTimezoneOffset convention, so a US Central device reads +300). Present exactly when the device reported one; never fabricated. Other reply kinds never carry it: the similarly named field on a time-slot reply is an echo of the event your own send defined, not customer data. - `title: optional string` The chosen option's display text, as the customer saw it, on `choices` replies. Display only: match on `item_id`, never on this text. Present only when the device sent it, so replies recorded before it was captured have no such key. - `text: string` Plain-text body; null when the inbound carried none. - `attachments: optional array of object { decrypted, id, decrypted_size, 6 more }` Present when the inbound carried files: one entry per attachment, with the outcome of our decrypt-and-retain pass. Absent when the message carried none, so an ordinary text inbound's shape is unchanged. - `decrypted: boolean` Whether we could decrypt the file. `false` means no bytes exist to serve and `error` says what happened. - `id: optional string` The retained file's `att_id`; `GET /v1/attachments/{att}/content` serves the bytes and this part sends them onward. Absent when nothing was retained, and `retention` then says why. - `decrypted_size: optional number` Plaintext size in bytes; present when decrypted. - `error: optional string` The decrypt failure, when `decrypted` is `false`. - `mime_type: optional string` - `name: optional string` Filename as the customer sent it. - `retention: optional "too_large" or "brand_unresolved" or "store_unavailable" or "disabled"` Why no `id` was minted for a file that DID decrypt; present exactly when that happened. `too_large` is yours to act on (the file exceeds what we retain); the other values are platform conditions. - `"too_large"` - `"brand_unresolved"` - `"store_unavailable"` - `"disabled"` - `sha256: optional string` Hex SHA-256 of the plaintext; present when decrypted. - `size: optional number` Transfer size in bytes, as delivered. - `group: optional string` The entry point's groupID, same delivery and same present-only-when-set rule as `intent`. - `intent: optional string` The entry point's intentID, exactly as Apple delivered it. The button, URL or QR code the customer started from sets this, not the message body. It is present only when the entry point set one, so an ordinary chat's shape is unchanged. Route your own skills on it — our brand routing matches the same representation. - `raw: optional unknown` The full inbound Apple envelope, verbatim, when present. A legacy escape hatch, not a surface to automate on: everything a workflow needs is in the normalized fields (`text`, `reply`, `attachments`, `business_id`), and this key is ABSENT on ordinary deliveries unless the platform is configured to send it. Two promises hold whatever that configuration says: a delivery carrying `reply_unresolved` always includes `raw` (the payload we could not normalize), and the verbatim envelope is always readable from `GET /v1/chats/{chat}/events`, which is the forensic record. - `reply_unresolved: optional object { reason, step }` Present only when Apple substituted an interactiveDataRef for a >10 KB reply and resolution failed permanently — `reply` is then null. - `reason: string` - `step: string` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The inbound's seq — the events cursor, and part of the dedupe key. - `business_id: optional string` The Apple business UUID this message arrived on. Present when the inbound envelope named one, which is every ordinary delivery; when Apple sends none the key is absent, never an empty string. Use this rather than reading the envelope inside `raw`. - `type: "message.received"` - `"message.received"` - `MessageSentWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, message_id, seq, 2 more }` - `chat_id: string` - `message_id: string` - `seq: number` - `group_id: optional string` The FIRST message's id of a fan-out (a promoted URL) — absent on an ordinary single-message send. - `invitation: optional object { id, reference_id, template_id }` Present only for an invitation delivery (POST /v1/invitations); `reference_id` is the partner's correlation handle. - `id: string` - `reference_id: string` - `template_id: string` - `type: "message.sent"` - `"message.sent"` - `MessageFailedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, message_id, reason, 4 more }` - `chat_id: string` - `message_id: string` - `reason: "permanent" or "undelivered" or "auth_error" or 5 more` `platform_error` and `invitation_undeliverable` occur only for invitation-class sends, so those two are worth resubmitting only there. - `"permanent"` - `"undelivered"` - `"auth_error"` - `"suppressed"` - `"consent_revoked"` - `"automation_paused"` - `"platform_error"` - `"invitation_undeliverable"` - `seq: number` - `group_id: optional string` Same meaning as on `message.sent` — ties a fan-out member's failure back to the one request. - `last_error: optional string` Gateway error of the last attempt; rides only on a gateway-verdict failure. - `last_status: optional number` Gateway HTTP status of the last attempt; rides only on a gateway-verdict failure. - `type: "message.failed"` - `"message.failed"` - `ChatClosedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq }` - `chat_id: string` - `seq: number` - `type: "chat.closed"` - `"chat.closed"` - `ChatReopenedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq }` - `chat_id: string` - `seq: number` - `type: "chat.reopened"` - `"chat.reopened"` - `ChatOwnerChangedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, owner, seq, 2 more }` - `chat_id: string` - `owner: string` The NEW owner — `partner`, `flow`, `human_pending` or `human`. `flow` is a platform flow holding the turn (the survey `/resolve` starts, the question `/clarify` asks): it ends on the customer's answer or its own timeout, and ownership then returns through this same event. - `seq: number` - `reason: optional string` Free-text reason when the causing transition carried one (e.g. a partner handoff). - `summary: optional string` Partner-supplied summary when the causing transition carried one. - `type: "chat.owner_changed"` - `"chat.owner_changed"` - `ChatHandoffRequestedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, owner, seq, 3 more }` - `chat_id: string` - `owner: string` Always `human_pending` for this kind. - `seq: number` - `reason: optional string` Why — your own free-text reason, or `trigger_word` for an escalation we matched. - `summary: optional string` Partner-supplied summary when the handoff carried one. - `term: optional string` The exact standardized word/phrase matched; only on `reason:"trigger_word"`. - `type: "chat.handoff_requested"` - `"chat.handoff_requested"` - `ChatSendForcedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, message_id, overridden_owner, 2 more }` - `chat_id: string` - `message_id: string` Joins the event to the forced send. - `overridden_owner: string` The owner as it was at commit — who was spoken over. - `owner: string` - `seq: number` - `type: "chat.send_forced"` - `"chat.send_forced"` - `ChatAuthenticatedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq, sub, 2 more }` - `chat_id: string` - `seq: number` - `sub: string` The provider's opaque subject identifier. This is the whole of what we pass through from the claims. - `message_id: optional string` The id of the authenticate message this sign-in completed, equal to the send response's `message_id` and the transcript business row's `message_id`. A correlation id, not a claim. Absent on a sign-in whose bubble was sent before this shipped. - `request_id: optional string` The requestIdentifier the authenticate message this sign-in completed was delivered under, equal to the transcript business row's `request_id`. A correlation id, not a claim. Every authenticate send has minted one since the flow shipped, so it is present on a sign-in whose bubble predates this field. - `type: "chat.authenticated"` - `"chat.authenticated"` - `ChatAuthenticationClosedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, request_id, seq, 2 more }` - `chat_id: string` - `request_id: string` The requestIdentifier the authenticate message was delivered under, equal to the transcript business row's `request_id` and to `chat.authenticated`'s. A correlation id, not a claim; always present. - `seq: number` - `status: "cancel" or "failure" or "unknown" or "expired"` Why the bubble is dead. `cancel`: the customer dismissed it. `failure`: the sign-in did not complete. `unknown`: the device could not say, or reported a status outside this set. `expired`: the 30-minute sign-in window, counted from when the message was accepted, closed with no `chat.authenticated` and no earlier close. - `"cancel"` - `"failure"` - `"unknown"` - `"expired"` - `message_id: optional string` The id of the authenticate message that closed, equal to the send response's `message_id` and the transcript business row's. A correlation id, not a claim. Absent on a bubble that was sent before this shipped. - `type: "chat.authentication_closed"` - `"chat.authentication_closed"` - `ChatTriggerWordWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { action, chat_id, handled_by, 8 more }` - `action: "escalate" or "opt_out" or "opt_in" or 3 more` - `"escalate"` - `"opt_out"` - `"opt_in"` - `"menu"` - `"preferences"` - `"stop_automation"` - `chat_id: string` - `handled_by: "partner" or "platform"` Who the event asks something of. `platform`: informational, nothing owed. `partner`: yours, and the delegation fields say how much. With `queued_for_human` present, routing was already seized and only the acknowledgement is delegated to you, deadline `ack_by`. With `queued_for_human` ABSENT, your brand runs self-managed escalation and the whole escalation is yours: nothing was queued, and the chat moves only on your `/handoff` or `/clarify`. - `"partner"` - `"platform"` - `owner: string` - `seq: number` The seq of the `message.received` this rides with. - `term: string` The exact word/phrase matched — the deterministic floor to tune your NLU against. - `ack_by: optional string` The deadline for your acknowledgement, about 10 seconds out. Send anything before it and yours is what the customer reads; past it we send our own. It is not a routing fallback. - `agents_available: optional boolean` Honest reachability signal — never fabricated. - `escalation_channel: optional "offered"` The escalation entered the escalation-channel flow (out-of-hours/exempt brand) — no ack is delegated and no `ack_by` is promised. - `"offered"` - `queued_for_human: optional boolean` Routing was secured at t=0 regardless of partner behaviour; false on the escalation-channel path. ABSENT (not false) on a self-managed brand's escalation, and that absence is the field to branch on. - `wait_estimate_seconds: optional number` Rides only when a real estimate is configured — never invented. - `type: "chat.trigger_word"` - `"chat.trigger_word"` - `ChatEscalationChannelRequestedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { channel, chat_id, seq, 3 more }` - `channel: "phone" or "email"` - `"phone"` - `"email"` - `chat_id: string` - `seq: number` The seq of the customer turn that completed the request. - `email: optional string` The reply-to address — rides with `channel:"email"`. - `message: optional string` The customer's message body — rides with `channel:"email"`. - `phone: optional string` The callback number — rides with `channel:"phone"`. - `type: "chat.escalation_channel_requested"` - `"chat.escalation_channel_requested"` - `InvitationAcceptedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { accepted_chat_id, chat_id, invitation_id, seq }` - `accepted_chat_id: string` The opaque chat the customer landed in — send there. - `chat_id: string` - `invitation_id: string` - `seq: number` - `type: "invitation.accepted"` - `"invitation.accepted"` - `InvitationDeclinedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, invitation_id, seq }` - `chat_id: string` - `invitation_id: string` - `seq: number` - `type: "invitation.declined"` - `"invitation.declined"` - `InvitationOptedOutWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq }` - `chat_id: string` - `seq: number` - `type: "invitation.opted_out"` - `"invitation.opted_out"` - `InvitationBatchRecipientFailedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { batch_id, brand_id, idx, 4 more }` - `batch_id: string` The run this recipient belongs to (`invb_…`). - `brand_id: string` - `idx: number` This row's zero-based position in the array you submitted. It is the same index each validation error names, and the key the recipients page walks. - `reason: object { code, message }` Why it will never send. The same `{code, message}` pair the recipients page returns for this row. - `code: string` - `message: string` - `reference_id: string` Your correlation handle for this recipient, echoed back. - `state: string` Always `failed` — the recipient state this row now holds. - `to: string` The recipient string AS YOU SENT IT, not our canonical form. - `type: "invitation_batch.recipient_failed"` - `"invitation_batch.recipient_failed"` - `InvitationBatchCompletedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { accepted_count, batch_id, brand_id, 3 more }` - `accepted_count: number` `recipient_count` minus the rows the submit screen skipped — the same number the submit returned. - `batch_id: string` - `brand_id: string` - `counts: object { cancelled, failed, pending, 2 more }` The run's recipients by state, all five keys present. `pending` is zero on a `completed` run by definition. - `cancelled: number` - `failed: number` - `pending: number` - `sent: number` - `skipped: number` - `recipient_count: number` The submitted size, frozen at creation. Always `counts`' total. - `status: "completed" or "cancelled"` How the run ended. - `"completed"` - `"cancelled"` - `type: "invitation_batch.completed"` - `"invitation_batch.completed"` - `BrandEscalationHandlingChangedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, new, old }` - `brand_id: string` - `new: "managed" or "self_managed"` - `"managed"` - `"self_managed"` - `old: "managed" or "self_managed"` - `"managed"` - `"self_managed"` - `type: "brand.escalation_handling_changed"` - `"brand.escalation_handling_changed"` - `ConsentGrantedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { category, chat_id, seq, state }` - `category: string` Always `marketing` today — a keyword opt-in re-grants nothing else. - `chat_id: string` - `seq: number` - `state: "granted"` - `"granted"` - `type: "consent.granted"` - `"consent.granted"` - `ConsentRevokedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { category, chat_id, seq, state }` - `category: string` `marketing` or `account_notification` — or `invitation` for a tel-thread STOP. - `chat_id: string` - `seq: number` - `state: "revoked"` - `"revoked"` - `type: "consent.revoked"` - `"consent.revoked"` - `ConsentExpiredWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { category, chat_id, state }` - `category: string` - `chat_id: string` - `state: "expired"` - `"expired"` - `type: "consent.expired"` - `"consent.expired"` - `ChatAutomationPausedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq }` - `chat_id: string` - `seq: number` The causing inbound message's seq. - `type: "chat.automation_paused"` - `"chat.automation_paused"` - `ChatAutomationResumedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { chat_id, seq }` - `chat_id: string` - `seq: number` The causing inbound message's seq. - `type: "chat.automation_resumed"` - `"chat.automation_resumed"` - `ReactionAddedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, correlation, 12 more }` - `brand_id: string` - `chat_id: string` - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"` HOW the target was reached, and the same value `GET /v1/chats/{chat}/transcript` serves for this reaction. `inferred` means exactly one message in the recent window carried that text; `ambiguous` means several did and `candidate_count` says how many; `unmatched` means none did; `unavailable` means Apple named no target. There is no `exact`, because no reaction on this wire carries an id. - `"inferred"` - `"ambiguous"` - `"unmatched"` - `"unavailable"` - `kind: "like" or "love" or "dislike" or 5 more` What the customer did. The same closed set the transcript's `reaction.kind` uses, spelled the same way. - `"like"` - `"love"` - `"dislike"` - `"laugh"` - `"emphasize"` - `"question"` - `"sticker"` - `"emoji"` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). It rides every envelope so an orchestrator branches structurally instead of guessing. - `seq: number` This reaction's own `seq` in the chat. - `source_seq: number` The seq of the customer message that CARRIED the reaction. It is not the message reacted to; that is `target_seq`, and it is an inference. - `attachment_id: optional string` The sticker's image, when `kind` is `sticker` and we retained it. Pass it to `GET /v1/attachments/{att}/content`. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker. Do not assume a format; read the response's own content type. A present id is NOT a promise of bytes, exactly as for `att_id`. Retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers `404`. - `candidate_count: optional number` How many messages carried that exact text, on `ambiguous`. No winner is picked for you. - `emoji: optional string` The character the customer picked, present only when `kind` is `emoji`. Served verbatim. - `generic: optional boolean` Apple named no target at all, which is what a reaction to a non-text message looks like. `correlation` is then `unavailable`. - `quoted_text: optional string` The original message's text as Apple quoted it back — the only handle this wire offers, and what the correlation was made against. - `target_msg_id: optional string` The message reacted to, on `inferred`. - `target_seq: optional number` That message's seq, on `inferred`. - `target_side: optional "business" or "customer"` Whose message was reacted to, on `inferred`. A customer can react to their own, so it is not derivable from the reaction being a customer event. - `"business"` - `"customer"` - `type: "reaction.added"` - `"reaction.added"` - `ReactionRemovedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, correlation, 11 more }` - `brand_id: string` - `chat_id: string` - `correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"` HOW the target was reached, and the same value `GET /v1/chats/{chat}/transcript` serves for this reaction. `inferred` means exactly one message in the recent window carried that text; `ambiguous` means several did and `candidate_count` says how many; `unmatched` means none did; `unavailable` means Apple named no target. There is no `exact`, because no reaction on this wire carries an id. - `"inferred"` - `"ambiguous"` - `"unmatched"` - `"unavailable"` - `kind: "like" or "love" or "dislike" or 5 more` What the customer did. The same closed set the transcript's `reaction.kind` uses, spelled the same way. - `"like"` - `"love"` - `"dislike"` - `"laugh"` - `"emphasize"` - `"question"` - `"sticker"` - `"emoji"` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). It rides every envelope so an orchestrator branches structurally instead of guessing. - `seq: number` This reaction's own `seq` in the chat. - `source_seq: number` The seq of the customer message that CARRIED the reaction. It is not the message reacted to; that is `target_seq`, and it is an inference. - `candidate_count: optional number` How many messages carried that exact text, on `ambiguous`. No winner is picked for you. - `emoji: optional string` The character the customer picked, present only when `kind` is `emoji`. Served verbatim. - `generic: optional boolean` Apple named no target at all, which is what a reaction to a non-text message looks like. `correlation` is then `unavailable`. - `quoted_text: optional string` The original message's text as Apple quoted it back — the only handle this wire offers, and what the correlation was made against. - `target_msg_id: optional string` The message reacted to, on `inferred`. - `target_seq: optional number` That message's seq, on `inferred`. - `target_side: optional "business" or "customer"` Whose message was reacted to, on `inferred`. A customer can react to their own, so it is not derivable from the reaction being a customer event. - `"business"` - `"customer"` - `type: "reaction.removed"` - `"reaction.removed"` - `ChoiceReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, item_id, 5 more }` - `brand_id: string` - `chat_id: string` - `item_id: string` The chosen option's id, your own id from the sent part. The only match key. - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one. - `request_id: optional string` The identifier of the interactive message this reply answers; present when the send recorded one. - `title: optional string` The chosen option's display text as the reply carried it; present when it did. Display passthrough, never a match key. - `type: "choice.received"` - `"choice.received"` - `PickerReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, items, 4 more }` - `brand_id: string` - `chat_id: string` - `items: array of object { item_id, subtitle, title }` The chosen entries, in the reply's order. - `item_id: string` Your own item id from the sent part. The only match key. - `subtitle: optional string` The item's subtitle; present when the reply carried it. - `title: optional string` The item's display text; present when the reply carried it. - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one. - `request_id: optional string` The identifier of the interactive message this reply answers; present when the send recorded one. - `type: "picker.received"` - `"picker.received"` - `TimeslotReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, owner, 5 more }` - `brand_id: string` - `chat_id: string` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`. - `start: string` The chosen slot's start time, carried as the device sent it. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one. - `request_id: optional string` The identifier of the interactive message this reply answers; present when the send recorded one. - `slot_id: optional string` The chosen slot's identifier from your sent part; present when the reply carried it. - `type: "timeslot.received"` - `"timeslot.received"` - `FormCompletedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, owner, 11 more }` - `brand_id: string` - `chat_id: string` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The seq of the event that concluded the form, the join to `GET /v1/chats/{chat}/events`. - `transport: "native" or "paged"` How the form reached the customer. `native` is a single-page card submission; `paged` is the degraded multi-page collection. - `"native"` - `"paged"` - `values: map[string]` The flat answers, keyed by your original page ids, multi-select values comma-joined. Byte-congruent with the message-level reply's `values` and with `form.response`. - `answers: optional array of object { kind, page_id, field_id, 4 more }` The typed per-field record; present when captured at ingest. One entry per answered field, preserving page grouping and field kind. - `kind: "input" or "single_select" or "multi_select" or 2 more` The field kind, normalized. - `"input"` - `"single_select"` - `"multi_select"` - `"picker"` - `"date"` - `page_id: string` Your original page id. - `field_id: optional string` The field's own id, when the page distinguishes one. - `item_ids: optional array of string` The selected item ids for a choice field. - `question: optional string` The page's own prompt text, so the answer reads with its meaning; present when the submission carried it. - `value: optional string` The answer for a single-valued field. - `values: optional array of string` The answers for a multi-valued field. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one (native transport only). - `closed_at: optional string` Paged transport; when the collection concluded. - `group_id: optional string` Paged transport; the correlation id tying the record to the page messages that asked. - `item_ids: optional array of string` Every selected item id across the form's choice pages; present when any page was a choice and the ids were captured. - `opened_at: optional string` Paged transport; when the collection opened. - `request_id: optional string` Native transport; the identifier of the form message this submission answers, present when the send recorded one. - `timezone_offset_minutes: optional number` The customer device's UTC offset in minutes, positive west of UTC, exactly as the submission carried it; present when it did (native transport only). - `type: "form.completed"` - `"form.completed"` - `ApplePayReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { brand_id, chat_id, owner, 4 more }` - `brand_id: string` - `chat_id: string` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `payment_state: string` The completion message's state, advisory only. NON-AUTHORITATIVE; reconcile against your own payment record, never this field. - `seq: number` The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one. - `request_id: optional string` The identifier of the payment message this completion answers; present when the send recorded one. - `type: "apple_pay.received"` - `"apple_pay.received"` - `AppExtensionReceivedWebhookEvent object { id, created_at, data, type }` - `id: string` - `created_at: string` - `data: object { bid, brand_id, chat_id, 8 more }` - `bid: string` The extension bundle id the reply arrived under. - `brand_id: string` - `chat_id: string` - `owner: string` Who owned the turn when this was recorded (`partner`, `flow`, `human_pending`, `human`). - `seq: number` The same seq as the inbound event that produced it, the join to `GET /v1/chats/{chat}/events`. - `business_id: optional string` The Apple business UUID the chat arrived on; present when the inbound named one. - `data: optional unknown` The raw dictionary your extension sent, verbatim; present when one exists. - `request_id: optional string` The identifier of the message this reply answers; present when the reply echoed one. - `session_id: optional string` The extension's session identifier; present when the reply carried one. - `url: optional string` The URL-encoded state your extension sent; present when the reply carried one. - `url_params: optional array of object { name, value }` The `url` query string parsed to pairs, in order, duplicates preserved; present when the URL carried a query. - `name: string` - `value: string` - `type: "app_extension.received"` - `"app_extension.received"`