diff --git a/.changeset/add-linq-adapter.md b/.changeset/add-linq-adapter.md new file mode 100644 index 00000000..a68381a7 --- /dev/null +++ b/.changeset/add-linq-adapter.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/linq": minor +--- + +Add Linq adapter for iMessage, SMS, and RCS messaging via the Linq Partner API diff --git a/apps/docs/content/docs/adapters/index.mdx b/apps/docs/content/docs/adapters/index.mdx index c2d91091..c1d8dbf1 100644 --- a/apps/docs/content/docs/adapters/index.mdx +++ b/apps/docs/content/docs/adapters/index.mdx @@ -1,6 +1,6 @@ --- title: Overview -description: Platform-specific adapters for Slack, Teams, Google Chat, Discord, Telegram, GitHub, and Linear. +description: Platform-specific adapters for Slack, Teams, Google Chat, Discord, Telegram, GitHub, Linear, and Linq. type: overview prerequisites: - /docs/getting-started @@ -12,50 +12,50 @@ Adapters handle webhook verification, message parsing, and API calls for each pl ### Messaging -| Feature | [Slack](/docs/adapters/slack) | [Teams](/docs/adapters/teams) | [Google Chat](/docs/adapters/gchat) | [Discord](/docs/adapters/discord) | [Telegram](/docs/adapters/telegram) | [GitHub](/docs/adapters/github) | [Linear](/docs/adapters/linear) | -|---------|-------|-------|-------------|---------|---------|--------|--------| -| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | -| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | +| Feature | [Slack](/docs/adapters/slack) | [Teams](/docs/adapters/teams) | [Google Chat](/docs/adapters/gchat) | [Discord](/docs/adapters/discord) | [Telegram](/docs/adapters/telegram) | [GitHub](/docs/adapters/github) | [Linear](/docs/adapters/linear) | [Linq](/docs/adapters/linq) | +|---------|-------|-------|-------------|---------|---------|--------|--------|------| +| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | ✅ | +| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | ⚠️ Post+Edit | ### Rich content -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | -|---------|-------|-------|-------------|---------|----------|--------|--------| -| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | -| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | -| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | -| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ✅ GFM | ✅ GFM | -| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | -| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | Linq | +|---------|-------|-------|-------------|---------|----------|--------|--------|------| +| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | Text fallback | +| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | ❌ | +| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | ❌ | +| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ✅ GFM | ✅ GFM | ⚠️ ASCII | +| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | +| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Conversations -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | -|---------|-------|-------|-------------|---------|----------|--------|--------| -| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | -| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | -| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | -| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | -| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | Linq | +|---------|-------|-------|-------------|---------|----------|--------|--------|------| +| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ✅ | +| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅ | +| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | +| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ### Message history -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | -|---------|-------|-------|-------------|---------|----------|--------|--------| -| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | -| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | -| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ❌ | -| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | -| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | Linq | +|---------|-------|-------|-------------|---------|----------|--------|--------|------| +| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | ✅ | +| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | ✅ | +| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ❌ | ✅ | +| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | +| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ⚠️ indicates partial support — the feature works with limitations. See individual adapter pages for details. @@ -72,6 +72,7 @@ Adapters handle webhook verification, message parsing, and API calls for each pl | [Telegram](/docs/adapters/telegram) | `@chat-adapter/telegram` | | [GitHub](/docs/adapters/github) | `@chat-adapter/github` | | [Linear](/docs/adapters/linear) | `@chat-adapter/linear` | +| [Linq](/docs/adapters/linq) | `@chat-adapter/linq` | ## Community adapters diff --git a/apps/docs/content/docs/adapters/linq.mdx b/apps/docs/content/docs/adapters/linq.mdx new file mode 100644 index 00000000..f03aa4e0 --- /dev/null +++ b/apps/docs/content/docs/adapters/linq.mdx @@ -0,0 +1,116 @@ +--- +title: Linq +description: Configure the Linq adapter for iMessage, SMS, and RCS messaging via the Linq API. +type: integration +prerequisites: + - /docs/getting-started +--- + +## Installation + +```sh title="Terminal" +pnpm add @chat-adapter/linq +``` + +## Usage + +The adapter auto-detects `LINQ_API_TOKEN`, `LINQ_SIGNING_SECRET`, and `LINQ_PHONE_NUMBER` from environment variables: + +```typescript title="lib/bot.ts" lineNumbers +import { Chat } from "chat"; +import { createLinqAdapter } from "@chat-adapter/linq"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + linq: createLinqAdapter(), + }, +}); + +bot.onNewMention(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); +``` + +## Webhook route + +```typescript title="app/api/webhooks/linq/route.ts" lineNumbers +import { bot } from "@/lib/bot"; + +export async function POST(request: Request): Promise { + return bot.webhooks.linq(request); +} +``` + +Configure this URL as your webhook endpoint in the Linq partner dashboard. + +## Configuration + +All options are auto-detected from environment variables when not provided. + +| Option | Required | Description | +|--------|----------|-------------| +| `apiToken` | No* | Linq API token. Auto-detected from `LINQ_API_TOKEN` | +| `signingSecret` | No | Webhook signing secret for signature verification. Auto-detected from `LINQ_SIGNING_SECRET` | +| `phoneNumber` | No | Bot phone number, required for `openDM` and `listThreads`. Auto-detected from `LINQ_PHONE_NUMBER` | +| `userName` | No | Bot display name (defaults to `"bot"`) | +| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | + +*`apiToken` is required — either via config or `LINQ_API_TOKEN` env var. + +## Environment variables + +```bash title=".env.local" +LINQ_API_TOKEN=... +LINQ_SIGNING_SECRET=... # Optional, for webhook signature verification +LINQ_PHONE_NUMBER=... # Required for openDM and listThreads +``` + +## Webhook verification + +When `signingSecret` is configured, the adapter verifies incoming webhooks using HMAC-SHA256 signatures. It checks the `x-webhook-signature` and `x-webhook-timestamp` headers and rejects requests with timestamps older than 5 minutes. + +## Features + +| Feature | Supported | +|---------|-----------| +| Mentions | Yes (all inbound messages treated as mentions) | +| Reactions (add/remove) | Yes | +| Cards | Text fallback | +| Modals | No | +| Slash commands | No | +| Streaming | Post+Edit fallback | +| DMs | Yes | +| Ephemeral messages | No | +| File uploads | Yes | +| Typing indicator | Yes | +| Message history | Yes | +| Fetch single message | Yes | +| List threads | Yes (requires `phoneNumber`) | + +## Reactions + +Linq supports a fixed set of reaction types that map to standard emoji names: + +| Linq reaction | Emoji name | +|---------------|------------| +| `love` | `heart` | +| `like` | `thumbsup` | +| `dislike` | `thumbsdown` | +| `laugh` | `laughing` | +| `emphasize` | `exclamation` | +| `question` | `question` | + +Reactions not in this list are sent as custom emoji. + +## Thread ID format + +Linq thread IDs follow the pattern `linq:{chatId}`. + +## Notes + +- Linq is an SMS/iMessage/RCS gateway — messages are plain text. Markdown formatting (bold, italic, links) is stripped to plain text automatically. +- Tables render as ASCII art in code blocks. +- All inbound messages are treated as mentions since Linq chats are direct conversations. +- `openDM` and `listThreads` require the `phoneNumber` config option. +- The adapter uses the Linq Partner API v3. diff --git a/apps/docs/content/docs/adapters/meta.json b/apps/docs/content/docs/adapters/meta.json index 7767db24..ec89dd65 100644 --- a/apps/docs/content/docs/adapters/meta.json +++ b/apps/docs/content/docs/adapters/meta.json @@ -8,6 +8,7 @@ "discord", "telegram", "github", - "linear" + "linear", + "linq" ] } diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json index dcd0531e..66e49ce8 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -15,6 +15,7 @@ "@chat-adapter/gchat": "workspace:*", "@chat-adapter/github": "workspace:*", "@chat-adapter/linear": "workspace:*", + "@chat-adapter/linq": "workspace:*", "@chat-adapter/slack": "workspace:*", "@chat-adapter/state-memory": "workspace:*", "@chat-adapter/state-redis": "workspace:*", diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts index 11c362b8..957f7fda 100644 --- a/examples/nextjs-chat/src/lib/adapters.ts +++ b/examples/nextjs-chat/src/lib/adapters.ts @@ -8,6 +8,7 @@ import { } from "@chat-adapter/gchat"; import { createGitHubAdapter, type GitHubAdapter } from "@chat-adapter/github"; import { createLinearAdapter, type LinearAdapter } from "@chat-adapter/linear"; +import { createLinqAdapter, type LinqAdapter } from "@chat-adapter/linq"; import { createSlackAdapter, type SlackAdapter } from "@chat-adapter/slack"; import { createTeamsAdapter, type TeamsAdapter } from "@chat-adapter/teams"; import { @@ -25,6 +26,7 @@ export interface Adapters { gchat?: GoogleChatAdapter; github?: GitHubAdapter; linear?: LinearAdapter; + linq?: LinqAdapter; slack?: SlackAdapter; teams?: TeamsAdapter; telegram?: TelegramAdapter; @@ -86,6 +88,15 @@ const LINEAR_METHODS = [ "addReaction", "fetchMessages", ]; +const LINQ_METHODS = [ + "postMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "startTyping", + "fetchMessages", +]; const TELEGRAM_METHODS = [ "postMessage", "editMessage", @@ -204,6 +215,18 @@ export function buildAdapters(): Adapters { } } + // Linq adapter (optional) - env vars: LINQ_API_TOKEN, LINQ_SIGNING_SECRET, LINQ_PHONE_NUMBER + if (process.env.LINQ_API_TOKEN) { + adapters.linq = withRecording( + createLinqAdapter({ + userName: "Chat SDK Bot", + logger: logger.child("linq"), + }), + "linq", + LINQ_METHODS + ); + } + // Telegram adapter (optional) - env vars: TELEGRAM_BOT_TOKEN if (process.env.TELEGRAM_BOT_TOKEN) { adapters.telegram = withRecording( diff --git a/packages/adapter-linq/lib/linq/linq-api-v3.yaml b/packages/adapter-linq/lib/linq/linq-api-v3.yaml new file mode 100644 index 00000000..769fc73b --- /dev/null +++ b/packages/adapter-linq/lib/linq/linq-api-v3.yaml @@ -0,0 +1,5854 @@ +openapi: 3.1.0 +info: + title: Linq Partner API + version: 1.0.0 + description: | + The Linq Partner API enables you to send and receive iMessages programmatically at scale. Build powerful messaging experiences, automate conversations, and integrate iMessage infrastructure directly into your applications. + + Messages are sent through Apple's iMessage protocol using real devices, ensuring full compatibility with all iMessage features including read receipts, typing indicators, reactions, and rich media attachments. + + ## Getting Started + + All API requests require authentication via the `Authorization: Bearer` header. Your bearer token determines which phone numbers you can send from and which data you can access. + + ```bash + curl https://api.linqapp.com/api/partner/v3/chats \ + -H "Authorization: Bearer your_token_here" + ``` + + ## Key Concepts + + - **Chats**: Conversation threads with one or more participants + - **Messages**: Individual messages within a chat, supporting text, attachments, effects, and reactions + - **Participants**: Phone numbers or email addresses involved in a conversation + - **Attachments**: Files (images, videos, documents, audio) sent with messages + - **Webhooks**: Real-time notifications for incoming messages, reactions, and events + + ## Trace Context (OpenTelemetry) + + The Linq API supports [W3C Trace Context](https://www.w3.org/TR/trace-context/) for distributed tracing. All API responses include a `trace_id` field that can be used for debugging and support requests. + + Linq generates a new trace context for each incoming request. Any standardized `traceparent` or `tracestate` headers you provide will be ignored and replaced with a server-generated trace ID. This is by design per the W3C specification's security recommendations for public APIs acting as trust boundaries. + If you need to correlate multiple API requests on your end, we recommend: + - Using our `trace_id` returned in each API response + - Maintaining your own correlation ID and storing a mapping to our trace IDs + - Add your own custom headers as needed + + + ## Example Apps + + These open-source example apps show what you can build with the Linq Partner API. Each repo is ready to clone, configure with your API keys, and run. + + ### AI Agent + A complete, open-source example of a Claude-powered AI agent running on iMessage via the Linq Blue API. Fork it, customize it, and deploy your own in minutes. + + [🔗 Use Case](https://linqapp.com/s/use-cases/ai-agent) + + [📦 GitHub Repo](https://github.com/linq-team/ai-agent-example) + + ### Bookings Agent + A booking agent powered by Claude that searches restaurants, books tables, and manages reservations through Resy — all from iMessage, built on Linq. Text the number to try it, or clone the open-source repo to run your own. + + [🔗 Use Case](https://linqapp.com/s/use-cases/bookings-agent) + + [📦 GitHub Repo](https://github.com/linq-team/linq-resy-agent) + + ### Markets Agent + Kai is an open-source trading agent with 14 Claude tools, RSA-PSS signed Kalshi API calls, and rich iMessage features — built on Linq. Search markets, place trades, and track your portfolio entirely via text. + + [🔗 Use Case](https://linqapp.com/s/use-cases/kalshi-agent) + + [📦 GitHub Repo](https://github.com/linq-team/kalshi-agent) + contact: + name: Linq API Support + email: support@linq.com +servers: + - url: https://api.linqapp.com/api/partner + description: Production server +security: + - BearerAuth: [] +tags: + - name: Chats + x-page-title: Chats + x-page-icon: comments + description: | + A Chat is a conversation thread with one or more participants. + + To begin a chat, you must create a Chat with at least one recipient handle. + Including multiple handles creates a group chat. + + When creating a chat, the `from` field specifies which of your + authorized phone numbers the message originates from. Your authentication token grants + access to one or more phone numbers, but the `from` field determines the actual sender. + + **Handle Format:** + - Handles can be phone numbers or email addresses + - Phone numbers MUST be in E.164 format (starting with +) + - Phone format: `+[country code][subscriber number]` + - Example phone: `+12223334444` (US), `+442071234567` (UK), `+81312345678` (Japan) + - Example email: `user@example.com` + - No spaces, dashes, or parentheses in phone numbers + - name: Messages + x-page-title: Messages + x-page-icon: paper-plane + description: | + Messages are individual text or multimedia communications within a chat thread. + + Messages can include text, attachments, special effects (like confetti or fireworks), + and reactions. All messages are associated with a specific chat and sent from a + phone number you own. + + Messages support delivery status tracking, read receipts, and editing capabilities. + - name: Attachments + x-page-title: Attachments + x-page-icon: paperclip + description: | + Send files (images, videos, documents, audio) with messages by providing a URL in a media part. + Pre-uploading via `POST /v3/attachments` is **optional** and only needed for specific optimization scenarios. + + ## Sending Media via URL (up to 10MB) + + Provide a publicly accessible HTTPS URL with a [supported media type](#supported-file-types) in the `url` field of a media part. + + ```json + { + "parts": [ + { "type": "media", "url": "https://your-cdn.com/images/photo.jpg" } + ] + } + ``` + + This works with any URL you already host — no pre-upload step required. **Maximum file size: 10MB.** + + ## Pre-Upload (required for files over 10MB) + + Use `POST /v3/attachments` when you want to: + - **Send files larger than 10MB** (up to 100MB) — URL-based downloads are limited to 10MB + - **Send the same file to many recipients** — upload once, reuse the `attachment_id` without re-downloading each time + - **Reduce message send latency** — the file is already stored, so sending is faster + + **How it works:** + 1. `POST /v3/attachments` with file metadata → returns a presigned `upload_url` (valid for **15 minutes**) and a permanent `attachment_id` + 2. PUT the raw file bytes to the `upload_url` with the `required_headers` (no JSON or multipart — just the binary content) + 3. Reference the `attachment_id` in your media part when sending messages (no expiration) + + **Key difference:** When you provide an external `url`, we download and process the file on every send. + When you use a pre-uploaded `attachment_id`, the file is already stored — so repeated sends skip the download step entirely. + + ## Domain Allowlisting + + Attachment URLs in API responses are served from `cdn.linqapp.com`. This includes: + - `url` fields in media and voice memo message parts + - `download_url` fields in attachment and upload response objects + + If your application enforces domain allowlists (e.g., for SSRF protection), add: + + ``` + cdn.linqapp.com + ``` + + ## Supported File Types + + - **Images:** JPEG, PNG, GIF, HEIC, HEIF, TIFF, BMP + - **Videos:** MP4, MOV, M4V + - **Audio:** M4A, AAC, MP3, WAV, AIFF, CAF, AMR + - **Documents:** PDF, TXT, RTF, CSV, Office formats, ZIP + - **Contact & Calendar:** VCF, ICS + + ## Audio: Attachment vs Voice Memo + + Audio files sent as media parts appear as **downloadable file attachments** in iMessage. + To send audio as an **iMessage voice memo bubble** (with native inline playback UI), + use the dedicated `POST /v3/chats/{chatId}/voicememo` endpoint instead. + + ## File Size Limits + + - **URL-based (`url` field):** 10MB maximum + - **Pre-upload (`attachment_id`):** 100MB maximum + - name: Phone Numbers + x-page-title: Phone Numbers + x-page-icon: phone + description: | + Phone Numbers represent the phone numbers assigned to your partner account. + + Use the list phone numbers endpoint to discover which phone numbers are available + for sending messages. + + When creating chats, listing chats, or sending a voice memo, use one of your assigned phone numbers + in the `from` field. + - name: My Cards + x-page-title: My Cards + x-page-icon: address-card + description: | + My Cards let you set and share your contact information (name and profile photo) with chat participants via iMessage Name and Photo Sharing. + + Use `POST /v3/my_cards` to create or update a card for a phone number. + Use `POST /v3/my_cards/{chatId}/share` to share the card into a specific chat. + Use `GET /v3/my_cards` to retrieve the active card(s) for your partner account. + - name: Capability Checks + x-page-title: Capability Checks + x-page-icon: signal-bars + description: | + Check whether a recipient address supports iMessage or RCS before sending a message. + - name: Webhooks + x-page-title: Webhooks + x-page-icon: bell + description: | + Webhook Subscriptions allow you to receive real-time notifications when events + occur on your account. + + Configure webhook endpoints to receive events such as messages sent/received, + delivery status changes, reactions, typing indicators, and more. + + Failed deliveries (5xx, 429, network errors) are retried up to 10 times over + ~2 hours with exponential backoff. Each event includes a unique ID for + deduplication. + + ## Webhook Headers + + Each webhook request includes the following headers: + + | Header | Description | + |--------|-------------| + | `X-Webhook-Event` | The event type (e.g., `message.sent`, `message.received`) | + | `X-Webhook-Subscription-ID` | Your webhook subscription ID | + | `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent | + | `X-Webhook-Signature` | HMAC-SHA256 signature for verification | + + ## Verifying Webhook Signatures + + All webhooks are signed using HMAC-SHA256. You should always verify the signature + to ensure the webhook originated from Linq and hasn't been tampered with. + + **Signature Construction:** + + The signature is computed over a concatenation of the timestamp and payload: + + ``` + {timestamp}.{payload} + ``` + + Where: + - `timestamp` is the value from the `X-Webhook-Timestamp` header + - `payload` is the raw JSON request body (exact bytes, not re-serialized) + + **Verification Steps:** + + 1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers + 2. Get the raw request body bytes (do not parse and re-serialize) + 3. Concatenate: `"{timestamp}.{payload}"` + 4. Compute HMAC-SHA256 using your signing secret as the key + 5. Hex-encode the result and compare with `X-Webhook-Signature` + 6. Use constant-time comparison to prevent timing attacks + + **Example (Python):** + + ```python + import hmac + import hashlib + + def verify_webhook(signing_secret, payload, timestamp, signature): + message = f"{timestamp}.{payload.decode('utf-8')}" + expected = hmac.new( + signing_secret.encode('utf-8'), + message.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, signature) + ``` + + **Example (Node.js):** + + ```javascript + const crypto = require('crypto'); + + function verifyWebhook(signingSecret, payload, timestamp, signature) { + const message = `${timestamp}.${payload}`; + const expected = crypto + .createHmac('sha256', signingSecret) + .update(message) + .digest('hex'); + return crypto.timingSafeEqual( + Buffer.from(expected), + Buffer.from(signature) + ); + } + ``` + + **Security Best Practices:** + + - Reject webhooks with timestamps older than 5 minutes to prevent replay attacks + - Always use constant-time comparison for signature verification + - Store your signing secret securely (e.g., environment variable, secrets manager) + - Return a 2xx status code quickly, then process the webhook asynchronously + - name: Webhook Events + x-page-title: Webhook Events + x-page-icon: bolt + description: | + Webhook event definitions for the Linq API. + + These webhooks are delivered to your configured endpoint when events occur + in your messaging infrastructure. + + ## Webhook Versioning + + Webhook payloads are versioned using dates. **We strongly recommend specifying a version** + when creating webhook subscriptions by adding `?version=YYYY-MM-DD` to your target URL. + + ### Available Versions + + | Subscription Created | `webhook_version` | + |---------------------|-------------------| + | Before 2026-02-03 | `2025-01-01` | + | 2026-02-03 or later | `2026-02-03` | + + ### How to Specify a Version + + Add `?version=YYYY-MM-DD` to your webhook subscription URL: + + ``` + https://your-server.com/webhook?version=2026-02-03 + ``` + + If no version is specified, the subscription uses the latest available version at creation time. + + > **Warning:** If you delete and recreate a subscription without specifying a version, + > you may receive a different payload format than before. Always check the `webhook_version` + > in your current payloads before recreating subscriptions to ensure continuity. + + ### Differences Between Versions + + Only **message events** have different payload structures between versions: + + | Field | `2025-01-01` | `2026-02-03` | + |-------|--------------|--------------| + | Direction | `is_from_me: true/false` | `direction: "outbound"/"inbound"` | + | Sender | `from_handle` | `sender_handle` | + | Chat info | `chat_id`, `is_group` at top level | `chat` object with `id`, `is_group`, `owner_handle` | + | Message fields | Nested in `message` object | Flat at top level (`id`, `parts`, `sent_at`, etc.) | + + All other events (reactions, participants, chat events, typing indicators) use the same format regardless of version. + + ## Webhook Headers + + All webhook requests include these headers: + + | Header | Description | + |--------|-------------| + | `X-Webhook-Signature` | HMAC-SHA256 signature (hex-encoded) | + | `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent | + | `X-Webhook-Event` | Event type (e.g., `message.received`) | + | `X-Webhook-Subscription-ID` | Your webhook subscription ID | + + ## Signature Verification + + All webhooks are signed with HMAC-SHA256 using your webhook signing secret. + + **Important:** The signature is computed over `{timestamp}.{payload}`, not just the payload. + + ### Verification Steps + + 1. Extract the `X-Webhook-Timestamp` and `X-Webhook-Signature` headers + 2. Get the **raw request body** (exact bytes, not parsed/re-serialized JSON) + 3. Compute: `HMAC-SHA256(secret, "{timestamp}.{raw_body}")` + 4. Compare your computed signature (hex-encoded) with `X-Webhook-Signature` + + ### JavaScript Example + + ```javascript + const crypto = require('crypto'); + + function verifyWebhookSignature(secret, rawBody, timestamp, signature) { + const signedData = `${timestamp}.${rawBody}`; + const expected = crypto.createHmac('sha256', secret).update(signedData).digest('hex'); + return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex')); + } + + // In your handler: + const timestamp = req.headers['x-webhook-timestamp']; + const signature = req.headers['x-webhook-signature']; + const isValid = verifyWebhookSignature(secret, rawBody, timestamp, signature); + ``` + + ### Common Mistakes + + - **Missing timestamp:** Signing just the payload without prepending `{timestamp}.` + - **Re-serialized JSON:** Using `JSON.stringify(parsedBody)` instead of the raw request body — this can change key order or whitespace, breaking the signature + + ## Delivery Guarantees & SLA + + | Guarantee | Value | + |-----------|-------| + | **Response timeout** | 10 seconds | + | **Retry attempts** | 6 attempts per endpoint | + | **Retry backoff** | Exponential: 2s, 4s, 8s, 16s, 30s | + | **Total retry window** | ~60 seconds | + | **Delivery model** | At-least-once (duplicates possible during retries) | + + ### Retry Behavior + + If your endpoint returns a non-2xx status code or times out, we will retry delivery with exponential backoff: + + - **Attempt 1:** Immediate + - **Attempt 2:** After 2 seconds + - **Attempt 3:** After 4 seconds + - **Attempt 4:** After 8 seconds + - **Attempt 5:** After 16 seconds + - **Attempt 6:** After 30 seconds + + After 6 failed attempts (~60 seconds total), the webhook is dropped for that endpoint. + + **Important:** Retries are per-endpoint. If you have multiple webhook subscriptions, a failure on one endpoint does not affect delivery to your other endpoints. + + ### What Triggers a Retry? + + - HTTP 5xx responses (server errors) + - HTTP 429 (rate limited) + - Connection timeout (>10 seconds) + - Connection refused / network errors + + **Not retried:** HTTP 4xx responses (except 429) are considered permanent failures and are not retried. + + ### Your Endpoint Should + + 1. **Return `200` quickly** — respond within 10 seconds, process asynchronously if needed + 2. **Verify the HMAC signature** — reject requests with invalid signatures + 3. **Deduplicate using `event_id`** — the same event may arrive multiple times during retries + 4. **Be idempotent** — processing the same event twice should have no side effects + - name: '2025-01-01' + x-parent: Webhook Events + description: | + Webhook payload format for subscriptions created **before February 3, 2026**. + + This version uses `is_from_me` for direction and nests message fields under a `message` object. + - name: '2026-02-03' + x-parent: Webhook Events + description: | + Webhook payload format for subscriptions created **on or after February 3, 2026**. + + This version uses `direction: "inbound"/"outbound"` and places message fields at the top level of the `data` object. +paths: + /v3/chats: + post: + operationId: createChat + summary: Create a new chat + description: | + Create a new chat with specified participants and send an initial message. + The initial message is required when creating a chat. + + ## Message Effects + + You can add iMessage effects to make your messages more expressive. Effects are + optional and can be either screen effects (full-screen animations) or bubble effects + (message bubble animations). + + **Screen Effects:** `confetti`, `fireworks`, `lasers`, `sparkles`, `celebration`, + `hearts`, `love`, `balloons`, `happy_birthday`, `echo`, `spotlight` + + **Bubble Effects:** `slam`, `loud`, `gentle`, `invisible` + + Only one effect type can be applied per message. + tags: + - Chats + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateChatRequest' + examples: + chatWithTextMessage: + summary: Text-only message + value: + from: '+12052535597' + to: + - '+12052532136' + message: + parts: + - type: text + value: Hello! How can I help you today? + chatWithMediaUrl: + summary: Media via external URL (server downloads automatically) + value: + from: '+12052535597' + to: + - '+12052532136' + message: + parts: + - type: text + value: Check out this image! + - type: media + url: https://images.dog.ceo/breeds/terrier-cairn/n02096177_13328.jpg + chatWithPreuploadedMedia: + summary: Media via pre-uploaded attachment (after POST /v3/attachments) + value: + from: '+12052535597' + to: + - '+12052532136' + message: + parts: + - type: media + attachment_id: 550e8400-e29b-41d4-a716-446655440000 + chatWithConfettiEffect: + summary: Message with confetti screen effect + value: + from: '+12052535597' + to: + - '+12052532136' + message: + parts: + - type: text + value: Welcome aboard! 🎉 + effect: + screen_effect: confetti + chatWithInvisibleInk: + summary: Message with invisible ink bubble effect + value: + from: '+12052535597' + to: + - '+12052532136' + message: + parts: + - type: text + value: 'Here''s a secret code: ABC123' + effect: + bubble_effect: invisible + responses: + '201': + description: Chat created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CreateChatResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + get: + operationId: listChats + summary: List all chats + description: | + Retrieves a paginated list of chats for the authenticated partner filtered by phone number. + Returns all chats involving the specified phone number with their participants and recent activity. + + **Pagination:** + - Use `limit` to control page size (default: 20, max: 100) + - The response includes `next_cursor` for fetching the next page + - When `next_cursor` is `null`, there are no more results to fetch + - Pass the `next_cursor` value as the `cursor` parameter for the next request + + **Example pagination flow:** + 1. First request: `GET /v3/chats?from=%2B12223334444&limit=20` + 2. Response includes `next_cursor: "20"` (more results exist) + 3. Next request: `GET /v3/chats?from=%2B12223334444&limit=20&cursor=20` + 4. Response includes `next_cursor: null` (no more results) + tags: + - Chats + security: + - BearerAuth: [] + parameters: + - name: from + in: query + required: true + description: | + Phone number to filter chats by. Returns all chats made from this phone number. + Must be in E.164 format (e.g., `+13343284472`). The `+` is automatically URL-encoded by HTTP clients. + schema: + type: string + pattern: ^\+?[1-9]\d{1,14}$ + example: '+13343284472' + - name: limit + in: query + required: false + description: Maximum number of chats to return per page + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + example: 20 + - name: cursor + in: query + required: false + description: | + Pagination cursor from the previous response's `next_cursor` field. + Omit this parameter for the first page of results. + schema: + type: string + example: '20' + responses: + '200': + description: List of chats retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ListChatsResult' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/chats/{chatId}: + get: + operationId: getChat + summary: Get a chat by ID + description: Retrieve a chat by its unique identifier. + tags: + - Chats + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + responses: + '200': + description: Chat found and returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Chat' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: updateChat + summary: Update a chat + description: | + Update chat properties such as display name and group chat icon. + tags: + - Chats + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateChatRequest' + examples: + updateName: + summary: Update chat display name + value: + display_name: Team Discussion + updateIcon: + summary: Update group chat icon + value: + group_chat_icon: https://example.com/group-icon.png + updateBoth: + summary: Update both display name and icon + value: + display_name: Team Discussion + group_chat_icon: https://example.com/group-icon.png + responses: + '200': + description: Chat updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Chat' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/chats/{chatId}/participants: + post: + operationId: addParticipant + summary: Add a participant to a chat + description: | + Add a new participant to an existing group chat. + + **Requirements:** + - Group chats only (3+ existing participants) + - New participant must support the same messaging service as the group + - Cross-service additions not allowed (e.g., can't add RCS-only user to iMessage group) + - For cross-service scenarios, create a new chat instead + tags: + - Chats + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddParticipantRequest' + examples: + addUser: + summary: Add a participant + value: + handle: '+12052499136' + responses: + '202': + description: Participant addition queued successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: accepted + trace_id: + type: string + message: + type: string + example: Participant addition queued + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: removeParticipant + summary: Remove a participant from a chat + description: | + Remove a participant from an existing group chat. + + **Requirements:** + - Group chats only + - Must have 3+ participants after removal + tags: + - Chats + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RemoveParticipantRequest' + examples: + removeUser: + summary: Remove a participant + value: + handle: '+12052499136' + responses: + '202': + description: Participant removal queued successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: accepted + trace_id: + type: string + message: + type: string + example: Participant removal queued + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/chats/{chatId}/typing: + post: + operationId: startTyping + summary: Start typing indicator + description: | + Send a typing indicator to show that someone is typing in the chat. + + **Note:** Group chat typing indicators are not currently supported. + tags: + - Chats + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + responses: + '204': + description: Typing indicator started successfully + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: stopTyping + summary: Stop typing indicator + description: | + Stop the typing indicator for the chat. + + **Note:** Typing indicators are automatically stopped when a message is sent, + so calling this endpoint after sending a message is unnecessary. + + **Note:** Group chat typing indicators are not currently supported. + tags: + - Chats + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + responses: + '204': + description: Typing indicator stopped successfully + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/chats/{chatId}/read: + post: + operationId: markChatAsRead + summary: Mark chat as read + description: | + Mark all messages in a chat as read. + tags: + - Chats + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + responses: + '204': + description: Chat marked as read successfully + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/chats/{chatId}/share_contact_card: + post: + operationId: shareContactWithChat + summary: Share your contact card with a chat + deprecated: true + description: | + **Deprecated:** Use `POST /v3/my_cards/{chatId}/share` instead. + + Share your contact information (Name and Photo Sharing) with a chat. + + **Note:** A contact card must be configured before sharing. You can set up your contact card on the [Linq dashboard](https://dashboard.linqapp.com/contact-cards). + tags: + - Chats + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + responses: + '204': + description: Contact shared successfully + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/chats/{chatId}/messages: + post: + operationId: sendMessageToChat + summary: Send a message to an existing chat + description: | + Send a message to an existing chat. Use this endpoint when you already have + a chat ID and want to send additional messages to it. + + ## Message Effects + + You can add iMessage effects to make your messages more expressive. Effects are + optional and can be either screen effects (full-screen animations) or bubble effects + (message bubble animations). + + **Screen Effects:** `confetti`, `fireworks`, `lasers`, `sparkles`, `celebration`, + `hearts`, `love`, `balloons`, `happy_birthday`, `echo`, `spotlight` + + **Bubble Effects:** `slam`, `loud`, `gentle`, `invisible` + + Only one effect type can be applied per message. + tags: + - Messages + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendMessageToChatRequest' + examples: + simple_text: + summary: Simple text message + value: + message: + parts: + - type: text + value: Hello, world! + with_confetti: + summary: Message with confetti effect + value: + message: + parts: + - type: text + value: Congratulations! 🎉 + effect: + screen_effect: confetti + with_slam: + summary: Message with slam bubble effect + value: + message: + parts: + - type: text + value: BOOM! + effect: + bubble_effect: slam + with_invisible_ink: + summary: Message with invisible ink effect + value: + message: + parts: + - type: text + value: This is a secret message + effect: + bubble_effect: invisible + responses: + '202': + description: Message accepted for delivery + content: + application/json: + schema: + $ref: '#/components/schemas/SendMessageResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + get: + operationId: getMessages + summary: Get messages from a chat + description: | + Retrieve messages from a specific chat with pagination support. + tags: + - Messages + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + - name: cursor + in: query + description: Pagination cursor from previous next_cursor response + schema: + type: string + - name: limit + in: query + description: Maximum number of messages to return + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + responses: + '200': + description: Messages retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetMessagesResult' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/messages/{messageId}/thread: + get: + operationId: getMessageThread + summary: Get all messages in a thread + description: | + Retrieve all messages in a conversation thread. Given any message ID in the thread, + returns the originator message and all replies in chronological order. + + If the message is not part of a thread, returns just that single message. + + Supports pagination and configurable ordering. + tags: + - Messages + security: + - BearerAuth: [] + parameters: + - name: messageId + in: path + required: true + description: ID of any message in the thread (can be originator or any reply) + schema: + type: string + format: uuid + example: 69a37c7d-af4f-4b5e-af42-e28e98ce873a + - name: cursor + in: query + description: Pagination cursor from previous next_cursor response + schema: + type: string + - name: limit + in: query + description: Maximum number of messages to return + schema: + type: integer + minimum: 1 + maximum: 100 + default: 50 + - name: order + in: query + description: Sort order for messages (asc = oldest first, desc = newest first) + schema: + type: string + enum: + - asc + - desc + default: asc + responses: + '200': + description: Thread retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/GetThreadResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/chats/{chatId}/voicememo: + post: + operationId: sendVoiceMemoToChat + summary: Send a voice memo to a chat + description: | + Send an audio file as an **iMessage voice memo bubble** to all participants in a chat. + Voice memos appear with iMessage's native inline playback UI, unlike regular audio + attachments sent via media parts which appear as downloadable files. + + **Supported audio formats:** + - MP3 (audio/mpeg) + - M4A (audio/x-m4a, audio/mp4) + - AAC (audio/aac) + - CAF (audio/x-caf) - Core Audio Format + - WAV (audio/wav) + - AIFF (audio/aiff, audio/x-aiff) + - AMR (audio/amr) + tags: + - Messages + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + example: f19ee7b8-8533-4c5c-83ec-4ef8d6d1ddbd + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendVoiceMemoToChatRequest' + example: + voice_memo_url: https://example.com/voice-memo.m4a + responses: + '202': + description: Voice memo accepted for delivery + content: + application/json: + schema: + $ref: '#/components/schemas/SendVoiceMemoToChatResult' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '413': + description: Voice memo file too large + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + status: 413 + code: 5001 + message: Voice memo file too large - maximum size is 10MB + success: false + '422': + $ref: '#/components/responses/UnprocessableEntity' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/messages/{messageId}: + get: + operationId: getMessage + summary: Get a message by ID + description: | + Retrieve a specific message by its ID. This endpoint returns the full message + details including text, attachments, reactions, and metadata. + tags: + - Messages + security: + - BearerAuth: [] + parameters: + - name: messageId + in: path + required: true + description: Unique identifier of the message to retrieve + schema: + type: string + format: uuid + example: 69a37c7d-af4f-4b5e-af42-e28e98ce873a + responses: + '200': + description: Message found and returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Message' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + patch: + operationId: editMessage + summary: Edit the content of a message part + description: Edit the text content of a specific part of a previously sent message. + tags: + - Messages + security: + - BearerAuth: [] + parameters: + - name: messageId + in: path + required: true + description: Unique identifier of the message to edit + schema: + type: string + format: uuid + example: 69a37c7d-af4f-4b5e-af42-e28e98ce873a + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EditMessageRequest' + examples: + editMessage: + summary: Edit the first part of a message + value: + part_index: 0 + text: This is the edited message content + responses: + '200': + description: Message updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Message' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: deleteMessage + summary: Delete a message from system + description: | + Deletes a message from the Linq API only. This does NOT unsend or remove the message + from the actual chat - recipients will still see the message. + + Use this endpoint to remove messages from your records and prevent them from appearing + in API responses. + tags: + - Messages + security: + - BearerAuth: [] + parameters: + - name: messageId + in: path + required: true + description: Unique identifier of the message to delete + schema: + type: string + format: uuid + example: 69a37c7d-af4f-4b5e-af42-e28e98ce873a + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteMessageRequest' + examples: + deleteMessage: + summary: Delete a message + value: + chat_id: 94c6bf33-31d9-40e3-a0e9-f94250ecedb9 + responses: + '204': + description: Message deleted successfully (no content returned) + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/messages/{messageId}/reactions: + post: + operationId: sendReaction + summary: Add or remove a reaction to a message + description: | + Add or remove emoji reactions to messages. Reactions let users express + their response to a message without sending a new message. + + **Supported Reactions:** + - love ❤️ + - like 👍 + - dislike 👎 + - laugh 😂 + - emphasize ‼️ + - question ❓ + - custom - any emoji (use `custom_emoji` field to specify) + tags: + - Messages + security: + - BearerAuth: [] + parameters: + - name: messageId + in: path + required: true + description: Unique identifier of the message to react to + schema: + type: string + format: uuid + example: 69a37c7d-af4f-4b5e-af42-e28e98ce873a + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SendReactionRequest' + examples: + addLove: + summary: Add a love reaction + value: + operation: add + type: love + removeLike: + summary: Remove a like reaction + value: + operation: remove + type: like + reactToMessagePart: + summary: React to a specific message part + value: + operation: add + type: laugh + part_index: 1 + responses: + '202': + description: Reaction queued successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: accepted + trace_id: + type: string + message: + type: string + example: Reaction processed + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/attachments: + post: + operationId: requestUpload + summary: Pre-upload a file + description: | + **This endpoint is optional.** You can send media by simply providing a URL in your + message's media part — no pre-upload required. Use this endpoint only when you want + to upload a file ahead of time for reuse or latency optimization. + + Returns a presigned upload URL and a permanent `attachment_id` you can reference + in future messages. + + ## Step 1: Request an upload URL + + Call this endpoint with file metadata: + + ```json + POST /v3/attachments + { + "filename": "photo.jpg", + "content_type": "image/jpeg", + "size_bytes": 1024000 + } + ``` + + The response includes an `upload_url` (valid for 15 minutes) and a permanent `attachment_id`. + + ## Step 2: Upload the file + + Make a PUT request to the `upload_url` with the raw file bytes as the request body.. Include the headers from `required_headers`. + The request body is the binary file content — **not** JSON, **not** multipart form data. + + ```bash + curl -X PUT "" \ + -H "Content-Type: image/jpeg" \ + --data-binary @filebytes + ``` + + ## Step 3: Send a message with the attachment + + Reference the `attachment_id` in a media part. The ID never expires — use it in as many messages as you want. + + ```json + POST /v3/messages + { + "to": ["+15551234567"], + "from": "+15559876543", + "parts": [ + { "type": "media", "attachment_id": "" } + ] + } + ``` + + ## When to use this instead of a URL in the media part + + - Sending the same file to multiple recipients (avoids re-downloading each time) + - Large files where you want to separate upload from message send + - Latency-sensitive sends where the file should already be stored + + If you just need to send a file once, skip all of this and pass a `url` directly in the media part instead. + + **File Size Limit:** 100MB + + **Unsupported Types:** WebP, SVG, FLAC, OGG, and executable files are explicitly rejected. + tags: + - Attachments + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RequestUploadRequest' + examples: + imageUpload: + summary: Request upload for an image + value: + filename: photo.jpg + content_type: image/jpeg + size_bytes: 1024000 + videoUpload: + summary: Request upload for a video + value: + filename: video.mp4 + content_type: video/mp4 + size_bytes: 50000000 + responses: + '200': + description: Upload URL generated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/RequestUploadResult' + examples: + presignedUrlResponse: + summary: Presigned upload URL ready + value: + attachment_id: 550e8400-e29b-41d4-a716-446655440000 + upload_url: https://uploads.linqapp.com/attachments/550e8400?X-Amz-Algorithm=AWS4-HMAC-SHA256&... + download_url: https://cdn.linqapp.com/uploads/partner-id/550e8400/photo.jpg + http_method: PUT + expires_at: '2024-01-15T10:45:00Z' + required_headers: + Content-Type: image/jpeg + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/attachments/{attachmentId}: + get: + operationId: getAttachment + summary: Get attachment metadata + description: | + Retrieve metadata for a specific attachment including its status, + file information, and URLs for downloading. + tags: + - Attachments + security: + - BearerAuth: [] + parameters: + - name: attachmentId + in: path + required: true + description: Unique identifier of the attachment + schema: + type: string + format: uuid + example: abc12345-1234-5678-9abc-def012345678 + responses: + '200': + description: Attachment found and returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Attachment' + example: + id: 550e8400-e29b-41d4-a716-446655440000 + filename: photo.jpg + content_type: image/jpeg + size_bytes: 1024000 + status: complete + download_url: https://cdn.linqapp.com/attachments/550e8400-e29b-41d4-a716-446655440000/photo.jpg + created_at: '2024-01-15T10:30:00Z' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/phonenumbers: + get: + operationId: listPhoneNumbersDeprecated + summary: List phone numbers (deprecated) + deprecated: true + description: | + **Deprecated.** Use `GET /v3/phone_numbers` instead. + tags: + - Phone Numbers + security: + - BearerAuth: [] + x-internal: true + responses: + '200': + description: Phone numbers retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ListPhoneNumbersResult' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/phone_numbers: + get: + operationId: listPhoneNumbers + summary: List phone numbers + description: | + Returns all phone numbers assigned to the authenticated partner. + Use this endpoint to discover which phone numbers are available for + use as the `from` field when creating a chat, listing chats, or sending a voice memo. + tags: + - Phone Numbers + security: + - BearerAuth: [] + responses: + '200': + description: Phone numbers retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ListPhoneNumbersResultV2' + example: + phone_numbers: + - id: 550e8400-e29b-41d4-a716-446655440000 + phone_number: '+12025551234' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/webhook-events: + get: + operationId: listWebhookEvents + summary: List available webhook event types + description: | + Returns all available webhook event types that can be subscribed to. + Use this endpoint to discover valid values for the `subscribed_events` + field when creating or updating webhook subscriptions. + tags: + - Webhooks + security: + - BearerAuth: [] + responses: + '200': + description: List of available webhook event types + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookEventsResult' + example: + events: + - message.sent + - message.received + - message.read + - message.delivered + - message.failed + - reaction.added + - reaction.removed + - participant.added + - participant.removed + - chat.created + - chat.group_name_updated + - chat.group_icon_updated + - chat.group_name_update_failed + - chat.group_icon_update_failed + - chat.typing_indicator.started + - chat.typing_indicator.stopped + doc_url: https://apidocs.linqapp.com/documentation/webhook-events + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/webhook-subscriptions: + post: + operationId: createWebhookSubscription + summary: Create a new webhook subscription + description: | + Create a new webhook subscription to receive events at a target URL. + Upon creation, a signing secret is generated for verifying webhook + authenticity. **Store this secret securely — it cannot be retrieved later.** + + **Webhook Delivery:** + - Events are sent via HTTP POST to the target URL + - Each request includes `X-Webhook-Signature` and `X-Webhook-Timestamp` headers + - Signature is HMAC-SHA256 over `{timestamp}.{payload}` — see [Webhook Events](/docs/webhook-events) for verification details + - Failed deliveries (5xx, 429, network errors) are retried up to 10 times over ~2 hours with exponential backoff + - Client errors (4xx except 429) are not retried + tags: + - Webhooks + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWebhookSubscriptionRequest' + examples: + messageEvents: + summary: Subscribe to message events + value: + target_url: https://webhooks.example.com/linq/events + subscribed_events: + - message.sent + - message.delivered + - message.read + messageEventsWithVersion: + summary: Subscribe with explicit webhook version + value: + target_url: https://webhooks.example.com/linq/events?version=2026-02-03 + subscribed_events: + - message.sent + - message.delivered + - message.read + allEvents: + summary: Subscribe to all events + value: + target_url: https://webhooks.example.com/linq/events + subscribed_events: + - message.sent + - message.delivered + - message.read + - message.failed + - message.received + - reaction.added + - reaction.removed + - participant.added + - participant.removed + responses: + '201': + description: Webhook subscription created successfully. The signing_secret is only returned in this response - store it securely. + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSubscriptionCreatedResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + get: + operationId: listWebhookSubscriptions + summary: List all webhook subscriptions + description: | + Retrieve all webhook subscriptions for the authenticated partner. + Returns a list of active and inactive subscriptions with their + configuration and status. + tags: + - Webhooks + security: + - BearerAuth: [] + responses: + '200': + description: List of webhook subscriptions retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ListWebhookSubscriptionsResult' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/webhook-subscriptions/{subscriptionId}: + get: + operationId: getWebhookSubscription + summary: Get a webhook subscription by ID + description: | + Retrieve details for a specific webhook subscription including its + target URL, subscribed events, and current status. + tags: + - Webhooks + security: + - BearerAuth: [] + parameters: + - name: subscriptionId + in: path + required: true + description: Unique identifier of the webhook subscription + schema: + type: string + example: b2c3d4e5-f6a7-8901-bcde-f23456789012 + responses: + '200': + description: Webhook subscription found and returned successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSubscriptionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + put: + operationId: updateWebhookSubscription + summary: Update a webhook subscription + description: | + Update an existing webhook subscription. You can modify the target URL, + subscribed events, or activate/deactivate the subscription. + + **Note:** The signing secret cannot be changed via this endpoint. + tags: + - Webhooks + security: + - BearerAuth: [] + parameters: + - name: subscriptionId + in: path + required: true + description: Unique identifier of the webhook subscription + schema: + type: string + example: b2c3d4e5-f6a7-8901-bcde-f23456789012 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateWebhookSubscriptionRequest' + examples: + updateURL: + summary: Update target URL + value: + target_url: https://webhooks.example.com/linq/events + updateEvents: + summary: Update subscribed events + value: + subscribed_events: + - message.sent + - message.delivered + deactivate: + summary: Deactivate subscription + value: + is_active: false + responses: + '200': + description: Webhook subscription updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSubscriptionResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + operationId: deleteWebhookSubscription + summary: Delete a webhook subscription + description: Delete a webhook subscription. + tags: + - Webhooks + security: + - BearerAuth: [] + parameters: + - name: subscriptionId + in: path + required: true + description: Unique identifier of the webhook subscription + schema: + type: string + example: b2c3d4e5-f6a7-8901-bcde-f23456789012 + responses: + '204': + description: Webhook subscription deleted successfully + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/capability/check_imessage: + post: + operationId: checkImessageCapability + summary: Check iMessage capability + description: | + Check whether a recipient address (phone number or email) is reachable via iMessage. + tags: + - Capability Checks + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/HandleCheckRequest' + example: + address: '+15551234567' + responses: + '200': + description: iMessage capability check completed + content: + application/json: + schema: + $ref: '#/components/schemas/HandleCheckResponse' + example: + address: '+15551234567' + available: true + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/capability/check_rcs: + post: + operationId: checkRcsCapability + summary: Check RCS capability + description: | + Check whether a recipient address (phone number) supports RCS messaging. + tags: + - Capability Checks + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/HandleCheckRequest' + example: + address: '+15551234567' + responses: + '200': + description: RCS capability check completed + content: + application/json: + schema: + $ref: '#/components/schemas/HandleCheckResponse' + example: + address: '+15551234567' + available: true + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalServerError' + '503': + $ref: '#/components/responses/ServiceUnavailable' + /v3/my_cards/{chatId}/share: + post: + operationId: shareMyCard + summary: Share my card with a chat + description: | + Share your contact information (Name and Photo Sharing) with a chat. + + **Note:** A my card must be configured before sharing. + tags: + - My Cards + security: + - BearerAuth: [] + parameters: + - name: chatId + in: path + required: true + description: Unique identifier of the chat + schema: + type: string + format: uuid + responses: + '204': + description: My card shared successfully + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /v3/my_cards: + get: + operationId: getMyCards + summary: Get my cards + description: | + Returns the my card for a specific phone number, or all my cards for the + authenticated partner if no `phone_number` is provided. + tags: + - My Cards + security: + - BearerAuth: [] + parameters: + - name: phone_number + in: query + required: false + description: E.164 phone number to filter by. If omitted, all my cards for the partner are returned. + schema: + type: string + example: '+15551234567' + responses: + '200': + description: My card(s) returned + content: + application/json: + schema: + $ref: '#/components/schemas/GetMyCardResponse' + example: + my_cards: + - phone_number: '+15551234567' + first_name: John + last_name: Doe + image_url: https://cdn.linqapp.com/my-card/example.jpg + is_active: true + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' + post: + operationId: setupMyCard + summary: Setup my card + description: | + Creates or updates a my card for a phone number and syncs it to the device. + + The my card is stored in an inactive state first. Once it's applied successfully, + it is activated and `is_active` is returned as `true`. On failure, `is_active` is `false`. + tags: + - My Cards + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SetMyCardRequest' + example: + phone_number: '+15551234567' + first_name: John + last_name: Doe + image_url: https://cdn.linqapp.com/my-card/example.jpg + responses: + '200': + description: My card created/updated and sync attempted + content: + application/json: + schema: + $ref: '#/components/schemas/SetMyCardResponse' + example: + phone_number: '+15551234567' + first_name: John + last_name: Doe + image_url: https://cdn.linqapp.com/my-card/example.jpg + is_active: true + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/InternalServerError' +webhooks: + message.sent.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookMessageSentV2026 + summary: Message sent + description: | + Triggered when a message has been successfully sent from your phone number. + This confirms the message has been sent, but potentially has not yet been delivered. + + **Timestamps:** `sent_at` is set, `delivered_at` and `read_at` are null. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageSentWebhookV2' + example: + api_version: v3 + webhook_version: '2026-02-03' + event_type: message.sent + event_id: e20feb41-7f67-43f0-89c8-a985cff3b568 + created_at: '2026-02-05T19:52:18.101373886Z' + trace_id: 2eff5df5c6f688733c007523c4d61cd9 + partner_id: your-partner-id + data: + chat: + id: 0c961e93-e7bf-4db2-bf7b-ea06826bcab4 + is_group: false + owner_handle: + handle: '+12025551234' + id: 8d79532a-f529-4244-a5cf-d443de051434 + is_me: true + joined_at: '2026-01-21T21:59:45.191571Z' + left_at: null + service: iMessage + status: active + id: 347d62c2-2170-4754-8d30-c76d0c727d96 + idempotency_key: null + direction: outbound + sender_handle: + handle: '+12025551234' + id: 8d79532a-f529-4244-a5cf-d443de051434 + is_me: true + joined_at: '2026-01-21T21:59:45.191571Z' + left_at: null + service: iMessage + status: active + parts: + - type: text + value: Hello from Linq! + - filename: photo.jpg + id: f13dda7d-ecac-49eb-b3fe-16fe286abf19 + mime_type: image/jpeg + size_bytes: 245678 + type: media + url: https://cdn.linqapp.com/attachments/a1b2c3d4/photo.jpg?signature=... + effect: null + sent_at: '2026-02-05T19:52:17.219Z' + delivered_at: null + read_at: null + service: iMessage + preferred_service: null + responses: + '200': + description: Webhook received successfully + message.received.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookMessageReceivedV2026 + summary: Message received + description: | + Triggered when an incoming message is received on your phone number. + Contains the full message content including any attachments. + + **Timestamps:** `sent_at` is set, `delivered_at` and `read_at` are null. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageReceivedWebhookV2' + example: + api_version: v3 + webhook_version: '2026-02-03' + event_type: message.received + event_id: 2915e81c-5068-4796-ace2-21d2c94ad298 + created_at: '2026-02-05T19:31:13.736444093Z' + trace_id: 8af9171a45022df2eb74ba4e4c83be0f + partner_id: your-partner-id + data: + chat: + id: 8f392755-6865-4b18-880a-227f9d8b458f + is_group: false + owner_handle: + handle: '+12025551234' + id: 6d6c617f-187a-4dcd-a0d5-988347a8c092 + is_me: true + joined_at: '2026-01-04T05:48:51.321469Z' + left_at: null + service: iMessage + status: active + id: 89e3566e-1d13-49e5-a8ee-48490d5bfeb7 + direction: inbound + sender_handle: + handle: '+12025559876' + id: e604375a-5913-483a-8278-c631e8f0ffda + is_me: false + joined_at: '2026-01-04T05:48:51.321469Z' + left_at: null + service: iMessage + status: active + parts: + - type: text + value: Hello! + effect: null + reply_to: null + sent_at: '2026-02-05T19:31:13.074Z' + delivered_at: null + read_at: null + service: iMessage + responses: + '200': + description: Webhook received successfully + message.read.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookMessageReadV2026 + summary: Message read + description: | + Triggered when a sent message has been read by the recipient. + Only available for iMessage conversations with read receipts enabled. + + **Timestamps:** `sent_at`, `delivered_at`, and `read_at` are all set. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageReadWebhookV2' + example: + api_version: v3 + webhook_version: '2026-02-03' + event_type: message.read + event_id: 8fd42065-b998-482a-93b3-da855f8dad17 + created_at: '2026-02-05T19:13:58.833366566Z' + trace_id: cbb93c08fa1a3f3c4c2efc161d67f36d + partner_id: your-partner-id + data: + chat: + id: 24e33345-e6cf-4f50-9d35-1d7fde8c9818 + is_group: false + owner_handle: + handle: '+12025551234' + id: d31678e9-0442-48fd-b7ed-c898d245dd15 + is_me: true + joined_at: '2026-01-18T03:38:41.442254Z' + left_at: null + service: iMessage + status: active + id: dc6d3f68-90df-48f0-a504-e65f239a383c + idempotency_key: null + direction: outbound + sender_handle: + handle: '+12025551234' + id: d31678e9-0442-48fd-b7ed-c898d245dd15 + is_me: true + joined_at: '2026-01-18T03:38:41.442254Z' + left_at: null + service: iMessage + status: active + parts: + - type: text + value: Hello world! + effect: null + sent_at: '2026-02-05T19:13:57.814Z' + delivered_at: '2026-02-05T19:13:57.948Z' + read_at: '2026-02-05T19:13:58.177Z' + service: iMessage + preferred_service: null + responses: + '200': + description: Webhook received successfully + message.delivered.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookMessageDeliveredV2026 + summary: Message delivered + description: | + Triggered when a sent message has been delivered to the recipient's device. + This confirms the message reached the recipient, but does not indicate it was read. + Only available for iMessage conversations with delivery receipts. + + **Timestamps:** `sent_at` and `delivered_at` are set, `read_at` is null. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageDeliveredWebhookV2' + example: + api_version: v3 + webhook_version: '2026-02-03' + event_type: message.delivered + event_id: 67c4ad39-e9b0-47f6-82f8-64bdd8ceafa6 + created_at: '2026-02-05T19:52:22.593689073Z' + trace_id: abde7f6248fba00f97e8c7dc4782d7e0 + partner_id: your-partner-id + data: + chat: + id: 0c961e93-e7bf-4db2-bf7b-ea06826bcab4 + is_group: false + owner_handle: + handle: '+12025551234' + id: 8d79532a-f529-4244-a5cf-d443de051434 + is_me: true + joined_at: '2026-01-21T21:59:45.191571Z' + left_at: null + service: iMessage + status: active + id: 347d62c2-2170-4754-8d30-c76d0c727d96 + idempotency_key: null + direction: outbound + sender_handle: + handle: '+12025551234' + id: 8d79532a-f529-4244-a5cf-d443de051434 + is_me: true + joined_at: '2026-01-21T21:59:45.191571Z' + left_at: null + service: iMessage + status: active + parts: + - type: text + value: Hello world! + - filename: photo.gif + id: b9ed828d-dbac-431f-889a-23f276384389 + mime_type: image/gif + size_bytes: 2776819 + type: media + url: https://cdn.linqapp.com/attachments/b9ed828d/photo.gif?signature=... + effect: null + sent_at: '2026-02-05T19:52:17.219Z' + delivered_at: '2026-02-05T19:52:22.291Z' + read_at: null + service: iMessage + preferred_service: null + responses: + '200': + description: Webhook received successfully + message.failed.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookMessageFailedV2026 + summary: Message delivery failed + description: | + Triggered when a message fails to be delivered. This can happen due to: + - Request timeout (message expired before being processed) + - Upstream service error (processing failed after retries) + - Request cancelled (message was explicitly cancelled) + - Service unavailable (no service available to process the request) + + Note: The original message content may not be available if the message + expired before being consumed by a processor. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageFailedWebhook' + responses: + '200': + description: Webhook received successfully + reaction.added.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookReactionAddedV2026 + summary: Reaction added + description: | + Triggered when a reaction (tapback) is added to a message. + Includes standard reactions like "loved", "liked", "disliked", etc. + and custom emoji reactions. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReactionAddedWebhook' + responses: + '200': + description: Webhook received successfully + reaction.removed.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookReactionRemovedV2026 + summary: Reaction removed + description: | + Triggered when a reaction (tapback) is removed from a message. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReactionRemovedWebhook' + responses: + '200': + description: Webhook received successfully + participant.added.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookParticipantAddedV2026 + summary: Participant added + description: | + Triggered when a new participant is added to a group chat. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ParticipantAddedWebhook' + responses: + '200': + description: Webhook received successfully + participant.removed.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookParticipantRemovedV2026 + summary: Participant removed + description: | + Triggered when a participant is removed from a group chat. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ParticipantRemovedWebhook' + responses: + '200': + description: Webhook received successfully + chat.group_name_updated.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookChatGroupNameUpdatedV2026 + summary: Group name updated + description: | + Triggered when a group chat's display name is updated. + A null `new_value` indicates the name was removed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatGroupNameUpdatedWebhook' + responses: + '200': + description: Webhook received successfully + chat.group_icon_updated.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookChatGroupIconUpdatedV2026 + summary: Group icon updated + description: | + Triggered when a group chat's icon is updated. + A null `new_value` indicates the icon was removed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatGroupIconUpdatedWebhook' + responses: + '200': + description: Webhook received successfully + chat.group_name_update_failed.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookChatGroupNameUpdateFailedV2026 + summary: Group name update failed + description: Triggered when a group chat name update request fails. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatGroupNameUpdateFailedWebhook' + responses: + '200': + description: Webhook received successfully + chat.group_icon_update_failed.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookChatGroupIconUpdateFailedV2026 + summary: Group icon update failed + description: Triggered when a group chat icon update request fails. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatGroupIconUpdateFailedWebhook' + responses: + '200': + description: Webhook received successfully + chat.created.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookChatCreatedV2026 + summary: Chat created + description: Triggered when a new chat is created. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatCreatedWebhook' + responses: + '200': + description: Webhook received successfully + chat.typing_indicator.started.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookChatTypingIndicatorStartedV2026 + summary: Typing indicator started + description: Triggered when a participant starts typing in a chat. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatTypingIndicatorStartedWebhook' + responses: + '200': + description: Webhook received successfully + chat.typing_indicator.stopped.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookChatTypingIndicatorStoppedV2026 + summary: Typing indicator stopped + description: Triggered when a participant stops typing in a chat. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatTypingIndicatorStoppedWebhook' + responses: + '200': + description: Webhook received successfully + phone_number.status_updated.v2026: + post: + tags: + - '2026-02-03' + operationId: webhookPhoneNumberStatusUpdatedV2026 + summary: Phone number status updated + description: | + Triggered when a phone number's service status changes from active to flagged or vice versa. + Use this to monitor phone number health and availability. + + **Status values:** + - `ACTIVE` — Phone number is healthy and can send/receive messages + - `FLAGGED` — Phone number has been flagged and may have limited functionality + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PhoneNumberStatusUpdatedWebhook' + example: + api_version: v3 + webhook_version: '2026-02-03' + event_type: phone_number.status_updated + event_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + created_at: '2026-02-18T18:35:05.363Z' + trace_id: b66e67c5c6b2c20e41d53c51698db27a + partner_id: your-partner-id + data: + phone_number: '+12025551234' + previous_status: ACTIVE + new_status: FLAGGED + changed_at: '2026-02-18T18:35:05.000Z' + responses: + '200': + description: Webhook received successfully + message.sent.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookMessageSentV2025 + summary: Message sent + description: | + Triggered when a message has been successfully sent from your phone number. + This confirms the message has been sent, but potentially has not yet been delivered. + + **Timestamps:** `sent_at` is set, `delivered_at` and `read_at` are null. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageSentWebhook' + example: + api_version: v3 + webhook_version: '2025-01-01' + event_type: message.sent + event_id: e20feb41-7f67-43f0-89c8-a985cff3b568 + created_at: '2026-02-05T19:52:18.101373886Z' + trace_id: 2eff5df5c6f688733c007523c4d61cd9 + partner_id: your-partner-id + data: + chat_id: 0c961e93-e7bf-4db2-bf7b-ea06826bcab4 + from: '+12025551234' + from_handle: + handle: '+12025551234' + id: 8d79532a-f529-4244-a5cf-d443de051434 + is_me: true + joined_at: '2026-01-21T21:59:45.191571Z' + left_at: null + service: iMessage + status: active + idempotency_key: null + is_from_me: true + is_group: false + message: + created_at: '2026-02-05T19:52:17.041183Z' + delivered_at: null + id: 347d62c2-2170-4754-8d30-c76d0c727d96 + is_delivered: false + is_read: false + parts: + - type: text + value: Hello from Linq! + - filename: photo.gif + id: f13dda7d-ecac-49eb-b3fe-16fe286abf19 + mime_type: image/gif + size_bytes: 2776819 + type: media + url: https://cdn.linqapp.com/attachments/example/photo.gif + read_at: null + sent_at: '2026-02-05T19:52:17.219Z' + updated_at: '2026-02-05T19:52:18.084038Z' + preferred_service: null + received_at: null + recipient_handle: null + recipient_phone: null + service: iMessage + responses: + '200': + description: Webhook received successfully + message.received.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookMessageReceivedV2025 + summary: Message received + description: | + Triggered when an incoming message is received on your phone number. + Contains the full message content including any attachments. + + **Timestamps:** `sent_at` is set, `delivered_at` and `read_at` are null. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageReceivedWebhook' + example: + api_version: v3 + webhook_version: '2025-01-01' + event_type: message.received + event_id: 2915e81c-5068-4796-ace2-21d2c94ad298 + created_at: '2026-02-05T19:31:13.736444093Z' + trace_id: 8af9171a45022df2eb74ba4e4c83be0f + partner_id: your-partner-id + data: + chat_id: 8f392755-6865-4b18-880a-227f9d8b458f + from: '+12025559876' + from_handle: + handle: '+12025559876' + id: e604375a-5913-483a-8278-c631e8f0ffda + is_me: false + joined_at: '2026-01-04T05:48:51.321469Z' + left_at: null + service: iMessage + status: active + idempotency_key: null + is_from_me: false + is_group: false + message: + created_at: '2026-02-05T19:31:12.892Z' + delivered_at: null + id: 89e3566e-1d13-49e5-a8ee-48490d5bfeb7 + is_delivered: false + is_read: false + parts: + - type: text + value: Hello! + read_at: null + sent_at: '2026-02-05T19:31:13.074Z' + updated_at: '2026-02-05T19:31:13.712Z' + preferred_service: null + received_at: '2026-02-05T19:31:13.074Z' + recipient_handle: + handle: '+12025551234' + id: 6d6c617f-187a-4dcd-a0d5-988347a8c092 + is_me: true + joined_at: '2026-01-04T05:48:51.321469Z' + left_at: null + service: iMessage + status: active + recipient_phone: null + service: iMessage + responses: + '200': + description: Webhook received successfully + message.read.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookMessageReadV2025 + summary: Message read + description: | + Triggered when a sent message has been read by the recipient. + Only available for iMessage conversations with read receipts enabled. + + **Timestamps:** `sent_at`, `delivered_at`, and `read_at` are all set. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageReadWebhook' + example: + api_version: v3 + webhook_version: '2025-01-01' + event_type: message.read + event_id: 8fd42065-b998-482a-93b3-da855f8dad17 + created_at: '2026-02-05T19:13:58.833366566Z' + trace_id: cbb93c08fa1a3f3c4c2efc161d67f36d + partner_id: your-partner-id + data: + chat_id: 24e33345-e6cf-4f50-9d35-1d7fde8c9818 + from: '+12025551234' + from_handle: + handle: '+12025551234' + id: d31678e9-0442-48fd-b7ed-c898d245dd15 + is_me: true + joined_at: '2026-01-18T03:38:41.442254Z' + left_at: null + service: iMessage + status: active + idempotency_key: null + is_from_me: true + is_group: false + message: + created_at: '2026-02-05T19:13:57.612Z' + delivered_at: '2026-02-05T19:13:57.948Z' + id: dc6d3f68-90df-48f0-a504-e65f239a383c + is_delivered: true + is_read: true + parts: + - type: text + value: Hello world! + read_at: '2026-02-05T19:13:58.177Z' + sent_at: '2026-02-05T19:13:57.814Z' + updated_at: '2026-02-05T19:13:58.811Z' + preferred_service: null + received_at: null + recipient_handle: null + recipient_phone: null + service: iMessage + responses: + '200': + description: Webhook received successfully + message.delivered.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookMessageDeliveredV2025 + summary: Message delivered + description: | + Triggered when a sent message has been delivered to the recipient's device. + This confirms the message reached the recipient, but does not indicate it was read. + Only available for iMessage conversations with delivery receipts. + + **Timestamps:** `sent_at` and `delivered_at` are set, `read_at` is null. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageDeliveredWebhook' + example: + api_version: v3 + webhook_version: '2025-01-01' + event_type: message.delivered + event_id: 67c4ad39-e9b0-47f6-82f8-64bdd8ceafa6 + created_at: '2026-02-05T19:52:22.593689073Z' + trace_id: abde7f6248fba00f97e8c7dc4782d7e0 + partner_id: your-partner-id + data: + chat_id: 0c961e93-e7bf-4db2-bf7b-ea06826bcab4 + from: '+12025551234' + from_handle: + handle: '+12025551234' + id: 8d79532a-f529-4244-a5cf-d443de051434 + is_me: true + joined_at: '2026-01-21T21:59:45.191571Z' + left_at: null + service: iMessage + status: active + idempotency_key: null + is_from_me: true + is_group: false + message: + created_at: '2026-02-05T19:52:17.041183Z' + delivered_at: '2026-02-05T19:52:22.291Z' + id: 347d62c2-2170-4754-8d30-c76d0c727d96 + is_delivered: true + is_read: false + parts: + - type: text + value: Hello world! + - filename: photo.gif + id: b9ed828d-dbac-431f-889a-23f276384389 + mime_type: image/gif + size_bytes: 2776819 + type: media + url: https://cdn.linqapp.com/attachments/example/photo.gif + read_at: null + sent_at: '2026-02-05T19:52:17.219Z' + updated_at: '2026-02-05T19:52:22.571Z' + preferred_service: null + received_at: null + recipient_handle: null + recipient_phone: null + service: iMessage + responses: + '200': + description: Webhook received successfully + message.failed.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookMessageFailedV2025 + summary: Message delivery failed + description: | + Triggered when a message fails to be delivered. This can happen due to: + - Request timeout (message expired before being processed) + - Upstream service error (processing failed after retries) + - Request cancelled (message was explicitly cancelled) + - Service unavailable (no service available to process the request) + + Note: The original message content may not be available if the message + expired before being consumed by a processor. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MessageFailedWebhook' + responses: + '200': + description: Webhook received successfully + reaction.added.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookReactionAddedV2025 + summary: Reaction added + description: | + Triggered when a reaction (tapback) is added to a message. + Includes standard reactions like "loved", "liked", "disliked", etc. + and custom emoji reactions. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReactionAddedWebhook' + responses: + '200': + description: Webhook received successfully + reaction.removed.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookReactionRemovedV2025 + summary: Reaction removed + description: | + Triggered when a reaction (tapback) is removed from a message. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReactionRemovedWebhook' + responses: + '200': + description: Webhook received successfully + participant.added.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookParticipantAddedV2025 + summary: Participant added + description: | + Triggered when a new participant is added to a group chat. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ParticipantAddedWebhook' + responses: + '200': + description: Webhook received successfully + participant.removed.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookParticipantRemovedV2025 + summary: Participant removed + description: | + Triggered when a participant is removed from a group chat. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ParticipantRemovedWebhook' + responses: + '200': + description: Webhook received successfully + chat.group_name_updated.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookChatGroupNameUpdatedV2025 + summary: Group name updated + description: | + Triggered when a group chat's display name is updated. + A null `new_value` indicates the name was removed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatGroupNameUpdatedWebhook' + responses: + '200': + description: Webhook received successfully + chat.group_icon_updated.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookChatGroupIconUpdatedV2025 + summary: Group icon updated + description: | + Triggered when a group chat's icon is updated. + A null `new_value` indicates the icon was removed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatGroupIconUpdatedWebhook' + responses: + '200': + description: Webhook received successfully + chat.group_name_update_failed.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookChatGroupNameUpdateFailedV2025 + summary: Group name update failed + description: Triggered when a group chat name update request fails. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatGroupNameUpdateFailedWebhook' + responses: + '200': + description: Webhook received successfully + chat.group_icon_update_failed.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookChatGroupIconUpdateFailedV2025 + summary: Group icon update failed + description: Triggered when a group chat icon update request fails. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatGroupIconUpdateFailedWebhook' + responses: + '200': + description: Webhook received successfully + chat.created.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookChatCreatedV2025 + summary: Chat created + description: Triggered when a new chat is created. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatCreatedWebhook' + responses: + '200': + description: Webhook received successfully + chat.typing_indicator.started.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookChatTypingIndicatorStartedV2025 + summary: Typing indicator started + description: Triggered when a participant starts typing in a chat. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatTypingIndicatorStartedWebhook' + responses: + '200': + description: Webhook received successfully + chat.typing_indicator.stopped.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookChatTypingIndicatorStoppedV2025 + summary: Typing indicator stopped + description: Triggered when a participant stops typing in a chat. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ChatTypingIndicatorStoppedWebhook' + responses: + '200': + description: Webhook received successfully + phone_number.status_updated.v2025: + post: + tags: + - '2025-01-01' + operationId: webhookPhoneNumberStatusUpdatedV2025 + summary: Phone number status updated + description: | + Triggered when a phone number's service status changes from active to flagged or vice versa. + Use this to monitor phone number health and availability. + + **Status values:** + - `ACTIVE` — Phone number is healthy and can send/receive messages + - `FLAGGED` — Phone number has been flagged and may have limited functionality + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PhoneNumberStatusUpdatedWebhook' + example: + api_version: v3 + webhook_version: '2025-01-01' + event_type: phone_number.status_updated + event_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + created_at: '2026-02-18T18:35:05.363Z' + trace_id: b66e67c5c6b2c20e41d53c51698db27a + partner_id: your-partner-id + data: + phone_number: '+12025551234' + previous_status: ACTIVE + new_status: FLAGGED + changed_at: '2026-02-18T18:35:05.000Z' + responses: + '200': + description: Webhook received successfully +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: | + Bearer token authentication. Include your API token in the Authorization header. + + Format: `Authorization: Bearer ` + responses: + BadRequest: + description: Invalid request - validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + status: 400 + code: 1002 + message: Phone number must be in E.164 format + success: false + Unauthorized: + description: Unauthorized - missing or invalid authentication + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + status: 401 + code: 2004 + message: Unauthorized - missing or invalid authentication token + success: false + Forbidden: + description: Forbidden - no access to this resource + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + status: 403 + code: 2005 + message: Access denied - insufficient permissions for this resource + success: false + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + status: 404 + code: 2001 + message: Resource not found + success: false + UnprocessableEntity: + description: Unprocessable Entity - request is valid but cannot be processed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + status: 422 + code: 1003 + message: Request is valid but cannot be processed + success: false + RateLimited: + description: Too many requests - rate limit exceeded + headers: + Retry-After: + description: Number of seconds until the rate limit resets + schema: + type: integer + example: 10 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + status: 429 + code: 4001 + message: Rate limit exceeded. Try again in a few seconds. + success: false + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + status: 500 + code: 3006 + message: Internal server error + success: false + ServiceUnavailable: + description: Service temporarily unavailable + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: + status: 503 + code: 4004 + message: RCS capability check is temporarily unavailable. + success: false + schemas: + MyCardItem: + type: object + required: + - phone_number + - first_name + - is_active + properties: + phone_number: + type: string + example: '+15551234567' + first_name: + type: string + example: John + last_name: + type: string + example: Doe + image_url: + type: string + example: https://cdn.linqapp.com/my-card/example.jpg + is_active: + type: boolean + example: true + GetMyCardResponse: + type: object + required: + - my_cards + properties: + my_cards: + type: array + items: + $ref: '#/components/schemas/MyCardItem' + SetMyCardRequest: + type: object + required: + - phone_number + properties: + phone_number: + type: string + description: E.164 phone number to associate the my card with + example: '+15551234567' + first_name: + type: string + description: First name for the my card. Required when no existing my card exists for this phone number. + example: John + last_name: + type: string + description: Last name for the my card + example: Doe + image_url: + type: string + description: URL of the profile image to rehost on the CDN. Only re-uploaded when a new value is provided. + example: https://cdn.linqapp.com/my-card/example.jpg + SetMyCardResponse: + type: object + required: + - phone_number + - first_name + - is_active + properties: + phone_number: + type: string + description: The phone number the my card is associated with + example: '+15551234567' + first_name: + type: string + description: First name on the my card + example: John + last_name: + type: string + description: Last name on the my card + example: Doe + image_url: + type: string + description: Image URL on the my card + example: https://cdn.linqapp.com/my-card/example.jpg + is_active: + type: boolean + description: Whether the my card was successfully applied to the device + example: true + HandleCheckRequest: + type: object + required: + - address + properties: + address: + type: string + description: The recipient phone number or email address to check + example: '+15551234567' + from: + type: string + description: Optional sender phone number. If omitted, an available phone from your pool is used automatically. + example: '+15559876543' + HandleCheckResponse: + type: object + required: + - address + - available + properties: + address: + type: string + description: The recipient address that was checked + example: '+15551234567' + available: + type: boolean + description: Whether the recipient supports the checked messaging service + example: true + DeliveryStatus: + type: string + description: Current delivery status of a message + enum: + - pending + - queued + - sent + - delivered + - failed + example: pending + ServiceType: + type: string + description: Messaging service type + enum: + - iMessage + - SMS + - RCS + example: iMessage + ServicePreferenceType: + type: string + description: Preferred messaging service type. Includes "auto" for default fallback behavior. + enum: + - iMessage + - SMS + - RCS + - auto + example: iMessage + SentMessage: + type: object + description: A message that was sent (used in CreateChat and SendMessage responses) + required: + - id + - parts + - sent_at + - delivery_status + - is_read + properties: + id: + type: string + format: uuid + description: Message identifier (UUID) + example: 69a37c7d-af4f-4b5e-af42-e28e98ce873a + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + nullable: true + description: Service used to send this message + example: iMessage + preferred_service: + allOf: + - $ref: '#/components/schemas/ServiceType' + nullable: true + description: Preferred service for sending this message + example: iMessage + parts: + type: array + description: Message parts in order (text and media) + items: + oneOf: + - $ref: '#/components/schemas/TextPartResponse' + - $ref: '#/components/schemas/MediaPartResponse' + sent_at: + type: string + format: date-time + description: When the message was sent + example: '2025-10-23T13:07:55.019-05:00' + delivered_at: + type: string + format: date-time + description: When the message was delivered + example: null + nullable: true + delivery_status: + $ref: '#/components/schemas/DeliveryStatus' + is_read: + type: boolean + description: Whether the message has been read + example: false + effect: + allOf: + - $ref: '#/components/schemas/MessageEffect' + nullable: true + description: iMessage effect applied to this message (screen or bubble effect) + from_handle: + nullable: true + allOf: + - $ref: '#/components/schemas/ChatHandle' + description: The sender of this message as a full handle object + reply_to: + nullable: true + allOf: + - $ref: '#/components/schemas/ReplyTo' + SendMessageResponse: + type: object + description: Response for sending a message to a chat + required: + - chat_id + - message + properties: + chat_id: + type: string + format: uuid + description: Unique identifier of the chat this message was sent to + example: 550e8400-e29b-41d4-a716-446655440000 + message: + $ref: '#/components/schemas/SentMessage' + GetThreadResponse: + type: object + description: Response containing messages in a thread with pagination + required: + - messages + properties: + messages: + type: array + description: Messages in the thread, ordered by the specified order parameter + items: + $ref: '#/components/schemas/Message' + next_cursor: + type: string + description: Cursor for fetching the next page of results (null if no more results) + nullable: true + example: eyJpZCI6IjEyMzQ1Njc4OTAiLCJ0cyI6MTYzMDUwMDAwMH0= + SendVoiceMemoToChatRequest: + type: object + description: Request to send a voice memo to a chat (chat_id provided in path) + required: + - from + - voice_memo_url + properties: + from: + type: string + description: Sender phone number in E.164 format + pattern: ^\+[1-9][0-9]{1,14}$ + example: '+12052535597' + voice_memo_url: + type: string + format: uri + description: URL of the voice memo audio file. Must be a publicly accessible HTTPS URL. + example: https://example.com/voice-memo.m4a + SendVoiceMemoResponse: + type: object + required: + - success + - message + - chat + properties: + success: + type: boolean + description: Whether the voice memo was successfully queued + example: true + message: + $ref: '#/components/schemas/VoiceMemoMessage' + chat: + $ref: '#/components/schemas/ChatInfo' + SendVoiceMemoToChatResult: + type: object + description: Response for sending a voice memo to a chat + required: + - voice_memo + properties: + voice_memo: + type: object + required: + - id + - from + - to + - status + - voice_memo + - created_at + - chat + properties: + id: + type: string + format: uuid + description: Message identifier + example: 69a37c7d-af4f-4b5e-af42-e28e98ce873a + from: + type: string + description: Sender phone number + example: '+12052535597' + to: + type: array + items: + type: string + description: Recipient handles (phone numbers or email addresses) + example: + - '+12052532136' + status: + type: string + description: Current delivery status + example: queued + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + nullable: true + description: Service used to send this voice memo + example: iMessage + voice_memo: + $ref: '#/components/schemas/VoiceMemoAttachment' + created_at: + type: string + format: date-time + description: When the voice memo was created + chat: + $ref: '#/components/schemas/ChatInfo' + VoiceMemoMessage: + type: object + required: + - id + - chat_id + - from + - to + - voice_memo + - status + - created_at + properties: + id: + type: string + format: uuid + description: Message identifier + example: 69a37c7d-af4f-4b5e-af42-e28e98ce873a + chat_id: + type: string + format: uuid + description: Chat identifier + example: 94c6bf33-31d9-40e3-a0e9-f94250ecedb9 + from: + type: string + description: 'DEPRECATED: Use from_handle instead. Sender phone number.' + deprecated: true + example: '+12052535597' + from_handle: + allOf: + - $ref: '#/components/schemas/ChatHandle' + description: The sender of this voice memo as a full handle object + to: + type: array + items: + type: string + description: Recipient handles (phone numbers or email addresses) + example: + - '+12052532136' + voice_memo: + $ref: '#/components/schemas/VoiceMemoAttachment' + status: + type: string + description: Current delivery status + example: queued + created_at: + type: string + format: date-time + description: When the voice memo was created + VoiceMemoAttachment: + type: object + required: + - id + - url + - filename + - mime_type + - size_bytes + properties: + id: + type: string + format: uuid + description: Attachment identifier + example: 550e8400-e29b-41d4-a716-446655440000 + url: + type: string + format: uri + description: CDN URL for downloading the voice memo + example: https://cdn.linqapp.com/voice-memos/abc123.m4a + filename: + type: string + description: Original filename + example: voice-memo.m4a + mime_type: + type: string + description: Audio MIME type + example: audio/x-m4a + size_bytes: + type: integer + description: File size in bytes + example: 524288 + duration_ms: + type: integer + description: Duration in milliseconds + example: 15000 + nullable: true + ChatInfo: + type: object + required: + - id + - handles + - is_group + - service + - is_active + properties: + id: + type: string + format: uuid + description: Chat identifier + handles: + type: array + description: Chat participants + items: + $ref: '#/components/schemas/ChatHandle' + is_group: + type: boolean + description: Whether this is a group chat + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + description: Messaging service + example: iMessage + is_active: + type: boolean + description: Whether the chat is active + MessagePart: + type: object + required: + - type + discriminator: + propertyName: type + mapping: + text: '#/components/schemas/TextPart' + media: '#/components/schemas/MediaPart' + oneOf: + - $ref: '#/components/schemas/TextPart' + - $ref: '#/components/schemas/MediaPart' + TextPart: + type: object + required: + - type + - value + properties: + type: + type: string + enum: + - text + description: Indicates this is a text message part + value: + type: string + description: The text content + minLength: 1 + maxLength: 10000 + example: Hello! + example: + type: text + value: Check this out! + MediaPart: + type: object + required: + - type + properties: + type: + type: string + enum: + - media + description: Indicates this is a media attachment part + url: + type: string + format: uri + description: | + Any publicly accessible HTTPS URL to the media file. The server downloads and + sends the file automatically — no pre-upload step required. + + **Size limit:** 10MB maximum for URL-based downloads. For larger files (up to 100MB), + use the pre-upload flow: `POST /v3/attachments` to get a presigned URL, upload directly, + then reference by `attachment_id`. + + **Requirements:** + - URL must use HTTPS + - File content must be a supported format (the server validates the actual file content) + + **Supported formats:** + - Images: .jpg, .jpeg, .png, .gif, .heic, .heif, .tif, .tiff, .bmp + - Videos: .mp4, .mov, .m4v, .mpeg, .mpg, .3gp + - Audio: .m4a, .mp3, .aac, .caf, .wav, .aiff, .amr + - Documents: .pdf, .txt, .rtf, .csv, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .pages, .numbers, .key, .epub, .zip, .html, .htm + - Contact & Calendar: .vcf, .ics + + **Tip:** Audio sent here appears as a regular file attachment. To send audio as an + iMessage voice memo bubble (with inline playback), use `/v3/chats/{chatId}/voicememo`. + For repeated sends of the same file, use `attachment_id` to avoid redundant downloads. + + Either `url` or `attachment_id` must be provided, but not both. + pattern: ^https:// + example: https://example.com/images/photo.jpg + attachment_id: + type: string + format: uuid + description: | + Reference to a file pre-uploaded via `POST /v3/attachments` (optional). + The file is already stored, so sends using this ID skip the download step — + useful when sending the same file to many recipients. + + Either `url` or `attachment_id` must be provided, but not both. + example: 550e8400-e29b-41d4-a716-446655440000 + example: + type: media + url: https://skywalker-next.linqapp.com/_next/static/media/conversations-imessage.0dc825b0.png + TextPartResponse: + type: object + description: A text message part + required: + - type + - value + - reactions + properties: + type: + type: string + enum: + - text + description: Indicates this is a text message part + value: + type: string + description: The text content + example: Check this out! + reactions: + type: array + nullable: true + description: Reactions on this message part + items: + $ref: '#/components/schemas/Reaction' + example: + type: text + value: Hello! + MediaPartResponse: + type: object + description: A media attachment part + required: + - type + - id + - url + - filename + - mime_type + - size_bytes + - reactions + properties: + type: + type: string + enum: + - media + description: Indicates this is a media attachment part + id: + type: string + format: uuid + description: Unique attachment identifier + example: abc12345-1234-5678-9abc-def012345678 + url: + type: string + format: uri + description: | + Presigned URL for downloading the attachment (expires in 1 hour). + example: https://cdn.linqapp.com/attachments/550e8400/photo.jpg?signature=... + filename: + type: string + description: Original filename + example: photo.jpg + mime_type: + type: string + description: MIME type of the file + example: image/jpeg + size_bytes: + type: integer + description: File size in bytes + example: 245678 + reactions: + type: array + nullable: true + description: Reactions on this message part + items: + $ref: '#/components/schemas/Reaction' + example: + type: media + id: abc12345-1234-5678-9abc-def012345678 + url: https://cdn.linqapp.com/attachments/abc12345/photo.jpg?signature=... + filename: photo.jpg + mime_type: image/jpeg + size_bytes: 245678 + MessageEffects: + type: object + description: | + Message effects for iMessage. Only one effect type can be applied per message. + Screen effects play a full-screen animation. Bubble effects animate the message bubble. + properties: + screen_effect: + type: string + description: | + Full-screen animation effect. Available screen effects: + - confetti: Colorful confetti falls from top of screen + - fireworks: Fireworks explode across the screen + - lasers: Laser beams scan across the screen + - sparkles: Sparkle/celebration effect + - celebration: Alias for sparkles (matches UI name) + - hearts: Floating hearts fill the screen + - love: Alias for hearts (matches UI name) + - balloons: Colorful balloons float up + - happy_birthday: Alias for balloons + - echo: Message text echoes/multiplies across screen + - spotlight: Spotlight illuminates the message + enum: + - confetti + - fireworks + - lasers + - sparkles + - celebration + - hearts + - love + - balloons + - happy_birthday + - echo + - spotlight + example: confetti + bubble_effect: + type: string + description: | + Message bubble animation effect. Available bubble effects: + - slam: Message slams onto screen with impact + - loud: Message appears larger with emphasis + - gentle: Message fades in softly + - invisible: Invisible ink effect (revealed on tap) + enum: + - slam + - loud + - gentle + - invisible + example: slam + MessageContent: + type: object + description: | + Message content container. Groups all message-related fields together, + separating the "what" (message content) from the "where" (routing fields like from/to). + required: + - parts + properties: + parts: + type: array + description: | + Array of message parts. Each part can be either text or media. + Parts are displayed in order. Text and media can be mixed. + + **Supported Media:** + - Images: .jpg, .jpeg, .png, .gif, .heic, .heif, .tif, .tiff, .bmp + - Videos: .mp4, .mov, .m4v, .mpeg, .mpg, .3gp + - Audio: .m4a, .mp3, .aac, .caf, .wav, .aiff, .amr + - Documents: .pdf, .txt, .rtf, .csv, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .pages, .numbers, .key, .epub, .zip, .html, .htm + - Contact & Calendar: .vcf, .ics + + **Audio:** + - Audio files (.m4a, .mp3, .aac, .caf, .wav, .aiff, .amr) are fully supported as media parts + - To send audio as an **iMessage voice memo bubble** (inline playback UI), use the dedicated + `/v3/chats/{chatId}/voicememo` endpoint instead + + **Validation Rules:** + - Consecutive text parts are not allowed. Text parts must be separated by + media parts. For example, [text, text] is invalid, but [text, media, text] is valid. + - Maximum of **100 parts** total. + - Media parts using a public `url` (downloaded by the server on send) are + capped at **40**. Parts using `attachment_id` or presigned URLs + are exempt from this sub-limit. For bulk media sends exceeding 40 files, + pre-upload via `POST /v3/attachments` and reference by `attachment_id` or `download_url`. + minItems: 1 + maxItems: 100 + items: + $ref: '#/components/schemas/MessagePart' + example: + - type: text + value: Check this out! + - type: media + url: https://skywalker-next.linqapp.com/_next/static/media/conversations-imessage.0dc825b0.png + effect: + $ref: '#/components/schemas/MessageEffect' + description: iMessage effect to apply to this message (screen or bubble effect) + reply_to: + $ref: '#/components/schemas/ReplyTo' + description: Reply to another message to create a threaded conversation + idempotency_key: + type: string + description: | + Optional idempotency key for this message. + Use this to prevent duplicate sends of the same message. + maxLength: 255 + example: msg-abc123xyz + preferred_service: + allOf: + - $ref: '#/components/schemas/ServiceType' + description: | + Preferred messaging service to use for this message. + If not specified, uses default fallback chain: iMessage → RCS → SMS. + - iMessage: Enforces iMessage without fallback to RCS or SMS. Message fails if recipient doesn't support iMessage. + - RCS: Enforces RCS or SMS (no iMessage). Uses RCS if recipient supports it, otherwise falls back to SMS. + - SMS: Enforces SMS (no iMessage). Uses RCS if recipient supports it, otherwise falls back to SMS. + example: iMessage + ErrorResponse: + type: object + required: + - error + - success + properties: + error: + $ref: '#/components/schemas/ErrorDetail' + success: + type: boolean + description: Always false for error responses + example: false + trace_id: + type: string + description: Unique trace ID for request tracing and debugging + example: trace_abc123def456 + ErrorDetail: + type: object + required: + - status + - code + - message + properties: + status: + type: integer + description: HTTP status code (e.g., 400, 404, 500) + example: 400 + code: + $ref: '#/components/schemas/ErrorCode' + message: + type: string + description: Human-readable error message + example: Phone number must be in E.164 format + Message: + type: object + required: + - id + - chat_id + - is_from_me + - is_delivered + - is_read + - created_at + - updated_at + properties: + id: + type: string + description: Unique identifier for the message + format: uuid + example: 69a37c7d-af4f-4b5e-af42-e28e98ce873a + chat_id: + type: string + description: ID of the chat this message belongs to + format: uuid + example: 94c6bf33-31d9-40e3-a0e9-f94250ecedb9 + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + nullable: true + description: Service used to send/receive this message + example: iMessage + preferred_service: + allOf: + - $ref: '#/components/schemas/ServiceType' + nullable: true + description: Preferred service for sending this message + example: iMessage + from: + type: string + nullable: true + description: 'DEPRECATED: Use from_handle instead. Phone number of the message sender.' + deprecated: true + example: '+12052535597' + from_handle: + nullable: true + allOf: + - $ref: '#/components/schemas/ChatHandle' + description: The sender of this message as a full handle object + parts: + type: array + nullable: true + description: Message parts in order (text and media) + items: + oneOf: + - $ref: '#/components/schemas/TextPartResponse' + - $ref: '#/components/schemas/MediaPartResponse' + reply_to: + nullable: true + allOf: + - $ref: '#/components/schemas/ReplyTo' + is_from_me: + type: boolean + description: Whether this message was sent by the authenticated user + example: true + is_delivered: + type: boolean + description: Whether the message has been delivered + example: true + is_read: + type: boolean + description: Whether the message has been read + example: false + created_at: + type: string + format: date-time + description: When the message was created + example: '2024-01-15T10:30:00Z' + updated_at: + type: string + format: date-time + description: When the message was last updated + example: '2024-01-15T10:30:00Z' + sent_at: + type: string + format: date-time + nullable: true + description: When the message was sent + example: '2024-01-15T10:30:05Z' + delivered_at: + type: string + format: date-time + nullable: true + description: When the message was delivered + example: '2024-01-15T10:30:10Z' + read_at: + type: string + format: date-time + nullable: true + description: When the message was read + example: '2024-01-15T10:35:00Z' + effect: + allOf: + - $ref: '#/components/schemas/MessageEffect' + nullable: true + description: iMessage effect applied to this message (screen or bubble effect) + MessageEffect: + type: object + description: iMessage effect applied to a message (screen or bubble effect) + properties: + type: + type: string + description: Type of effect + enum: + - screen + - bubble + example: screen + name: + type: string + description: | + Name of the effect. Common values: + - Screen effects: confetti, fireworks, lasers, sparkles, celebration, hearts, love, balloons, happy_birthday, echo, spotlight + - Bubble effects: slam, loud, gentle, invisible + example: confetti + ReplyTo: + type: object + description: Indicates this message is a threaded reply to another message + required: + - message_id + properties: + message_id: + type: string + format: uuid + description: The ID of the message to reply to + example: 550e8400-e29b-41d4-a716-446655440000 + part_index: + type: integer + format: int32 + description: | + The specific message part to reply to (0-based index). + Defaults to 0 (first part) if not provided. + Use this when replying to a specific part of a multipart message. + minimum: 0 + example: 0 + Attachment: + type: object + required: + - id + - filename + - content_type + - size_bytes + - status + - created_at + properties: + id: + type: string + description: Unique identifier for the attachment (UUID) + example: 550e8400-e29b-41d4-a716-446655440000 + filename: + type: string + description: Original filename of the attachment + example: photo.jpg + content_type: + $ref: '#/components/schemas/SupportedContentType' + size_bytes: + type: integer + format: int64 + description: Size of the attachment in bytes + example: 1024000 + status: + type: string + description: Current upload/processing status + enum: + - pending + - complete + - failed + example: complete + download_url: + type: string + format: uri + description: URL to download the attachment + created_at: + type: string + format: date-time + description: When the attachment was created + example: '2024-01-15T10:30:00Z' + ReactionType: + type: string + description: | + Type of reaction. Standard iMessage tapbacks are love, like, dislike, laugh, emphasize, question. + Custom emoji reactions have type "custom" with the actual emoji in the custom_emoji field. + Sticker reactions have type "sticker" with sticker attachment details in the sticker field. + enum: + - love + - like + - dislike + - laugh + - emphasize + - question + - custom + - sticker + example: love + Reaction: + type: object + required: + - is_me + - handle + - type + properties: + is_me: + type: boolean + description: Whether this reaction is from the current user + example: false + handle: + $ref: '#/components/schemas/ChatHandle' + type: + $ref: '#/components/schemas/ReactionType' + custom_emoji: + type: string + description: Custom emoji if type is "custom", null otherwise + example: 🚀 + nullable: true + sticker: + type: object + nullable: true + description: Sticker attachment details when reaction_type is "sticker". Null for non-sticker reactions. + properties: + url: + type: string + format: uri + description: | + Presigned URL for downloading the sticker image (expires in 1 hour). + example: https://cdn.linqapp.com/attachments/a1b2c3d4/sticker.png?signature=... + mime_type: + type: string + description: MIME type of the sticker image + example: image/png + width: + type: integer + description: Sticker image width in pixels + example: 420 + height: + type: integer + description: Sticker image height in pixels + example: 420 + file_name: + type: string + description: Filename of the sticker + example: sticker.png + EditMessageRequest: + type: object + required: + - text + properties: + part_index: + type: integer + description: Index of the message part to edit. Defaults to 0. + default: 0 + example: 0 + text: + type: string + description: New text content for the message part + minLength: 1 + maxLength: 10000 + example: This is the edited message content + DeleteMessageRequest: + type: object + required: + - chat_id + properties: + chat_id: + type: string + description: ID of the chat containing the message to delete + format: uuid + example: 94c6bf33-31d9-40e3-a0e9-f94250ecedb9 + SendReactionRequest: + type: object + required: + - operation + - type + properties: + operation: + type: string + description: Whether to add or remove the reaction + enum: + - add + - remove + example: add + type: + $ref: '#/components/schemas/ReactionType' + custom_emoji: + type: string + description: | + Custom emoji string. Required when type is "custom". + example: 😍 + part_index: + type: integer + description: | + Optional index of the message part to react to. + If not provided, reacts to the entire message (part 0). + example: 1 + SupportedContentType: + type: string + description: | + Supported MIME types for file attachments and media URLs. + + **Images:** image/jpeg, image/png, image/gif, image/heic, image/heif, image/tiff, image/bmp + + **Videos:** video/mp4, video/quicktime, video/mpeg, video/3gpp + + **Audio:** audio/mpeg, audio/mp4, audio/x-m4a, audio/x-caf, audio/wav, audio/aiff, audio/aac, audio/amr + + **Documents:** application/pdf, text/plain, text/markdown, text/vcard, text/rtf, text/csv, text/html, text/calendar, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.apple.pages, application/vnd.apple.numbers, application/vnd.apple.keynote, application/epub+zip, application/zip + + **Unsupported:** WebP, SVG, FLAC, OGG, and executable files are explicitly rejected. + enum: + - image/jpeg + - image/jpg + - image/png + - image/gif + - image/heic + - image/heif + - image/tiff + - image/bmp + - image/x-ms-bmp + - video/mp4 + - video/quicktime + - video/mpeg + - video/x-m4v + - video/3gpp + - audio/mpeg + - audio/mp3 + - audio/mp4 + - audio/x-m4a + - audio/m4a + - audio/x-caf + - audio/wav + - audio/x-wav + - audio/aiff + - audio/x-aiff + - audio/aac + - audio/x-aac + - audio/amr + - application/pdf + - text/plain + - text/markdown + - text/vcard + - text/x-vcard + - text/rtf + - application/rtf + - text/csv + - text/html + - text/calendar + - application/msword + - application/vnd.openxmlformats-officedocument.wordprocessingml.document + - application/vnd.ms-excel + - application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + - application/vnd.ms-powerpoint + - application/vnd.openxmlformats-officedocument.presentationml.presentation + - application/vnd.apple.pages + - application/x-iwork-pages-sffpages + - application/vnd.apple.numbers + - application/x-iwork-numbers-sffnumbers + - application/vnd.apple.keynote + - application/x-iwork-keynote-sffkey + - application/epub+zip + - application/zip + - application/x-zip-compressed + example: image/jpeg + RequestUploadRequest: + type: object + required: + - filename + - content_type + - size_bytes + properties: + filename: + type: string + description: Name of the file to upload + minLength: 1 + maxLength: 255 + example: photo.jpg + content_type: + $ref: '#/components/schemas/SupportedContentType' + size_bytes: + type: integer + format: int64 + description: Size of the file in bytes (max 100MB) + minimum: 1 + maximum: 104857600 + example: 1024000 + RequestUploadResult: + type: object + required: + - attachment_id + - upload_url + - download_url + - http_method + - expires_at + - required_headers + properties: + attachment_id: + type: string + description: Unique identifier for the attachment (for status checks via GET /v3/attachments/{id}) + format: uuid + example: abc12345-1234-5678-9abc-def012345678 + upload_url: + type: string + format: uri + description: | + Presigned URL for uploading the file. PUT the raw binary file content to this URL + with the `required_headers`. Do not JSON-encode or multipart-wrap the body. + Expires after 15 minutes. + example: https://uploads.linqapp.com/attachments/abc12345?X-Amz-Algorithm=... + download_url: + type: string + format: uri + description: | + Permanent CDN URL for the file. Does not expire. Use the `attachment_id` + to reference this file in media parts when sending messages. + example: https://cdn.linqapp.com/uploads/partner-id/abc12345/photo.jpg + http_method: + type: string + description: HTTP method to use for upload (always PUT) + enum: + - PUT + example: PUT + expires_at: + type: string + format: date-time + description: When the upload URL expires (15 minutes from now) + example: '2024-01-15T10:45:00Z' + required_headers: + type: object + description: HTTP headers required for the upload request + additionalProperties: + type: string + example: + Content-Type: image/jpeg + Chat: + type: object + required: + - id + - display_name + - handles + - is_archived + - is_group + - created_at + - updated_at + properties: + id: + type: string + description: Unique identifier for the chat + format: uuid + example: 550e8400-e29b-41d4-a716-446655440000 + display_name: + type: string + description: Display name for the chat. Defaults to a comma-separated list of recipient handles. Can be updated for group chats. + example: +14155551234, +14155559876 + nullable: true + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + description: Service type for the chat + example: iMessage + nullable: true + handles: + type: array + description: List of chat participants with full handle details. Always contains at least two handles (your phone number and the other participant). + items: + $ref: '#/components/schemas/ChatHandle' + example: + - id: 550e8400-e29b-41d4-a716-446655440010 + handle: '+14155551234' + service: iMessage + status: active + joined_at: '2025-05-21T15:30:00.000Z' + is_me: true + - id: 550e8400-e29b-41d4-a716-446655440011 + handle: '+14155559876' + service: iMessage + status: active + joined_at: '2025-05-21T15:30:00.000Z' + is_me: false + is_archived: + type: boolean + description: Whether the chat is archived + default: false + is_group: + type: boolean + description: Whether this is a group chat + default: false + created_at: + type: string + format: date-time + description: When the chat was created + example: '2024-01-15T10:30:00Z' + updated_at: + type: string + format: date-time + description: When the chat was last updated + example: '2024-01-15T10:30:00Z' + ChatHandle: + type: object + required: + - id + - handle + - service + - joined_at + properties: + id: + type: string + format: uuid + description: Unique identifier for this handle + example: 550e8400-e29b-41d4-a716-446655440000 + handle: + type: string + description: Phone number (E.164) or email address of the participant + example: '+15551234567' + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + description: Service type (iMessage, SMS, RCS, etc.) + example: iMessage + status: + type: string + nullable: true + description: Participant status + enum: + - active + - left + - removed + default: active + joined_at: + type: string + format: date-time + description: When this participant joined the chat + example: '2025-05-21T15:30:00.000-05:00' + left_at: + type: string + format: date-time + description: When they left (if applicable) + nullable: true + is_me: + type: boolean + nullable: true + description: Whether this handle belongs to the sender (your phone number) + example: false + ListChatsResult: + type: object + required: + - chats + properties: + chats: + type: array + description: List of chats + items: + $ref: '#/components/schemas/Chat' + next_cursor: + type: string + nullable: true + description: | + Cursor for fetching the next page of results. + Null if there are no more results to fetch. + Pass this value as the `cursor` parameter in the next request. + CreateChatRequest: + type: object + required: + - from + - to + - message + properties: + from: + type: string + description: | + Sender phone number in E.164 format. Must be a phone number that the + authenticated partner has permission to send from. + pattern: ^\+[1-9][0-9]{1,14}$ + example: '+12052535597' + to: + type: array + description: | + Array of recipient handles (phone numbers in E.164 format or email addresses). + For individual chats, provide one recipient. For group chats, provide multiple. + minItems: 1 + maxItems: 31 + items: + type: string + example: + - '+14155559876' + - '+14155550123' + message: + $ref: '#/components/schemas/MessageContent' + CreateChatResult: + type: object + description: Response for creating a new chat with an initial message + required: + - chat + properties: + chat: + type: object + required: + - id + - display_name + - service + - is_group + - handles + - message + properties: + id: + type: string + format: uuid + description: Unique identifier for the created chat (UUID) + example: 94c6bf33-31d9-40e3-a0e9-f94250ecedb9 + display_name: + type: string + description: Display name for the chat. Defaults to a comma-separated list of recipient handles. Can be updated for group chats. + example: +14155551234, +14155559876 + nullable: true + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + description: Messaging service used + example: iMessage + is_group: + type: boolean + description: Whether this is a group chat + example: false + handles: + type: array + description: List of participants in the chat. Always contains at least two handles (your phone number and the other participant). + items: + $ref: '#/components/schemas/ChatHandle' + example: + - id: 550e8400-e29b-41d4-a716-446655440010 + handle: '+14155551234' + service: iMessage + status: active + joined_at: '2025-05-21T15:30:00.000Z' + is_me: true + - id: 550e8400-e29b-41d4-a716-446655440011 + handle: '+14155559876' + service: iMessage + status: active + joined_at: '2025-05-21T15:30:00.000Z' + is_me: false + message: + $ref: '#/components/schemas/SentMessage' + UpdateChatRequest: + type: object + properties: + display_name: + type: string + description: New display name for the chat (group chats only) + example: Updated Team Name + group_chat_icon: + type: string + format: uri + description: URL of an image to set as the group chat icon (group chats only) + example: https://example.com/icon.png + AddParticipantRequest: + type: object + required: + - handle + properties: + handle: + type: string + description: Phone number (E.164 format) or email address of the participant to add + example: '+12052532136' + RemoveParticipantRequest: + type: object + required: + - handle + properties: + handle: + type: string + description: Phone number (E.164 format) or email address of the participant to remove + example: '+12052532136' + SendMessageToChatRequest: + type: object + required: + - message + properties: + message: + $ref: '#/components/schemas/MessageContent' + GetMessagesResult: + type: object + required: + - messages + properties: + messages: + type: array + description: List of messages + items: + $ref: '#/components/schemas/Message' + next_cursor: + type: string + nullable: true + description: | + Cursor for fetching the next page of results. + Null if there are no more results to fetch. + Pass this value as the `cursor` parameter in the next request. + WebhookEventType: + type: string + description: Valid webhook event types that can be subscribed to + enum: + - message.sent + - message.received + - message.read + - message.delivered + - message.failed + - message.edited + - reaction.added + - reaction.removed + - participant.added + - participant.removed + - chat.created + - chat.group_name_updated + - chat.group_icon_updated + - chat.group_name_update_failed + - chat.group_icon_update_failed + - chat.typing_indicator.started + - chat.typing_indicator.stopped + - phone_number.status_updated + WebhookEventsResult: + type: object + required: + - events + - doc_url + properties: + events: + type: array + description: List of all available webhook event types + items: + $ref: '#/components/schemas/WebhookEventType' + doc_url: + type: string + format: uri + description: URL to the webhook events documentation + const: https://apidocs.linqapp.com/documentation/webhook-events + WebhookSubscriptionResponse: + type: object + required: + - id + - target_url + - subscribed_events + - is_active + - created_at + - updated_at + properties: + id: + type: string + description: Unique identifier for the webhook subscription + example: b2c3d4e5-f6a7-8901-bcde-f23456789012 + target_url: + type: string + format: uri + description: URL where webhook events will be sent + example: https://webhooks.example.com/linq/events + subscribed_events: + type: array + description: List of event types this subscription receives + items: + $ref: '#/components/schemas/WebhookEventType' + example: + - message.sent + - message.delivered + - message.read + is_active: + type: boolean + description: Whether this subscription is currently active + example: true + created_at: + type: string + format: date-time + description: When the subscription was created + example: '2024-01-15T10:30:00Z' + updated_at: + type: string + format: date-time + description: When the subscription was last updated + example: '2024-01-15T10:30:00Z' + WebhookSubscriptionCreatedResponse: + type: object + description: Response returned when creating a webhook subscription. Includes the signing secret which is only shown once. + required: + - id + - target_url + - signing_secret + - subscribed_events + - is_active + - created_at + - updated_at + properties: + id: + type: string + description: Unique identifier for the webhook subscription + example: b2c3d4e5-f6a7-8901-bcde-f23456789012 + target_url: + type: string + format: uri + description: URL where webhook events will be sent + example: https://webhooks.example.com/linq/events + signing_secret: + type: string + description: Secret for verifying webhook signatures. Store this securely - it cannot be retrieved again. + example: whsec_abc123def456 + subscribed_events: + type: array + description: List of event types this subscription receives + items: + $ref: '#/components/schemas/WebhookEventType' + example: + - message.sent + - message.delivered + - message.read + is_active: + type: boolean + description: Whether this subscription is currently active + example: true + created_at: + type: string + format: date-time + description: When the subscription was created + example: '2024-01-15T10:30:00Z' + updated_at: + type: string + format: date-time + description: When the subscription was last updated + example: '2024-01-15T10:30:00Z' + CreateWebhookSubscriptionRequest: + type: object + required: + - target_url + - subscribed_events + properties: + target_url: + type: string + format: uri + description: URL where webhook events will be sent. Must be HTTPS. + example: https://webhooks.example.com/linq/events + subscribed_events: + type: array + description: List of event types to subscribe to + minItems: 1 + items: + $ref: '#/components/schemas/WebhookEventType' + example: + - message.sent + - message.delivered + UpdateWebhookSubscriptionRequest: + type: object + properties: + target_url: + type: string + format: uri + description: New target URL for webhook events + example: https://webhooks.example.com/linq/events + subscribed_events: + type: array + description: Updated list of event types to subscribe to + minItems: 1 + items: + $ref: '#/components/schemas/WebhookEventType' + example: + - message.sent + - message.delivered + is_active: + type: boolean + description: Activate or deactivate the subscription + example: true + ListWebhookSubscriptionsResult: + type: object + required: + - subscriptions + properties: + subscriptions: + type: array + description: List of webhook subscriptions + items: + $ref: '#/components/schemas/WebhookSubscriptionResponse' + ListPhoneNumbersResult: + type: object + required: + - phone_numbers + properties: + phone_numbers: + type: array + description: List of phone numbers assigned to the partner + items: + $ref: '#/components/schemas/PhoneNumberInfo' + PhoneNumberInfo: + type: object + required: + - id + - phone_number + properties: + id: + type: string + format: uuid + description: Unique identifier for the phone number + example: 550e8400-e29b-41d4-a716-446655440000 + phone_number: + type: string + description: Phone number in E.164 format + example: '+12025551234' + type: + type: string + description: Deprecated. Always null. + enum: + - TWILIO + - APPLE_ID + example: APPLE_ID + country_code: + type: string + description: Deprecated. Always null. + example: US + capabilities: + $ref: '#/components/schemas/PhoneCapabilities' + PhoneCapabilities: + type: object + required: + - sms + - mms + - voice + properties: + sms: + type: boolean + description: Whether SMS messaging is supported + example: true + mms: + type: boolean + description: Whether MMS messaging is supported + example: true + voice: + type: boolean + description: Whether voice calls are supported + example: false + ListPhoneNumbersResultV2: + type: object + required: + - phone_numbers + properties: + phone_numbers: + type: array + description: List of phone numbers assigned to the partner + items: + $ref: '#/components/schemas/PhoneNumberInfoV2' + PhoneNumberInfoV2: + type: object + required: + - id + - phone_number + properties: + id: + type: string + format: uuid + description: Unique identifier for the phone number + example: 550e8400-e29b-41d4-a716-446655440000 + phone_number: + type: string + description: Phone number in E.164 format + example: '+12025551234' + ErrorCode: + type: integer + description: Linq API error code. + WebhookEnvelopeBase: + type: object + required: + - api_version + - webhook_version + - event_type + - event_id + - created_at + - data + - trace_id + - partner_id + properties: + api_version: + type: string + description: API version for the webhook payload format + example: v3 + webhook_version: + type: string + description: | + Date-based webhook payload version. + Determined by the `?version=` query parameter in your webhook subscription URL. + If no version parameter is specified, defaults based on subscription creation date. + example: '2025-01-01' + event_type: + $ref: '#/components/schemas/WebhookEventType' + event_id: + type: string + format: uuid + description: Unique identifier for this event (for deduplication) + example: 550e8400-e29b-41d4-a716-446655440000 + created_at: + type: string + format: date-time + description: When the event was created + example: '2025-11-23T17:30:00Z' + trace_id: + type: string + description: Trace ID for debugging and correlation across systems. + example: abc123def456 + partner_id: + type: string + description: Partner identifier. Present on all webhooks for cross-referencing. + example: partner_abc123 + schemas-TextPartResponse: + type: object + description: A text message part + required: + - type + - value + properties: + type: + type: string + enum: + - text + description: Indicates this is a text message part + value: + type: string + description: The text content + example: Check this out! + example: + type: text + value: Hello! + schemas-MediaPartResponse: + type: object + description: A media attachment part + required: + - type + - id + - url + - filename + - mime_type + - size_bytes + properties: + type: + type: string + enum: + - media + description: Indicates this is a media attachment part + id: + type: string + format: uuid + description: Unique attachment identifier + example: abc12345-1234-5678-9abc-def012345678 + url: + type: string + format: uri + description: | + Presigned URL for downloading the attachment (expires in 1 hour). + example: https://cdn.linqapp.com/attachments/550e8400/photo.jpg?signature=... + filename: + type: string + description: Original filename + example: photo.jpg + mime_type: + type: string + description: MIME type of the file + example: image/jpeg + size_bytes: + type: integer + description: File size in bytes + example: 245678 + example: + type: media + id: abc12345-1234-5678-9abc-def012345678 + url: https://cdn.linqapp.com/attachments/abc12345/photo.jpg?signature=... + filename: photo.jpg + mime_type: image/jpeg + size_bytes: 245678 + schemas-MessageEffect: + type: object + description: iMessage effect applied to a message (screen or bubble animation) + properties: + type: + type: string + description: Effect category + enum: + - screen + - bubble + example: bubble + name: + type: string + description: Effect name (confetti, fireworks, slam, gentle, etc.) + example: gentle + MessageEventV2: + type: object + description: | + Unified payload for message webhooks when using `webhook_version: "2026-02-03"`. + + This schema is used for message.sent, message.received, message.delivered, and message.read + events when the subscription URL includes `?version=2026-02-03`. + + Key differences from V1 (2025-01-01): + - `direction`: "inbound" or "outbound" instead of `is_from_me` boolean + - `sender_handle`: Full handle object for the sender + - `chat`: Nested object with `id`, `is_group`, and `owner_handle` + - Message fields (`id`, `parts`, `effect`, etc.) are at the top level, not nested in `message` + + Timestamps indicate the message state: + - `message.sent`: sent_at set, delivered_at=null, read_at=null + - `message.received`: sent_at set, delivered_at=null, read_at=null + - `message.delivered`: sent_at set, delivered_at set, read_at=null + - `message.read`: sent_at set, delivered_at set, read_at set + required: + - chat + - id + - direction + - sender_handle + - service + - parts + properties: + chat: + type: object + description: Chat information + required: + - id + properties: + id: + type: string + format: uuid + description: Chat identifier + example: 550e8400-e29b-41d4-a716-446655440000 + is_group: + type: boolean + nullable: true + description: Whether this is a group chat + example: true + owner_handle: + allOf: + - $ref: '#/components/schemas/ChatHandle' + nullable: true + description: Your phone number's handle. Always has is_me=true. + id: + type: string + format: uuid + description: Message identifier + example: 550e8400-e29b-41d4-a716-446655440001 + idempotency_key: + type: string + nullable: true + description: Idempotency key for deduplication of outbound messages. + example: unique-key + direction: + type: string + description: Message direction - "outbound" if sent by you, "inbound" if received + enum: + - inbound + - outbound + example: outbound + sender_handle: + allOf: + - $ref: '#/components/schemas/ChatHandle' + description: The handle that sent this message + parts: + type: array + description: Message parts (text and/or media) + items: + oneOf: + - $ref: '#/components/schemas/schemas-TextPartResponse' + - $ref: '#/components/schemas/schemas-MediaPartResponse' + discriminator: + propertyName: type + mapping: + text: '#/components/schemas/schemas-TextPartResponse' + media: '#/components/schemas/schemas-MediaPartResponse' + sent_at: + type: string + format: date-time + nullable: true + description: When the message was sent. Null if not yet sent. + example: '2026-01-30T20:49:19.704Z' + delivered_at: + type: string + format: date-time + nullable: true + description: When the message was delivered. Null if not yet delivered. + example: '2026-01-30T20:49:20.352Z' + read_at: + type: string + format: date-time + nullable: true + description: When the message was read. Null if not yet read. + example: null + reply_to: + type: object + nullable: true + description: Reference to the message this is replying to (for threaded replies) + properties: + message_id: + type: string + format: uuid + description: ID of the message being replied to + part_index: + type: integer + format: int32 + minimum: 0 + description: Index of the part being replied to + effect: + allOf: + - $ref: '#/components/schemas/schemas-MessageEffect' + nullable: true + description: iMessage effect applied to the message (bubble or screen animation). Null if no effect. + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + description: The service used to send/receive the message + example: iMessage + preferred_service: + allOf: + - $ref: '#/components/schemas/ServicePreferenceType' + nullable: true + description: The service that was requested when sending. Null for inbound messages. + example: iMessage + MessageSentWebhookV2: + description: Complete webhook payload for message.sent events (2026-02-03 format) + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/MessageEventV2' + MessageReceivedWebhookV2: + description: Complete webhook payload for message.received events (2026-02-03 format) + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/MessageEventV2' + MessageReadWebhookV2: + description: Complete webhook payload for message.read events (2026-02-03 format) + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/MessageEventV2' + MessageDeliveredWebhookV2: + description: Complete webhook payload for message.delivered events (2026-02-03 format) + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/MessageEventV2' + WebhookErrorCode: + type: integer + description: Error codes in webhook failure events (3007, 4001). + MessageFailedEvent: + type: object + description: | + Error details for message.failed webhook events. + See [WebhookErrorCode](#/components/schemas/WebhookErrorCode) for the full error code reference. + required: + - code + - failed_at + properties: + chat_id: + type: string + description: Chat identifier (UUID) + example: 550e8400-e29b-41d4-a716-446655440000 + message_id: + type: string + description: Message identifier (UUID) + example: 550e8400-e29b-41d4-a716-446655440001 + code: + $ref: '#/components/schemas/WebhookErrorCode' + reason: + type: string + description: Human-readable description of the failure + example: Request expired before being processed + failed_at: + type: string + format: date-time + description: When the failure was detected + example: '2025-11-23T17:35:00Z' + MessageFailedWebhook: + description: Complete webhook payload for message.failed events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/MessageFailedEvent' + ReactionEventBase: + type: object + required: + - reaction_type + - is_from_me + properties: + chat_id: + type: string + description: Chat identifier (UUID) + example: 550e8400-e29b-41d4-a716-446655440000 + from: + type: string + description: 'DEPRECATED: Use from_handle instead. Phone number or email address of the person who added/removed the reaction.' + example: '+14155559876' + deprecated: true + from_handle: + allOf: + - $ref: '#/components/schemas/ChatHandle' + description: The person who added/removed the reaction as a full handle object + example: + id: 550e8400-e29b-41d4-a716-446655440011 + handle: '+14155559876' + is_me: false + service: iMessage + status: active + joined_at: '2025-11-23T17:30:00.000Z' + left_at: null + message_id: + type: string + description: Message identifier (UUID) that the reaction was added to or removed from + example: 550e8400-e29b-41d4-a716-446655440001 + part_index: + type: integer + format: int32 + minimum: 0 + description: Index of the message part that was reacted to (0-based) + example: 0 + reaction_type: + $ref: '#/components/schemas/ReactionType' + custom_emoji: + type: string + nullable: true + description: The actual emoji when reaction_type is "custom". Null for standard tapbacks. + example: null + is_from_me: + type: boolean + description: Whether this reaction was from the owner of the phone number (true) or from someone else (false) + example: false + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + description: Message service type + example: iMessage + reacted_at: + type: string + format: date-time + description: When the reaction was added or removed + example: '2025-11-23T17:35:00Z' + sticker: + type: object + nullable: true + description: Sticker attachment details when reaction_type is "sticker". Null for non-sticker reactions. + properties: + url: + type: string + format: uri + description: | + Presigned URL for downloading the sticker image (expires in 1 hour). + example: https://cdn.linqapp.com/attachments/a1b2c3d4/sticker.png?signature=... + mime_type: + type: string + description: MIME type of the sticker image + example: image/png + width: + type: integer + description: Sticker image width in pixels + example: 420 + height: + type: integer + description: Sticker image height in pixels + example: 420 + file_name: + type: string + description: Filename of the sticker + example: sticker.png + ReactionAddedEvent: + description: Payload for reaction.added webhook events + allOf: + - $ref: '#/components/schemas/ReactionEventBase' + ReactionAddedWebhook: + description: Complete webhook payload for reaction.added events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ReactionAddedEvent' + ReactionRemovedEvent: + description: Payload for reaction.removed webhook events + allOf: + - $ref: '#/components/schemas/ReactionEventBase' + ReactionRemovedWebhook: + description: Complete webhook payload for reaction.removed events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ReactionRemovedEvent' + ParticipantAddedEvent: + type: object + description: Payload for participant.added webhook events + required: + - handle + properties: + chat_id: + type: string + description: Chat identifier (UUID) of the group chat + example: 550e8400-e29b-41d4-a716-446655440000 + handle: + type: string + description: 'DEPRECATED: Use participant instead. Handle (phone number or email address) of the added participant.' + deprecated: true + example: '+14155559876' + participant: + allOf: + - $ref: '#/components/schemas/ChatHandle' + description: The added participant as a full handle object + example: + id: 550e8400-e29b-41d4-a716-446655440011 + handle: '+14155559876' + is_me: false + service: iMessage + status: active + joined_at: '2025-11-23T17:40:00.000Z' + left_at: null + added_at: + type: string + format: date-time + description: When the participant was added + example: '2025-11-23T17:40:00Z' + ParticipantAddedWebhook: + description: Complete webhook payload for participant.added events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ParticipantAddedEvent' + ParticipantRemovedEvent: + type: object + description: Payload for participant.removed webhook events + required: + - handle + properties: + chat_id: + type: string + description: Chat identifier (UUID) of the group chat + example: 550e8400-e29b-41d4-a716-446655440000 + handle: + type: string + description: 'DEPRECATED: Use participant instead. Handle (phone number or email address) of the removed participant.' + deprecated: true + example: '+14155559876' + participant: + allOf: + - $ref: '#/components/schemas/ChatHandle' + description: The removed participant as a full handle object + example: + id: 550e8400-e29b-41d4-a716-446655440011 + handle: '+14155559876' + is_me: false + service: iMessage + status: removed + joined_at: '2025-11-23T17:30:00.000Z' + left_at: '2025-11-23T17:45:00.000Z' + removed_at: + type: string + format: date-time + description: When the participant was removed + example: '2025-11-23T17:45:00Z' + ParticipantRemovedWebhook: + description: Complete webhook payload for participant.removed events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ParticipantRemovedEvent' + ChatGroupNameUpdatedEvent: + type: object + description: Payload for chat.group_name_updated webhook events + required: + - chat_id + - updated_at + properties: + chat_id: + type: string + description: Chat identifier (UUID) of the group chat + example: 550e8400-e29b-41d4-a716-446655440000 + old_value: + type: string + nullable: true + description: Previous group name (null if no previous name) + example: Old Group Name + new_value: + type: string + nullable: true + description: New group name (null if the name was removed) + example: New Group Name + changed_by_handle: + allOf: + - $ref: '#/components/schemas/ChatHandle' + nullable: true + description: The handle who made the change. + example: + id: 550e8400-e29b-41d4-a716-446655440011 + handle: '+14155559876' + is_me: false + service: iMessage + status: active + joined_at: '2025-11-23T17:30:00.000Z' + left_at: null + updated_at: + type: string + format: date-time + description: When the update occurred + example: '2025-11-23T17:50:00Z' + ChatGroupNameUpdatedWebhook: + description: Complete webhook payload for chat.group_name_updated events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ChatGroupNameUpdatedEvent' + ChatGroupIconUpdatedEvent: + type: object + description: Payload for chat.group_icon_updated webhook events + required: + - chat_id + - updated_at + properties: + chat_id: + type: string + description: Chat identifier (UUID) of the group chat + example: 550e8400-e29b-41d4-a716-446655440000 + old_value: + type: string + nullable: true + description: Previous icon URL (null if no previous icon) + example: https://example.com/old-icon.png + new_value: + type: string + nullable: true + description: New icon URL (null if the icon was removed) + example: https://example.com/new-icon.png + changed_by_handle: + allOf: + - $ref: '#/components/schemas/ChatHandle' + nullable: true + description: The handle who made the change. + example: + id: 550e8400-e29b-41d4-a716-446655440011 + handle: '+14155559876' + is_me: false + service: iMessage + status: active + joined_at: '2025-11-23T17:30:00.000Z' + left_at: null + updated_at: + type: string + format: date-time + description: When the update occurred + example: '2025-11-23T17:50:00Z' + ChatGroupIconUpdatedWebhook: + description: Complete webhook payload for chat.group_icon_updated events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ChatGroupIconUpdatedEvent' + ChatGroupNameUpdateFailedEvent: + type: object + description: | + Error details for chat.group_name_update_failed webhook events. + See [WebhookErrorCode](#/components/schemas/WebhookErrorCode) for the full error code reference. + required: + - chat_id + - error_code + - failed_at + properties: + chat_id: + type: string + description: Chat identifier (UUID) of the group chat + example: 550e8400-e29b-41d4-a716-446655440000 + error_code: + $ref: '#/components/schemas/WebhookErrorCode' + failed_at: + type: string + format: date-time + description: When the failure was detected + example: '2025-11-23T17:55:00Z' + ChatGroupNameUpdateFailedWebhook: + description: Complete webhook payload for chat.group_name_update_failed events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ChatGroupNameUpdateFailedEvent' + ChatGroupIconUpdateFailedEvent: + type: object + description: | + Error details for chat.group_icon_update_failed webhook events. + See [WebhookErrorCode](#/components/schemas/WebhookErrorCode) for the full error code reference. + required: + - chat_id + - error_code + - failed_at + properties: + chat_id: + type: string + description: Chat identifier (UUID) of the group chat + example: 550e8400-e29b-41d4-a716-446655440000 + error_code: + $ref: '#/components/schemas/WebhookErrorCode' + failed_at: + type: string + format: date-time + description: When the failure was detected + example: '2025-11-23T17:55:00Z' + ChatGroupIconUpdateFailedWebhook: + description: Complete webhook payload for chat.group_icon_update_failed events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ChatGroupIconUpdateFailedEvent' + ChatCreatedEvent: + type: object + description: Payload for chat.created webhook events. Matches GET /v3/chats/{chatId} response. + required: + - id + - display_name + - handles + - is_group + - created_at + - updated_at + properties: + id: + type: string + format: uuid + description: Unique identifier for the chat + example: 550e8400-e29b-41d4-a716-446655440000 + display_name: + type: string + nullable: true + description: Display name for the chat. Defaults to a comma-separated list of recipient handles. Can be updated for group chats. + example: +14155551234, +14155559876 + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + description: Service type for the chat + example: iMessage + nullable: true + handles: + type: array + description: List of chat participants with full handle details. Always contains at least two handles (your phone number and the other participant). + items: + $ref: '#/components/schemas/ChatHandle' + example: + - id: 550e8400-e29b-41d4-a716-446655440010 + handle: '+14155551234' + is_me: true + service: iMessage + status: active + joined_at: '2025-11-23T17:30:00.000Z' + left_at: null + - id: 550e8400-e29b-41d4-a716-446655440011 + handle: '+14155559876' + is_me: false + service: iMessage + status: active + joined_at: '2025-11-23T17:30:00.000Z' + left_at: null + is_group: + type: boolean + description: Whether this is a group chat + default: false + example: true + created_at: + type: string + format: date-time + description: When the chat was created + example: '2025-11-23T17:30:00Z' + updated_at: + type: string + format: date-time + description: When the chat was last updated + example: '2025-11-23T17:30:00Z' + ChatCreatedWebhook: + description: Complete webhook payload for chat.created events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ChatCreatedEvent' + ChatTypingIndicatorStartedEvent: + type: object + description: Payload for chat.typing_indicator.started webhook events + required: + - chat_id + properties: + chat_id: + type: string + format: uuid + description: Chat identifier + example: 550e8400-e29b-41d4-a716-446655440000 + ChatTypingIndicatorStartedWebhook: + description: Complete webhook payload for chat.typing_indicator.started events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ChatTypingIndicatorStartedEvent' + ChatTypingIndicatorStoppedEvent: + type: object + description: Payload for chat.typing_indicator.stopped webhook events + required: + - chat_id + properties: + chat_id: + type: string + format: uuid + description: Chat identifier + example: 550e8400-e29b-41d4-a716-446655440000 + ChatTypingIndicatorStoppedWebhook: + description: Complete webhook payload for chat.typing_indicator.stopped events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/ChatTypingIndicatorStoppedEvent' + PhoneNumberStatusUpdatedEvent: + type: object + description: Payload for phone_number.status_updated webhook events + required: + - phone_number + - previous_status + - new_status + - changed_at + properties: + phone_number: + type: string + description: Phone number in E.164 format + example: '+15551234567' + previous_status: + type: string + description: The previous service status + enum: + - ACTIVE + - FLAGGED + example: ACTIVE + new_status: + type: string + description: The new service status + enum: + - ACTIVE + - FLAGGED + example: FLAGGED + changed_at: + type: string + format: date-time + description: When the status change occurred + example: '2024-01-15T10:30:00Z' + PhoneNumberStatusUpdatedWebhook: + description: Complete webhook payload for phone_number.status_updated events + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + event_type: + type: string + description: The type of event + example: phone_number.status_updated + data: + $ref: '#/components/schemas/PhoneNumberStatusUpdatedEvent' + MessagePayload: + type: object + description: Message content nested within webhook events + properties: + id: + type: string + format: uuid + description: Message identifier + example: 550e8400-e29b-41d4-a716-446655440001 + parts: + type: array + description: Message content parts (text and/or media) + items: + oneOf: + - $ref: '#/components/schemas/schemas-TextPartResponse' + - $ref: '#/components/schemas/schemas-MediaPartResponse' + effect: + $ref: '#/components/schemas/schemas-MessageEffect' + reply_to: + type: object + description: Reference to the message this is replying to + properties: + message_id: + type: string + format: uuid + description: The ID of the message being replied to + example: 550e8400-e29b-41d4-a716-446655440000 + part_index: + type: integer + format: int32 + minimum: 0 + description: Index of the message part being replied to (0-based) + example: 0 + is_delivered: + type: boolean + description: Whether the message has been delivered + example: false + is_read: + type: boolean + description: Whether the message has been read + example: false + sent_at: + type: string + format: date-time + nullable: true + description: When the message was sent + example: '2024-01-15T10:30:05Z' + delivered_at: + type: string + format: date-time + nullable: true + description: When the message was delivered + example: null + read_at: + type: string + format: date-time + nullable: true + description: When the message was read + example: null + created_at: + type: string + format: date-time + description: When the message record was created + example: '2024-01-15T10:30:00Z' + updated_at: + type: string + format: date-time + description: When the message record was last updated + example: '2024-01-15T10:30:00Z' + MessageEvent: + type: object + description: Unified payload for message.sent and message.received webhook events (2025-01-01 format) + properties: + chat_id: + type: string + format: uuid + description: Chat identifier + example: 550e8400-e29b-41d4-a716-446655440000 + is_group: + type: boolean + description: Whether this is a group chat + example: false + idempotency_key: + type: string + nullable: true + description: Idempotency key for the message. Used for deduplication of outbound messages. + example: unique-request-key-12345 + from: + type: string + description: 'DEPRECATED: Use from_handle instead. Phone number or email address of the message sender.' + example: '+14155551234' + deprecated: true + from_handle: + allOf: + - $ref: '#/components/schemas/ChatHandle' + description: The sender of this message as a full handle object + message: + $ref: '#/components/schemas/MessagePayload' + recipient_phone: + type: string + nullable: true + description: 'DEPRECATED: Use recipient_handle instead. Our phone number that received the message. Null for sent events.' + example: '+14155551234' + deprecated: true + recipient_handle: + nullable: true + allOf: + - $ref: '#/components/schemas/ChatHandle' + description: Our phone number that received the message as a full handle object. Null for sent events. + is_from_me: + type: boolean + description: Whether the message was sent by us (true for sent events, false for received events) + example: true + received_at: + type: string + format: date-time + nullable: true + description: When the message was received. Null for sent events. + example: '2025-11-23T17:29:55Z' + service: + allOf: + - $ref: '#/components/schemas/ServiceType' + nullable: true + description: The service used to send/receive the message + example: iMessage + preferred_service: + allOf: + - $ref: '#/components/schemas/ServicePreferenceType' + nullable: true + description: The service that was requested when sending the message. Null for received events. + example: iMessage + MessageSentWebhook: + description: Complete webhook payload for message.sent events (2025-01-01 format) + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/MessageEvent' + MessageReceivedWebhook: + description: Complete webhook payload for message.received events (2025-01-01 format) + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/MessageEvent' + MessageReadEvent: + description: Payload for message.read webhook events (2025-01-01 format). Extends MessageEvent with read_at and message_id. + allOf: + - $ref: '#/components/schemas/MessageEvent' + - type: object + required: + - read_at + properties: + message_id: + type: string + description: Message identifier (UUID) + example: 550e8400-e29b-41d4-a716-446655440001 + read_at: + type: string + format: date-time + description: When the message was read + example: '2025-11-23T17:35:00Z' + MessageReadWebhook: + description: Complete webhook payload for message.read events (2025-01-01 format) + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/MessageReadEvent' + MessageDeliveredEvent: + description: Payload for message.delivered webhook events (2025-01-01 format). Extends MessageEvent with delivered_at and message_id. + allOf: + - $ref: '#/components/schemas/MessageEvent' + - type: object + required: + - delivered_at + properties: + message_id: + type: string + description: Message identifier (UUID) + example: 550e8400-e29b-41d4-a716-446655440001 + delivered_at: + type: string + format: date-time + description: When the message was delivered to the recipient's device + example: '2025-11-23T17:32:00Z' + MessageDeliveredWebhook: + description: Complete webhook payload for message.delivered events (2025-01-01 format) + allOf: + - $ref: '#/components/schemas/WebhookEnvelopeBase' + - type: object + properties: + data: + $ref: '#/components/schemas/MessageDeliveredEvent' +x-tagGroups: + - name: Messaging + tags: + - Chats + - Messages + - Attachments + - Phone Numbers + - Capability Checks + - name: Webhooks + tags: + - Webhooks + - Webhook Events + - '2025-01-01' + - '2026-02-03' diff --git a/packages/adapter-linq/package.json b/packages/adapter-linq/package.json new file mode 100644 index 00000000..d6d6a9ce --- /dev/null +++ b/packages/adapter-linq/package.json @@ -0,0 +1,61 @@ +{ + "name": "@chat-adapter/linq", + "version": "0.1.0", + "description": "Linq adapter for chat - iMessage, SMS, and RCS via Linq API", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "generate-types": "pnpm dlx openapi-typescript lib/linq/linq-api-v3.yaml -o src/schema.ts", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chat-adapter/shared": "workspace:*", + "chat": "workspace:*", + "openapi-fetch": "^0.14.0" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "openapi-typescript": "^7.8.0", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/chat.git", + "directory": "packages/adapter-linq" + }, + "homepage": "https://github.com/vercel/chat#readme", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "linq", + "imessage", + "sms", + "rcs", + "bot", + "adapter" + ], + "license": "MIT" +} diff --git a/packages/adapter-linq/src/index.test.ts b/packages/adapter-linq/src/index.test.ts new file mode 100644 index 00000000..eabeb483 --- /dev/null +++ b/packages/adapter-linq/src/index.test.ts @@ -0,0 +1,1322 @@ +import { + AdapterRateLimitError, + AuthenticationError, + NetworkError, + ResourceNotFoundError, + ValidationError, +} from "@chat-adapter/shared"; +import type { ChatInstance, Logger } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createLinqAdapter, LinqAdapter } from "./index"; + +const mockLogger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +}; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function createMockChat(): ChatInstance { + return { + getLogger: vi.fn().mockReturnValue(mockLogger), + getState: vi.fn(), + getUserName: vi.fn().mockReturnValue("testbot"), + handleIncomingMessage: vi.fn().mockResolvedValue(undefined), + processMessage: vi.fn(), + processReaction: vi.fn(), + processAction: vi.fn(), + processModalClose: vi.fn(), + processModalSubmit: vi.fn().mockResolvedValue(undefined), + processSlashCommand: vi.fn(), + processAssistantThreadStarted: vi.fn(), + processAssistantContextChanged: vi.fn(), + processAppHomeOpened: vi.fn(), + processMemberJoinedChannel: vi.fn(), + } as unknown as ChatInstance; +} + +function linqOk(result: unknown, status = 200): Response { + return new Response(JSON.stringify(result), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function linqError(status: number): Response { + return new Response(JSON.stringify({ error: "error" }), { + status, + headers: { "content-type": "application/json" }, + }); +} + +async function generateSignature( + secret: string, + timestamp: string, + body: string +): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(`${timestamp}.${body}`) + ); + return Array.from(new Uint8Array(sig)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +describe("createLinqAdapter", () => { + it("throws when API token is missing", () => { + process.env.LINQ_API_TOKEN = ""; + expect(() => createLinqAdapter({ logger: mockLogger })).toThrow( + ValidationError + ); + }); + + it("uses env vars when config is omitted", () => { + process.env.LINQ_API_TOKEN = "test-token"; + const adapter = createLinqAdapter({ logger: mockLogger }); + expect(adapter).toBeInstanceOf(LinqAdapter); + expect(adapter.name).toBe("linq"); + process.env.LINQ_API_TOKEN = undefined; + }); + + it("creates adapter with explicit token", () => { + const adapter = createLinqAdapter({ + apiToken: "explicit-token", + logger: mockLogger, + }); + expect(adapter.name).toBe("linq"); + }); +}); + +describe("thread ID encoding", () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + + it("encodes thread ID", () => { + const threadId = adapter.encodeThreadId({ + chatId: "550e8400-e29b-41d4-a716-446655440000", + }); + expect(threadId).toBe("linq:550e8400-e29b-41d4-a716-446655440000"); + }); + + it("decodes thread ID", () => { + const decoded = adapter.decodeThreadId( + "linq:550e8400-e29b-41d4-a716-446655440000" + ); + expect(decoded).toEqual({ + chatId: "550e8400-e29b-41d4-a716-446655440000", + }); + }); + + it("throws on invalid thread ID", () => { + expect(() => adapter.decodeThreadId("invalid")).toThrow(ValidationError); + expect(() => adapter.decodeThreadId("slack:123:456")).toThrow( + ValidationError + ); + }); + + it("channelIdFromThreadId returns chatId", () => { + expect( + adapter.channelIdFromThreadId("linq:550e8400-e29b-41d4-a716-446655440000") + ).toBe("550e8400-e29b-41d4-a716-446655440000"); + }); +}); + +describe("handleWebhook", () => { + it("rejects missing signature when signingSecret is set", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + signingSecret: "test-secret", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify({ event_type: "message.received" }), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("verifies valid HMAC signature", async () => { + const secret = "test-secret"; + const adapter = new LinqAdapter({ + apiToken: "test-token", + signingSecret: secret, + logger: mockLogger, + }); + const mockChat = createMockChat(); + await adapter.initialize(mockChat); + + const body = JSON.stringify({ + api_version: "v3", + webhook_version: "2026-02-03", + event_type: "message.received", + event_id: "evt-123", + created_at: new Date().toISOString(), + trace_id: "trace-1", + partner_id: "partner-1", + data: { + chat: { id: "chat-123" }, + id: "msg-123", + direction: "inbound", + sender_handle: { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + parts: [{ type: "text", value: "Hello" }], + service: "iMessage", + }, + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = await generateSignature(secret, timestamp, body); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + headers: { + "x-webhook-signature": signature, + "x-webhook-timestamp": timestamp, + }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(mockChat.processMessage).toHaveBeenCalledOnce(); + }); + + it("routes reaction.added events", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + const mockChat = createMockChat(); + await adapter.initialize(mockChat); + + const body = JSON.stringify({ + api_version: "v3", + webhook_version: "2026-02-03", + event_type: "reaction.added", + event_id: "evt-456", + created_at: new Date().toISOString(), + trace_id: "trace-2", + partner_id: "partner-1", + data: { + chat_id: "chat-123", + message_id: "msg-123", + reaction_type: "love", + is_from_me: false, + from_handle: { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + }, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(mockChat.processReaction).toHaveBeenCalledOnce(); + + const reactionCall = (mockChat.processReaction as ReturnType) + .mock.calls[0][0]; + expect(reactionCall.added).toBe(true); + expect(reactionCall.messageId).toBe("msg-123"); + }); + + it("returns 400 for invalid JSON", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body: "not json", + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(400); + }); +}); + +describe("postMessage", () => { + it("sends a text message", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk( + { + chat_id: "chat-123", + message: { + id: "msg-456", + parts: [{ type: "text", value: "Hello" }], + status: "queued", + created_at: "2025-01-01T00:00:00Z", + }, + }, + 202 + ) + ); + + const result = await adapter.postMessage("linq:chat-123", "Hello"); + + expect(result.id).toBe("msg-456"); + expect(result.threadId).toBe("linq:chat-123"); + }); + + it("throws on empty text", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + + await expect(adapter.postMessage("linq:chat-123", "")).rejects.toThrow( + ValidationError + ); + }); +}); + +describe("editMessage", () => { + it("edits a message", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk({ + id: "msg-456", + chat_id: "chat-123", + is_from_me: true, + is_delivered: true, + is_read: false, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:01:00Z", + parts: [{ type: "text", value: "Updated", reactions: null }], + }) + ); + + const result = await adapter.editMessage( + "linq:chat-123", + "msg-456", + "Updated" + ); + expect(result.id).toBe("msg-456"); + }); +}); + +describe("deleteMessage", () => { + it("deletes a message", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect( + adapter.deleteMessage("linq:chat-123", "msg-456") + ).resolves.toBeUndefined(); + }); +}); + +describe("API error handling", () => { + it("throws AuthenticationError on 401", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce(linqError(401)); + + await expect(adapter.postMessage("linq:chat-123", "Hello")).rejects.toThrow( + AuthenticationError + ); + }); + + it("throws AdapterRateLimitError on 429", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce(linqError(429)); + + await expect(adapter.postMessage("linq:chat-123", "Hello")).rejects.toThrow( + AdapterRateLimitError + ); + }); +}); + +describe("parseMessage", () => { + it("parses a raw Linq message", () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + + const message = adapter.parseMessage({ + id: "msg-1", + chat_id: "chat-1", + is_from_me: false, + is_delivered: true, + is_read: true, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + parts: [ + { type: "text", value: "Hello world", reactions: null }, + ] as unknown as null, + from_handle: { + id: "h1", + handle: "+15551234567", + service: "iMessage" as const, + joined_at: "2025-01-01T00:00:00Z", + }, + }); + + expect(message.text).toBe("Hello world"); + expect(message.threadId).toBe("linq:chat-1"); + expect(message.author.userId).toBe("+15551234567"); + }); +}); + +describe("startTyping", () => { + it("sends typing indicator", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); + + await expect(adapter.startTyping("linq:chat-123")).resolves.toBeUndefined(); + }); +}); + +describe("reactions", () => { + it("adds a reaction", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk({ status: "accepted", message: "Reaction processed" }, 202) + ); + + await expect( + adapter.addReaction("linq:chat-123", "msg-456", "heart") + ).resolves.toBeUndefined(); + }); + + it("removes a reaction", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk({ status: "accepted", message: "Reaction processed" }, 202) + ); + + await expect( + adapter.removeReaction("linq:chat-123", "msg-456", "heart") + ).resolves.toBeUndefined(); + }); +}); + +describe("isDM", () => { + it("returns true for all Linq threads", () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + expect(adapter.isDM("linq:chat-123")).toBe(true); + }); +}); + +describe("fetchMessages", () => { + it("fetches messages with pagination cursor", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk({ + messages: [ + { + id: "msg-1", + chat_id: "chat-123", + is_from_me: false, + is_delivered: true, + is_read: true, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + parts: [{ type: "text", value: "Hello", reactions: null }], + from_handle: { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + }, + ], + next_cursor: "cursor-abc", + }) + ); + + const result = await adapter.fetchMessages("linq:chat-123", { + cursor: "prev-cursor", + limit: 10, + }); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].text).toBe("Hello"); + expect(result.nextCursor).toBe("cursor-abc"); + }); + + it("returns undefined nextCursor when no more pages", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk({ + messages: [], + next_cursor: null, + }) + ); + + const result = await adapter.fetchMessages("linq:chat-123"); + expect(result.messages).toHaveLength(0); + expect(result.nextCursor).toBeUndefined(); + }); +}); + +describe("fetchThread", () => { + it("fetches thread info from chat API", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk({ + id: "chat-123", + display_name: "Test Chat", + is_group: false, + handles: [ + { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + ], + }) + ); + + const info = await adapter.fetchThread("linq:chat-123"); + expect(info.id).toBe("linq:chat-123"); + expect(info.channelId).toBe("chat-123"); + expect(info.channelName).toBe("Test Chat"); + expect(info.isDM).toBe(true); + }); +}); + +describe("fetchChannelInfo", () => { + it("fetches channel info from chat API", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk({ + id: "chat-123", + display_name: "Group Chat", + is_group: true, + handles: [ + { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + { + id: "h2", + handle: "+15559876543", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + ], + }) + ); + + const info = await adapter.fetchChannelInfo("chat-123"); + expect(info.id).toBe("chat-123"); + expect(info.name).toBe("Group Chat"); + expect(info.isDM).toBe(false); + expect(info.memberCount).toBe(2); + }); +}); + +describe("fetchMessage", () => { + it("fetches a single message", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk({ + id: "msg-1", + chat_id: "chat-123", + is_from_me: false, + is_delivered: true, + is_read: true, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + parts: [{ type: "text", value: "Hi there", reactions: null }], + from_handle: { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + }) + ); + + const message = await adapter.fetchMessage("linq:chat-123", "msg-1"); + expect(message).not.toBeNull(); + expect(message?.text).toBe("Hi there"); + expect(message?.id).toBe("msg-1"); + }); + + it("returns null on API error", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce(linqError(404)); + + const message = await adapter.fetchMessage("linq:chat-123", "nonexistent"); + expect(message).toBeNull(); + }); +}); + +describe("editMessage result", () => { + it("returns correct structure from edit", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk({ + id: "msg-456", + chat_id: "chat-123", + is_from_me: true, + is_delivered: true, + is_read: false, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:01:00Z", + parts: [{ type: "text", value: "Edited text", reactions: null }], + }) + ); + + const result = await adapter.editMessage( + "linq:chat-123", + "msg-456", + "Edited text" + ); + expect(result.id).toBe("msg-456"); + expect(result.threadId).toBe("linq:chat-123"); + expect(result.raw.chat_id).toBe("chat-123"); + }); +}); + +describe("webhook edge cases", () => { + it("rejects invalid signature", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + signingSecret: "test-secret", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + const body = JSON.stringify({ + api_version: "v3", + webhook_version: "2026-02-03", + event_type: "message.received", + event_id: "evt-123", + created_at: new Date().toISOString(), + trace_id: "trace-1", + partner_id: "partner-1", + data: { + chat: { id: "chat-123" }, + id: "msg-123", + direction: "inbound", + sender_handle: { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + parts: [{ type: "text", value: "Hello" }], + service: "iMessage", + }, + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + headers: { + "x-webhook-signature": "deadbeef1234567890abcdef", + "x-webhook-timestamp": timestamp, + }, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("routes reaction.removed events", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + const mockChat = createMockChat(); + await adapter.initialize(mockChat); + + const body = JSON.stringify({ + api_version: "v3", + webhook_version: "2026-02-03", + event_type: "reaction.removed", + event_id: "evt-789", + created_at: new Date().toISOString(), + trace_id: "trace-3", + partner_id: "partner-1", + data: { + chat_id: "chat-123", + message_id: "msg-123", + reaction_type: "like", + is_from_me: false, + from_handle: { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + }, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(mockChat.processReaction).toHaveBeenCalledOnce(); + + const reactionCall = (mockChat.processReaction as ReturnType) + .mock.calls[0][0]; + expect(reactionCall.added).toBe(false); + expect(reactionCall.emoji.name).toBe("thumbsup"); + }); + + it("routes message.edited events", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + const mockChat = createMockChat(); + await adapter.initialize(mockChat); + + const body = JSON.stringify({ + api_version: "v3", + webhook_version: "2026-02-03", + event_type: "message.edited", + event_id: "evt-edit-1", + created_at: new Date().toISOString(), + trace_id: "trace-4", + partner_id: "partner-1", + data: { + chat: { id: "chat-123" }, + id: "msg-edited", + direction: "inbound", + sender_handle: { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + parts: [{ type: "text", value: "Edited content" }], + service: "iMessage", + }, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(mockChat.processMessage).toHaveBeenCalledOnce(); + + const messageCall = (mockChat.processMessage as ReturnType) + .mock.calls[0][2]; + expect(messageCall.metadata.edited).toBe(true); + }); + + it("silently ignores unknown event types", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + const mockChat = createMockChat(); + await adapter.initialize(mockChat); + + const body = JSON.stringify({ + api_version: "v3", + webhook_version: "2026-02-03", + event_type: "chat.typing_indicator.started", + event_id: "evt-unknown", + created_at: new Date().toISOString(), + trace_id: "trace-5", + partner_id: "partner-1", + data: {}, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(mockChat.processMessage).not.toHaveBeenCalled(); + expect(mockChat.processReaction).not.toHaveBeenCalled(); + }); + + it("parses media attachments from webhook message", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + const mockChat = createMockChat(); + await adapter.initialize(mockChat); + + const body = JSON.stringify({ + api_version: "v3", + webhook_version: "2026-02-03", + event_type: "message.received", + event_id: "evt-media", + created_at: new Date().toISOString(), + trace_id: "trace-6", + partner_id: "partner-1", + data: { + chat: { id: "chat-123" }, + id: "msg-media", + direction: "inbound", + sender_handle: { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + parts: [ + { type: "text", value: "Check this out" }, + { + type: "media", + id: "att-1", + url: "https://example.com/image.png", + filename: "image.png", + mime_type: "image/png", + size_bytes: 12345, + }, + ], + service: "iMessage", + }, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + const messageCall = (mockChat.processMessage as ReturnType) + .mock.calls[0][2]; + expect(messageCall.text).toBe("Check this out"); + expect(messageCall.attachments).toHaveLength(1); + expect(messageCall.attachments[0].type).toBe("image"); + expect(messageCall.attachments[0].name).toBe("image.png"); + }); +}); + +describe("API error handling extended", () => { + it("throws ResourceNotFoundError on 404", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce(linqError(404)); + + await expect(adapter.fetchThread("linq:chat-123")).rejects.toThrow( + ResourceNotFoundError + ); + }); + + it("throws NetworkError on 500", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce(linqError(500)); + + await expect(adapter.postMessage("linq:chat-123", "Hello")).rejects.toThrow( + NetworkError + ); + }); +}); + +describe("openDM", () => { + it("throws when phoneNumber is not configured", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + + await expect(adapter.openDM("+15559876543")).rejects.toThrow( + ValidationError + ); + }); + + it("creates a chat and returns thread ID", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + phoneNumber: "+15551234567", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk( + { + chat: { id: "new-chat-id", display_name: "+15559876543" }, + message: { id: "msg-init", status: "queued" }, + }, + 201 + ) + ); + + const threadId = await adapter.openDM("+15559876543"); + expect(threadId).toBe("linq:new-chat-id"); + }); +}); + +describe("botUserId", () => { + it("returns phoneNumber when configured", () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + phoneNumber: "+15551234567", + logger: mockLogger, + }); + expect(adapter.botUserId).toBe("+15551234567"); + }); + + it("returns undefined when phoneNumber is not configured", () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + expect(adapter.botUserId).toBeUndefined(); + }); +}); + +describe("postMessage with file attachments", () => { + it("uploads files and includes attachment_id in message parts", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + // First call: POST /v3/attachments (pre-upload) + mockFetch.mockResolvedValueOnce( + linqOk({ + attachment_id: "att-uuid-123", + upload_url: "https://uploads.example.com/presigned", + download_url: "https://cdn.example.com/photo.jpg", + http_method: "PUT", + expires_at: "2025-01-15T10:45:00Z", + required_headers: { "Content-Type": "image/jpeg" }, + }) + ); + + // Second call: PUT to upload_url + mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 })); + + // Third call: POST /v3/chats/{chatId}/messages + mockFetch.mockResolvedValueOnce( + linqOk( + { + chat_id: "chat-123", + message: { + id: "msg-with-file", + parts: [ + { type: "text", value: "Check this photo" }, + { type: "media", attachment_id: "att-uuid-123" }, + ], + status: "queued", + created_at: "2025-01-01T00:00:00Z", + }, + }, + 202 + ) + ); + + const result = await adapter.postMessage("linq:chat-123", { + markdown: "Check this photo", + files: [ + { + data: Buffer.from("fake-image-data"), + filename: "photo.jpg", + mimeType: "image/jpeg", + }, + ], + }); + + expect(result.id).toBe("msg-with-file"); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("skips failed uploads and still sends message", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + // First call: POST /v3/attachments fails + mockFetch.mockResolvedValueOnce(linqError(500)); + + // Second call: POST /v3/chats/{chatId}/messages (text only) + mockFetch.mockResolvedValueOnce( + linqOk( + { + chat_id: "chat-123", + message: { + id: "msg-text-only", + parts: [{ type: "text", value: "Hello" }], + status: "queued", + created_at: "2025-01-01T00:00:00Z", + }, + }, + 202 + ) + ); + + const result = await adapter.postMessage("linq:chat-123", { + markdown: "Hello", + files: [ + { + data: Buffer.from("bad-data"), + filename: "doc.pdf", + mimeType: "application/pdf", + }, + ], + }); + + expect(result.id).toBe("msg-text-only"); + expect(mockLogger.warn).toHaveBeenCalledWith( + "Failed to upload file, skipping attachment", + expect.objectContaining({ filename: "doc.pdf" }) + ); + }); +}); + +describe("fetchChannelMessages", () => { + it("delegates to fetchMessages with encoded thread ID", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk({ + messages: [ + { + id: "msg-1", + chat_id: "chat-123", + is_from_me: false, + is_delivered: true, + is_read: true, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + parts: [{ type: "text", value: "Hello", reactions: null }], + from_handle: { + id: "h1", + handle: "+15551234567", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + }, + ], + next_cursor: null, + }) + ); + + const result = await adapter.fetchChannelMessages("chat-123", { + limit: 10, + }); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].text).toBe("Hello"); + }); +}); + +describe("listThreads", () => { + it("throws when phoneNumber is not configured", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + + await expect(adapter.listThreads("any")).rejects.toThrow(ValidationError); + }); + + it("lists chats as threads", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + phoneNumber: "+15551234567", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + // First call: GET /v3/chats + mockFetch.mockResolvedValueOnce( + linqOk({ + chats: [ + { + id: "chat-aaa", + display_name: "Chat A", + is_group: false, + is_archived: false, + handles: [], + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-02T00:00:00Z", + }, + ], + next_cursor: "cursor-next", + }) + ); + + // Second call: GET /v3/chats/{chatId}/messages (for root message) + mockFetch.mockResolvedValueOnce( + linqOk({ + messages: [ + { + id: "msg-root", + chat_id: "chat-aaa", + is_from_me: false, + is_delivered: true, + is_read: true, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + parts: [{ type: "text", value: "First msg", reactions: null }], + from_handle: { + id: "h1", + handle: "+15559876543", + service: "iMessage", + joined_at: "2025-01-01T00:00:00Z", + }, + }, + ], + next_cursor: null, + }) + ); + + const result = await adapter.listThreads("any-channel", { limit: 5 }); + expect(result.threads).toHaveLength(1); + expect(result.threads[0].id).toBe("linq:chat-aaa"); + expect(result.threads[0].rootMessage.text).toBe("First msg"); + expect(result.nextCursor).toBe("cursor-next"); + }); +}); + +describe("message.failed webhook", () => { + it("logs error for failed messages", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + const mockChat = createMockChat(); + await adapter.initialize(mockChat); + + const body = JSON.stringify({ + api_version: "v3", + webhook_version: "2026-02-03", + event_type: "message.failed", + event_id: "evt-fail-1", + created_at: new Date().toISOString(), + trace_id: "trace-f", + partner_id: "partner-1", + data: { + chat_id: "chat-123", + message_id: "msg-failed", + code: 3007, + reason: "Request expired before being processed", + failed_at: "2025-11-23T17:35:00Z", + }, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(mockLogger.error).toHaveBeenCalledWith( + "Linq message send failed", + expect.objectContaining({ + chatId: "chat-123", + messageId: "msg-failed", + code: 3007, + reason: "Request expired before being processed", + }) + ); + expect(mockChat.processMessage).not.toHaveBeenCalled(); + }); +}); + +describe("message.delivered webhook", () => { + it("logs delivery status without processing as message", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + const mockChat = createMockChat(); + await adapter.initialize(mockChat); + + const body = JSON.stringify({ + api_version: "v3", + webhook_version: "2026-02-03", + event_type: "message.delivered", + event_id: "evt-del-1", + created_at: new Date().toISOString(), + trace_id: "trace-d", + partner_id: "partner-1", + data: { + chat: { id: "chat-123" }, + id: "msg-123", + direction: "outbound", + }, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(mockChat.processMessage).not.toHaveBeenCalled(); + }); +}); + +describe("postChannelMessage", () => { + it("delegates to postMessage with encoded thread ID", async () => { + const adapter = new LinqAdapter({ + apiToken: "test-token", + logger: mockLogger, + }); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + linqOk( + { + chat_id: "chat-123", + message: { + id: "msg-789", + parts: [{ type: "text", value: "Channel msg" }], + status: "queued", + created_at: "2025-01-01T00:00:00Z", + }, + }, + 202 + ) + ); + + const result = await adapter.postChannelMessage("chat-123", "Channel msg"); + expect(result.id).toBe("msg-789"); + expect(result.threadId).toBe("linq:chat-123"); + }); +}); diff --git a/packages/adapter-linq/src/index.ts b/packages/adapter-linq/src/index.ts new file mode 100644 index 00000000..7cb5d4c6 --- /dev/null +++ b/packages/adapter-linq/src/index.ts @@ -0,0 +1,1056 @@ +import { timingSafeEqual } from "node:crypto"; +import { + AdapterRateLimitError, + AuthenticationError, + cardToFallbackText, + extractCard, + extractFiles, + NetworkError, + ResourceNotFoundError, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + ChannelInfo, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + ListThreadsOptions, + ListThreadsResult, + Logger, + RawMessage, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { ConsoleLogger, Message } from "chat"; +import createClient from "openapi-fetch"; +import { LinqFormatConverter } from "./markdown"; +import type { components, paths } from "./schema"; +import type { + LinqAdapterConfig, + LinqChat, + LinqMessage, + LinqMessageEventV2, + LinqMessageFailedEvent, + LinqRawMessage, + LinqReactionEventBase, + LinqThreadId, + LinqWebhookPayload, +} from "./types"; + +const LINQ_API_BASE = "https://api.linqapp.com/api/partner"; +const WEBHOOK_SIGNATURE_HEADER = "x-webhook-signature"; +const WEBHOOK_TIMESTAMP_HEADER = "x-webhook-timestamp"; +const WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS = 300; + +const LINQ_REACTION_MAP: Record = { + love: "heart", + like: "thumbsup", + dislike: "thumbsdown", + laugh: "laughing", + emphasize: "exclamation", + question: "question", +}; + +const EMOJI_TO_LINQ_REACTION: Record = { + heart: "love", + thumbsup: "like", + thumbsdown: "dislike", + laughing: "laugh", + exclamation: "emphasize", + question: "question", +}; + +export class LinqAdapter implements Adapter { + readonly name = "linq"; + + private readonly apiToken: string; + private readonly signingSecret?: string; + private readonly phoneNumber?: string; + private readonly logger: Logger; + private readonly formatConverter = new LinqFormatConverter(); + private readonly client: ReturnType>; + + private chat: ChatInstance | null = null; + private _userName: string; + + get userName(): string { + return this._userName; + } + + get botUserId(): string | undefined { + return this.phoneNumber; + } + + constructor(config: LinqAdapterConfig = {}) { + const apiToken = config.apiToken ?? process.env.LINQ_API_TOKEN; + if (!apiToken) { + throw new ValidationError( + "linq", + "apiToken is required. Set LINQ_API_TOKEN or provide it in config." + ); + } + + this.apiToken = apiToken; + this.signingSecret = + config.signingSecret ?? process.env.LINQ_SIGNING_SECRET; + this.phoneNumber = config.phoneNumber ?? process.env.LINQ_PHONE_NUMBER; + this.logger = config.logger ?? new ConsoleLogger("info").child("linq"); + this._userName = config.userName ?? "bot"; + + this.client = createClient({ + baseUrl: LINQ_API_BASE, + headers: { + Authorization: `Bearer ${this.apiToken}`, + }, + }); + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + + const chatUserName = chat.getUserName?.(); + if (typeof chatUserName === "string" && chatUserName.trim()) { + this._userName = chatUserName; + } + + this.logger.info("Linq adapter initialized", { + phoneNumber: this.phoneNumber, + }); + } + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + let body: string; + try { + body = await request.text(); + } catch { + return new Response("Invalid request body", { status: 400 }); + } + + if (this.signingSecret) { + const signature = request.headers.get(WEBHOOK_SIGNATURE_HEADER); + const timestamp = request.headers.get(WEBHOOK_TIMESTAMP_HEADER); + + if (!(signature && timestamp)) { + this.logger.warn("Linq webhook missing signature or timestamp headers"); + return new Response("Missing signature headers", { status: 401 }); + } + + const timestampNum = Number.parseInt(timestamp, 10); + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - timestampNum) > WEBHOOK_TIMESTAMP_TOLERANCE_SECONDS) { + this.logger.warn("Linq webhook timestamp out of tolerance"); + return new Response("Timestamp out of tolerance", { status: 401 }); + } + + const isValid = await this.verifySignature(signature, timestamp, body); + if (!isValid) { + this.logger.warn("Linq webhook signature verification failed"); + return new Response("Invalid signature", { status: 401 }); + } + } + + let payload: LinqWebhookPayload; + try { + payload = JSON.parse(body) as LinqWebhookPayload; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + if (!this.chat) { + this.logger.warn("Chat instance not initialized, ignoring Linq webhook"); + return new Response("OK", { status: 200 }); + } + + try { + this.routeWebhookEvent(payload, options); + } catch (error) { + this.logger.warn("Failed to process Linq webhook event", { + error: String(error), + eventType: payload.event_type, + eventId: payload.event_id, + }); + } + + return new Response("OK", { status: 200 }); + } + + encodeThreadId(platformData: LinqThreadId): string { + return `linq:${platformData.chatId}`; + } + + decodeThreadId(threadId: string): LinqThreadId { + const parts = threadId.split(":"); + if (parts[0] !== "linq" || parts.length !== 2 || !parts[1]) { + throw new ValidationError("linq", `Invalid Linq thread ID: ${threadId}`); + } + return { chatId: parts[1] }; + } + + channelIdFromThreadId(threadId: string): string { + return this.decodeThreadId(threadId).chatId; + } + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const { chatId } = this.decodeThreadId(threadId); + + const card = extractCard(message); + const text = card + ? cardToFallbackText(card) + : this.formatConverter.renderPostable(message); + + if (!text.trim()) { + throw new ValidationError("linq", "Message text cannot be empty"); + } + + const files = extractFiles(message); + const parts: unknown[] = [{ type: "text", value: text }]; + + for (const file of files) { + try { + const attachmentId = await this.uploadFile(file); + parts.push({ type: "media", attachment_id: attachmentId }); + } catch (error) { + this.logger.warn("Failed to upload file, skipping attachment", { + filename: file.filename, + error: String(error), + }); + } + } + + const { data, error, response } = await this.client.POST( + "/v3/chats/{chatId}/messages", + { + params: { path: { chatId } }, + body: { + message: { + parts: parts as [{ type: "text"; value: string }], + }, + }, + } + ); + + if (error || !data) { + this.handleApiError(response, "postMessage"); + } + + const sentMessage = data.message; + + return { + id: sentMessage.id, + threadId, + raw: this.sentMessageToRawMessage(sentMessage, chatId), + }; + } + + async editMessage( + _threadId: string, + messageId: string, + message: AdapterPostableMessage + ): Promise> { + const card = extractCard(message); + const text = card + ? cardToFallbackText(card) + : this.formatConverter.renderPostable(message); + + if (!text.trim()) { + throw new ValidationError("linq", "Message text cannot be empty"); + } + + const { data, error, response } = await this.client.PATCH( + "/v3/messages/{messageId}", + { + params: { path: { messageId } }, + body: { + part_index: 0, + text, + }, + } + ); + + if (error || !data) { + this.handleApiError(response, "editMessage"); + } + + const threadId = this.encodeThreadId({ chatId: data.chat_id }); + + return { + id: data.id, + threadId, + raw: data, + }; + } + + async deleteMessage(_threadId: string, messageId: string): Promise { + const { chatId } = this.decodeThreadId(_threadId); + + const { error, response } = await this.client.DELETE( + "/v3/messages/{messageId}", + { + params: { path: { messageId } }, + body: { chat_id: chatId }, + } + ); + + if (error) { + this.handleApiError(response, "deleteMessage"); + } + } + + async fetchMessages( + threadId: string, + options: FetchOptions = {} + ): Promise> { + const { chatId } = this.decodeThreadId(threadId); + const limit = Math.max(1, Math.min(options.limit ?? 50, 100)); + + const { data, error, response } = await this.client.GET( + "/v3/chats/{chatId}/messages", + { + params: { + path: { chatId }, + query: { + limit, + cursor: options.cursor ?? undefined, + }, + }, + } + ); + + if (error || !data) { + this.handleApiError(response, "fetchMessages"); + } + + const messages = data.messages.map((msg) => + this.parseLinqMessage(msg, threadId) + ); + + return { + messages, + nextCursor: data.next_cursor ?? undefined, + }; + } + + async fetchMessage( + threadId: string, + messageId: string + ): Promise | null> { + const { data, error } = await this.client.GET("/v3/messages/{messageId}", { + params: { path: { messageId } }, + }); + + if (error || !data) { + return null; + } + + return this.parseLinqMessage(data, threadId); + } + + async fetchThread(threadId: string): Promise { + const { chatId } = this.decodeThreadId(threadId); + + const { data, error, response } = await this.client.GET( + "/v3/chats/{chatId}", + { + params: { path: { chatId } }, + } + ); + + if (error || !data) { + this.handleApiError(response, "fetchThread"); + } + + return this.chatToThreadInfo(data, threadId); + } + + async fetchChannelInfo(channelId: string): Promise { + const threadId = this.encodeThreadId({ chatId: channelId }); + const { chatId } = this.decodeThreadId(threadId); + + const { data, error, response } = await this.client.GET( + "/v3/chats/{chatId}", + { + params: { path: { chatId } }, + } + ); + + if (error || !data) { + this.handleApiError(response, "fetchChannelInfo"); + } + + return { + id: channelId, + name: data.display_name ?? channelId, + isDM: !data.is_group, + memberCount: data.handles?.length, + metadata: { chat: data }, + }; + } + + // TODO: Linq chats can be group chats (is_group). This synchronous method + // can't call the API, so we default to true. Use fetchThread/fetchChannelInfo + // for accurate isDM values. + isDM(_threadId: string): boolean { + return true; + } + + async addReaction( + _threadId: string, + messageId: string, + emoji: EmojiValue | string + ): Promise { + const reactionType = this.emojiToLinqReaction(emoji); + + const body: Record = { + operation: "add", + type: reactionType.type, + }; + if (reactionType.customEmoji) { + body.custom_emoji = reactionType.customEmoji; + } + + const { error, response } = await this.client.POST( + "/v3/messages/{messageId}/reactions", + { + params: { path: { messageId } }, + body: body as { operation: "add"; type: "love" }, + } + ); + + if (error) { + this.handleApiError(response, "addReaction"); + } + } + + async removeReaction( + _threadId: string, + messageId: string, + emoji: EmojiValue | string + ): Promise { + const reactionType = this.emojiToLinqReaction(emoji); + + const body: Record = { + operation: "remove", + type: reactionType.type, + }; + if (reactionType.customEmoji) { + body.custom_emoji = reactionType.customEmoji; + } + + const { error, response } = await this.client.POST( + "/v3/messages/{messageId}/reactions", + { + params: { path: { messageId } }, + body: body as { operation: "remove"; type: "love" }, + } + ); + + if (error) { + this.handleApiError(response, "removeReaction"); + } + } + + async startTyping(threadId: string): Promise { + const { chatId } = this.decodeThreadId(threadId); + + const { error, response } = await this.client.POST( + "/v3/chats/{chatId}/typing", + { + params: { path: { chatId } }, + } + ); + + if (error) { + this.handleApiError(response, "startTyping"); + } + } + + async openDM(userId: string): Promise { + if (!this.phoneNumber) { + throw new ValidationError( + "linq", + "phoneNumber is required for openDM. Set LINQ_PHONE_NUMBER or provide it in config." + ); + } + + const { data, error, response } = await this.client.POST("/v3/chats", { + body: { + from: this.phoneNumber, + to: [userId], + message: { + parts: [{ type: "text", value: " " }], + }, + }, + }); + + if (error || !data) { + this.handleApiError(response, "openDM"); + } + + return this.encodeThreadId({ chatId: data.chat.id }); + } + + async postChannelMessage( + channelId: string, + message: AdapterPostableMessage + ): Promise> { + const threadId = this.encodeThreadId({ chatId: channelId }); + return this.postMessage(threadId, message); + } + + async fetchChannelMessages( + channelId: string, + options: FetchOptions = {} + ): Promise> { + const threadId = this.encodeThreadId({ chatId: channelId }); + return this.fetchMessages(threadId, options); + } + + async listThreads( + _channelId: string, + options: ListThreadsOptions = {} + ): Promise> { + if (!this.phoneNumber) { + throw new ValidationError( + "linq", + "phoneNumber is required for listThreads. Set LINQ_PHONE_NUMBER or provide it in config." + ); + } + + const limit = Math.max(1, Math.min(options.limit ?? 20, 100)); + + const { data, error, response } = await this.client.GET("/v3/chats", { + params: { + query: { + from: this.phoneNumber, + limit, + cursor: options.cursor ?? undefined, + }, + }, + }); + + if (error || !data) { + this.handleApiError(response, "listThreads"); + } + + const threads = await Promise.all( + data.chats.map(async (chat) => { + const threadId = this.encodeThreadId({ chatId: chat.id }); + const messagesResult = await this.fetchMessages(threadId, { limit: 1 }); + const rootMessage = + messagesResult.messages[0] ?? this.createEmptyMessage(chat, threadId); + + return { + id: threadId, + rootMessage, + lastReplyAt: chat.updated_at ? new Date(chat.updated_at) : undefined, + }; + }) + ); + + return { + threads, + nextCursor: data.next_cursor ?? undefined, + }; + } + + parseMessage(raw: LinqRawMessage): Message { + const threadId = this.encodeThreadId({ chatId: raw.chat_id }); + return this.parseLinqMessage(raw, threadId); + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + private routeWebhookEvent( + payload: LinqWebhookPayload, + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + + const eventType = payload.event_type; + + switch (eventType) { + case "message.received": + case "message.sent": + case "message.edited": { + this.handleMessageEvent(payload, options); + break; + } + case "reaction.added": + case "reaction.removed": { + this.handleReactionEvent( + payload, + eventType === "reaction.added", + options + ); + break; + } + case "message.failed": { + const failedData = payload.data as LinqMessageFailedEvent; + this.logger.error("Linq message send failed", { + chatId: failedData.chat_id, + messageId: failedData.message_id, + code: failedData.code, + reason: failedData.reason, + failedAt: failedData.failed_at, + }); + break; + } + case "message.delivered": + case "message.read": { + this.logger.debug("Linq delivery status event", { + eventType, + eventId: payload.event_id, + }); + break; + } + default: + this.logger.debug("Ignoring Linq webhook event", { + eventType, + eventId: payload.event_id, + }); + } + } + + private handleMessageEvent( + payload: LinqWebhookPayload, + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + + const eventData = payload.data as LinqMessageEventV2; + const chatId = eventData.chat?.id; + if (!chatId) { + this.logger.warn("Linq message webhook missing chat ID"); + return; + } + + const threadId = this.encodeThreadId({ chatId }); + + const isEdited = payload.event_type === "message.edited"; + const message = this.parseWebhookMessage(eventData, threadId, isEdited); + + this.chat.processMessage(this, threadId, message, options); + } + + private handleReactionEvent( + payload: LinqWebhookPayload, + added: boolean, + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + + const data = payload.data as LinqReactionEventBase; + const chatId = data.chat_id; + if (!chatId) { + return; + } + + const threadId = this.encodeThreadId({ chatId }); + const messageId = data.message_id ?? ""; + + const reactionType = data.reaction_type ?? "like"; + const emoji = LINQ_REACTION_MAP[reactionType] ?? reactionType; + + const handle = data.from_handle?.handle ?? data.from ?? "unknown"; + + this.chat.processReaction( + { + adapter: this, + threadId, + messageId, + emoji: { name: emoji, toString: () => emoji, toJSON: () => emoji }, + rawEmoji: data.custom_emoji ?? reactionType, + added, + user: { + userId: handle, + userName: handle, + fullName: handle, + isBot: false, + isMe: data.is_from_me ?? false, + }, + raw: data, + }, + options + ); + } + + private parseLinqMessage( + raw: LinqMessage, + threadId: string + ): Message { + const parts = raw.parts ?? []; + const textParts: string[] = []; + const attachments: Attachment[] = []; + + for (const part of parts) { + if (part.type === "text") { + textParts.push((part as { type: "text"; value: string }).value); + } else if (part.type === "media") { + const mediaPart = part as { + type: "media"; + id: string; + url: string; + filename: string; + mime_type: string; + size_bytes: number; + }; + attachments.push({ + type: this.mimeTypeToAttachmentType(mediaPart.mime_type), + name: mediaPart.filename, + mimeType: mediaPart.mime_type, + size: mediaPart.size_bytes, + fetchData: async () => { + const response = await fetch(mediaPart.url); + if (!response.ok) { + throw new NetworkError( + "linq", + `Failed to download attachment ${mediaPart.id}` + ); + } + return Buffer.from(await response.arrayBuffer()); + }, + }); + } + } + const text = textParts.join("\n"); + + const senderHandle = raw.from_handle?.handle ?? raw.from ?? "unknown"; + const isMe = raw.is_from_me ?? false; + + return new Message({ + id: raw.id, + threadId, + text, + formatted: this.formatConverter.toAst(text), + raw, + author: { + userId: senderHandle, + userName: senderHandle, + fullName: senderHandle, + isBot: false, + isMe, + }, + metadata: { + dateSent: raw.created_at ? new Date(raw.created_at) : new Date(), + edited: false, + }, + attachments, + isMention: !isMe, + }); + } + + private parseWebhookMessage( + eventData: LinqMessageEventV2, + threadId: string, + isEdited = false + ): Message { + const parts = eventData.parts ?? []; + const textParts: string[] = []; + const attachments: Attachment[] = []; + + for (const part of parts) { + if (part.type === "text") { + textParts.push((part as { type: "text"; value: string }).value); + } else if (part.type === "media") { + const mediaPart = part as { + type: "media"; + id: string; + url: string; + filename: string; + mime_type: string; + size_bytes: number; + }; + attachments.push({ + type: this.mimeTypeToAttachmentType(mediaPart.mime_type), + name: mediaPart.filename, + mimeType: mediaPart.mime_type, + size: mediaPart.size_bytes, + fetchData: async () => { + const response = await fetch(mediaPart.url); + if (!response.ok) { + throw new NetworkError( + "linq", + `Failed to download attachment ${mediaPart.id}` + ); + } + return Buffer.from(await response.arrayBuffer()); + }, + }); + } + } + const text = textParts.join("\n"); + + const senderHandle = eventData.sender_handle?.handle ?? "unknown"; + const isMe = eventData.direction === "outbound"; + + const chatId = eventData.chat?.id ?? ""; + const messageId = eventData.id ?? ""; + + const rawMessage: LinqRawMessage = { + id: messageId, + chat_id: chatId, + is_from_me: isMe, + is_delivered: false, + is_read: false, + created_at: eventData.sent_at ?? new Date().toISOString(), + updated_at: eventData.sent_at ?? new Date().toISOString(), + parts: eventData.parts as LinqMessage["parts"], + from_handle: eventData.sender_handle as LinqMessage["from_handle"], + }; + + return new Message({ + id: messageId, + threadId, + text, + formatted: this.formatConverter.toAst(text), + raw: rawMessage, + author: { + userId: senderHandle, + userName: senderHandle, + fullName: senderHandle, + isBot: false, + isMe, + }, + metadata: { + dateSent: eventData.sent_at ? new Date(eventData.sent_at) : new Date(), + edited: isEdited, + }, + attachments, + isMention: !isMe, + }); + } + + private async uploadFile(file: { + data: Buffer | Blob | ArrayBuffer; + filename: string; + mimeType?: string; + }): Promise { + const mimeType = file.mimeType ?? "application/octet-stream"; + let fileData: Buffer; + if (Buffer.isBuffer(file.data)) { + fileData = file.data; + } else if (file.data instanceof ArrayBuffer) { + fileData = Buffer.from(file.data); + } else { + fileData = Buffer.from(await (file.data as Blob).arrayBuffer()); + } + + const { data, error, response } = await this.client.POST( + "/v3/attachments", + { + body: { + filename: file.filename, + content_type: + mimeType as components["schemas"]["SupportedContentType"], + size_bytes: fileData.byteLength, + }, + } + ); + + if (error || !data) { + this.handleApiError(response, "uploadFile"); + } + + const uploadResponse = await fetch(data.upload_url, { + method: data.http_method, + headers: data.required_headers, + body: fileData, + }); + + if (!uploadResponse.ok) { + throw new NetworkError( + "linq", + `File upload PUT failed with status ${uploadResponse.status}` + ); + } + + return data.attachment_id; + } + + private createEmptyMessage( + chat: LinqChat, + threadId: string + ): Message { + const raw: LinqRawMessage = { + id: "", + chat_id: chat.id, + is_from_me: false, + is_delivered: false, + is_read: false, + created_at: chat.created_at ?? new Date().toISOString(), + updated_at: chat.updated_at ?? new Date().toISOString(), + parts: null, + }; + + return new Message({ + id: "", + threadId, + text: "", + formatted: this.formatConverter.toAst(""), + raw, + author: { + userId: "unknown", + userName: "unknown", + fullName: "unknown", + isBot: false, + isMe: false, + }, + metadata: { + dateSent: chat.created_at ? new Date(chat.created_at) : new Date(), + edited: false, + }, + attachments: [], + isMention: false, + }); + } + + private async verifySignature( + signature: string, + timestamp: string, + body: string + ): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(this.signingSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + + const signedPayload = `${timestamp}.${body}`; + const signatureBuffer = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(signedPayload) + ); + + const expectedSignature = Array.from(new Uint8Array(signatureBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + try { + return timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch { + return false; + } + } + + private handleApiError(response: Response, operation: string): never { + const status = response.status; + + if (status === 401 || status === 403) { + throw new AuthenticationError( + "linq", + `${operation} failed: unauthorized (${status})` + ); + } + if (status === 404) { + throw new ResourceNotFoundError("linq", "resource", operation); + } + if (status === 429) { + throw new AdapterRateLimitError("linq"); + } + throw new NetworkError("linq", `${operation} failed with status ${status}`); + } + + private chatToThreadInfo(chat: LinqChat, threadId: string): ThreadInfo { + return { + id: threadId, + channelId: chat.id, + channelName: chat.display_name ?? chat.id, + isDM: !chat.is_group, + metadata: { chat }, + }; + } + + private sentMessageToRawMessage( + sentMessage: { + id: string; + parts?: unknown[]; + status?: string; + created_at?: string; + }, + chatId: string + ): LinqRawMessage { + return { + id: sentMessage.id, + chat_id: chatId, + is_from_me: true, + is_delivered: false, + is_read: false, + created_at: sentMessage.created_at ?? new Date().toISOString(), + updated_at: sentMessage.created_at ?? new Date().toISOString(), + parts: sentMessage.parts as LinqMessage["parts"], + }; + } + + private emojiToLinqReaction(emoji: EmojiValue | string): { + type: string; + customEmoji?: string; + } { + const emojiStr = typeof emoji === "string" ? emoji : emoji.name; + const mapped = EMOJI_TO_LINQ_REACTION[emojiStr]; + + if (mapped) { + return { type: mapped }; + } + + return { type: "custom", customEmoji: emojiStr }; + } + + private mimeTypeToAttachmentType(mimeType: string): Attachment["type"] { + if (mimeType.startsWith("image/")) { + return "image"; + } + if (mimeType.startsWith("video/")) { + return "video"; + } + if (mimeType.startsWith("audio/")) { + return "audio"; + } + return "file"; + } +} + +export function createLinqAdapter(config?: LinqAdapterConfig): LinqAdapter { + return new LinqAdapter(config ?? {}); +} + +export { LinqFormatConverter } from "./markdown"; +export type { + LinqAdapterConfig, + LinqChat, + LinqChatHandle, + LinqMessage, + LinqMessageEventV2, + LinqMessageFailedEvent, + LinqRawMessage, + LinqReactionEventBase, + LinqReactionType, + LinqThreadId, + LinqWebhookEventType, + LinqWebhookPayload, +} from "./types"; diff --git a/packages/adapter-linq/src/markdown.test.ts b/packages/adapter-linq/src/markdown.test.ts new file mode 100644 index 00000000..1358177e --- /dev/null +++ b/packages/adapter-linq/src/markdown.test.ts @@ -0,0 +1,133 @@ +import { parseMarkdown, stringifyMarkdown } from "chat"; +import { describe, expect, it } from "vitest"; +import { LinqFormatConverter } from "./markdown"; + +const converter = new LinqFormatConverter(); + +describe("LinqFormatConverter", () => { + describe("fromAst", () => { + it("strips bold formatting", () => { + const ast = parseMarkdown("**bold text**"); + const result = converter.fromAst(ast); + expect(result).toBe("bold text"); + }); + + it("strips italic formatting", () => { + const ast = parseMarkdown("*italic text*"); + const result = converter.fromAst(ast); + expect(result).toBe("italic text"); + }); + + it("strips strikethrough formatting", () => { + const ast = parseMarkdown("~~deleted~~"); + const result = converter.fromAst(ast); + expect(result).toBe("deleted"); + }); + + it("converts links to text with URL", () => { + const ast = parseMarkdown("[click here](https://example.com)"); + const result = converter.fromAst(ast); + expect(result).toContain("click here"); + expect(result).toContain("https://example.com"); + }); + + it("strips header formatting", () => { + const ast = parseMarkdown("# Header\n\nContent"); + const result = converter.fromAst(ast); + expect(result).toContain("Header"); + expect(result).not.toContain("#"); + }); + + it("converts tables to ASCII", () => { + const markdown = "| A | B |\n| --- | --- |\n| 1 | 2 |"; + const ast = parseMarkdown(markdown); + const result = converter.fromAst(ast); + expect(result).toContain("A"); + expect(result).toContain("B"); + expect(result).toContain("1"); + expect(result).toContain("2"); + }); + + it("handles plain text passthrough", () => { + const ast = parseMarkdown("Hello world"); + const result = converter.fromAst(ast); + expect(result).toBe("Hello world"); + }); + + it("strips nested bold+italic formatting", () => { + const ast = parseMarkdown("**bold _italic_**"); + const result = converter.fromAst(ast); + expect(result).toContain("bold"); + expect(result).toContain("italic"); + expect(result).not.toContain("**"); + expect(result).not.toContain("_"); + }); + + it("strips fenced code blocks", () => { + const ast = parseMarkdown("```js\nconst x = 1;\n```"); + const result = converter.fromAst(ast); + expect(result).toContain("const x = 1;"); + }); + + it("strips inline code", () => { + const ast = parseMarkdown("Use `foo()` here"); + const result = converter.fromAst(ast); + expect(result).toBe("Use foo() here"); + }); + + it("handles unordered lists", () => { + const ast = parseMarkdown("- item one\n- item two"); + const result = converter.fromAst(ast); + expect(result).toContain("item one"); + expect(result).toContain("item two"); + }); + + it("handles ordered lists", () => { + const ast = parseMarkdown("1. first\n2. second"); + const result = converter.fromAst(ast); + expect(result).toContain("first"); + expect(result).toContain("second"); + }); + + it("handles blockquotes", () => { + const ast = parseMarkdown("> quoted text"); + const result = converter.fromAst(ast); + expect(result).toContain("quoted text"); + }); + }); + + describe("toAst", () => { + it("parses plain text", () => { + const ast = converter.toAst("Hello world"); + const markdown = stringifyMarkdown(ast).trim(); + expect(markdown).toBe("Hello world"); + }); + + it("parses markdown text", () => { + const ast = converter.toAst("**bold** and *italic*"); + expect(ast.type).toBe("root"); + expect(ast.children.length).toBeGreaterThan(0); + }); + }); + + describe("renderPostable", () => { + it("renders string messages", () => { + expect(converter.renderPostable("Hello")).toBe("Hello"); + }); + + it("renders raw messages", () => { + expect(converter.renderPostable({ raw: "raw text" })).toBe("raw text"); + }); + + it("renders markdown messages", () => { + const result = converter.renderPostable({ markdown: "**bold**" }); + expect(result).toBe("bold"); + }); + + it("renders AST messages", () => { + const ast = parseMarkdown("plain text"); + const result = converter.renderPostable({ ast }); + expect(result).toBe("plain text"); + }); + }); +}); diff --git a/packages/adapter-linq/src/markdown.ts b/packages/adapter-linq/src/markdown.ts new file mode 100644 index 00000000..f1832222 --- /dev/null +++ b/packages/adapter-linq/src/markdown.ts @@ -0,0 +1,68 @@ +import { + type AdapterPostableMessage, + BaseFormatConverter, + type Content, + isTableNode, + parseMarkdown, + type Root, + stringifyMarkdown, + tableToAscii, + walkAst, +} from "chat"; + +export class LinqFormatConverter extends BaseFormatConverter { + fromAst(ast: Root): string { + const transformed = walkAst(structuredClone(ast), (node: Content) => { + if (isTableNode(node)) { + return { + type: "code" as const, + value: tableToAscii(node), + lang: undefined, + } as Content; + } + return node; + }); + + return this.stripMarkdown(stringifyMarkdown(transformed).trim()); + } + + toAst(text: string): Root { + return parseMarkdown(text); + } + + override renderPostable(message: AdapterPostableMessage): string { + if (typeof message === "string") { + return message; + } + if ("raw" in message) { + return message.raw; + } + if ("markdown" in message) { + return this.fromMarkdown(message.markdown); + } + if ("ast" in message) { + return this.fromAst(message.ast); + } + return super.renderPostable(message); + } + + private stripMarkdown(markdown: string): string { + return ( + markdown + // Bold + .replace(/\*\*(.+?)\*\*/g, "$1") + // Italic + .replace(/\*(.+?)\*/g, "$1") + .replace(/_(.+?)_/g, "$1") + // Strikethrough + .replace(/~~(.+?)~~/g, "$1") + // Inline code + .replace(/`(.+?)`/g, "$1") + // Links + .replace(/\[(.+?)\]\((.+?)\)/g, "$1 ($2)") + // Headers + .replace(/^#{1,6}\s+(.+)$/gm, "$1") + .trim() + ); + } +} diff --git a/packages/adapter-linq/src/schema.ts b/packages/adapter-linq/src/schema.ts new file mode 100644 index 00000000..6075dddb --- /dev/null +++ b/packages/adapter-linq/src/schema.ts @@ -0,0 +1,6132 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/v3/attachments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Pre-upload a file + * @description **This endpoint is optional.** You can send media by simply providing a URL in your + * message's media part — no pre-upload required. Use this endpoint only when you want + * to upload a file ahead of time for reuse or latency optimization. + * + * Returns a presigned upload URL and a permanent `attachment_id` you can reference + * in future messages. + * + * ## Step 1: Request an upload URL + * + * Call this endpoint with file metadata: + * + * ```json + * POST /v3/attachments + * { + * "filename": "photo.jpg", + * "content_type": "image/jpeg", + * "size_bytes": 1024000 + * } + * ``` + * + * The response includes an `upload_url` (valid for 15 minutes) and a permanent `attachment_id`. + * + * ## Step 2: Upload the file + * + * Make a PUT request to the `upload_url` with the raw file bytes as the request body.. Include the headers from `required_headers`. + * The request body is the binary file content — **not** JSON, **not** multipart form data. + * + * ```bash + * curl -X PUT "" \ + * -H "Content-Type: image/jpeg" \ + * --data-binary @filebytes + * ``` + * + * ## Step 3: Send a message with the attachment + * + * Reference the `attachment_id` in a media part. The ID never expires — use it in as many messages as you want. + * + * ```json + * POST /v3/messages + * { + * "to": ["+15551234567"], + * "from": "+15559876543", + * "parts": [ + * { "type": "media", "attachment_id": "" } + * ] + * } + * ``` + * + * ## When to use this instead of a URL in the media part + * + * - Sending the same file to multiple recipients (avoids re-downloading each time) + * - Large files where you want to separate upload from message send + * - Latency-sensitive sends where the file should already be stored + * + * If you just need to send a file once, skip all of this and pass a `url` directly in the media part instead. + * + * **File Size Limit:** 100MB + * + * **Unsupported Types:** WebP, SVG, FLAC, OGG, and executable files are explicitly rejected. + */ + post: operations["requestUpload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/attachments/{attachmentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get attachment metadata + * @description Retrieve metadata for a specific attachment including its status, + * file information, and URLs for downloading. + */ + get: operations["getAttachment"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/capability/check_imessage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Check iMessage capability + * @description Check whether a recipient address (phone number or email) is reachable via iMessage. + */ + post: operations["checkImessageCapability"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/capability/check_rcs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Check RCS capability + * @description Check whether a recipient address (phone number) supports RCS messaging. + */ + post: operations["checkRcsCapability"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/chats": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all chats + * @description Retrieves a paginated list of chats for the authenticated partner filtered by phone number. + * Returns all chats involving the specified phone number with their participants and recent activity. + * + * **Pagination:** + * - Use `limit` to control page size (default: 20, max: 100) + * - The response includes `next_cursor` for fetching the next page + * - When `next_cursor` is `null`, there are no more results to fetch + * - Pass the `next_cursor` value as the `cursor` parameter for the next request + * + * **Example pagination flow:** + * 1. First request: `GET /v3/chats?from=%2B12223334444&limit=20` + * 2. Response includes `next_cursor: "20"` (more results exist) + * 3. Next request: `GET /v3/chats?from=%2B12223334444&limit=20&cursor=20` + * 4. Response includes `next_cursor: null` (no more results) + */ + get: operations["listChats"]; + put?: never; + /** + * Create a new chat + * @description Create a new chat with specified participants and send an initial message. + * The initial message is required when creating a chat. + * + * ## Message Effects + * + * You can add iMessage effects to make your messages more expressive. Effects are + * optional and can be either screen effects (full-screen animations) or bubble effects + * (message bubble animations). + * + * **Screen Effects:** `confetti`, `fireworks`, `lasers`, `sparkles`, `celebration`, + * `hearts`, `love`, `balloons`, `happy_birthday`, `echo`, `spotlight` + * + * **Bubble Effects:** `slam`, `loud`, `gentle`, `invisible` + * + * Only one effect type can be applied per message. + */ + post: operations["createChat"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/chats/{chatId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a chat by ID + * @description Retrieve a chat by its unique identifier. + */ + get: operations["getChat"]; + /** + * Update a chat + * @description Update chat properties such as display name and group chat icon. + */ + put: operations["updateChat"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/chats/{chatId}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get messages from a chat + * @description Retrieve messages from a specific chat with pagination support. + */ + get: operations["getMessages"]; + put?: never; + /** + * Send a message to an existing chat + * @description Send a message to an existing chat. Use this endpoint when you already have + * a chat ID and want to send additional messages to it. + * + * ## Message Effects + * + * You can add iMessage effects to make your messages more expressive. Effects are + * optional and can be either screen effects (full-screen animations) or bubble effects + * (message bubble animations). + * + * **Screen Effects:** `confetti`, `fireworks`, `lasers`, `sparkles`, `celebration`, + * `hearts`, `love`, `balloons`, `happy_birthday`, `echo`, `spotlight` + * + * **Bubble Effects:** `slam`, `loud`, `gentle`, `invisible` + * + * Only one effect type can be applied per message. + */ + post: operations["sendMessageToChat"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/chats/{chatId}/participants": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add a participant to a chat + * @description Add a new participant to an existing group chat. + * + * **Requirements:** + * - Group chats only (3+ existing participants) + * - New participant must support the same messaging service as the group + * - Cross-service additions not allowed (e.g., can't add RCS-only user to iMessage group) + * - For cross-service scenarios, create a new chat instead + */ + post: operations["addParticipant"]; + /** + * Remove a participant from a chat + * @description Remove a participant from an existing group chat. + * + * **Requirements:** + * - Group chats only + * - Must have 3+ participants after removal + */ + delete: operations["removeParticipant"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/chats/{chatId}/read": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Mark chat as read + * @description Mark all messages in a chat as read. + */ + post: operations["markChatAsRead"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/chats/{chatId}/share_contact_card": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Share your contact card with a chat + * @deprecated + * @description **Deprecated:** Use `POST /v3/my_cards/{chatId}/share` instead. + * + * Share your contact information (Name and Photo Sharing) with a chat. + * + * **Note:** A contact card must be configured before sharing. You can set up your contact card on the [Linq dashboard](https://dashboard.linqapp.com/contact-cards). + */ + post: operations["shareContactWithChat"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/chats/{chatId}/typing": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Start typing indicator + * @description Send a typing indicator to show that someone is typing in the chat. + * + * **Note:** Group chat typing indicators are not currently supported. + */ + post: operations["startTyping"]; + /** + * Stop typing indicator + * @description Stop the typing indicator for the chat. + * + * **Note:** Typing indicators are automatically stopped when a message is sent, + * so calling this endpoint after sending a message is unnecessary. + * + * **Note:** Group chat typing indicators are not currently supported. + */ + delete: operations["stopTyping"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/chats/{chatId}/voicememo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Send a voice memo to a chat + * @description Send an audio file as an **iMessage voice memo bubble** to all participants in a chat. + * Voice memos appear with iMessage's native inline playback UI, unlike regular audio + * attachments sent via media parts which appear as downloadable files. + * + * **Supported audio formats:** + * - MP3 (audio/mpeg) + * - M4A (audio/x-m4a, audio/mp4) + * - AAC (audio/aac) + * - CAF (audio/x-caf) - Core Audio Format + * - WAV (audio/wav) + * - AIFF (audio/aiff, audio/x-aiff) + * - AMR (audio/amr) + */ + post: operations["sendVoiceMemoToChat"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/messages/{messageId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a message by ID + * @description Retrieve a specific message by its ID. This endpoint returns the full message + * details including text, attachments, reactions, and metadata. + */ + get: operations["getMessage"]; + put?: never; + post?: never; + /** + * Delete a message from system + * @description Deletes a message from the Linq API only. This does NOT unsend or remove the message + * from the actual chat - recipients will still see the message. + * + * Use this endpoint to remove messages from your records and prevent them from appearing + * in API responses. + */ + delete: operations["deleteMessage"]; + options?: never; + head?: never; + /** + * Edit the content of a message part + * @description Edit the text content of a specific part of a previously sent message. + */ + patch: operations["editMessage"]; + trace?: never; + }; + "/v3/messages/{messageId}/reactions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add or remove a reaction to a message + * @description Add or remove emoji reactions to messages. Reactions let users express + * their response to a message without sending a new message. + * + * **Supported Reactions:** + * - love ❤️ + * - like 👍 + * - dislike 👎 + * - laugh 😂 + * - emphasize ‼️ + * - question ❓ + * - custom - any emoji (use `custom_emoji` field to specify) + */ + post: operations["sendReaction"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/messages/{messageId}/thread": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get all messages in a thread + * @description Retrieve all messages in a conversation thread. Given any message ID in the thread, + * returns the originator message and all replies in chronological order. + * + * If the message is not part of a thread, returns just that single message. + * + * Supports pagination and configurable ordering. + */ + get: operations["getMessageThread"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/my_cards": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get my cards + * @description Returns the my card for a specific phone number, or all my cards for the + * authenticated partner if no `phone_number` is provided. + */ + get: operations["getMyCards"]; + put?: never; + /** + * Setup my card + * @description Creates or updates a my card for a phone number and syncs it to the device. + * + * The my card is stored in an inactive state first. Once it's applied successfully, + * it is activated and `is_active` is returned as `true`. On failure, `is_active` is `false`. + */ + post: operations["setupMyCard"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/my_cards/{chatId}/share": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Share my card with a chat + * @description Share your contact information (Name and Photo Sharing) with a chat. + * + * **Note:** A my card must be configured before sharing. + */ + post: operations["shareMyCard"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/phone_numbers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List phone numbers + * @description Returns all phone numbers assigned to the authenticated partner. + * Use this endpoint to discover which phone numbers are available for + * use as the `from` field when creating a chat, listing chats, or sending a voice memo. + */ + get: operations["listPhoneNumbers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/phonenumbers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List phone numbers (deprecated) + * @deprecated + * @description **Deprecated.** Use `GET /v3/phone_numbers` instead. + */ + get: operations["listPhoneNumbersDeprecated"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/webhook-events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List available webhook event types + * @description Returns all available webhook event types that can be subscribed to. + * Use this endpoint to discover valid values for the `subscribed_events` + * field when creating or updating webhook subscriptions. + */ + get: operations["listWebhookEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/webhook-subscriptions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List all webhook subscriptions + * @description Retrieve all webhook subscriptions for the authenticated partner. + * Returns a list of active and inactive subscriptions with their + * configuration and status. + */ + get: operations["listWebhookSubscriptions"]; + put?: never; + /** + * Create a new webhook subscription + * @description Create a new webhook subscription to receive events at a target URL. + * Upon creation, a signing secret is generated for verifying webhook + * authenticity. **Store this secret securely — it cannot be retrieved later.** + * + * **Webhook Delivery:** + * - Events are sent via HTTP POST to the target URL + * - Each request includes `X-Webhook-Signature` and `X-Webhook-Timestamp` headers + * - Signature is HMAC-SHA256 over `{timestamp}.{payload}` — see [Webhook Events](/docs/webhook-events) for verification details + * - Failed deliveries (5xx, 429, network errors) are retried up to 10 times over ~2 hours with exponential backoff + * - Client errors (4xx except 429) are not retried + */ + post: operations["createWebhookSubscription"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v3/webhook-subscriptions/{subscriptionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get a webhook subscription by ID + * @description Retrieve details for a specific webhook subscription including its + * target URL, subscribed events, and current status. + */ + get: operations["getWebhookSubscription"]; + /** + * Update a webhook subscription + * @description Update an existing webhook subscription. You can modify the target URL, + * subscribed events, or activate/deactivate the subscription. + * + * **Note:** The signing secret cannot be changed via this endpoint. + */ + put: operations["updateWebhookSubscription"]; + post?: never; + /** + * Delete a webhook subscription + * @description Delete a webhook subscription. + */ + delete: operations["deleteWebhookSubscription"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export interface webhooks { + "chat.created.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Chat created + * @description Triggered when a new chat is created. + */ + post: operations["webhookChatCreatedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.created.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Chat created + * @description Triggered when a new chat is created. + */ + post: operations["webhookChatCreatedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.group_icon_update_failed.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Group icon update failed + * @description Triggered when a group chat icon update request fails. + */ + post: operations["webhookChatGroupIconUpdateFailedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.group_icon_update_failed.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Group icon update failed + * @description Triggered when a group chat icon update request fails. + */ + post: operations["webhookChatGroupIconUpdateFailedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.group_icon_updated.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Group icon updated + * @description Triggered when a group chat's icon is updated. + * A null `new_value` indicates the icon was removed. + */ + post: operations["webhookChatGroupIconUpdatedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.group_icon_updated.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Group icon updated + * @description Triggered when a group chat's icon is updated. + * A null `new_value` indicates the icon was removed. + */ + post: operations["webhookChatGroupIconUpdatedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.group_name_update_failed.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Group name update failed + * @description Triggered when a group chat name update request fails. + */ + post: operations["webhookChatGroupNameUpdateFailedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.group_name_update_failed.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Group name update failed + * @description Triggered when a group chat name update request fails. + */ + post: operations["webhookChatGroupNameUpdateFailedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.group_name_updated.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Group name updated + * @description Triggered when a group chat's display name is updated. + * A null `new_value` indicates the name was removed. + */ + post: operations["webhookChatGroupNameUpdatedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.group_name_updated.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Group name updated + * @description Triggered when a group chat's display name is updated. + * A null `new_value` indicates the name was removed. + */ + post: operations["webhookChatGroupNameUpdatedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.typing_indicator.started.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Typing indicator started + * @description Triggered when a participant starts typing in a chat. + */ + post: operations["webhookChatTypingIndicatorStartedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.typing_indicator.started.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Typing indicator started + * @description Triggered when a participant starts typing in a chat. + */ + post: operations["webhookChatTypingIndicatorStartedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.typing_indicator.stopped.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Typing indicator stopped + * @description Triggered when a participant stops typing in a chat. + */ + post: operations["webhookChatTypingIndicatorStoppedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "chat.typing_indicator.stopped.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Typing indicator stopped + * @description Triggered when a participant stops typing in a chat. + */ + post: operations["webhookChatTypingIndicatorStoppedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "message.delivered.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Message delivered + * @description Triggered when a sent message has been delivered to the recipient's device. + * This confirms the message reached the recipient, but does not indicate it was read. + * Only available for iMessage conversations with delivery receipts. + * + * **Timestamps:** `sent_at` and `delivered_at` are set, `read_at` is null. + */ + post: operations["webhookMessageDeliveredV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "message.delivered.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Message delivered + * @description Triggered when a sent message has been delivered to the recipient's device. + * This confirms the message reached the recipient, but does not indicate it was read. + * Only available for iMessage conversations with delivery receipts. + * + * **Timestamps:** `sent_at` and `delivered_at` are set, `read_at` is null. + */ + post: operations["webhookMessageDeliveredV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "message.failed.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Message delivery failed + * @description Triggered when a message fails to be delivered. This can happen due to: + * - Request timeout (message expired before being processed) + * - Upstream service error (processing failed after retries) + * - Request cancelled (message was explicitly cancelled) + * - Service unavailable (no service available to process the request) + * + * Note: The original message content may not be available if the message + * expired before being consumed by a processor. + */ + post: operations["webhookMessageFailedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "message.failed.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Message delivery failed + * @description Triggered when a message fails to be delivered. This can happen due to: + * - Request timeout (message expired before being processed) + * - Upstream service error (processing failed after retries) + * - Request cancelled (message was explicitly cancelled) + * - Service unavailable (no service available to process the request) + * + * Note: The original message content may not be available if the message + * expired before being consumed by a processor. + */ + post: operations["webhookMessageFailedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "message.read.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Message read + * @description Triggered when a sent message has been read by the recipient. + * Only available for iMessage conversations with read receipts enabled. + * + * **Timestamps:** `sent_at`, `delivered_at`, and `read_at` are all set. + */ + post: operations["webhookMessageReadV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "message.read.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Message read + * @description Triggered when a sent message has been read by the recipient. + * Only available for iMessage conversations with read receipts enabled. + * + * **Timestamps:** `sent_at`, `delivered_at`, and `read_at` are all set. + */ + post: operations["webhookMessageReadV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "message.received.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Message received + * @description Triggered when an incoming message is received on your phone number. + * Contains the full message content including any attachments. + * + * **Timestamps:** `sent_at` is set, `delivered_at` and `read_at` are null. + */ + post: operations["webhookMessageReceivedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "message.received.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Message received + * @description Triggered when an incoming message is received on your phone number. + * Contains the full message content including any attachments. + * + * **Timestamps:** `sent_at` is set, `delivered_at` and `read_at` are null. + */ + post: operations["webhookMessageReceivedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "message.sent.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Message sent + * @description Triggered when a message has been successfully sent from your phone number. + * This confirms the message has been sent, but potentially has not yet been delivered. + * + * **Timestamps:** `sent_at` is set, `delivered_at` and `read_at` are null. + */ + post: operations["webhookMessageSentV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "message.sent.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Message sent + * @description Triggered when a message has been successfully sent from your phone number. + * This confirms the message has been sent, but potentially has not yet been delivered. + * + * **Timestamps:** `sent_at` is set, `delivered_at` and `read_at` are null. + */ + post: operations["webhookMessageSentV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "participant.added.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Participant added + * @description Triggered when a new participant is added to a group chat. + */ + post: operations["webhookParticipantAddedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "participant.added.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Participant added + * @description Triggered when a new participant is added to a group chat. + */ + post: operations["webhookParticipantAddedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "participant.removed.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Participant removed + * @description Triggered when a participant is removed from a group chat. + */ + post: operations["webhookParticipantRemovedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "participant.removed.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Participant removed + * @description Triggered when a participant is removed from a group chat. + */ + post: operations["webhookParticipantRemovedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "phone_number.status_updated.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Phone number status updated + * @description Triggered when a phone number's service status changes from active to flagged or vice versa. + * Use this to monitor phone number health and availability. + * + * **Status values:** + * - `ACTIVE` — Phone number is healthy and can send/receive messages + * - `FLAGGED` — Phone number has been flagged and may have limited functionality + */ + post: operations["webhookPhoneNumberStatusUpdatedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "phone_number.status_updated.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Phone number status updated + * @description Triggered when a phone number's service status changes from active to flagged or vice versa. + * Use this to monitor phone number health and availability. + * + * **Status values:** + * - `ACTIVE` — Phone number is healthy and can send/receive messages + * - `FLAGGED` — Phone number has been flagged and may have limited functionality + */ + post: operations["webhookPhoneNumberStatusUpdatedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "reaction.added.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reaction added + * @description Triggered when a reaction (tapback) is added to a message. + * Includes standard reactions like "loved", "liked", "disliked", etc. + * and custom emoji reactions. + */ + post: operations["webhookReactionAddedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "reaction.added.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reaction added + * @description Triggered when a reaction (tapback) is added to a message. + * Includes standard reactions like "loved", "liked", "disliked", etc. + * and custom emoji reactions. + */ + post: operations["webhookReactionAddedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "reaction.removed.v2025": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reaction removed + * @description Triggered when a reaction (tapback) is removed from a message. + */ + post: operations["webhookReactionRemovedV2025"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "reaction.removed.v2026": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reaction removed + * @description Triggered when a reaction (tapback) is removed from a message. + */ + post: operations["webhookReactionRemovedV2026"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export interface components { + headers: never; + parameters: never; + pathItems: never; + requestBodies: never; + responses: { + /** @description Invalid request - validation error */ + BadRequest: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": { + * "status": 400, + * "code": 1002, + * "message": "Phone number must be in E.164 format" + * }, + * "success": false + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unauthorized - missing or invalid authentication */ + Unauthorized: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": { + * "status": 401, + * "code": 2004, + * "message": "Unauthorized - missing or invalid authentication token" + * }, + * "success": false + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Forbidden - no access to this resource */ + Forbidden: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": { + * "status": 403, + * "code": 2005, + * "message": "Access denied - insufficient permissions for this resource" + * }, + * "success": false + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Resource not found */ + NotFound: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": { + * "status": 404, + * "code": 2001, + * "message": "Resource not found" + * }, + * "success": false + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unprocessable Entity - request is valid but cannot be processed */ + UnprocessableEntity: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": { + * "status": 422, + * "code": 1003, + * "message": "Request is valid but cannot be processed" + * }, + * "success": false + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Too many requests - rate limit exceeded */ + RateLimited: { + headers: { + /** + * @description Number of seconds until the rate limit resets + * @example 10 + */ + "Retry-After"?: number; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": { + * "status": 429, + * "code": 4001, + * "message": "Rate limit exceeded. Try again in a few seconds." + * }, + * "success": false + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Internal server error */ + InternalServerError: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": { + * "status": 500, + * "code": 3006, + * "message": "Internal server error" + * }, + * "success": false + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Service temporarily unavailable */ + ServiceUnavailable: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": { + * "status": 503, + * "code": 4004, + * "message": "RCS capability check is temporarily unavailable." + * }, + * "success": false + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + schemas: { + MyCardItem: { + /** @example +15551234567 */ + phone_number: string; + /** @example John */ + first_name: string; + /** @example Doe */ + last_name?: string; + /** @example https://cdn.linqapp.com/my-card/example.jpg */ + image_url?: string; + /** @example true */ + is_active: boolean; + }; + GetMyCardResponse: { + my_cards: components["schemas"]["MyCardItem"][]; + }; + SetMyCardRequest: { + /** + * @description E.164 phone number to associate the my card with + * @example +15551234567 + */ + phone_number: string; + /** + * @description First name for the my card. Required when no existing my card exists for this phone number. + * @example John + */ + first_name?: string; + /** + * @description Last name for the my card + * @example Doe + */ + last_name?: string; + /** + * @description URL of the profile image to rehost on the CDN. Only re-uploaded when a new value is provided. + * @example https://cdn.linqapp.com/my-card/example.jpg + */ + image_url?: string; + }; + SetMyCardResponse: { + /** + * @description The phone number the my card is associated with + * @example +15551234567 + */ + phone_number: string; + /** + * @description First name on the my card + * @example John + */ + first_name: string; + /** + * @description Last name on the my card + * @example Doe + */ + last_name?: string; + /** + * @description Image URL on the my card + * @example https://cdn.linqapp.com/my-card/example.jpg + */ + image_url?: string; + /** + * @description Whether the my card was successfully applied to the device + * @example true + */ + is_active: boolean; + }; + HandleCheckRequest: { + /** + * @description The recipient phone number or email address to check + * @example +15551234567 + */ + address: string; + /** + * @description Optional sender phone number. If omitted, an available phone from your pool is used automatically. + * @example +15559876543 + */ + from?: string; + }; + HandleCheckResponse: { + /** + * @description The recipient address that was checked + * @example +15551234567 + */ + address: string; + /** + * @description Whether the recipient supports the checked messaging service + * @example true + */ + available: boolean; + }; + /** + * @description Current delivery status of a message + * @example pending + * @enum {string} + */ + DeliveryStatus: "pending" | "queued" | "sent" | "delivered" | "failed"; + /** + * @description Messaging service type + * @example iMessage + * @enum {string} + */ + ServiceType: "iMessage" | "SMS" | "RCS"; + /** + * @description Preferred messaging service type. Includes "auto" for default fallback behavior. + * @example iMessage + * @enum {string} + */ + ServicePreferenceType: "iMessage" | "SMS" | "RCS" | "auto"; + /** @description A message that was sent (used in CreateChat and SendMessage responses) */ + SentMessage: { + /** + * Format: uuid + * @description Message identifier (UUID) + * @example 69a37c7d-af4f-4b5e-af42-e28e98ce873a + */ + id: string; + /** + * @description Service used to send this message + * @example iMessage + */ + service?: components["schemas"]["ServiceType"] | null; + /** + * @description Preferred service for sending this message + * @example iMessage + */ + preferred_service?: components["schemas"]["ServiceType"] | null; + /** @description Message parts in order (text and media) */ + parts: ( + | components["schemas"]["TextPartResponse"] + | components["schemas"]["MediaPartResponse"] + )[]; + /** + * Format: date-time + * @description When the message was sent + * @example 2025-10-23T13:07:55.019-05:00 + */ + sent_at: string; + /** + * Format: date-time + * @description When the message was delivered + * @example null + */ + delivered_at?: string | null; + delivery_status: components["schemas"]["DeliveryStatus"]; + /** + * @description Whether the message has been read + * @example false + */ + is_read: boolean; + /** @description iMessage effect applied to this message (screen or bubble effect) */ + effect?: components["schemas"]["MessageEffect"] | null; + /** @description The sender of this message as a full handle object */ + from_handle?: components["schemas"]["ChatHandle"] | null; + reply_to?: components["schemas"]["ReplyTo"] | null; + }; + /** @description Response for sending a message to a chat */ + SendMessageResponse: { + /** + * Format: uuid + * @description Unique identifier of the chat this message was sent to + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id: string; + message: components["schemas"]["SentMessage"]; + }; + /** @description Response containing messages in a thread with pagination */ + GetThreadResponse: { + /** @description Messages in the thread, ordered by the specified order parameter */ + messages: components["schemas"]["Message"][]; + /** + * @description Cursor for fetching the next page of results (null if no more results) + * @example eyJpZCI6IjEyMzQ1Njc4OTAiLCJ0cyI6MTYzMDUwMDAwMH0= + */ + next_cursor?: string | null; + }; + /** @description Request to send a voice memo to a chat (chat_id provided in path) */ + SendVoiceMemoToChatRequest: { + /** + * @description Sender phone number in E.164 format + * @example +12052535597 + */ + from: string; + /** + * Format: uri + * @description URL of the voice memo audio file. Must be a publicly accessible HTTPS URL. + * @example https://example.com/voice-memo.m4a + */ + voice_memo_url: string; + }; + SendVoiceMemoResponse: { + /** + * @description Whether the voice memo was successfully queued + * @example true + */ + success: boolean; + message: components["schemas"]["VoiceMemoMessage"]; + chat: components["schemas"]["ChatInfo"]; + }; + /** @description Response for sending a voice memo to a chat */ + SendVoiceMemoToChatResult: { + voice_memo: { + /** + * Format: uuid + * @description Message identifier + * @example 69a37c7d-af4f-4b5e-af42-e28e98ce873a + */ + id: string; + /** + * @description Sender phone number + * @example +12052535597 + */ + from: string; + /** + * @description Recipient handles (phone numbers or email addresses) + * @example [ + * "+12052532136" + * ] + */ + to: string[]; + /** + * @description Current delivery status + * @example queued + */ + status: string; + /** + * @description Service used to send this voice memo + * @example iMessage + */ + service?: components["schemas"]["ServiceType"] | null; + voice_memo: components["schemas"]["VoiceMemoAttachment"]; + /** + * Format: date-time + * @description When the voice memo was created + */ + created_at: string; + chat: components["schemas"]["ChatInfo"]; + }; + }; + VoiceMemoMessage: { + /** + * Format: uuid + * @description Message identifier + * @example 69a37c7d-af4f-4b5e-af42-e28e98ce873a + */ + id: string; + /** + * Format: uuid + * @description Chat identifier + * @example 94c6bf33-31d9-40e3-a0e9-f94250ecedb9 + */ + chat_id: string; + /** + * @deprecated + * @description DEPRECATED: Use from_handle instead. Sender phone number. + * @example +12052535597 + */ + from: string; + /** @description The sender of this voice memo as a full handle object */ + from_handle?: components["schemas"]["ChatHandle"]; + /** + * @description Recipient handles (phone numbers or email addresses) + * @example [ + * "+12052532136" + * ] + */ + to: string[]; + voice_memo: components["schemas"]["VoiceMemoAttachment"]; + /** + * @description Current delivery status + * @example queued + */ + status: string; + /** + * Format: date-time + * @description When the voice memo was created + */ + created_at: string; + }; + VoiceMemoAttachment: { + /** + * Format: uuid + * @description Attachment identifier + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** + * Format: uri + * @description CDN URL for downloading the voice memo + * @example https://cdn.linqapp.com/voice-memos/abc123.m4a + */ + url: string; + /** + * @description Original filename + * @example voice-memo.m4a + */ + filename: string; + /** + * @description Audio MIME type + * @example audio/x-m4a + */ + mime_type: string; + /** + * @description File size in bytes + * @example 524288 + */ + size_bytes: number; + /** + * @description Duration in milliseconds + * @example 15000 + */ + duration_ms?: number | null; + }; + ChatInfo: { + /** + * Format: uuid + * @description Chat identifier + */ + id: string; + /** @description Chat participants */ + handles: components["schemas"]["ChatHandle"][]; + /** @description Whether this is a group chat */ + is_group: boolean; + /** + * @description Messaging service + * @example iMessage + */ + service: components["schemas"]["ServiceType"]; + /** @description Whether the chat is active */ + is_active: boolean; + }; + MessagePart: + | components["schemas"]["TextPart"] + | components["schemas"]["MediaPart"]; + /** + * @example { + * "type": "text", + * "value": "Check this out!" + * } + */ + TextPart: { + /** + * @description Indicates this is a text message part (enum property replaced by openapi-typescript) + * @enum {string} + */ + type: "text"; + /** + * @description The text content + * @example Hello! + */ + value: string; + }; + /** + * @example { + * "type": "media", + * "url": "https://skywalker-next.linqapp.com/_next/static/media/conversations-imessage.0dc825b0.png" + * } + */ + MediaPart: { + /** + * @description Indicates this is a media attachment part (enum property replaced by openapi-typescript) + * @enum {string} + */ + type: "media"; + /** + * Format: uri + * @description Any publicly accessible HTTPS URL to the media file. The server downloads and + * sends the file automatically — no pre-upload step required. + * + * **Size limit:** 10MB maximum for URL-based downloads. For larger files (up to 100MB), + * use the pre-upload flow: `POST /v3/attachments` to get a presigned URL, upload directly, + * then reference by `attachment_id`. + * + * **Requirements:** + * - URL must use HTTPS + * - File content must be a supported format (the server validates the actual file content) + * + * **Supported formats:** + * - Images: .jpg, .jpeg, .png, .gif, .heic, .heif, .tif, .tiff, .bmp + * - Videos: .mp4, .mov, .m4v, .mpeg, .mpg, .3gp + * - Audio: .m4a, .mp3, .aac, .caf, .wav, .aiff, .amr + * - Documents: .pdf, .txt, .rtf, .csv, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .pages, .numbers, .key, .epub, .zip, .html, .htm + * - Contact & Calendar: .vcf, .ics + * + * **Tip:** Audio sent here appears as a regular file attachment. To send audio as an + * iMessage voice memo bubble (with inline playback), use `/v3/chats/{chatId}/voicememo`. + * For repeated sends of the same file, use `attachment_id` to avoid redundant downloads. + * + * Either `url` or `attachment_id` must be provided, but not both. + * @example https://example.com/images/photo.jpg + */ + url?: string; + /** + * Format: uuid + * @description Reference to a file pre-uploaded via `POST /v3/attachments` (optional). + * The file is already stored, so sends using this ID skip the download step — + * useful when sending the same file to many recipients. + * + * Either `url` or `attachment_id` must be provided, but not both. + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + attachment_id?: string; + }; + /** + * @description A text message part + * @example { + * "type": "text", + * "value": "Hello!" + * } + */ + TextPartResponse: { + /** + * @description Indicates this is a text message part + * @enum {string} + */ + type: "text"; + /** + * @description The text content + * @example Check this out! + */ + value: string; + /** @description Reactions on this message part */ + reactions: components["schemas"]["Reaction"][] | null; + }; + /** + * @description A media attachment part + * @example { + * "type": "media", + * "id": "abc12345-1234-5678-9abc-def012345678", + * "url": "https://cdn.linqapp.com/attachments/abc12345/photo.jpg?signature=...", + * "filename": "photo.jpg", + * "mime_type": "image/jpeg", + * "size_bytes": 245678 + * } + */ + MediaPartResponse: { + /** + * @description Indicates this is a media attachment part + * @enum {string} + */ + type: "media"; + /** + * Format: uuid + * @description Unique attachment identifier + * @example abc12345-1234-5678-9abc-def012345678 + */ + id: string; + /** + * Format: uri + * @description Presigned URL for downloading the attachment (expires in 1 hour). + * @example https://cdn.linqapp.com/attachments/550e8400/photo.jpg?signature=... + */ + url: string; + /** + * @description Original filename + * @example photo.jpg + */ + filename: string; + /** + * @description MIME type of the file + * @example image/jpeg + */ + mime_type: string; + /** + * @description File size in bytes + * @example 245678 + */ + size_bytes: number; + /** @description Reactions on this message part */ + reactions: components["schemas"]["Reaction"][] | null; + }; + /** + * @description Message effects for iMessage. Only one effect type can be applied per message. + * Screen effects play a full-screen animation. Bubble effects animate the message bubble. + */ + MessageEffects: { + /** + * @description Full-screen animation effect. Available screen effects: + * - confetti: Colorful confetti falls from top of screen + * - fireworks: Fireworks explode across the screen + * - lasers: Laser beams scan across the screen + * - sparkles: Sparkle/celebration effect + * - celebration: Alias for sparkles (matches UI name) + * - hearts: Floating hearts fill the screen + * - love: Alias for hearts (matches UI name) + * - balloons: Colorful balloons float up + * - happy_birthday: Alias for balloons + * - echo: Message text echoes/multiplies across screen + * - spotlight: Spotlight illuminates the message + * @example confetti + * @enum {string} + */ + screen_effect?: + | "confetti" + | "fireworks" + | "lasers" + | "sparkles" + | "celebration" + | "hearts" + | "love" + | "balloons" + | "happy_birthday" + | "echo" + | "spotlight"; + /** + * @description Message bubble animation effect. Available bubble effects: + * - slam: Message slams onto screen with impact + * - loud: Message appears larger with emphasis + * - gentle: Message fades in softly + * - invisible: Invisible ink effect (revealed on tap) + * @example slam + * @enum {string} + */ + bubble_effect?: "slam" | "loud" | "gentle" | "invisible"; + }; + /** + * @description Message content container. Groups all message-related fields together, + * separating the "what" (message content) from the "where" (routing fields like from/to). + */ + MessageContent: { + /** + * @description Array of message parts. Each part can be either text or media. + * Parts are displayed in order. Text and media can be mixed. + * + * **Supported Media:** + * - Images: .jpg, .jpeg, .png, .gif, .heic, .heif, .tif, .tiff, .bmp + * - Videos: .mp4, .mov, .m4v, .mpeg, .mpg, .3gp + * - Audio: .m4a, .mp3, .aac, .caf, .wav, .aiff, .amr + * - Documents: .pdf, .txt, .rtf, .csv, .doc, .docx, .xls, .xlsx, .ppt, .pptx, .pages, .numbers, .key, .epub, .zip, .html, .htm + * - Contact & Calendar: .vcf, .ics + * + * **Audio:** + * - Audio files (.m4a, .mp3, .aac, .caf, .wav, .aiff, .amr) are fully supported as media parts + * - To send audio as an **iMessage voice memo bubble** (inline playback UI), use the dedicated + * `/v3/chats/{chatId}/voicememo` endpoint instead + * + * **Validation Rules:** + * - Consecutive text parts are not allowed. Text parts must be separated by + * media parts. For example, [text, text] is invalid, but [text, media, text] is valid. + * - Maximum of **100 parts** total. + * - Media parts using a public `url` (downloaded by the server on send) are + * capped at **40**. Parts using `attachment_id` or presigned URLs + * are exempt from this sub-limit. For bulk media sends exceeding 40 files, + * pre-upload via `POST /v3/attachments` and reference by `attachment_id` or `download_url`. + * @example [ + * { + * "type": "text", + * "value": "Check this out!" + * }, + * { + * "type": "media", + * "url": "https://skywalker-next.linqapp.com/_next/static/media/conversations-imessage.0dc825b0.png" + * } + * ] + */ + parts: components["schemas"]["MessagePart"][]; + /** @description iMessage effect to apply to this message (screen or bubble effect) */ + effect?: components["schemas"]["MessageEffect"]; + /** @description Reply to another message to create a threaded conversation */ + reply_to?: components["schemas"]["ReplyTo"]; + /** + * @description Optional idempotency key for this message. + * Use this to prevent duplicate sends of the same message. + * @example msg-abc123xyz + */ + idempotency_key?: string; + /** + * @description Preferred messaging service to use for this message. + * If not specified, uses default fallback chain: iMessage → RCS → SMS. + * - iMessage: Enforces iMessage without fallback to RCS or SMS. Message fails if recipient doesn't support iMessage. + * - RCS: Enforces RCS or SMS (no iMessage). Uses RCS if recipient supports it, otherwise falls back to SMS. + * - SMS: Enforces SMS (no iMessage). Uses RCS if recipient supports it, otherwise falls back to SMS. + * @example iMessage + */ + preferred_service?: components["schemas"]["ServiceType"]; + }; + ErrorResponse: { + error: components["schemas"]["ErrorDetail"]; + /** + * @description Always false for error responses + * @example false + */ + success: boolean; + /** + * @description Unique trace ID for request tracing and debugging + * @example trace_abc123def456 + */ + trace_id?: string; + }; + ErrorDetail: { + /** + * @description HTTP status code (e.g., 400, 404, 500) + * @example 400 + */ + status: number; + code: components["schemas"]["ErrorCode"]; + /** + * @description Human-readable error message + * @example Phone number must be in E.164 format + */ + message: string; + }; + Message: { + /** + * Format: uuid + * @description Unique identifier for the message + * @example 69a37c7d-af4f-4b5e-af42-e28e98ce873a + */ + id: string; + /** + * Format: uuid + * @description ID of the chat this message belongs to + * @example 94c6bf33-31d9-40e3-a0e9-f94250ecedb9 + */ + chat_id: string; + /** + * @description Service used to send/receive this message + * @example iMessage + */ + service?: components["schemas"]["ServiceType"] | null; + /** + * @description Preferred service for sending this message + * @example iMessage + */ + preferred_service?: components["schemas"]["ServiceType"] | null; + /** + * @deprecated + * @description DEPRECATED: Use from_handle instead. Phone number of the message sender. + * @example +12052535597 + */ + from?: string | null; + /** @description The sender of this message as a full handle object */ + from_handle?: components["schemas"]["ChatHandle"] | null; + /** @description Message parts in order (text and media) */ + parts?: + | ( + | components["schemas"]["TextPartResponse"] + | components["schemas"]["MediaPartResponse"] + )[] + | null; + reply_to?: components["schemas"]["ReplyTo"] | null; + /** + * @description Whether this message was sent by the authenticated user + * @example true + */ + is_from_me: boolean; + /** + * @description Whether the message has been delivered + * @example true + */ + is_delivered: boolean; + /** + * @description Whether the message has been read + * @example false + */ + is_read: boolean; + /** + * Format: date-time + * @description When the message was created + * @example 2024-01-15T10:30:00Z + */ + created_at: string; + /** + * Format: date-time + * @description When the message was last updated + * @example 2024-01-15T10:30:00Z + */ + updated_at: string; + /** + * Format: date-time + * @description When the message was sent + * @example 2024-01-15T10:30:05Z + */ + sent_at?: string | null; + /** + * Format: date-time + * @description When the message was delivered + * @example 2024-01-15T10:30:10Z + */ + delivered_at?: string | null; + /** + * Format: date-time + * @description When the message was read + * @example 2024-01-15T10:35:00Z + */ + read_at?: string | null; + /** @description iMessage effect applied to this message (screen or bubble effect) */ + effect?: components["schemas"]["MessageEffect"] | null; + }; + /** @description iMessage effect applied to a message (screen or bubble effect) */ + MessageEffect: { + /** + * @description Type of effect + * @example screen + * @enum {string} + */ + type?: "screen" | "bubble"; + /** + * @description Name of the effect. Common values: + * - Screen effects: confetti, fireworks, lasers, sparkles, celebration, hearts, love, balloons, happy_birthday, echo, spotlight + * - Bubble effects: slam, loud, gentle, invisible + * @example confetti + */ + name?: string; + }; + /** @description Indicates this message is a threaded reply to another message */ + ReplyTo: { + /** + * Format: uuid + * @description The ID of the message to reply to + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + message_id: string; + /** + * Format: int32 + * @description The specific message part to reply to (0-based index). + * Defaults to 0 (first part) if not provided. + * Use this when replying to a specific part of a multipart message. + * @example 0 + */ + part_index?: number; + }; + Attachment: { + /** + * @description Unique identifier for the attachment (UUID) + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** + * @description Original filename of the attachment + * @example photo.jpg + */ + filename: string; + content_type: components["schemas"]["SupportedContentType"]; + /** + * Format: int64 + * @description Size of the attachment in bytes + * @example 1024000 + */ + size_bytes: number; + /** + * @description Current upload/processing status + * @example complete + * @enum {string} + */ + status: "pending" | "complete" | "failed"; + /** + * Format: uri + * @description URL to download the attachment + */ + download_url?: string; + /** + * Format: date-time + * @description When the attachment was created + * @example 2024-01-15T10:30:00Z + */ + created_at: string; + }; + /** + * @description Type of reaction. Standard iMessage tapbacks are love, like, dislike, laugh, emphasize, question. + * Custom emoji reactions have type "custom" with the actual emoji in the custom_emoji field. + * Sticker reactions have type "sticker" with sticker attachment details in the sticker field. + * @example love + * @enum {string} + */ + ReactionType: + | "love" + | "like" + | "dislike" + | "laugh" + | "emphasize" + | "question" + | "custom" + | "sticker"; + Reaction: { + /** + * @description Whether this reaction is from the current user + * @example false + */ + is_me: boolean; + handle: components["schemas"]["ChatHandle"]; + type: components["schemas"]["ReactionType"]; + /** + * @description Custom emoji if type is "custom", null otherwise + * @example 🚀 + */ + custom_emoji?: string | null; + /** @description Sticker attachment details when reaction_type is "sticker". Null for non-sticker reactions. */ + sticker?: { + /** + * Format: uri + * @description Presigned URL for downloading the sticker image (expires in 1 hour). + * @example https://cdn.linqapp.com/attachments/a1b2c3d4/sticker.png?signature=... + */ + url?: string; + /** + * @description MIME type of the sticker image + * @example image/png + */ + mime_type?: string; + /** + * @description Sticker image width in pixels + * @example 420 + */ + width?: number; + /** + * @description Sticker image height in pixels + * @example 420 + */ + height?: number; + /** + * @description Filename of the sticker + * @example sticker.png + */ + file_name?: string; + } | null; + }; + EditMessageRequest: { + /** + * @description Index of the message part to edit. Defaults to 0. + * @default 0 + * @example 0 + */ + part_index: number; + /** + * @description New text content for the message part + * @example This is the edited message content + */ + text: string; + }; + DeleteMessageRequest: { + /** + * Format: uuid + * @description ID of the chat containing the message to delete + * @example 94c6bf33-31d9-40e3-a0e9-f94250ecedb9 + */ + chat_id: string; + }; + SendReactionRequest: { + /** + * @description Whether to add or remove the reaction + * @example add + * @enum {string} + */ + operation: "add" | "remove"; + type: components["schemas"]["ReactionType"]; + /** + * @description Custom emoji string. Required when type is "custom". + * @example 😍 + */ + custom_emoji?: string; + /** + * @description Optional index of the message part to react to. + * If not provided, reacts to the entire message (part 0). + * @example 1 + */ + part_index?: number; + }; + /** + * @description Supported MIME types for file attachments and media URLs. + * + * **Images:** image/jpeg, image/png, image/gif, image/heic, image/heif, image/tiff, image/bmp + * + * **Videos:** video/mp4, video/quicktime, video/mpeg, video/3gpp + * + * **Audio:** audio/mpeg, audio/mp4, audio/x-m4a, audio/x-caf, audio/wav, audio/aiff, audio/aac, audio/amr + * + * **Documents:** application/pdf, text/plain, text/markdown, text/vcard, text/rtf, text/csv, text/html, text/calendar, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.apple.pages, application/vnd.apple.numbers, application/vnd.apple.keynote, application/epub+zip, application/zip + * + * **Unsupported:** WebP, SVG, FLAC, OGG, and executable files are explicitly rejected. + * @example image/jpeg + * @enum {string} + */ + SupportedContentType: + | "image/jpeg" + | "image/jpg" + | "image/png" + | "image/gif" + | "image/heic" + | "image/heif" + | "image/tiff" + | "image/bmp" + | "image/x-ms-bmp" + | "video/mp4" + | "video/quicktime" + | "video/mpeg" + | "video/x-m4v" + | "video/3gpp" + | "audio/mpeg" + | "audio/mp3" + | "audio/mp4" + | "audio/x-m4a" + | "audio/m4a" + | "audio/x-caf" + | "audio/wav" + | "audio/x-wav" + | "audio/aiff" + | "audio/x-aiff" + | "audio/aac" + | "audio/x-aac" + | "audio/amr" + | "application/pdf" + | "text/plain" + | "text/markdown" + | "text/vcard" + | "text/x-vcard" + | "text/rtf" + | "application/rtf" + | "text/csv" + | "text/html" + | "text/calendar" + | "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + | "application/vnd.ms-powerpoint" + | "application/vnd.openxmlformats-officedocument.presentationml.presentation" + | "application/vnd.apple.pages" + | "application/x-iwork-pages-sffpages" + | "application/vnd.apple.numbers" + | "application/x-iwork-numbers-sffnumbers" + | "application/vnd.apple.keynote" + | "application/x-iwork-keynote-sffkey" + | "application/epub+zip" + | "application/zip" + | "application/x-zip-compressed"; + RequestUploadRequest: { + /** + * @description Name of the file to upload + * @example photo.jpg + */ + filename: string; + content_type: components["schemas"]["SupportedContentType"]; + /** + * Format: int64 + * @description Size of the file in bytes (max 100MB) + * @example 1024000 + */ + size_bytes: number; + }; + RequestUploadResult: { + /** + * Format: uuid + * @description Unique identifier for the attachment (for status checks via GET /v3/attachments/{id}) + * @example abc12345-1234-5678-9abc-def012345678 + */ + attachment_id: string; + /** + * Format: uri + * @description Presigned URL for uploading the file. PUT the raw binary file content to this URL + * with the `required_headers`. Do not JSON-encode or multipart-wrap the body. + * Expires after 15 minutes. + * @example https://uploads.linqapp.com/attachments/abc12345?X-Amz-Algorithm=... + */ + upload_url: string; + /** + * Format: uri + * @description Permanent CDN URL for the file. Does not expire. Use the `attachment_id` + * to reference this file in media parts when sending messages. + * @example https://cdn.linqapp.com/uploads/partner-id/abc12345/photo.jpg + */ + download_url: string; + /** + * @description HTTP method to use for upload (always PUT) + * @example PUT + * @enum {string} + */ + http_method: "PUT"; + /** + * Format: date-time + * @description When the upload URL expires (15 minutes from now) + * @example 2024-01-15T10:45:00Z + */ + expires_at: string; + /** + * @description HTTP headers required for the upload request + * @example { + * "Content-Type": "image/jpeg" + * } + */ + required_headers: { + [key: string]: string; + }; + }; + Chat: { + /** + * Format: uuid + * @description Unique identifier for the chat + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** + * @description Display name for the chat. Defaults to a comma-separated list of recipient handles. Can be updated for group chats. + * @example +14155551234, +14155559876 + */ + display_name: string | null; + /** + * @description Service type for the chat + * @example iMessage + */ + service?: components["schemas"]["ServiceType"] | null; + /** + * @description List of chat participants with full handle details. Always contains at least two handles (your phone number and the other participant). + * @example [ + * { + * "id": "550e8400-e29b-41d4-a716-446655440010", + * "handle": "+14155551234", + * "service": "iMessage", + * "status": "active", + * "joined_at": "2025-05-21T15:30:00.000Z", + * "is_me": true + * }, + * { + * "id": "550e8400-e29b-41d4-a716-446655440011", + * "handle": "+14155559876", + * "service": "iMessage", + * "status": "active", + * "joined_at": "2025-05-21T15:30:00.000Z", + * "is_me": false + * } + * ] + */ + handles: components["schemas"]["ChatHandle"][]; + /** + * @description Whether the chat is archived + * @default false + */ + is_archived: boolean; + /** + * @description Whether this is a group chat + * @default false + */ + is_group: boolean; + /** + * Format: date-time + * @description When the chat was created + * @example 2024-01-15T10:30:00Z + */ + created_at: string; + /** + * Format: date-time + * @description When the chat was last updated + * @example 2024-01-15T10:30:00Z + */ + updated_at: string; + }; + ChatHandle: { + /** + * Format: uuid + * @description Unique identifier for this handle + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** + * @description Phone number (E.164) or email address of the participant + * @example +15551234567 + */ + handle: string; + /** + * @description Service type (iMessage, SMS, RCS, etc.) + * @example iMessage + */ + service: components["schemas"]["ServiceType"]; + /** + * @description Participant status + * @default active + * @enum {string|null} + */ + status: "active" | "left" | "removed" | null; + /** + * Format: date-time + * @description When this participant joined the chat + * @example 2025-05-21T15:30:00.000-05:00 + */ + joined_at: string; + /** + * Format: date-time + * @description When they left (if applicable) + */ + left_at?: string | null; + /** + * @description Whether this handle belongs to the sender (your phone number) + * @example false + */ + is_me?: boolean | null; + }; + ListChatsResult: { + /** @description List of chats */ + chats: components["schemas"]["Chat"][]; + /** + * @description Cursor for fetching the next page of results. + * Null if there are no more results to fetch. + * Pass this value as the `cursor` parameter in the next request. + */ + next_cursor?: string | null; + }; + CreateChatRequest: { + /** + * @description Sender phone number in E.164 format. Must be a phone number that the + * authenticated partner has permission to send from. + * @example +12052535597 + */ + from: string; + /** + * @description Array of recipient handles (phone numbers in E.164 format or email addresses). + * For individual chats, provide one recipient. For group chats, provide multiple. + * @example [ + * "+14155559876", + * "+14155550123" + * ] + */ + to: string[]; + message: components["schemas"]["MessageContent"]; + }; + /** @description Response for creating a new chat with an initial message */ + CreateChatResult: { + chat: { + /** + * Format: uuid + * @description Unique identifier for the created chat (UUID) + * @example 94c6bf33-31d9-40e3-a0e9-f94250ecedb9 + */ + id: string; + /** + * @description Display name for the chat. Defaults to a comma-separated list of recipient handles. Can be updated for group chats. + * @example +14155551234, +14155559876 + */ + display_name: string | null; + /** + * @description Messaging service used + * @example iMessage + */ + service: components["schemas"]["ServiceType"]; + /** + * @description Whether this is a group chat + * @example false + */ + is_group: boolean; + /** + * @description List of participants in the chat. Always contains at least two handles (your phone number and the other participant). + * @example [ + * { + * "id": "550e8400-e29b-41d4-a716-446655440010", + * "handle": "+14155551234", + * "service": "iMessage", + * "status": "active", + * "joined_at": "2025-05-21T15:30:00.000Z", + * "is_me": true + * }, + * { + * "id": "550e8400-e29b-41d4-a716-446655440011", + * "handle": "+14155559876", + * "service": "iMessage", + * "status": "active", + * "joined_at": "2025-05-21T15:30:00.000Z", + * "is_me": false + * } + * ] + */ + handles: components["schemas"]["ChatHandle"][]; + message: components["schemas"]["SentMessage"]; + }; + }; + UpdateChatRequest: { + /** + * @description New display name for the chat (group chats only) + * @example Updated Team Name + */ + display_name?: string; + /** + * Format: uri + * @description URL of an image to set as the group chat icon (group chats only) + * @example https://example.com/icon.png + */ + group_chat_icon?: string; + }; + AddParticipantRequest: { + /** + * @description Phone number (E.164 format) or email address of the participant to add + * @example +12052532136 + */ + handle: string; + }; + RemoveParticipantRequest: { + /** + * @description Phone number (E.164 format) or email address of the participant to remove + * @example +12052532136 + */ + handle: string; + }; + SendMessageToChatRequest: { + message: components["schemas"]["MessageContent"]; + }; + GetMessagesResult: { + /** @description List of messages */ + messages: components["schemas"]["Message"][]; + /** + * @description Cursor for fetching the next page of results. + * Null if there are no more results to fetch. + * Pass this value as the `cursor` parameter in the next request. + */ + next_cursor?: string | null; + }; + /** + * @description Valid webhook event types that can be subscribed to + * @enum {string} + */ + WebhookEventType: + | "message.sent" + | "message.received" + | "message.read" + | "message.delivered" + | "message.failed" + | "message.edited" + | "reaction.added" + | "reaction.removed" + | "participant.added" + | "participant.removed" + | "chat.created" + | "chat.group_name_updated" + | "chat.group_icon_updated" + | "chat.group_name_update_failed" + | "chat.group_icon_update_failed" + | "chat.typing_indicator.started" + | "chat.typing_indicator.stopped" + | "phone_number.status_updated"; + WebhookEventsResult: { + /** @description List of all available webhook event types */ + events: components["schemas"]["WebhookEventType"][]; + /** + * Format: uri + * @description URL to the webhook events documentation + * @constant + */ + doc_url: "https://apidocs.linqapp.com/documentation/webhook-events"; + }; + WebhookSubscriptionResponse: { + /** + * @description Unique identifier for the webhook subscription + * @example b2c3d4e5-f6a7-8901-bcde-f23456789012 + */ + id: string; + /** + * Format: uri + * @description URL where webhook events will be sent + * @example https://webhooks.example.com/linq/events + */ + target_url: string; + /** + * @description List of event types this subscription receives + * @example [ + * "message.sent", + * "message.delivered", + * "message.read" + * ] + */ + subscribed_events: components["schemas"]["WebhookEventType"][]; + /** + * @description Whether this subscription is currently active + * @example true + */ + is_active: boolean; + /** + * Format: date-time + * @description When the subscription was created + * @example 2024-01-15T10:30:00Z + */ + created_at: string; + /** + * Format: date-time + * @description When the subscription was last updated + * @example 2024-01-15T10:30:00Z + */ + updated_at: string; + }; + /** @description Response returned when creating a webhook subscription. Includes the signing secret which is only shown once. */ + WebhookSubscriptionCreatedResponse: { + /** + * @description Unique identifier for the webhook subscription + * @example b2c3d4e5-f6a7-8901-bcde-f23456789012 + */ + id: string; + /** + * Format: uri + * @description URL where webhook events will be sent + * @example https://webhooks.example.com/linq/events + */ + target_url: string; + /** + * @description Secret for verifying webhook signatures. Store this securely - it cannot be retrieved again. + * @example whsec_abc123def456 + */ + signing_secret: string; + /** + * @description List of event types this subscription receives + * @example [ + * "message.sent", + * "message.delivered", + * "message.read" + * ] + */ + subscribed_events: components["schemas"]["WebhookEventType"][]; + /** + * @description Whether this subscription is currently active + * @example true + */ + is_active: boolean; + /** + * Format: date-time + * @description When the subscription was created + * @example 2024-01-15T10:30:00Z + */ + created_at: string; + /** + * Format: date-time + * @description When the subscription was last updated + * @example 2024-01-15T10:30:00Z + */ + updated_at: string; + }; + CreateWebhookSubscriptionRequest: { + /** + * Format: uri + * @description URL where webhook events will be sent. Must be HTTPS. + * @example https://webhooks.example.com/linq/events + */ + target_url: string; + /** + * @description List of event types to subscribe to + * @example [ + * "message.sent", + * "message.delivered" + * ] + */ + subscribed_events: components["schemas"]["WebhookEventType"][]; + }; + UpdateWebhookSubscriptionRequest: { + /** + * Format: uri + * @description New target URL for webhook events + * @example https://webhooks.example.com/linq/events + */ + target_url?: string; + /** + * @description Updated list of event types to subscribe to + * @example [ + * "message.sent", + * "message.delivered" + * ] + */ + subscribed_events?: components["schemas"]["WebhookEventType"][]; + /** + * @description Activate or deactivate the subscription + * @example true + */ + is_active?: boolean; + }; + ListWebhookSubscriptionsResult: { + /** @description List of webhook subscriptions */ + subscriptions: components["schemas"]["WebhookSubscriptionResponse"][]; + }; + ListPhoneNumbersResult: { + /** @description List of phone numbers assigned to the partner */ + phone_numbers: components["schemas"]["PhoneNumberInfo"][]; + }; + PhoneNumberInfo: { + /** + * Format: uuid + * @description Unique identifier for the phone number + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** + * @description Phone number in E.164 format + * @example +12025551234 + */ + phone_number: string; + /** + * @description Deprecated. Always null. + * @example APPLE_ID + * @enum {string} + */ + type?: "TWILIO" | "APPLE_ID"; + /** + * @description Deprecated. Always null. + * @example US + */ + country_code?: string; + capabilities?: components["schemas"]["PhoneCapabilities"]; + }; + PhoneCapabilities: { + /** + * @description Whether SMS messaging is supported + * @example true + */ + sms: boolean; + /** + * @description Whether MMS messaging is supported + * @example true + */ + mms: boolean; + /** + * @description Whether voice calls are supported + * @example false + */ + voice: boolean; + }; + ListPhoneNumbersResultV2: { + /** @description List of phone numbers assigned to the partner */ + phone_numbers: components["schemas"]["PhoneNumberInfoV2"][]; + }; + PhoneNumberInfoV2: { + /** + * Format: uuid + * @description Unique identifier for the phone number + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** + * @description Phone number in E.164 format + * @example +12025551234 + */ + phone_number: string; + }; + /** @description Linq API error code. */ + ErrorCode: number; + WebhookEnvelopeBase: { + /** + * @description API version for the webhook payload format + * @example v3 + */ + api_version: string; + /** + * @description Date-based webhook payload version. + * Determined by the `?version=` query parameter in your webhook subscription URL. + * If no version parameter is specified, defaults based on subscription creation date. + * @example 2025-01-01 + */ + webhook_version: string; + event_type: components["schemas"]["WebhookEventType"]; + /** + * Format: uuid + * @description Unique identifier for this event (for deduplication) + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + event_id: string; + /** + * Format: date-time + * @description When the event was created + * @example 2025-11-23T17:30:00Z + */ + created_at: string; + /** + * @description Trace ID for debugging and correlation across systems. + * @example abc123def456 + */ + trace_id: string; + /** + * @description Partner identifier. Present on all webhooks for cross-referencing. + * @example partner_abc123 + */ + partner_id: string; + }; + /** + * @description A text message part + * @example { + * "type": "text", + * "value": "Hello!" + * } + */ + "schemas-TextPartResponse": { + /** + * @description Indicates this is a text message part (enum property replaced by openapi-typescript) + * @enum {string} + */ + type: "text"; + /** + * @description The text content + * @example Check this out! + */ + value: string; + }; + /** + * @description A media attachment part + * @example { + * "type": "media", + * "id": "abc12345-1234-5678-9abc-def012345678", + * "url": "https://cdn.linqapp.com/attachments/abc12345/photo.jpg?signature=...", + * "filename": "photo.jpg", + * "mime_type": "image/jpeg", + * "size_bytes": 245678 + * } + */ + "schemas-MediaPartResponse": { + /** + * @description Indicates this is a media attachment part (enum property replaced by openapi-typescript) + * @enum {string} + */ + type: "media"; + /** + * Format: uuid + * @description Unique attachment identifier + * @example abc12345-1234-5678-9abc-def012345678 + */ + id: string; + /** + * Format: uri + * @description Presigned URL for downloading the attachment (expires in 1 hour). + * @example https://cdn.linqapp.com/attachments/550e8400/photo.jpg?signature=... + */ + url: string; + /** + * @description Original filename + * @example photo.jpg + */ + filename: string; + /** + * @description MIME type of the file + * @example image/jpeg + */ + mime_type: string; + /** + * @description File size in bytes + * @example 245678 + */ + size_bytes: number; + }; + /** @description iMessage effect applied to a message (screen or bubble animation) */ + "schemas-MessageEffect": { + /** + * @description Effect category + * @example bubble + * @enum {string} + */ + type?: "screen" | "bubble"; + /** + * @description Effect name (confetti, fireworks, slam, gentle, etc.) + * @example gentle + */ + name?: string; + }; + /** + * @description Unified payload for message webhooks when using `webhook_version: "2026-02-03"`. + * + * This schema is used for message.sent, message.received, message.delivered, and message.read + * events when the subscription URL includes `?version=2026-02-03`. + * + * Key differences from V1 (2025-01-01): + * - `direction`: "inbound" or "outbound" instead of `is_from_me` boolean + * - `sender_handle`: Full handle object for the sender + * - `chat`: Nested object with `id`, `is_group`, and `owner_handle` + * - Message fields (`id`, `parts`, `effect`, etc.) are at the top level, not nested in `message` + * + * Timestamps indicate the message state: + * - `message.sent`: sent_at set, delivered_at=null, read_at=null + * - `message.received`: sent_at set, delivered_at=null, read_at=null + * - `message.delivered`: sent_at set, delivered_at set, read_at=null + * - `message.read`: sent_at set, delivered_at set, read_at set + */ + MessageEventV2: { + /** @description Chat information */ + chat: { + /** + * Format: uuid + * @description Chat identifier + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** + * @description Whether this is a group chat + * @example true + */ + is_group?: boolean | null; + /** @description Your phone number's handle. Always has is_me=true. */ + owner_handle?: components["schemas"]["ChatHandle"] | null; + }; + /** + * Format: uuid + * @description Message identifier + * @example 550e8400-e29b-41d4-a716-446655440001 + */ + id: string; + /** + * @description Idempotency key for deduplication of outbound messages. + * @example unique-key + */ + idempotency_key?: string | null; + /** + * @description Message direction - "outbound" if sent by you, "inbound" if received + * @example outbound + * @enum {string} + */ + direction: "inbound" | "outbound"; + /** @description The handle that sent this message */ + sender_handle: components["schemas"]["ChatHandle"]; + /** @description Message parts (text and/or media) */ + parts: ( + | components["schemas"]["schemas-TextPartResponse"] + | components["schemas"]["schemas-MediaPartResponse"] + )[]; + /** + * Format: date-time + * @description When the message was sent. Null if not yet sent. + * @example 2026-01-30T20:49:19.704Z + */ + sent_at?: string | null; + /** + * Format: date-time + * @description When the message was delivered. Null if not yet delivered. + * @example 2026-01-30T20:49:20.352Z + */ + delivered_at?: string | null; + /** + * Format: date-time + * @description When the message was read. Null if not yet read. + * @example null + */ + read_at?: string | null; + /** @description Reference to the message this is replying to (for threaded replies) */ + reply_to?: { + /** + * Format: uuid + * @description ID of the message being replied to + */ + message_id?: string; + /** + * Format: int32 + * @description Index of the part being replied to + */ + part_index?: number; + } | null; + /** @description iMessage effect applied to the message (bubble or screen animation). Null if no effect. */ + effect?: components["schemas"]["schemas-MessageEffect"] | null; + /** + * @description The service used to send/receive the message + * @example iMessage + */ + service: components["schemas"]["ServiceType"]; + /** + * @description The service that was requested when sending. Null for inbound messages. + * @example iMessage + */ + preferred_service?: components["schemas"]["ServicePreferenceType"] | null; + }; + /** @description Complete webhook payload for message.sent events (2026-02-03 format) */ + MessageSentWebhookV2: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["MessageEventV2"]; + }; + /** @description Complete webhook payload for message.received events (2026-02-03 format) */ + MessageReceivedWebhookV2: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["MessageEventV2"]; + }; + /** @description Complete webhook payload for message.read events (2026-02-03 format) */ + MessageReadWebhookV2: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["MessageEventV2"]; + }; + /** @description Complete webhook payload for message.delivered events (2026-02-03 format) */ + MessageDeliveredWebhookV2: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["MessageEventV2"]; + }; + /** @description Error codes in webhook failure events (3007, 4001). */ + WebhookErrorCode: number; + /** + * @description Error details for message.failed webhook events. + * See [WebhookErrorCode](#/components/schemas/WebhookErrorCode) for the full error code reference. + */ + MessageFailedEvent: { + /** + * @description Chat identifier (UUID) + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id?: string; + /** + * @description Message identifier (UUID) + * @example 550e8400-e29b-41d4-a716-446655440001 + */ + message_id?: string; + code: components["schemas"]["WebhookErrorCode"]; + /** + * @description Human-readable description of the failure + * @example Request expired before being processed + */ + reason?: string; + /** + * Format: date-time + * @description When the failure was detected + * @example 2025-11-23T17:35:00Z + */ + failed_at: string; + }; + /** @description Complete webhook payload for message.failed events */ + MessageFailedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["MessageFailedEvent"]; + }; + ReactionEventBase: { + /** + * @description Chat identifier (UUID) + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id?: string; + /** + * @deprecated + * @description DEPRECATED: Use from_handle instead. Phone number or email address of the person who added/removed the reaction. + * @example +14155559876 + */ + from?: string; + /** + * @description The person who added/removed the reaction as a full handle object + * @example { + * "id": "550e8400-e29b-41d4-a716-446655440011", + * "handle": "+14155559876", + * "is_me": false, + * "service": "iMessage", + * "status": "active", + * "joined_at": "2025-11-23T17:30:00.000Z", + * "left_at": null + * } + */ + from_handle?: components["schemas"]["ChatHandle"]; + /** + * @description Message identifier (UUID) that the reaction was added to or removed from + * @example 550e8400-e29b-41d4-a716-446655440001 + */ + message_id?: string; + /** + * Format: int32 + * @description Index of the message part that was reacted to (0-based) + * @example 0 + */ + part_index?: number; + reaction_type: components["schemas"]["ReactionType"]; + /** + * @description The actual emoji when reaction_type is "custom". Null for standard tapbacks. + * @example null + */ + custom_emoji?: string | null; + /** + * @description Whether this reaction was from the owner of the phone number (true) or from someone else (false) + * @example false + */ + is_from_me: boolean; + /** + * @description Message service type + * @example iMessage + */ + service?: components["schemas"]["ServiceType"]; + /** + * Format: date-time + * @description When the reaction was added or removed + * @example 2025-11-23T17:35:00Z + */ + reacted_at?: string; + /** @description Sticker attachment details when reaction_type is "sticker". Null for non-sticker reactions. */ + sticker?: { + /** + * Format: uri + * @description Presigned URL for downloading the sticker image (expires in 1 hour). + * @example https://cdn.linqapp.com/attachments/a1b2c3d4/sticker.png?signature=... + */ + url?: string; + /** + * @description MIME type of the sticker image + * @example image/png + */ + mime_type?: string; + /** + * @description Sticker image width in pixels + * @example 420 + */ + width?: number; + /** + * @description Sticker image height in pixels + * @example 420 + */ + height?: number; + /** + * @description Filename of the sticker + * @example sticker.png + */ + file_name?: string; + } | null; + }; + /** @description Payload for reaction.added webhook events */ + ReactionAddedEvent: components["schemas"]["ReactionEventBase"]; + /** @description Complete webhook payload for reaction.added events */ + ReactionAddedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ReactionAddedEvent"]; + }; + /** @description Payload for reaction.removed webhook events */ + ReactionRemovedEvent: components["schemas"]["ReactionEventBase"]; + /** @description Complete webhook payload for reaction.removed events */ + ReactionRemovedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ReactionRemovedEvent"]; + }; + /** @description Payload for participant.added webhook events */ + ParticipantAddedEvent: { + /** + * @description Chat identifier (UUID) of the group chat + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id?: string; + /** + * @deprecated + * @description DEPRECATED: Use participant instead. Handle (phone number or email address) of the added participant. + * @example +14155559876 + */ + handle: string; + /** + * @description The added participant as a full handle object + * @example { + * "id": "550e8400-e29b-41d4-a716-446655440011", + * "handle": "+14155559876", + * "is_me": false, + * "service": "iMessage", + * "status": "active", + * "joined_at": "2025-11-23T17:40:00.000Z", + * "left_at": null + * } + */ + participant?: components["schemas"]["ChatHandle"]; + /** + * Format: date-time + * @description When the participant was added + * @example 2025-11-23T17:40:00Z + */ + added_at?: string; + }; + /** @description Complete webhook payload for participant.added events */ + ParticipantAddedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ParticipantAddedEvent"]; + }; + /** @description Payload for participant.removed webhook events */ + ParticipantRemovedEvent: { + /** + * @description Chat identifier (UUID) of the group chat + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id?: string; + /** + * @deprecated + * @description DEPRECATED: Use participant instead. Handle (phone number or email address) of the removed participant. + * @example +14155559876 + */ + handle: string; + /** + * @description The removed participant as a full handle object + * @example { + * "id": "550e8400-e29b-41d4-a716-446655440011", + * "handle": "+14155559876", + * "is_me": false, + * "service": "iMessage", + * "status": "removed", + * "joined_at": "2025-11-23T17:30:00.000Z", + * "left_at": "2025-11-23T17:45:00.000Z" + * } + */ + participant?: components["schemas"]["ChatHandle"]; + /** + * Format: date-time + * @description When the participant was removed + * @example 2025-11-23T17:45:00Z + */ + removed_at?: string; + }; + /** @description Complete webhook payload for participant.removed events */ + ParticipantRemovedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ParticipantRemovedEvent"]; + }; + /** @description Payload for chat.group_name_updated webhook events */ + ChatGroupNameUpdatedEvent: { + /** + * @description Chat identifier (UUID) of the group chat + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id: string; + /** + * @description Previous group name (null if no previous name) + * @example Old Group Name + */ + old_value?: string | null; + /** + * @description New group name (null if the name was removed) + * @example New Group Name + */ + new_value?: string | null; + /** + * @description The handle who made the change. + * @example { + * "id": "550e8400-e29b-41d4-a716-446655440011", + * "handle": "+14155559876", + * "is_me": false, + * "service": "iMessage", + * "status": "active", + * "joined_at": "2025-11-23T17:30:00.000Z", + * "left_at": null + * } + */ + changed_by_handle?: components["schemas"]["ChatHandle"] | null; + /** + * Format: date-time + * @description When the update occurred + * @example 2025-11-23T17:50:00Z + */ + updated_at: string; + }; + /** @description Complete webhook payload for chat.group_name_updated events */ + ChatGroupNameUpdatedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ChatGroupNameUpdatedEvent"]; + }; + /** @description Payload for chat.group_icon_updated webhook events */ + ChatGroupIconUpdatedEvent: { + /** + * @description Chat identifier (UUID) of the group chat + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id: string; + /** + * @description Previous icon URL (null if no previous icon) + * @example https://example.com/old-icon.png + */ + old_value?: string | null; + /** + * @description New icon URL (null if the icon was removed) + * @example https://example.com/new-icon.png + */ + new_value?: string | null; + /** + * @description The handle who made the change. + * @example { + * "id": "550e8400-e29b-41d4-a716-446655440011", + * "handle": "+14155559876", + * "is_me": false, + * "service": "iMessage", + * "status": "active", + * "joined_at": "2025-11-23T17:30:00.000Z", + * "left_at": null + * } + */ + changed_by_handle?: components["schemas"]["ChatHandle"] | null; + /** + * Format: date-time + * @description When the update occurred + * @example 2025-11-23T17:50:00Z + */ + updated_at: string; + }; + /** @description Complete webhook payload for chat.group_icon_updated events */ + ChatGroupIconUpdatedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ChatGroupIconUpdatedEvent"]; + }; + /** + * @description Error details for chat.group_name_update_failed webhook events. + * See [WebhookErrorCode](#/components/schemas/WebhookErrorCode) for the full error code reference. + */ + ChatGroupNameUpdateFailedEvent: { + /** + * @description Chat identifier (UUID) of the group chat + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id: string; + error_code: components["schemas"]["WebhookErrorCode"]; + /** + * Format: date-time + * @description When the failure was detected + * @example 2025-11-23T17:55:00Z + */ + failed_at: string; + }; + /** @description Complete webhook payload for chat.group_name_update_failed events */ + ChatGroupNameUpdateFailedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ChatGroupNameUpdateFailedEvent"]; + }; + /** + * @description Error details for chat.group_icon_update_failed webhook events. + * See [WebhookErrorCode](#/components/schemas/WebhookErrorCode) for the full error code reference. + */ + ChatGroupIconUpdateFailedEvent: { + /** + * @description Chat identifier (UUID) of the group chat + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id: string; + error_code: components["schemas"]["WebhookErrorCode"]; + /** + * Format: date-time + * @description When the failure was detected + * @example 2025-11-23T17:55:00Z + */ + failed_at: string; + }; + /** @description Complete webhook payload for chat.group_icon_update_failed events */ + ChatGroupIconUpdateFailedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ChatGroupIconUpdateFailedEvent"]; + }; + /** @description Payload for chat.created webhook events. Matches GET /v3/chats/{chatId} response. */ + ChatCreatedEvent: { + /** + * Format: uuid + * @description Unique identifier for the chat + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + id: string; + /** + * @description Display name for the chat. Defaults to a comma-separated list of recipient handles. Can be updated for group chats. + * @example +14155551234, +14155559876 + */ + display_name: string | null; + /** + * @description Service type for the chat + * @example iMessage + */ + service?: components["schemas"]["ServiceType"] | null; + /** + * @description List of chat participants with full handle details. Always contains at least two handles (your phone number and the other participant). + * @example [ + * { + * "id": "550e8400-e29b-41d4-a716-446655440010", + * "handle": "+14155551234", + * "is_me": true, + * "service": "iMessage", + * "status": "active", + * "joined_at": "2025-11-23T17:30:00.000Z", + * "left_at": null + * }, + * { + * "id": "550e8400-e29b-41d4-a716-446655440011", + * "handle": "+14155559876", + * "is_me": false, + * "service": "iMessage", + * "status": "active", + * "joined_at": "2025-11-23T17:30:00.000Z", + * "left_at": null + * } + * ] + */ + handles: components["schemas"]["ChatHandle"][]; + /** + * @description Whether this is a group chat + * @default false + * @example true + */ + is_group: boolean; + /** + * Format: date-time + * @description When the chat was created + * @example 2025-11-23T17:30:00Z + */ + created_at: string; + /** + * Format: date-time + * @description When the chat was last updated + * @example 2025-11-23T17:30:00Z + */ + updated_at: string; + }; + /** @description Complete webhook payload for chat.created events */ + ChatCreatedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ChatCreatedEvent"]; + }; + /** @description Payload for chat.typing_indicator.started webhook events */ + ChatTypingIndicatorStartedEvent: { + /** + * Format: uuid + * @description Chat identifier + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id: string; + }; + /** @description Complete webhook payload for chat.typing_indicator.started events */ + ChatTypingIndicatorStartedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ChatTypingIndicatorStartedEvent"]; + }; + /** @description Payload for chat.typing_indicator.stopped webhook events */ + ChatTypingIndicatorStoppedEvent: { + /** + * Format: uuid + * @description Chat identifier + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id: string; + }; + /** @description Complete webhook payload for chat.typing_indicator.stopped events */ + ChatTypingIndicatorStoppedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["ChatTypingIndicatorStoppedEvent"]; + }; + /** @description Payload for phone_number.status_updated webhook events */ + PhoneNumberStatusUpdatedEvent: { + /** + * @description Phone number in E.164 format + * @example +15551234567 + */ + phone_number: string; + /** + * @description The previous service status + * @example ACTIVE + * @enum {string} + */ + previous_status: "ACTIVE" | "FLAGGED"; + /** + * @description The new service status + * @example FLAGGED + * @enum {string} + */ + new_status: "ACTIVE" | "FLAGGED"; + /** + * Format: date-time + * @description When the status change occurred + * @example 2024-01-15T10:30:00Z + */ + changed_at: string; + }; + /** @description Complete webhook payload for phone_number.status_updated events */ + PhoneNumberStatusUpdatedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + /** + * @description The type of event + * @example phone_number.status_updated + */ + event_type?: string; + data?: components["schemas"]["PhoneNumberStatusUpdatedEvent"]; + }; + /** @description Message content nested within webhook events */ + MessagePayload: { + /** + * Format: uuid + * @description Message identifier + * @example 550e8400-e29b-41d4-a716-446655440001 + */ + id?: string; + /** @description Message content parts (text and/or media) */ + parts?: ( + | components["schemas"]["schemas-TextPartResponse"] + | components["schemas"]["schemas-MediaPartResponse"] + )[]; + effect?: components["schemas"]["schemas-MessageEffect"]; + /** @description Reference to the message this is replying to */ + reply_to?: { + /** + * Format: uuid + * @description The ID of the message being replied to + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + message_id?: string; + /** + * Format: int32 + * @description Index of the message part being replied to (0-based) + * @example 0 + */ + part_index?: number; + }; + /** + * @description Whether the message has been delivered + * @example false + */ + is_delivered?: boolean; + /** + * @description Whether the message has been read + * @example false + */ + is_read?: boolean; + /** + * Format: date-time + * @description When the message was sent + * @example 2024-01-15T10:30:05Z + */ + sent_at?: string | null; + /** + * Format: date-time + * @description When the message was delivered + * @example null + */ + delivered_at?: string | null; + /** + * Format: date-time + * @description When the message was read + * @example null + */ + read_at?: string | null; + /** + * Format: date-time + * @description When the message record was created + * @example 2024-01-15T10:30:00Z + */ + created_at?: string; + /** + * Format: date-time + * @description When the message record was last updated + * @example 2024-01-15T10:30:00Z + */ + updated_at?: string; + }; + /** @description Unified payload for message.sent and message.received webhook events (2025-01-01 format) */ + MessageEvent: { + /** + * Format: uuid + * @description Chat identifier + * @example 550e8400-e29b-41d4-a716-446655440000 + */ + chat_id?: string; + /** + * @description Whether this is a group chat + * @example false + */ + is_group?: boolean; + /** + * @description Idempotency key for the message. Used for deduplication of outbound messages. + * @example unique-request-key-12345 + */ + idempotency_key?: string | null; + /** + * @deprecated + * @description DEPRECATED: Use from_handle instead. Phone number or email address of the message sender. + * @example +14155551234 + */ + from?: string; + /** @description The sender of this message as a full handle object */ + from_handle?: components["schemas"]["ChatHandle"]; + message?: components["schemas"]["MessagePayload"]; + /** + * @deprecated + * @description DEPRECATED: Use recipient_handle instead. Our phone number that received the message. Null for sent events. + * @example +14155551234 + */ + recipient_phone?: string | null; + /** @description Our phone number that received the message as a full handle object. Null for sent events. */ + recipient_handle?: components["schemas"]["ChatHandle"] | null; + /** + * @description Whether the message was sent by us (true for sent events, false for received events) + * @example true + */ + is_from_me?: boolean; + /** + * Format: date-time + * @description When the message was received. Null for sent events. + * @example 2025-11-23T17:29:55Z + */ + received_at?: string | null; + /** + * @description The service used to send/receive the message + * @example iMessage + */ + service?: components["schemas"]["ServiceType"] | null; + /** + * @description The service that was requested when sending the message. Null for received events. + * @example iMessage + */ + preferred_service?: components["schemas"]["ServicePreferenceType"] | null; + }; + /** @description Complete webhook payload for message.sent events (2025-01-01 format) */ + MessageSentWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["MessageEvent"]; + }; + /** @description Complete webhook payload for message.received events (2025-01-01 format) */ + MessageReceivedWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["MessageEvent"]; + }; + /** @description Payload for message.read webhook events (2025-01-01 format). Extends MessageEvent with read_at and message_id. */ + MessageReadEvent: components["schemas"]["MessageEvent"] & { + /** + * @description Message identifier (UUID) + * @example 550e8400-e29b-41d4-a716-446655440001 + */ + message_id?: string; + /** + * Format: date-time + * @description When the message was read + * @example 2025-11-23T17:35:00Z + */ + read_at: string; + }; + /** @description Complete webhook payload for message.read events (2025-01-01 format) */ + MessageReadWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["MessageReadEvent"]; + }; + /** @description Payload for message.delivered webhook events (2025-01-01 format). Extends MessageEvent with delivered_at and message_id. */ + MessageDeliveredEvent: components["schemas"]["MessageEvent"] & { + /** + * @description Message identifier (UUID) + * @example 550e8400-e29b-41d4-a716-446655440001 + */ + message_id?: string; + /** + * Format: date-time + * @description When the message was delivered to the recipient's device + * @example 2025-11-23T17:32:00Z + */ + delivered_at: string; + }; + /** @description Complete webhook payload for message.delivered events (2025-01-01 format) */ + MessageDeliveredWebhook: components["schemas"]["WebhookEnvelopeBase"] & { + data?: components["schemas"]["MessageDeliveredEvent"]; + }; + }; +} +export type $defs = Record; +export interface operations { + addParticipant: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddParticipantRequest"]; + }; + }; + responses: { + /** @description Participant addition queued successfully */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example accepted */ + status?: string; + trace_id?: string; + /** @example Participant addition queued */ + message?: string; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + checkImessageCapability: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "address": "+15551234567" + * } + */ + "application/json": components["schemas"]["HandleCheckRequest"]; + }; + }; + responses: { + /** @description iMessage capability check completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "address": "+15551234567", + * "available": true + * } + */ + "application/json": components["schemas"]["HandleCheckResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 429: components["responses"]["RateLimited"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + checkRcsCapability: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "address": "+15551234567" + * } + */ + "application/json": components["schemas"]["HandleCheckRequest"]; + }; + }; + responses: { + /** @description RCS capability check completed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "address": "+15551234567", + * "available": true + * } + */ + "application/json": components["schemas"]["HandleCheckResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 429: components["responses"]["RateLimited"]; + 500: components["responses"]["InternalServerError"]; + 503: components["responses"]["ServiceUnavailable"]; + }; + }; + createChat: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateChatRequest"]; + }; + }; + responses: { + /** @description Chat created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CreateChatResult"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + createWebhookSubscription: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateWebhookSubscriptionRequest"]; + }; + }; + responses: { + /** @description Webhook subscription created successfully. The signing_secret is only returned in this response - store it securely. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookSubscriptionCreatedResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + deleteMessage: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the message to delete */ + messageId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DeleteMessageRequest"]; + }; + }; + responses: { + /** @description Message deleted successfully (no content returned) */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + deleteWebhookSubscription: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the webhook subscription */ + subscriptionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Webhook subscription deleted successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + editMessage: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the message to edit */ + messageId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EditMessageRequest"]; + }; + }; + responses: { + /** @description Message updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Message"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getAttachment: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the attachment */ + attachmentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Attachment found and returned successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "filename": "photo.jpg", + * "content_type": "image/jpeg", + * "size_bytes": 1024000, + * "status": "complete", + * "download_url": "https://cdn.linqapp.com/attachments/550e8400-e29b-41d4-a716-446655440000/photo.jpg", + * "created_at": "2024-01-15T10:30:00Z" + * } + */ + "application/json": components["schemas"]["Attachment"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getChat: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Chat found and returned successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Chat"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getMessage: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the message to retrieve */ + messageId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Message found and returned successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Message"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getMessages: { + parameters: { + query?: { + /** @description Pagination cursor from previous next_cursor response */ + cursor?: string; + /** @description Maximum number of messages to return */ + limit?: number; + }; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Messages retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetMessagesResult"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getMessageThread: { + parameters: { + query?: { + /** @description Pagination cursor from previous next_cursor response */ + cursor?: string; + /** @description Maximum number of messages to return */ + limit?: number; + /** @description Sort order for messages (asc = oldest first, desc = newest first) */ + order?: "asc" | "desc"; + }; + header?: never; + path: { + /** @description ID of any message in the thread (can be originator or any reply) */ + messageId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Thread retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetThreadResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getMyCards: { + parameters: { + query?: { + /** @description E.164 phone number to filter by. If omitted, all my cards for the partner are returned. */ + phone_number?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description My card(s) returned */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "my_cards": [ + * { + * "phone_number": "+15551234567", + * "first_name": "John", + * "last_name": "Doe", + * "image_url": "https://cdn.linqapp.com/my-card/example.jpg", + * "is_active": true + * } + * ] + * } + */ + "application/json": components["schemas"]["GetMyCardResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + getWebhookSubscription: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the webhook subscription */ + subscriptionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Webhook subscription found and returned successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookSubscriptionResponse"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + listChats: { + parameters: { + query: { + /** + * @description Phone number to filter chats by. Returns all chats made from this phone number. + * Must be in E.164 format (e.g., `+13343284472`). The `+` is automatically URL-encoded by HTTP clients. + */ + from: string; + /** @description Maximum number of chats to return per page */ + limit?: number; + /** + * @description Pagination cursor from the previous response's `next_cursor` field. + * Omit this parameter for the first page of results. + */ + cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of chats retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListChatsResult"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + listPhoneNumbers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Phone numbers retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "phone_numbers": [ + * { + * "id": "550e8400-e29b-41d4-a716-446655440000", + * "phone_number": "+12025551234" + * } + * ] + * } + */ + "application/json": components["schemas"]["ListPhoneNumbersResultV2"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + listPhoneNumbersDeprecated: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Phone numbers retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListPhoneNumbersResult"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + listWebhookEvents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of available webhook event types */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "events": [ + * "message.sent", + * "message.received", + * "message.read", + * "message.delivered", + * "message.failed", + * "reaction.added", + * "reaction.removed", + * "participant.added", + * "participant.removed", + * "chat.created", + * "chat.group_name_updated", + * "chat.group_icon_updated", + * "chat.group_name_update_failed", + * "chat.group_icon_update_failed", + * "chat.typing_indicator.started", + * "chat.typing_indicator.stopped" + * ], + * "doc_url": "https://apidocs.linqapp.com/documentation/webhook-events" + * } + */ + "application/json": components["schemas"]["WebhookEventsResult"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + listWebhookSubscriptions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of webhook subscriptions retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ListWebhookSubscriptionsResult"]; + }; + }; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + markChatAsRead: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Chat marked as read successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + removeParticipant: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RemoveParticipantRequest"]; + }; + }; + responses: { + /** @description Participant removal queued successfully */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example accepted */ + status?: string; + trace_id?: string; + /** @example Participant removal queued */ + message?: string; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + requestUpload: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RequestUploadRequest"]; + }; + }; + responses: { + /** @description Upload URL generated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestUploadResult"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + sendMessageToChat: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendMessageToChatRequest"]; + }; + }; + responses: { + /** @description Message accepted for delivery */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SendMessageResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + sendReaction: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the message to react to */ + messageId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SendReactionRequest"]; + }; + }; + responses: { + /** @description Reaction queued successfully */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example accepted */ + status?: string; + trace_id?: string; + /** @example Reaction processed */ + message?: string; + }; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + sendVoiceMemoToChat: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "voice_memo_url": "https://example.com/voice-memo.m4a" + * } + */ + "application/json": components["schemas"]["SendVoiceMemoToChatRequest"]; + }; + }; + responses: { + /** @description Voice memo accepted for delivery */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SendVoiceMemoToChatResult"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + /** @description Voice memo file too large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": { + * "status": 413, + * "code": 5001, + * "message": "Voice memo file too large - maximum size is 10MB" + * }, + * "success": false + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + 422: components["responses"]["UnprocessableEntity"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + setupMyCard: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "phone_number": "+15551234567", + * "first_name": "John", + * "last_name": "Doe", + * "image_url": "https://cdn.linqapp.com/my-card/example.jpg" + * } + */ + "application/json": components["schemas"]["SetMyCardRequest"]; + }; + }; + responses: { + /** @description My card created/updated and sync attempted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "phone_number": "+15551234567", + * "first_name": "John", + * "last_name": "Doe", + * "image_url": "https://cdn.linqapp.com/my-card/example.jpg", + * "is_active": true + * } + */ + "application/json": components["schemas"]["SetMyCardResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + shareContactWithChat: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Contact shared successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + shareMyCard: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description My card shared successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + startTyping: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Typing indicator started successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + stopTyping: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Typing indicator stopped successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + updateChat: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the chat */ + chatId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateChatRequest"]; + }; + }; + responses: { + /** @description Chat updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Chat"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + updateWebhookSubscription: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Unique identifier of the webhook subscription */ + subscriptionId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateWebhookSubscriptionRequest"]; + }; + }; + responses: { + /** @description Webhook subscription updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookSubscriptionResponse"]; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthorized"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 500: components["responses"]["InternalServerError"]; + }; + }; + webhookChatCreatedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatCreatedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatCreatedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatCreatedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatGroupIconUpdatedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatGroupIconUpdatedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatGroupIconUpdatedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatGroupIconUpdatedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatGroupIconUpdateFailedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatGroupIconUpdateFailedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatGroupIconUpdateFailedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatGroupIconUpdateFailedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatGroupNameUpdatedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatGroupNameUpdatedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatGroupNameUpdatedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatGroupNameUpdatedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatGroupNameUpdateFailedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatGroupNameUpdateFailedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatGroupNameUpdateFailedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatGroupNameUpdateFailedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatTypingIndicatorStartedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatTypingIndicatorStartedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatTypingIndicatorStartedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatTypingIndicatorStartedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatTypingIndicatorStoppedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatTypingIndicatorStoppedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookChatTypingIndicatorStoppedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChatTypingIndicatorStoppedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookMessageDeliveredV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "api_version": "v3", + * "webhook_version": "2025-01-01", + * "event_type": "message.delivered", + * "event_id": "67c4ad39-e9b0-47f6-82f8-64bdd8ceafa6", + * "created_at": "2026-02-05T19:52:22.593689073Z", + * "trace_id": "abde7f6248fba00f97e8c7dc4782d7e0", + * "partner_id": "your-partner-id", + * "data": { + * "chat_id": "0c961e93-e7bf-4db2-bf7b-ea06826bcab4", + * "from": "+12025551234", + * "from_handle": { + * "handle": "+12025551234", + * "id": "8d79532a-f529-4244-a5cf-d443de051434", + * "is_me": true, + * "joined_at": "2026-01-21T21:59:45.191571Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * }, + * "idempotency_key": null, + * "is_from_me": true, + * "is_group": false, + * "message": { + * "created_at": "2026-02-05T19:52:17.041183Z", + * "delivered_at": "2026-02-05T19:52:22.291Z", + * "id": "347d62c2-2170-4754-8d30-c76d0c727d96", + * "is_delivered": true, + * "is_read": false, + * "parts": [ + * { + * "type": "text", + * "value": "Hello world!" + * }, + * { + * "filename": "photo.gif", + * "id": "b9ed828d-dbac-431f-889a-23f276384389", + * "mime_type": "image/gif", + * "size_bytes": 2776819, + * "type": "media", + * "url": "https://cdn.linqapp.com/attachments/example/photo.gif" + * } + * ], + * "read_at": null, + * "sent_at": "2026-02-05T19:52:17.219Z", + * "updated_at": "2026-02-05T19:52:22.571Z" + * }, + * "preferred_service": null, + * "received_at": null, + * "recipient_handle": null, + * "recipient_phone": null, + * "service": "iMessage" + * } + * } + */ + "application/json": components["schemas"]["MessageDeliveredWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookMessageDeliveredV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "api_version": "v3", + * "webhook_version": "2026-02-03", + * "event_type": "message.delivered", + * "event_id": "67c4ad39-e9b0-47f6-82f8-64bdd8ceafa6", + * "created_at": "2026-02-05T19:52:22.593689073Z", + * "trace_id": "abde7f6248fba00f97e8c7dc4782d7e0", + * "partner_id": "your-partner-id", + * "data": { + * "chat": { + * "id": "0c961e93-e7bf-4db2-bf7b-ea06826bcab4", + * "is_group": false, + * "owner_handle": { + * "handle": "+12025551234", + * "id": "8d79532a-f529-4244-a5cf-d443de051434", + * "is_me": true, + * "joined_at": "2026-01-21T21:59:45.191571Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * } + * }, + * "id": "347d62c2-2170-4754-8d30-c76d0c727d96", + * "idempotency_key": null, + * "direction": "outbound", + * "sender_handle": { + * "handle": "+12025551234", + * "id": "8d79532a-f529-4244-a5cf-d443de051434", + * "is_me": true, + * "joined_at": "2026-01-21T21:59:45.191571Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * }, + * "parts": [ + * { + * "type": "text", + * "value": "Hello world!" + * }, + * { + * "filename": "photo.gif", + * "id": "b9ed828d-dbac-431f-889a-23f276384389", + * "mime_type": "image/gif", + * "size_bytes": 2776819, + * "type": "media", + * "url": "https://cdn.linqapp.com/attachments/b9ed828d/photo.gif?signature=..." + * } + * ], + * "effect": null, + * "sent_at": "2026-02-05T19:52:17.219Z", + * "delivered_at": "2026-02-05T19:52:22.291Z", + * "read_at": null, + * "service": "iMessage", + * "preferred_service": null + * } + * } + */ + "application/json": components["schemas"]["MessageDeliveredWebhookV2"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookMessageFailedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MessageFailedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookMessageFailedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MessageFailedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookMessageReadV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "api_version": "v3", + * "webhook_version": "2025-01-01", + * "event_type": "message.read", + * "event_id": "8fd42065-b998-482a-93b3-da855f8dad17", + * "created_at": "2026-02-05T19:13:58.833366566Z", + * "trace_id": "cbb93c08fa1a3f3c4c2efc161d67f36d", + * "partner_id": "your-partner-id", + * "data": { + * "chat_id": "24e33345-e6cf-4f50-9d35-1d7fde8c9818", + * "from": "+12025551234", + * "from_handle": { + * "handle": "+12025551234", + * "id": "d31678e9-0442-48fd-b7ed-c898d245dd15", + * "is_me": true, + * "joined_at": "2026-01-18T03:38:41.442254Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * }, + * "idempotency_key": null, + * "is_from_me": true, + * "is_group": false, + * "message": { + * "created_at": "2026-02-05T19:13:57.612Z", + * "delivered_at": "2026-02-05T19:13:57.948Z", + * "id": "dc6d3f68-90df-48f0-a504-e65f239a383c", + * "is_delivered": true, + * "is_read": true, + * "parts": [ + * { + * "type": "text", + * "value": "Hello world!" + * } + * ], + * "read_at": "2026-02-05T19:13:58.177Z", + * "sent_at": "2026-02-05T19:13:57.814Z", + * "updated_at": "2026-02-05T19:13:58.811Z" + * }, + * "preferred_service": null, + * "received_at": null, + * "recipient_handle": null, + * "recipient_phone": null, + * "service": "iMessage" + * } + * } + */ + "application/json": components["schemas"]["MessageReadWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookMessageReadV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "api_version": "v3", + * "webhook_version": "2026-02-03", + * "event_type": "message.read", + * "event_id": "8fd42065-b998-482a-93b3-da855f8dad17", + * "created_at": "2026-02-05T19:13:58.833366566Z", + * "trace_id": "cbb93c08fa1a3f3c4c2efc161d67f36d", + * "partner_id": "your-partner-id", + * "data": { + * "chat": { + * "id": "24e33345-e6cf-4f50-9d35-1d7fde8c9818", + * "is_group": false, + * "owner_handle": { + * "handle": "+12025551234", + * "id": "d31678e9-0442-48fd-b7ed-c898d245dd15", + * "is_me": true, + * "joined_at": "2026-01-18T03:38:41.442254Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * } + * }, + * "id": "dc6d3f68-90df-48f0-a504-e65f239a383c", + * "idempotency_key": null, + * "direction": "outbound", + * "sender_handle": { + * "handle": "+12025551234", + * "id": "d31678e9-0442-48fd-b7ed-c898d245dd15", + * "is_me": true, + * "joined_at": "2026-01-18T03:38:41.442254Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * }, + * "parts": [ + * { + * "type": "text", + * "value": "Hello world!" + * } + * ], + * "effect": null, + * "sent_at": "2026-02-05T19:13:57.814Z", + * "delivered_at": "2026-02-05T19:13:57.948Z", + * "read_at": "2026-02-05T19:13:58.177Z", + * "service": "iMessage", + * "preferred_service": null + * } + * } + */ + "application/json": components["schemas"]["MessageReadWebhookV2"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookMessageReceivedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "api_version": "v3", + * "webhook_version": "2025-01-01", + * "event_type": "message.received", + * "event_id": "2915e81c-5068-4796-ace2-21d2c94ad298", + * "created_at": "2026-02-05T19:31:13.736444093Z", + * "trace_id": "8af9171a45022df2eb74ba4e4c83be0f", + * "partner_id": "your-partner-id", + * "data": { + * "chat_id": "8f392755-6865-4b18-880a-227f9d8b458f", + * "from": "+12025559876", + * "from_handle": { + * "handle": "+12025559876", + * "id": "e604375a-5913-483a-8278-c631e8f0ffda", + * "is_me": false, + * "joined_at": "2026-01-04T05:48:51.321469Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * }, + * "idempotency_key": null, + * "is_from_me": false, + * "is_group": false, + * "message": { + * "created_at": "2026-02-05T19:31:12.892Z", + * "delivered_at": null, + * "id": "89e3566e-1d13-49e5-a8ee-48490d5bfeb7", + * "is_delivered": false, + * "is_read": false, + * "parts": [ + * { + * "type": "text", + * "value": "Hello!" + * } + * ], + * "read_at": null, + * "sent_at": "2026-02-05T19:31:13.074Z", + * "updated_at": "2026-02-05T19:31:13.712Z" + * }, + * "preferred_service": null, + * "received_at": "2026-02-05T19:31:13.074Z", + * "recipient_handle": { + * "handle": "+12025551234", + * "id": "6d6c617f-187a-4dcd-a0d5-988347a8c092", + * "is_me": true, + * "joined_at": "2026-01-04T05:48:51.321469Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * }, + * "recipient_phone": null, + * "service": "iMessage" + * } + * } + */ + "application/json": components["schemas"]["MessageReceivedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookMessageReceivedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "api_version": "v3", + * "webhook_version": "2026-02-03", + * "event_type": "message.received", + * "event_id": "2915e81c-5068-4796-ace2-21d2c94ad298", + * "created_at": "2026-02-05T19:31:13.736444093Z", + * "trace_id": "8af9171a45022df2eb74ba4e4c83be0f", + * "partner_id": "your-partner-id", + * "data": { + * "chat": { + * "id": "8f392755-6865-4b18-880a-227f9d8b458f", + * "is_group": false, + * "owner_handle": { + * "handle": "+12025551234", + * "id": "6d6c617f-187a-4dcd-a0d5-988347a8c092", + * "is_me": true, + * "joined_at": "2026-01-04T05:48:51.321469Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * } + * }, + * "id": "89e3566e-1d13-49e5-a8ee-48490d5bfeb7", + * "direction": "inbound", + * "sender_handle": { + * "handle": "+12025559876", + * "id": "e604375a-5913-483a-8278-c631e8f0ffda", + * "is_me": false, + * "joined_at": "2026-01-04T05:48:51.321469Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * }, + * "parts": [ + * { + * "type": "text", + * "value": "Hello!" + * } + * ], + * "effect": null, + * "reply_to": null, + * "sent_at": "2026-02-05T19:31:13.074Z", + * "delivered_at": null, + * "read_at": null, + * "service": "iMessage" + * } + * } + */ + "application/json": components["schemas"]["MessageReceivedWebhookV2"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookMessageSentV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "api_version": "v3", + * "webhook_version": "2025-01-01", + * "event_type": "message.sent", + * "event_id": "e20feb41-7f67-43f0-89c8-a985cff3b568", + * "created_at": "2026-02-05T19:52:18.101373886Z", + * "trace_id": "2eff5df5c6f688733c007523c4d61cd9", + * "partner_id": "your-partner-id", + * "data": { + * "chat_id": "0c961e93-e7bf-4db2-bf7b-ea06826bcab4", + * "from": "+12025551234", + * "from_handle": { + * "handle": "+12025551234", + * "id": "8d79532a-f529-4244-a5cf-d443de051434", + * "is_me": true, + * "joined_at": "2026-01-21T21:59:45.191571Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * }, + * "idempotency_key": null, + * "is_from_me": true, + * "is_group": false, + * "message": { + * "created_at": "2026-02-05T19:52:17.041183Z", + * "delivered_at": null, + * "id": "347d62c2-2170-4754-8d30-c76d0c727d96", + * "is_delivered": false, + * "is_read": false, + * "parts": [ + * { + * "type": "text", + * "value": "Hello from Linq!" + * }, + * { + * "filename": "photo.gif", + * "id": "f13dda7d-ecac-49eb-b3fe-16fe286abf19", + * "mime_type": "image/gif", + * "size_bytes": 2776819, + * "type": "media", + * "url": "https://cdn.linqapp.com/attachments/example/photo.gif" + * } + * ], + * "read_at": null, + * "sent_at": "2026-02-05T19:52:17.219Z", + * "updated_at": "2026-02-05T19:52:18.084038Z" + * }, + * "preferred_service": null, + * "received_at": null, + * "recipient_handle": null, + * "recipient_phone": null, + * "service": "iMessage" + * } + * } + */ + "application/json": components["schemas"]["MessageSentWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookMessageSentV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "api_version": "v3", + * "webhook_version": "2026-02-03", + * "event_type": "message.sent", + * "event_id": "e20feb41-7f67-43f0-89c8-a985cff3b568", + * "created_at": "2026-02-05T19:52:18.101373886Z", + * "trace_id": "2eff5df5c6f688733c007523c4d61cd9", + * "partner_id": "your-partner-id", + * "data": { + * "chat": { + * "id": "0c961e93-e7bf-4db2-bf7b-ea06826bcab4", + * "is_group": false, + * "owner_handle": { + * "handle": "+12025551234", + * "id": "8d79532a-f529-4244-a5cf-d443de051434", + * "is_me": true, + * "joined_at": "2026-01-21T21:59:45.191571Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * } + * }, + * "id": "347d62c2-2170-4754-8d30-c76d0c727d96", + * "idempotency_key": null, + * "direction": "outbound", + * "sender_handle": { + * "handle": "+12025551234", + * "id": "8d79532a-f529-4244-a5cf-d443de051434", + * "is_me": true, + * "joined_at": "2026-01-21T21:59:45.191571Z", + * "left_at": null, + * "service": "iMessage", + * "status": "active" + * }, + * "parts": [ + * { + * "type": "text", + * "value": "Hello from Linq!" + * }, + * { + * "filename": "photo.jpg", + * "id": "f13dda7d-ecac-49eb-b3fe-16fe286abf19", + * "mime_type": "image/jpeg", + * "size_bytes": 245678, + * "type": "media", + * "url": "https://cdn.linqapp.com/attachments/a1b2c3d4/photo.jpg?signature=..." + * } + * ], + * "effect": null, + * "sent_at": "2026-02-05T19:52:17.219Z", + * "delivered_at": null, + * "read_at": null, + * "service": "iMessage", + * "preferred_service": null + * } + * } + */ + "application/json": components["schemas"]["MessageSentWebhookV2"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookParticipantAddedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ParticipantAddedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookParticipantAddedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ParticipantAddedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookParticipantRemovedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ParticipantRemovedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookParticipantRemovedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ParticipantRemovedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookPhoneNumberStatusUpdatedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "api_version": "v3", + * "webhook_version": "2025-01-01", + * "event_type": "phone_number.status_updated", + * "event_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + * "created_at": "2026-02-18T18:35:05.363Z", + * "trace_id": "b66e67c5c6b2c20e41d53c51698db27a", + * "partner_id": "your-partner-id", + * "data": { + * "phone_number": "+12025551234", + * "previous_status": "ACTIVE", + * "new_status": "FLAGGED", + * "changed_at": "2026-02-18T18:35:05.000Z" + * } + * } + */ + "application/json": components["schemas"]["PhoneNumberStatusUpdatedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookPhoneNumberStatusUpdatedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + /** + * @example { + * "api_version": "v3", + * "webhook_version": "2026-02-03", + * "event_type": "phone_number.status_updated", + * "event_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + * "created_at": "2026-02-18T18:35:05.363Z", + * "trace_id": "b66e67c5c6b2c20e41d53c51698db27a", + * "partner_id": "your-partner-id", + * "data": { + * "phone_number": "+12025551234", + * "previous_status": "ACTIVE", + * "new_status": "FLAGGED", + * "changed_at": "2026-02-18T18:35:05.000Z" + * } + * } + */ + "application/json": components["schemas"]["PhoneNumberStatusUpdatedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookReactionAddedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReactionAddedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookReactionAddedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReactionAddedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookReactionRemovedV2025: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReactionRemovedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + webhookReactionRemovedV2026: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReactionRemovedWebhook"]; + }; + }; + responses: { + /** @description Webhook received successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} diff --git a/packages/adapter-linq/src/types.ts b/packages/adapter-linq/src/types.ts new file mode 100644 index 00000000..ea8f5fe8 --- /dev/null +++ b/packages/adapter-linq/src/types.ts @@ -0,0 +1,57 @@ +import type { Logger } from "chat"; +import type { components } from "./schema"; + +export interface LinqAdapterConfig { + apiToken?: string; + logger?: Logger; + phoneNumber?: string; + signingSecret?: string; + userName?: string; +} + +export interface LinqThreadId { + chatId: string; +} + +export type LinqWebhookEventType = + | "message.received" + | "message.sent" + | "message.delivered" + | "message.read" + | "message.failed" + | "message.edited" + | "reaction.added" + | "reaction.removed" + | "chat.created" + | "chat.typing_indicator.started" + | "chat.typing_indicator.stopped" + | "participant.added" + | "participant.removed"; + +export interface LinqWebhookPayload { + api_version: string; + created_at: string; + data: unknown; + event_id: string; + event_type: LinqWebhookEventType; + partner_id: string; + trace_id: string; + webhook_version: string; +} + +export type LinqMessage = components["schemas"]["Message"]; + +export type LinqChat = components["schemas"]["Chat"]; + +export type LinqChatHandle = components["schemas"]["ChatHandle"]; + +export type LinqReactionType = components["schemas"]["ReactionType"]; + +export type LinqMessageEventV2 = components["schemas"]["MessageEventV2"]; + +export type LinqReactionEventBase = components["schemas"]["ReactionEventBase"]; + +export type LinqMessageFailedEvent = + components["schemas"]["MessageFailedEvent"]; + +export type LinqRawMessage = LinqMessage; diff --git a/packages/adapter-linq/tsconfig.json b/packages/adapter-linq/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-linq/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "strictNullChecks": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/adapter-linq/tsup.config.ts b/packages/adapter-linq/tsup.config.ts new file mode 100644 index 00000000..4753084b --- /dev/null +++ b/packages/adapter-linq/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + external: ["openapi-fetch"], +}); diff --git a/packages/adapter-linq/vitest.config.ts b/packages/adapter-linq/vitest.config.ts new file mode 100644 index 00000000..edc2d946 --- /dev/null +++ b/packages/adapter-linq/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75df8373..b3f3222b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: '@chat-adapter/linear': specifier: workspace:* version: link:../../packages/adapter-linear + '@chat-adapter/linq': + specifier: workspace:* + version: link:../../packages/adapter-linq '@chat-adapter/slack': specifier: workspace:* version: link:../../packages/adapter-slack @@ -348,6 +351,34 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-linq: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + chat: + specifier: workspace:* + version: link:../chat + openapi-fetch: + specifier: ^0.14.0 + version: 0.14.1 + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + openapi-typescript: + specifier: ^7.8.0 + version: 7.13.0(typescript@5.9.3) + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-shared: dependencies: chat: @@ -723,6 +754,10 @@ packages: resolution: {integrity: sha512-lvuAwsDpPDE/jSuVQOBMpLbXuVuLsPNRwWCyK3/6bPlBk0fGWegqoZ0qjZclMWyQ2JNvIY3vHY7hoFmFmFQcOw==} engines: {node: '>=16'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -2382,6 +2417,16 @@ packages: peerDependencies: '@redis/client': ^5.11.0 + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.10': + resolution: {integrity: sha512-XCBR/9WHJ0cpezuunHMZjuFMl4KqUo7eiFwzrQrvm7lTXt0EBd3No8UY+9OyzXpDfreGEMMtxmaLZ+ksVw378g==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rollup/rollup-android-arm-eabi@4.54.0': resolution: {integrity: sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==} cpu: [arm] @@ -3205,6 +3250,9 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -3279,6 +3327,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -4095,6 +4146,10 @@ packages: imsc@1.1.5: resolution: {integrity: sha512-V8je+CGkcvGhgl2C1GlhqFFiUOIEdwXbXLiu1Fcubvvbo+g9inauqT3l0pNYXGoLPBj3jxtZz9t+wCopMkwadQ==} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -4214,6 +4269,10 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -4231,6 +4290,9 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -4688,6 +4750,10 @@ packages: resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} engines: {node: 18 || 20 || >=22} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -4830,6 +4896,18 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + openssl-wrapper@0.3.4: resolution: {integrity: sha512-iITsrx6Ho8V3/2OVtmZzzX8wQaKAaFXEJQdzoPUZDtyf5jWFlqo+h+OhGT4TATQ47f9ACKHua8nw7Qoy85aeKQ==} @@ -4887,6 +4965,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -4981,6 +5063,10 @@ packages: player.style@0.3.1: resolution: {integrity: sha512-z/T8hJGaTkHT9vdXgWdOgF37eB1FV7/j52VXQZ2lgEhpru9oT8TaUWIxp6GoxTnhPBM4X6nSbpkAHrT7UTjUKg==} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -5228,6 +5314,10 @@ packages: remend@1.2.1: resolution: {integrity: sha512-4wC12bgXsfKAjF1ewwkNIQz5sqewz/z1xgIgjEMb3r1pEytQ37F0Cm6i+OhbTWEvguJD7lhOUJhK5fSasw9f0w==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -5450,6 +5540,10 @@ packages: super-media-element@1.4.2: resolution: {integrity: sha512-9pP/CVNp4NF2MNlRzLwQkjiTgKKe9WYXrLh9+8QokWmMxz+zt2mf1utkWLco26IuA3AfVcTb//qtlTIjY3VHxA==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -5603,6 +5697,10 @@ packages: twitch-video-element@0.1.6: resolution: {integrity: sha512-X7l8gy+DEFKJ/EztUwaVnAYwQN9fUJxPkOVJj2sE62sGvGU4DNLyvmOsmVulM+8Plc5dMg6hYIMNRAPaH+39Uw==} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -5672,6 +5770,9 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + url-template@2.0.8: resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} @@ -5910,6 +6011,13 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + youtube-video-element@1.8.1: resolution: {integrity: sha512-+5UuAGaj+5AnBf39huLVpy/4dLtR0rmJP1TxOHVZ81bac4ZHFpTtQ4Dz2FAn2GPnfXISezvUEaQoAdFW4hH9Xg==} @@ -6076,6 +6184,12 @@ snapshots: jsonwebtoken: 9.0.3 uuid: 8.3.2 + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -7740,6 +7854,29 @@ snapshots: dependencies: '@redis/client': 5.11.0 + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.10(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rollup/rollup-android-arm-eabi@4.54.0': optional: true @@ -8216,7 +8353,7 @@ snapshots: '@typespec/ts-http-runtime@0.3.2': dependencies: http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -8475,7 +8612,7 @@ snapshots: botframework-schema: 4.23.3 buffer: 6.0.3 cross-fetch: 4.1.0 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) jsonwebtoken: 9.0.3 node-fetch: 2.7.0 openssl-wrapper: 0.3.4 @@ -8555,6 +8692,8 @@ snapshots: chai@6.2.2: {} + change-case@5.4.4: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -8625,6 +8764,8 @@ snapshots: color-name@1.1.4: {} + colorette@1.4.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -8892,9 +9033,11 @@ snapshots: dayjs@1.11.19: {} - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decode-named-character-reference@1.2.0: dependencies: @@ -9343,7 +9486,7 @@ snapshots: gaxios@7.1.3: dependencies: extend: 3.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) node-fetch: 3.3.2 rimraf: 5.0.10 transitivePeerDependencies: @@ -9600,14 +9743,14 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -9633,6 +9776,8 @@ snapshots: dependencies: sax: 1.2.1 + index-to-position@1.2.0: {} + inline-style-parser@0.2.7: {} internmap@1.0.1: {} @@ -9643,7 +9788,7 @@ snapshots: dependencies: '@ioredis/commands': 1.4.0 cluster-key-slot: 1.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -9726,6 +9871,8 @@ snapshots: joycon@3.1.1: {} + js-levenshtein@1.1.6: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -9743,6 +9890,8 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-schema-traverse@1.0.0: {} + json-schema@0.4.0: {} json-with-bigint@3.5.3: {} @@ -10434,7 +10583,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) decode-named-character-reference: 1.2.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -10468,6 +10617,10 @@ snapshots: dependencies: brace-expansion: 5.0.2 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -10590,6 +10743,22 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.10(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + openssl-wrapper@0.3.4: {} outdent@0.5.0: {} @@ -10667,6 +10836,12 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -10752,6 +10927,8 @@ snapshots: transitivePeerDependencies: - react + pluralize@8.0.0: {} + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -11102,6 +11279,8 @@ snapshots: remend@1.2.1: {} + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -11371,6 +11550,8 @@ snapshots: super-media-element@1.4.2: {} + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -11442,7 +11623,7 @@ snapshots: cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) esbuild: 0.27.2 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -11502,6 +11683,8 @@ snapshots: twitch-video-element@0.1.6: {} + type-fest@4.41.0: {} + typescript@5.9.3: {} ua-parser-js@1.0.41: {} @@ -11577,6 +11760,8 @@ snapshots: universalify@2.0.1: {} + uri-js-replace@1.0.1: {} + url-template@2.0.8: {} use-callback-ref@1.3.3(@types/react@19.2.7)(react@19.2.3): @@ -11762,6 +11947,10 @@ snapshots: xtend@4.0.2: {} + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + youtube-video-element@1.8.1: {} zod@3.25.76: {} diff --git a/turbo.json b/turbo.json index 98bf25f6..26458c7b 100644 --- a/turbo.json +++ b/turbo.json @@ -11,7 +11,10 @@ "GOOGLE_CHAT_PUBSUB_TOPIC", "GOOGLE_CHAT_IMPERSONATE_USER", "BOT_USERNAME", - "REDIS_URL" + "REDIS_URL", + "LINQ_API_TOKEN", + "LINQ_SIGNING_SECRET", + "LINQ_PHONE_NUMBER" ], "tasks": { "build": { diff --git a/vitest.config.ts b/vitest.config.ts index 9a11f035..5c2830d0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ "packages/adapter-teams", "packages/state-ioredis", "packages/state-memory", + "packages/adapter-linq", "packages/state-redis", ], },