# Slide API: complete integration reference

Everything needed to integrate Slide, in one file. Written to be pasted whole into an AI coding
assistant as a prompt, and to be read directly by a human.

Also available at `https://slide.synquic.com/api-reference.md`.

---

1. Copy everything below the `--- PROMPT STARTS HERE ---` line.
2. Paste it into your assistant, in the repo you want the integration to live in.
3. Fill in the four `<<< >>>` placeholders at the top of the prompt. Leave the rest alone.
4. Send it.

Every endpoint, scope, header, retry delay and error shape below was read out of the running
source, not from memory. If you change the API, update this file in the same commit.

---

--- PROMPT STARTS HERE ---

# Task: integrate Slide into this codebase

You are integrating **Slide** (`https://slide.synquic.com`), a multi-channel customer messaging
and automation platform, into the repository you are currently in.

Fill these in before you start. If any is blank, ask me once, then proceed.

```
<<< WHAT I WANT TO DO:        e.g. "send order confirmations over WhatsApp with SMS fallback" >>>
<<< LANGUAGE / FRAMEWORK:     e.g. "TypeScript, Next.js App Router, Node 20" >>>
<<< WHERE SECRETS LIVE:       e.g. ".env.local, read through a typed config module" >>>
<<< DO I NEED WEBHOOKS:       yes / no  (yes if you must react to inbound events) >>>
```

## Ground rules for you, the assistant

- Read this whole brief before writing any code. It contains the complete API surface.
- Do **not** invent endpoints, fields, scopes or event names. If something you need is not in
  this brief, say so and stop, rather than guessing a plausible-looking route.
- Never hardcode an API key. Read it from the environment, always.
- Every external call gets a timeout and an error path. There are no fire-and-forget sends.
- Prefer the official SDK over raw `fetch` unless I told you otherwise.
- When you finish, work through the **Definition of done** checklist at the bottom and report
  each item honestly, including anything you could not verify.

---

## 1. Platform basics

| Property | Value |
|---|---|
| Base URL | `https://api.slide.cx/v1` |
| Protocol | HTTPS only |
| Format | JSON (`Content-Type: application/json` on every request with a body) |
| API version | `v1` |
| Auth scheme | `Authorization: Bearer sk_live_...` |
| Timestamps | ISO 8601, UTC, e.g. `2026-03-24T10:00:00.000Z` |
| List envelope | `{ data: [...], meta: { total, page, limit, totalPages } }` |

An API key is created in the Slide dashboard at **`/dashboard/api-keys`**. The key is shown once.
It carries the scopes chosen when it was created. Its scopes and its IP allowlist can be changed later
with **Edit** on the key in the dashboard, without re-issuing the key; a change applies on the key's
next request.

Two optional hardening controls exist per key and are worth turning on:

- `rateLimitPerMinute`: the per-minute request ceiling for that key.
- `ipWhitelistEnabled` + `allowedIps`: IPv4 addresses or CIDR ranges (up to 20) that may use the key.
  Set the list when creating the key or later with Edit; an empty list allows any IP. Requests from any
  other source IP are rejected `403`, and the rejecting response names the offending IP so the list
  can be corrected. The source IP is the address Slide's edge saw; an `X-Forwarded-For` value you send
  yourself is ignored.

---

## 2. Rate limits

Limits are applied **per key and per client IP**, so two servers sharing one key each get their
own 60 second window.

| Header | Meaning |
|---|---|
| `X-RateLimit-Limit` | Configured requests per minute for this key. Returned on every response. |
| `X-RateLimit-Remaining` | Requests left in the current window. Returned on every response. |
| `Retry-After` | Seconds to wait. Returned on `429` only. |

A `429` body looks like this:

```json
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Try again in 23 seconds.",
  "retryAfter": 23
}
```

Implement retries by honouring `Retry-After` first, falling back to exponential backoff with
jitter. Never retry in a tight loop.

```typescript
async function requestWithBackoff(url: string, init: RequestInit, maxRetries = 5) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const res = await fetch(url, init);
    if (res.status !== 429) return res;
    const retryAfter = Number(res.headers.get('Retry-After'));
    const backoffMs = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : Math.min(60_000, 2 ** attempt * 1000) + Math.random() * 500;
    await new Promise((r) => setTimeout(r, backoffMs));
  }
  throw new Error('Rate limited: retries exhausted');
}
```

OTP sends carry a second, independent throttle: each phone number is capped at a configured
number of sends per hour, on top of the key's per-minute limit.

---

## 3. Error codes

| Code | Meaning | Usual cause |
|---|---|---|
| 200 | OK | Success |
| 201 | Created | Resource created |
| 400 | Bad Request | Missing required field, malformed value |
| 401 | Unauthorized | Missing or invalid `Authorization` header |
| 403 | Forbidden | Key lacks the required scope, or IP not whitelisted |
| 404 | Not Found | Unknown id or path |
| 409 | Conflict | Duplicate resource |
| 422 | Unprocessable | Field present, value invalid |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Error | Unexpected server error |
| 502 | Bad Gateway | One API only: every channel in the fallback chain failed |

Missing scope responses are explicit and name what was needed:
`{ "message": "Insufficient scope. Required: rcs:send" }`

---

## 4. One API: the unified send endpoint

`POST /v1/messages/send` is the single endpoint that reaches every channel, with optional
automatic fallback. **Prefer it over the per-channel endpoints for outbound sends** unless you
need a channel-specific capability the unified contract does not expose.

```typescript
{
  to: string;                 // meaning depends on channel:
                              //   E.164 phone   -> whatsapp | sms | rcs
                              //   email address -> email
                              //   IGSID         -> instagram
  channel: 'whatsapp' | 'sms' | 'rcs' | 'email' | 'instagram';
  fallbackChannels?: ('whatsapp' | 'sms' | 'rcs')[];   // ordered; phone-based channels ONLY
  message?: string;           // required for instagram and for sms
  template?: {
    name?: string;            // WhatsApp template name  (required for whatsapp)
    id?: string;              // RCS / Email template id (required for rcs and for email)
    languageCode?: string;    // required for whatsapp
    variables?: Record<string, string>;
    components?: any[];       // advanced: raw WhatsApp components array
  };
  sms?: { senderId?: string; route?: string };          // senderId required for sms
  email?: {
    subject?: string; fromName?: string; fromEmail?: string;   // fromName + fromEmail required
    replyTo?: string; firstName?: string; lastName?: string;
  };
}
```

Success, `200`:

```json
{
  "status": "sent",
  "channelUsed": "whatsapp",
  "messageId": "wamid.HBg...",
  "attempts": [{ "channel": "whatsapp", "status": "sent", "messageId": "wamid.HBg..." }]
}
```

Every channel failed, `502`:

```json
{
  "message": "All channels failed to deliver the message",
  "attempts": [
    { "channel": "whatsapp", "status": "failed", "error": "WhatsApp is not connected for this organization" },
    { "channel": "sms", "status": "failed", "error": "SMS/RCS is not connected for this organization" }
  ]
}
```

Four rules that will bite you if you skip them:

1. **There is no `messages:send` scope.** The endpoint requires the real per-channel scope for
   *every* channel named in `channel` **and** `fallbackChannels`. A key that can reach WhatsApp
   through `/v1/whatsapp/send-template` can reach it here, and nothing more.
2. **`email` and `instagram` cannot appear in a fallback chain.** `to` carries one identifier,
   and a phone number is not an email address. Mixing them is rejected `400`.
3. **Validation runs across the whole chain before anything is sent.** A field required only by
   the third fallback channel fails the request up front rather than after a real WhatsApp send.
4. **Always read `attempts[]`**, not just `status`. It is the only place that records which
   channel actually delivered and why the earlier ones did not.

---

## 5. Complete endpoint catalogue

Every route below is prefixed with `https://api.slide.cx`. Scopes are exact.

### Messages (One API)
| Method | Path | Scope |
|---|---|---|
| POST | `/v1/messages/send` | per-channel, see above |

### MCP (hosted Model Context Protocol server)
| Method | Path | Scope |
|---|---|---|
| POST | `/mcp` | any valid key; per-tool scopes enforced on the inner call |

Note the path is `https://api.slide.cx/mcp`, without the `/v1` segment. See section 10.

### Contacts
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/contacts` | `contacts:read` |
| GET | `/v1/contacts/:id` | `contacts:read` |
| POST | `/v1/contacts` | `contacts:write` |
| PATCH | `/v1/contacts/:id` | `contacts:write` |
| DELETE | `/v1/contacts/:id` | `contacts:delete` |
| GET | `/v1/contacts/tags` | `contacts:read` |
| GET | `/v1/contacts/segments` | `contacts:read` |
| POST | `/v1/contacts/:id/block` | `contacts:block` |
| POST | `/v1/contacts/:id/unblock` | `contacts:block` |
| GET | `/v1/contacts/:id/notes` | `contacts:notes:read` |
| POST | `/v1/contacts/:id/notes` | `contacts:notes:write` |
| DELETE | `/v1/contacts/:id/notes/:noteId` | `contacts:notes:write` |

`GET /v1/contacts` lists contacts, most recently active first. It takes `page` (default 1), `limit`
(default 50, max 100), `search` (name, phone or email contains the text, any case), `phone` and
`email` (exact lookups; both together must both match) and `includeBlocked=true` (blocked contacts
are left out otherwise). `GET /v1/contacts/:id` answers one contact in the same shape:

```json
{
  "id": "4b1d2c3e-...", "unifiedContactId": "4b1d2c3e-...",
  "name": "John Doe", "firstName": "John", "lastName": "Doe",
  "email": "john@example.com", "phone": "+919876543210", "countryCode": "IN",
  "profilePicUrl": null, "tags": [{ "id": "...", "name": "VIP", "color": "#..." }],
  "customFields": { "city": "Pune" }, "lifecycleStageId": null, "lifecycleStage": null,
  "isBlocked": false, "blockedReason": null,
  "isSubscribed": true, "unsubscribedAt": null,
  "isWhatsappSubscribed": true, "whatsappUnsubscribedAt": null,
  "lastActiveAt": "2026-10-20T09:12:00.000Z",
  "createdAt": "2026-01-15T10:30:00.000Z", "updatedAt": "2026-10-20T09:12:00.000Z"
}
```

`POST /v1/contacts` creates or updates ONE contact, matched by phone first, then by email: `201`
when it was created, `200` when it was updated. Body: `phone` (with the country code, up to 32
characters) and/or `email` (one of the two is required), `name` (up to 128; else `firstName` +
`lastName`, up to 63 each), `company` (kept on the email list entry, needs `email`), `tags` (names,
at most 50, each up to 32 characters; added, never removed; a new name creates the tag) and
`customFields` (keyed by field id or display name, at most 100 keys; string up to 2,048 characters,
number, boolean or null). A blank string counts as not sent, and the call never changes consent. The
answer is the contact plus `created`, `emailContact`, `emailUnsubscribed`, `unmatchedCustomFields`
and `warnings`. Errors: `400 IDENTIFIER_REQUIRED`, `400 INVALID_PHONE`, `409 PHONE_IN_USE` (the
number belongs to another contact) and `409 CONTACT_UPSERT_IN_PROGRESS` with `Retry-After`.

`PATCH /v1/contacts/:id` changes ONE contact; a field you leave out stays as it is. Body: `name` (up to
128), `email`, `phone` (with the country code, up to 32), `lifecycleStageId` (a stage id of this
workspace, or `null` to clear it) and `tags` (names, at most 50, each up to 32). Send at least one.
**`tags` REPLACES the contact's tags** with exactly those names (new names are created, `[]` removes
all), unlike `POST /v1/contacts`, which only adds. Email and phone cannot be cleared, and consent,
notes and blocking are not changed here. The answer is the contact, in the shape above. Errors:
`400 NOTHING_TO_UPDATE`, `400 INVALID_PHONE`, `400 UNKNOWN_LIFECYCLE_STAGE`, `404` for a contact of
another workspace, `409 CONTACT_VALUE_IN_USE` when the phone or email belongs to another contact.

`DELETE /v1/contacts/:id` deletes the contact for good, with its channel records and conversations,
and answers `204`. **It cannot be undone**, which is why it has its own scope, `contacts:delete`,
not part of `contacts:write`.

`GET /v1/contacts/tags` answers `{ "data": [{ id, name, color, contactCount, createdAt }], "meta" }`,
A to Z. `GET /v1/contacts/segments` answers `{ "data": [{ id, name, size, lastSyncedAt, createdAt,
updatedAt }], "meta" }`, newest first; `size` is the member count at the last sync, and the segment's
rule is not returned. Both take `page` and `limit` (50 a page, 100 at most).

Notes: `GET .../notes` answers `{ "data": { "notes": [{ id, body, createdAt, updatedAt, author }] } }`,
the newest 50, newest first (`author` is null for a note added with a key). `POST .../notes` takes
`{ "body": "..." }` (1 to 4,000 characters) and answers `201` with the note; `DELETE` answers `204`.

### Live chat conversations
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/conversations` | `chat:conversations:read` |
| GET | `/v1/conversations/:id` | `chat:conversations:read` |
| GET | `/v1/conversations/:id/messages` | `chat:conversations:read` |
| POST | `/v1/conversations/:id/reply` | `chat:conversations:write` |
| POST | `/v1/conversations/:id/hand-back` | `chat:conversations:write` |

The live chat conversations of your workspace (your website and your own app), the same ones,
filters and order as the Inbox. The three reads keep working when live chat is switched off; a reply
needs live chat on.

**A reply is written by the key, not by a person.** The visitor sees the NAME OF THE API KEY as the
sender, so name the key what you want your customers to see. A reply takes the chat over from any AI
agent on it, exactly as a teammate's reply does, and it stays taken over (the AI agent stays out)
until you call `POST /v1/conversations/:id/hand-back`. A reply does not assign the chat to anyone:
a teammate can still be assigned to it the usual way, from the Inbox.

`GET /v1/conversations` takes `page`, `limit` (default 20, max 100), `isOpen` (`true` or `false`),
`assignedToId` (a teammate id, or `unassigned`), `unread=true`, `needsPerson=true` (chats an AI agent
handed to a person that nobody has answered), `contactId` and `search` (up to 200 characters). Each
row: `id`, `source` (`website` or `app`), `displayName`, `visitor` (`name`, `email`, `phone`: typed by
the visitor, not verified), `contactId`, `contact`, `contactClaimedByVisitor`, `lastMessageAt`,
`lastMessageBody`, `lastMessageDirection`, `unreadCount`, `isOpen`, `closedAt`, `handledBy` (`TEAM`,
`AGENT` or null), `pageUrl`, `pageTitle`, `assignedToId`, `assignedAt`, `needsPerson`, `handedOffAt`,
`preChatAnswers`, `createdAt`, `updatedAt`. `GET /v1/conversations/:id` adds `contactBlocked`,
`visitorBlocked` and `visitorOnline`; the id is a UUID. `GET .../messages` is newest first, 30 a page
(max 100): `id`, `conversationId`, `direction` (`inbound` or `outbound`), `senderType` (`VISITOR`,
`USER` for a teammate, `API_KEY` for a reply written with a key, `AGENT`, `SYSTEM`), `sender`
(`{ id, name }`; for `API_KEY` the id is null and the name is the key's name), `body`, `readAt`,
`createdAt` and `attachments` (`id`, `kind`, `mimeType`, `fileName`, `sizeBytes`, `durationMs`; never
a download link).

- `POST /v1/conversations/:id/reply`: body `body` (required, 1 to 2,000 characters after trimming;
  line breaks and tabs allowed, other control characters not) and `clientRequestId` (required, 8 to
  64 characters of letters, digits, `_` and `-`; you make it and reuse it on a retry). Text only: no
  file upload. Answers `200`:
  `{ "message": { ...as in the list above, "senderType": "API_KEY" }, "duplicate": false, "delivery": { "visitorOnline": true, "email": "NOT_SENT", "reason": "VISITOR_ONLINE" } }`.
  The same `clientRequestId` again returns the stored reply with `duplicate: true` and sends nothing
  a second time, so a retry is safe. `delivery.email` is `QUEUED` when the reply also follows by email
  to a visitor who has left the page and gave an email address in the chat, otherwise `NOT_SENT` with
  a `reason` (`VISITOR_ONLINE`, `NO_VISITOR_EMAIL`, `NO_SENDER`, `ADDRESS_SUPPRESSED` or
  `UNAVAILABLE`). A reply also marks the visitor's messages as read, reopens a closed chat and shows
  up in the team Inbox. Errors: `400` invalid body; `403` the contact or the visitor is blocked;
  `404` a conversation of another workspace, or `404 CHAT_NOT_AVAILABLE` when live chat is switched
  off (nothing is written); `409` the visitor has another open conversation to reply in instead.
- `POST /v1/conversations/:id/hand-back`: no body. Gives the chat back to its AI agent, which answers
  the visitor's next message (the same as Hand back to agent in the Inbox). Answers `200`
  `{ "id", "handledBy": "agent" | "person" | "none", "agent": { "id", "name" } | null, "canHandBack",
  "handedOffAt" }`. With no AI agent on the conversation it changes nothing and answers
  `handledBy: "none"`. A conversation of another workspace is a `404`.

### Automations
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/automations` | `automations:read` |
| GET | `/v1/automations/:id` | `automations:read` |
| GET | `/v1/automations/node-catalog` | `automations:read` |
| POST | `/v1/automations` | `automations:write` |
| PATCH | `/v1/automations/:id` | `automations:write` |
| POST | `/v1/automations/:id/activate` | `automations:write` |
| POST | `/v1/automations/:id/pause` | `automations:write` |
| DELETE | `/v1/automations/:id` | `automations:write` |
| GET | `/v1/automations/:id/executions` | `automations:executions:read` |
| POST | `/v1/automations/:id/executions/:execId/rerun` | `automations:write` |

**Call `GET /v1/automations/node-catalog` before creating or updating an automation.** It returns
every trigger type and every node type with the config fields each accepts, generated from the
schema so it cannot drift from what the engine accepts. A step whose type or config is wrong is
saved without error and then does nothing at runtime, so guessing the shape is not safe.

`POST /v1/automations` **always creates the automation paused**, whatever `active` you pass. A
live automation messages real customers, so going live is a separate `activate` call. Activation
is refused if any step needs an integration that is not connected.

### AI
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/ai/models` | `ai:models:read` |

Models are returned by capability label. The underlying model ids and the providers behind them
are never exposed. Each model has `slug`, `label`, `description`, `isDefault`, `supportsTools`,
`supportsVision`, `pricing` (per million tokens: `inputInrPer1M`, `outputInrPer1M`, and the same in
your `displayCurrency` as `inputPer1M`, `outputPer1M`) and `voice` (set on models a voice agent can
use). An agent's `modelRef` is a model's `slug`.

### AI agents
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/agents` | `agents:read` |
| GET | `/v1/agents/tool-catalog` | `agents:read` |
| GET | `/v1/agents/:id` | `agents:read` |
| GET | `/v1/agents/:id/versions` | `agents:read` |
| GET | `/v1/agents/:id/versions/:versionId` | `agents:read` |
| GET | `/v1/agents/:id/runs` | `agents:read` |
| GET | `/v1/agents/runs/:runId` | `agents:read` |
| GET | `/v1/agents/:id/performance` | `agents:read` |
| POST | `/v1/agents` | `agents:write` |
| POST | `/v1/agents/:id/versions` | `agents:write` |
| PATCH | `/v1/agents/:id/versions/:versionId` | `agents:write` |
| POST | `/v1/agents/:id/versions/:versionId/publish` | `agents:publish` |
| POST | `/v1/agents/:id/versions/:versionId/rollback` | `agents:publish` |

The AI agents that answer your customers on WhatsApp, Instagram, website chat, SMS and email. A key
only reaches its own workspace's agents: another workspace's agent is a `404`.

- `GET /v1/agents` (`page`; `limit`, default 50, max 100; `search` on name and description;
  `audience`; `isActive`): each agent with `versionCount`, `publishedVersion` and `draftVersion`. `GET /v1/agents/tool-catalog`: the tools a
  draft may hold, built-in and the workspace's own (`custom__...`).
- `GET .../versions` (`status`: `DRAFT`, `PUBLISHED`, `ARCHIVED`; `page`; `limit` up to 100): each
  version summarised (`version`, `status`, `modelRef`, `toolCount`, `knowledgeCount`, `runCount`,
  `publishedAt`, `publishedBy`). `GET .../versions/:versionId`: one version in full, with `editable`
  and `content` (`systemPrompt`, `modelRef`, `maxIterations`, `maxTurns`, `exitKeyword`, `hasMemory`,
  `tools`, `knowledgeIds`, `handoffPolicy`, `sessionPolicy`). Credentials are removed from tool
  configs.
- `GET .../runs` (`status`, `outcome`, `channel`, `versionId`, `from`, `to`, `page`, `limit` up to
  50): one run per customer message the agent answered, with each failed tool call and its error.
  `GET /v1/agents/runs/:runId`: one run with every tool call in order. Runs never contain what a
  customer wrote, what the agent replied or who the customer is.
- `GET .../performance?days=30` (1 to 180): the bookings, orders, payments and other results the
  agent's tools completed, cancellations counted apart, `conversionRate` (a percentage, `null` with no
  conversations), a daily `trend`, and `costPerConversionPaise`. Counted from successful tool calls
  only. Every agent also has an `objective` (its goal, from its tools) and `outcomes` (its runs
  answered without a person, handed to the team, resolved, failed).
- Writes: `POST /v1/agents` creates an agent and its draft version 1; `POST .../versions` opens a
  draft from the live version (one draft at a time, `409` with its id otherwise); `PATCH
  .../versions/:versionId` edits a draft only. A draft reaches no customer.
- **`agents:publish` changes what customers get at once**: `publish` makes the draft the live version
  on the customer's next message, after every check the dashboard runs (optional body
  `{ "expectedUpdatedAt" }`: a draft changed since you read it is not published, `409
  AGENT_VERSION_CHANGED`), and `rollback` publishes a copy of an earlier version as a new version.
  Grant it per key, deliberately.

### WhatsApp
| Method | Path | Scope |
|---|---|---|
| POST | `/v1/whatsapp/send-template` | `whatsapp:send` |
| GET | `/v1/whatsapp/send-status/:requestId` | `whatsapp:send` |
| POST | `/v1/whatsapp/header-media/upload` | `whatsapp:send` |
| GET | `/v1/whatsapp/messages/:wamid` | `whatsapp:logs:read` |
| GET | `/v1/whatsapp/logs` | `whatsapp:logs:read` |
| GET | `/v1/whatsapp/templates` | `whatsapp:templates:read` |
| POST | `/v1/whatsapp/templates` | `whatsapp:templates:write` |
| GET | `/v1/whatsapp/conversations/by-phone` | `whatsapp:conversations:read` |
| GET | `/v1/whatsapp/campaigns` | `whatsapp:campaigns:read` |
| GET | `/v1/whatsapp/campaigns/:id` | `whatsapp:campaigns:read` |
| GET | `/v1/whatsapp/campaigns/:id/analytics` | `whatsapp:campaigns:read` |
| POST | `/v1/whatsapp/campaigns` | `whatsapp:campaigns:write` |
| POST | `/v1/whatsapp/campaigns/:id/launch` | `whatsapp:campaigns:write` |
| POST | `/v1/whatsapp/campaigns/:id/cancel` | `whatsapp:campaigns:write` |

`POST /v1/whatsapp/send-template` takes `to`, `templateName`, `languageCode`, `components` and an
optional `async`. By default it answers `200` with `{ "wamid", "conversationId", "status": "sent" }`.
With `"async": true` it answers `202` with `{ "requestId", "status": "queued" }` before the message
is handed to WhatsApp; `GET /v1/whatsapp/send-status/:requestId` then answers `{ requestId, status
(queued | sent | failed), to, templateName, queuedAt, wamid?, conversationId?, completedAt?, error?,
errorCode? }`, kept 48 hours (404 after that). The message log and the `whatsapp.message.status`
webhook remain the lasting record.

`GET /v1/whatsapp/messages/:wamid` answers where one message stands: `{ wamid, status (pending |
sent | delivered | read | failed), direction, type, templateName, templateCategory, to, sentAt,
failure, retryCount, retryOfWamid }`. `failure` is null unless the message failed, and then carries
`{ code, label, reason, hint, title }` with WhatsApp's own explanation.

`POST /v1/whatsapp/templates` submits a template for review: `name` (lowercase letters, digits and
underscores), `language`, `category` (`MARKETING`, `UTILITY` or `AUTHENTICATION`) and `components`
(the template DEFINITION: `HEADER`, `BODY`, `FOOTER`, `BUTTONS`, carousel cards, not the values used
when sending). An `IMAGE`, `VIDEO` or `DOCUMENT` header (a carousel card's too) may give `mediaUrl`, a
public https link to a sample file, instead of an upload handle in `example.header_handle`: Slide
downloads it and uploads it to WhatsApp for you (JPEG or PNG up to 5 MB, MP4 or PDF up to 16 MB; a
link that cannot be downloaded, or a file of another type, is a `400` that names the link).
It answers `201` with the stored template and its review `status` (`PENDING` while under review). Poll
`GET /v1/whatsapp/templates`; it cannot be sent until its status is `APPROVED`.

### Email
| Method | Path | Scope |
|---|---|---|
| POST | `/v1/email/send` | `email:send` |
| GET | `/v1/email/templates` | `email:templates:read` |
| GET | `/v1/email/templates/:id` | `email:templates:read` |
| GET | `/v1/email/contacts` | `email:contacts:read` |
| POST | `/v1/email/contacts` | `email:contacts:write` |

A template that holds the "Recommended products" block shows it only in campaign emails. In `POST /v1/email/send` the
block is removed, because its picks are written per person when a campaign is sent.

`GET /v1/email/templates` returns the workspace's general templates, newest first, up to 200. It takes
no query parameters. Each template carries a `scope` field, which is `GENERIC` for every template in
this list.

### SMS
| Method | Path | Scope |
|---|---|---|
| POST | `/v1/sms/send` | `sms:send` |
| GET | `/v1/sms/templates` | `sms:templates:read` |
| GET | `/v1/sms/senders` | `sms:senders:read` |
| GET | `/v1/sms/campaigns` | `sms:campaigns:read` |
| GET | `/v1/sms/campaigns/:id` | `sms:campaigns:read` |
| POST | `/v1/sms/campaigns` | `sms:campaigns:write` |
| POST | `/v1/sms/campaigns/:id/launch` | `sms:campaigns:write` |
| POST | `/v1/sms/campaigns/:id/cancel` | `sms:campaigns:write` |
| GET | `/v1/sms/logs` | `sms:logs:read` |
| GET | `/v1/sms/stats` | `sms:logs:read` |

### RCS
| Method | Path | Scope |
|---|---|---|
| POST | `/v1/rcs/send` | `rcs:send` |
| GET | `/v1/rcs/bots` | `rcs:bots:read` |
| GET | `/v1/rcs/templates` | `rcs:templates:read` |
| POST | `/v1/rcs/campaigns` | `rcs:campaigns:write` |
| GET | `/v1/rcs/campaigns` | `rcs:campaigns:read` |
| GET | `/v1/rcs/logs` | `rcs:logs:read` |
| GET | `/v1/rcs/stats` | `rcs:logs:read` |

### OTP
| Method | Path | Scope |
|---|---|---|
| POST | `/v1/otp/send` | `otp:send` |
| POST | `/v1/otp/retry` | `otp:send` |
| POST | `/v1/otp/verify` | `otp:verify` |
| POST | `/v1/otp/verify-token` | `otp:verify` |
| GET | `/v1/otp/logs` | `otp:logs:read` |
| GET | `/v1/otp/analytics` | `otp:analytics:read` |

A code is delivered by SMS or on WhatsApp. These are the only channels: there is no email or voice
delivery. The channels of a widget (a first channel and an optional second one) are set on the widget
in the dashboard. WhatsApp codes go out from your own connected WhatsApp number with one of your
approved authentication templates, so a widget can use WhatsApp only when the workspace has both.

When the second channel is used:

- At once, when the first channel definitely could not send (for example, the template is no longer
  approved, the provider refused the message, or the message could not be handed to the provider at
  all). The same code goes out on the second channel.
- Never after a timeout or any other answer that leaves the outcome unknown: the code may already
  have arrived, so no second message is sent. The request stays open and the user can ask for the
  code again.
- Afterwards, when WhatsApp accepted the code and later reports that it could not be delivered (for
  example, the number is not on WhatsApp): if the widget's second channel is SMS, one SMS with a new
  code is sent automatically. It is sent only when the failure is reported within two minutes of the
  WhatsApp send and the request has not expired; a later failure sends nothing. It happens once per
  WhatsApp message, it does not use up one of the user's resends, and it publishes `otp.sent` with
  `channel: "sms"` and `isResend: true`. It restarts the resend wait: a `POST /v1/otp/retry` that
  arrives before the widget's resend wait has passed since that SMS answers `400` "Too soon to
  retry", as after any send. A widget with WhatsApp and no second channel sends nothing more to such
  a number; the user never receives a code on that widget.

A code is charged as one WhatsApp authentication message or as one SMS, from the wallet, before it
is sent. A WhatsApp code that is refused, or reported as not delivered, is refunded. No contact is
created for a code.

`POST /v1/otp/send` and `POST /v1/otp/retry` answer `{ "requestId": "...", "channel": "sms" }`, where
`channel` is the channel the code went out on (`sms` or `whatsapp`). A `200` means the code was
handed to that channel, not that it was delivered: a later automatic SMS is reported by `otp.sent`,
and `GET /v1/otp/logs` shows the channel each request ended on.

`POST /v1/otp/retry` takes an optional `channel` (`"sms"` or `"whatsapp"`). It is honoured only when
the widget sends on that channel. Any other value, including `"email"` and `"voice"`, is not an error:
the request is accepted with `200`, the code is sent on the widget's own channel, and the response
says so with `requestedChannel` (what you sent), `requestedChannelAvailable: false` and a `notice`
sentence. Read `channel` to know where the code went.

### Instagram
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/instagram/profile` | `instagram:messages:read` |
| GET | `/v1/instagram/conversations` | `instagram:messages:read` |
| POST | `/v1/instagram/messages` | `instagram:messages:send` |
| GET | `/v1/instagram/insights` | `instagram:insights:read` |

### Ads
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/ads/status` | `ads:read` |
| GET | `/v1/ads/accounts` | `ads:read` |
| GET | `/v1/ads/accounts/:adAccountId/campaigns` | `ads:read` |
| GET | `/v1/ads/campaigns/:campaignId/adsets` | `ads:read` |
| GET | `/v1/ads/adsets/:adsetId/ads` | `ads:read` |
| GET | `/v1/ads/insights` | `ads:read` |
| GET | `/v1/ads/insights/summary` | `ads:read` |
| GET | `/v1/ads/leads` | `ads:read` |
| POST | `/v1/ads/accounts/:adAccountId/campaigns` | `ads:write` |
| POST | `/v1/ads/:entity/:id/status` | `ads:publish` |
| POST | `/v1/ads/:entity/:id/budget` | `ads:publish` |

The ads account your workspace connected (Meta Ads), with the same figures and changes as the Ads page
of the dashboard. The plan must include the ads integration (else `403`), and an account must be
connected: without one, every route but `status` answers `409 ADS_NOT_CONNECTED`. Ids are the ones
the ad platform issues (`act_` and digits for an ad account, digits for the rest). Budgets are minor
units (paise or cents).

**`ads:publish` changes live spend.** Starting an ad, pausing it and changing a budget take effect on
the ad platform at once, so they need their own scope: a key that can only make a campaign
(`ads:write`) can never start or resize spending. Nothing here deletes or archives an ad, and a new
campaign is **always created paused**.

- Lists (`accounts`, `campaigns`, `adsets`, `ads`) take `page` and `limit` (50 a page, 100 at most) and
  answer `{ "data": [...], "meta": { total, page, limit, totalPages } }`.
- `status`: `connected`, `connectedAs`, `tokenExpiresAt`, `lastRefreshedAt`, `adAccountCount`,
  `pageCount`. It works before anything is connected.
- Account: `id`, `name`, `currency`, `timezoneName`, `accountStatus`, `businessName`, `syncEnabled`,
  `insightRows`, `newestDataAt`, `rowsInWindow`. Campaign: `id`, `name`, `status`, `effectiveStatus`,
  `objective`, `buyingType`, `bidStrategy`, `dailyBudgetMinor`, `lifetimeBudgetMinor`,
  `budgetRemainingMinor`, `spendCapMinor`, `startTime`, `stopTime`, `createdTime`, `updatedTime`. Ad
  set: the same style, with `campaignId`, `bidAmountMinor`, `billingEvent`, `optimizationGoal`,
  `destinationType`, `endTime`. Ad: `id`, `name`, `adsetId`, `campaignId`, `status`, `effectiveStatus`,
  `creative` (`id`, `name`, `title`, `body`, `callToActionType`, `imageUrl`, `thumbnailUrl`, or null).
  A value the platform does not send is `null`.
- `GET /v1/ads/insights`: daily results, oldest day first. Query: `level` (`account`, `campaign`,
  `adset`, `ad`; default `campaign`), `since`, `until` (ISO 8601; default the last 30 days),
  `adAccountId`, `campaignId`. Rows hold counts as numbers (`impressions`, `reach`, `clicks`,
  `inlineLinkClicks`, `uniqueClicks`, `videoViews`, `postEngagement`, `leads`) and money and ratios as
  strings (`spend`, `frequency`, `cpc`, `cpm`, `ctr`, `costPerLead`), plus `revenue` and `purchases`
  (numbers), the ids and names of the level, and `dateStart`, `dateStop`. `revenue` and `purchases` are
  `null`, not 0, when no purchases were reported. `meta` has `since`, `until`, `returned`, `truncated` and `cap` (5,000
  rows at most; narrow the dates when `truncated` is true).
- `GET /v1/ads/insights/summary`: one set of totals over `since`, `until` and `adAccountId` (without
  it, every ad account of the workspace): `data` has `impressions`, `reach`, `clicks`, `leads`, `spend`,
  `ctr`, `cpc`, `cpl`, `cpm`, `revenue`, `purchases`, `roas` (the ratios are `null` when there is
  nothing to divide by).
- `GET /v1/ads/leads`: the newest lead-form submissions, newest first, `limit` only (20, max 100; no
  paging). Each: `id`, `occurredAt`, `contactId`, `phone`, `email`, `fullName`, `formId`, `formName`,
  `campaignId`, `campaignName`, `adsetId`, `adsetName`, `adId`, `adName`, `pageName`, `platform`,
  `status`.
- `POST /v1/ads/accounts/:adAccountId/campaigns`: body `name` (required, up to 256), `objective`
  (upper-case words joined by `_`; default `OUTCOME_TRAFFIC`), `specialAdCategories` (at most 10).
  Answers `201 { "id", "status": "PAUSED" }`.
- `POST /v1/ads/:entity/:id/status`: `:entity` is `campaign`, `adset` or `ad`; body `{ "status":
  "ACTIVE" | "PAUSED" }`. Answers `{ id, status, previousStatus, changed }`; asking for the status it
  already has changes nothing (`changed: false`), so a retry is safe.
- `POST /v1/ads/:entity/:id/budget`: `:entity` is `campaign` or `adset`; body exactly one of
  `dailyMinor` (at least 100) or `lifetimeMinor`; sending both or neither is a `400`. Answers
  `{ id, budget: "daily" | "lifetime", previousMinor, newMinor }`.

### Google Ads
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/google-ads/status` | `ads:google:read` |
| GET | `/v1/google-ads/customers` | `ads:google:read` |
| GET | `/v1/google-ads/customers/:customerId/campaigns` | `ads:google:read` |
| GET | `/v1/google-ads/customers/:customerId/ad-groups` | `ads:google:read` |
| GET | `/v1/google-ads/customers/:customerId/ads` | `ads:google:read` |
| GET | `/v1/google-ads/customers/:customerId/conversion-actions` | `ads:google:read` |
| GET | `/v1/google-ads/lead-forms` | `ads:google:read` |
| GET | `/v1/google-ads/insights` | `ads:google:read` |
| POST | `/v1/google-ads/lead-forms` | `ads:google:write` |
| DELETE | `/v1/google-ads/lead-forms/:id` | `ads:google:write` |

The Google Ads account your workspace connected, with the same figures as the Google Ads page of the
dashboard. These are not the scopes of the Meta Ads routes above. The plan must include the Google
Ads integration (else `403`), and an account must be connected: without one, every route but `status`
answers `409 ADS_NOT_CONNECTED`. Nothing here changes a campaign or what the account spends. The two
changes only add or remove the place where a lead form's leads arrive in Slide. **A lead form's key
is never returned and cannot be read or rotated with an API key**: whoever pastes it into Google Ads
reads it in Slide, under Integrations. Connecting and disconnecting are also done in Slide.

- Lists take `page` and `limit` (50 a page, 100 at most) and answer
  `{ "data": [...], "meta": { total, page, limit, totalPages } }`. A customer id is the 10 digits
  Google issues, without dashes; a customer that is not one of the workspace's linked accounts is a
  `404`. Other Google ids are digits. Money is in full units of the account's currency, not minor units.
- `status`: `connected`, `connectedAs`, `tokenExpiresAt`, `lastRefreshedAt`, `customerCount`,
  `leadFormCount`. It works before anything is connected.
- Customer (managers first, then by name): `customerId`, `name`, `currencyCode`, `timeZone`,
  `isManager`, `isTestAccount`. Campaign (by name): `id`, `name`, `status`, `channelType`, `startDate`,
  `endDate`, `dailyBudget`. Ad group: `id`, `name`, `status`, `type`, `campaignId`, `campaignName`;
  `?campaignId=` (digits) keeps one campaign's. Ad: `id`, `name`, `type`, `status`, `adGroupId`,
  `adGroupName`; `?adGroupId=` (digits) keeps one ad group's. Conversion action: `id`, `name`, `type`,
  `category`. A value Google does not send is `null`.
- `GET /v1/google-ads/insights`: the stored daily figures, oldest day first. Query: `customerId`
  (10 digits), `level` (`customer`, `campaign`, `ad_group`, `ad`), `startDate`, `endDate` (ISO 8601),
  `page` (up to 200) and `limit` (default 100, max 500). Rows: `level`, `customerId`, `campaignId`,
  `campaignName`, `adGroupId`, `adGroupName`, `adId`, `adName`, `date`, `currencyCode`; counts as
  numbers (`impressions`, `clicks`, `leads`, `videoViews`, `engagements`); money and ratios as strings
  (`cost`, `averageCpc`, `averageCpm`, `ctr`, `conversions`, `conversionsValue`, `costPerConversion`,
  `costPerLead`). The ids and names of a level you did not ask for are `null`.
- `GET /v1/google-ads/lead-forms`: newest first. Each: `id` (a UUID), `formAssetId`, `formName`,
  `campaignId`, `campaignName`, `customerId`, `customerName`, `leadCount`, `lastLeadAt`, `isActive`,
  `createdAt`. The leads arrive as contacts: read them with `GET /v1/contacts`.
- `POST /v1/google-ads/lead-forms`: body, every field optional: `formAssetId` (up to 64), `formName`
  (up to 200), `campaignId` (up to 64), `campaignName` (up to 200), `customerId` (10 digits, one of the
  linked customers; any other is a `404`). Answers `201` with the form as the list shows it, and no
  key.
- `DELETE /v1/google-ads/lead-forms/:id`: removes the form; the leads it already brought stay. Answers
  `204` with no body. The id is a UUID; a form of another workspace is a `404`.

### Shopify
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/shopify/products/search` | `shopify:products:read` |
| GET | `/v1/shopify/products/types` | `shopify:products:read` |
| GET | `/v1/shopify/products/:id` | `shopify:products:read` |
| GET | `/v1/shopify/collections` | `shopify:products:read` |
| GET | `/v1/shopify/collections/:id/products` | `shopify:products:read` |
| GET | `/v1/shopify/orders/status` | `shopify:orders:read` |
| GET | `/v1/shopify/customers/orders` | `shopify:orders:read` |
| GET | `/v1/shopify/discounts/validate` | `shopify:discounts:read` |

### Shopify store audit
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/shopify/audit` | `shopify:audit:read` |
| GET | `/v1/shopify/audit/quote` | `shopify:audit:read` |
| GET | `/v1/shopify/audit/runs` | `shopify:audit:read` |
| GET | `/v1/shopify/audit/runs/:id` | `shopify:audit:read` |
| POST | `/v1/shopify/audit/runs` | `shopify:audit:write` |
| GET | `/v1/shopify/audit/issues` | `shopify:audit:read` |
| GET | `/v1/shopify/audit/issues/:id` | `shopify:audit:read` |
| PATCH | `/v1/shopify/audit/issues/:id` | `shopify:audit:write` |
| GET | `/v1/shopify/audit/issues/:id/fixes` | `shopify:audit:read` |
| POST | `/v1/shopify/audit/issues/:id/fixes/suggest` | `shopify:audit:write` |
| PATCH | `/v1/shopify/audit/fixes/:id` | `shopify:audit:write` |
| POST | `/v1/shopify/audit/fixes/apply` | `shopify:audit:write` |
| GET | `/v1/shopify/audit/fixes/batches/:batchId` | `shopify:audit:read` |
| POST | `/v1/shopify/audit/fixes/:id/undo` | `shopify:audit:write` |

The audit of the connected Shopify store: a score per section, the problems found, and fixes Slide
can suggest and write to the store. **`shopify:audit:write` spends money and changes the store**: one
audit per store per month is free and each further audit is charged from the wallet, AI fix
suggestions are charged by AI usage, and applying a fix changes live product, collection or page
data. A wallet that cannot pay answers `402` with `code` (`INSUFFICIENT_BALANCE` or
`WALLET_LOCKED`) and `reason`.

- `GET /v1/shopify/audit` answers `{ "run": ... }`, the newest audit with its `sectionResults`
  (`section`, `status`, `score`, `critical`, `high`, `medium`, `low`, `checks`, `finishedAt`), or
  `null` before the first. A run carries `id`, `shopDomain`, `status` (`QUEUED`, `RUNNING`,
  `COMPLETED`, `PARTIAL`, `FAILED`, `CANCELLED`), `trigger`, `source`, `overallScore`,
  `checksVersion`, `chargedPaise`, `errorMessage`, `startedAt`, `finishedAt`, `createdAt`.
  `GET .../runs/:id` answers one run in that shape; `GET .../runs` lists runs without section
  results (`page`, `limit` up to 100).
- `GET .../quote` answers `{ isFree, pricePaise, priceUsd, currency: "INR", freeResetsOn }`. Ask it
  before starting an audit.
- `POST .../runs` takes no body and answers `202` with `{ runId, status: "QUEUED", isFree, charged,
  pricePaise }`; when the audit is not free the wallet is charged first. `402` (body adds
  `pricePaise`), `409` while an audit runs, `404` when no store is connected.
- `GET .../issues` takes `status` (`OPEN`, `IN_PROGRESS`, `FIXED`, `IGNORED`), `section`,
  `severity` (`critical`, `high`, `medium`, `low`), `page`, `limit`. Each issue has `checkId`,
  `section`, `title`, `severity`, `impact` and `effort` (1 to 5), `priority` (impact / effort),
  `quickWin`, `status`, `firstSeenAt`, `lastSeenAt`, `fixPlan` (who fixes it, about how long, what it
  changes) and `latestFinding`. `GET .../issues/:id` adds `history` and the first page of `fixes`.
  `PATCH .../issues/:id` takes `{ "status" }` only; a later audit reopens a `FIXED` issue it finds
  again and never touches `IGNORED`.
- Fixes: `POST .../issues/:id/fixes/suggest` makes suggestions in the background (`202`; `422` with
  `reason: "no_fix"` when Slide cannot suggest one, `409` while already running). `GET .../fixes`
  lists them with `generation` (progress) and `access` (the store's permissions). `PATCH
  .../fixes/:id` edits one before applying: `{ "value": ... }` or `{ "clear": true }`. `POST
  .../fixes/apply` takes `{ "fixIds": [...] }` (1 to 200) and answers `202` with `{ batchId, count,
  skipped }`; `GET .../fixes/batches/:batchId` answers `{ batchId, total, counts, done, stale, items
  }`. `POST .../fixes/:id/undo` restores the earlier value; `409` when it was changed in Shopify since.
  A fix's `status` is `SUGGESTED`, `EDITED`, `APPLYING`, `APPLIED`, `SKIPPED_CHANGED`, `FAILED`,
  `REVERTED` or `DISMISSED`.

### AI search
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/aeo/summary` | `aeo:read` |
| GET | `/v1/aeo/trend` | `aeo:read` |
| GET | `/v1/aeo/answers` | `aeo:read` |
| GET | `/v1/aeo/prompts` | `aeo:read` |
| POST | `/v1/aeo/prompts` | `aeo:write` |
| PATCH | `/v1/aeo/prompts/:id` | `aeo:write` |
| GET | `/v1/aeo/settings` | `aeo:read` |
| PUT | `/v1/aeo/settings` | `aeo:write` |
| POST | `/v1/aeo/run` | `aeo:write` |

How AI assistants answer the questions your customers ask: whether they name your brand, in what
position, which competitors they name and whether they cite your website. For any workspace, with or
without Shopify. **`aeo:write` spends money**: a check is charged from the wallet by AI usage, and
active questions are also checked, and charged, every week. A wallet that cannot pay answers `402`.

- `GET /v1/aeo/summary`: the latest answer per question and assistant over the last 30 days, with
  `totals` (`answers`, `mentioned`, `mentionRate`, `averagePosition`, `competitorShare`,
  `competitorMentions`, `grounded`, `groundedCitingStore`, `citationShare`, `byProvider`), the
  `brand`, the `assistants` available, `prompts` with their `answers`, `activeRun` and `latestRun`.
  Each assistant is a `provider` id plus a `label`. Rates are fractions from 0 to 1, or `null`.
- `GET /v1/aeo/trend?days=90` (7 to 365): one point per finished check, oldest first:
  `{ batchId, sampledAt, answers, mentioned, mentionRate, byAssistant }`; at most the newest 100
  checks (`capped` says when there were more).
- `GET /v1/aeo/answers` (`promptId`, `provider`, `batchId`, `page`, `limit` up to 100): every
  stored answer, newest first, with `brandMentioned`, `position`, `competitors`, `products`,
  `citations`, `ownDomainCited`, `grounded`, `excerpt` and `chargedPaise`.
- `GET /v1/aeo/prompts`: every question (at most 100, so one page): `{ id, prompt, market, active,
  createdAt, updatedAt }`, with `activeCount` and `maxActive`. `POST` takes `{ "prompt", "market"? }`
  (5 to 300 characters; market up to 60) and answers `201` with `{ prompt, note }`; a new question is
  active, and at most 20 are active at once. `PATCH .../prompts/:id` changes `prompt`, `market` or
  `active`.
- `GET /v1/aeo/settings`: the brand profile `{ configured, brandNames, domains, competitors, market,
  language, updatedAt, suggestions, limits }`. `PUT` replaces it: `brandNames` (1 to 10), `domains` (1
  to 10 public website domains), `competitors` (up to 10, `{ name, domain? }`), `market`, `language`.
- `POST /v1/aeo/run` takes no body and answers `202` with `{ batchId, status: "QUEUED", questions,
  assistants, notAvailable }`. `400` until a brand profile and an active question exist, `402` when
  the wallet cannot pay, `409` with `reason: "active_run"` while a check runs.

### Website events
| Method | Path | Scope |
|---|---|---|
| POST | `/v1/analytics/events` | `analytics:events:write` |
| POST | `/v1/analytics/identify` | `analytics:events:write` |
| GET | `/v1/analytics/events` | `analytics:events:read` |
| GET | `/v1/analytics/events/:name` | `analytics:events:read` |
| GET | `/v1/analytics/funnels` | `analytics:events:read` |
| GET | `/v1/analytics/cohorts` | `analytics:events:read` |
| GET | `/v1/analytics/users/:userId/events` | `analytics:events:read` |

Named events (`pay`, `view_button_clicked`) from your own server or any language that can make an
HTTPS request, tied to the visit behind them when you pass that on. See them in the dashboard under
Web Analytics, Events, for the website you sent them to. The website sends its own events with
`slideAnalytics.track()`. **Send events with an
API key from your server only**: a request with an `Origin` header comes from a web page, where
anyone can read the key, and is refused with `400`. Every route works on one website of the key's
workspace: name it with `site` (domain, id or site key), or leave it out when the workspace has one.
The website must be verified (`409` otherwise). Each request is one API call against your plan, so
send events in batches.

- `POST /v1/analytics/events` takes `{ "site"?, "events": [...] }` with 1 to 50 events and answers
  `202` with `{ accepted, capped, rejected, droppedProperties }`. Each event is checked on its own:
  a bad one is listed in `rejected` as `{ index, code, message }` and the rest are kept. Event
  fields:
  - `name` (required): starts with a letter, up to 40 characters of Latin letters, digits, spaces
    and `_ . : -`; stored lower case with underscores (`View Button Clicked` becomes
    `view_button_clicked`). At most 500 different names a day per website, counted separately for this
    API and for the browser, so put changing values in `properties`, never in the name.
  - `user` or `visitorId` (one at least): `user` is `{ id?, email?, phone?, name?, createContact? }`
    with at least one of `id`, `email`, `phone`. `visitorId` and `sessionId` (6 to 64 letters, digits,
    dot, hyphen, underscore) come from `slideAnalytics.getIds()` in the browser; a `sessionId` needs
    its `visitorId`.
  - `time`: epoch seconds, epoch milliseconds or an ISO date; defaults to now; at most 7 days back
    (older is rejected as `too_old`, never moved to today).
  - `insertId` (up to 128 characters): the same `insertId` on the same event name never counts twice,
    so a retry is safe, and one order id can be used on `checkout_started` and on `pay`. Left out, one
    is generated and a resend counts again. The SDKs always send one.
  - `valueMinor` (whole number of paise or cents) with `currency` (3 letters), for example `199900`
    and `INR`.
  - `properties` (up to 20 keys; text, numbers or true/false, text cut at 200 characters) and `path`.
- **User data goes in `user`, never in `properties`.** A property whose key or value looks like
  personal data (an email address, phone number, password, token, card) is dropped and its key listed
  in `droppedProperties`. Keys ending in `_id` or `_ref` (`order_id`) keep their digits. A contact is
  matched by `user.email` or `user.phone` and linked to the visitor, and a matched contact is left
  unchanged. None is created unless you send `createContact: true` with a key that also holds
  `contacts:write`; it then follows the normal contact rules (contact allowance, automations).
- An event carries at most 20 properties. Any more are left off and listed in `droppedProperties`.
- Rejection `code`: `invalid_event`, `invalid_name`, `invalid_time`, `future_time`, `too_old`,
  `no_identity`, `invalid_id`, `invalid_value`, `too_many_names`, `daily_limit_reached`, `needs_contacts_scope`.
- An event finds its visit in this order: the `sessionId` you sent; else the visit that `visitorId`
  was on in the 30 minutes before the event; else the browser last linked to `user.id`. **Send
  `sessionId` when you have it.** The browser's own events reach Slide up to 30 seconds after they
  happen, and a `user.id` is linked to its browser about a minute after it is first sent, so an event
  sent straight after a page loads and carrying only a `visitorId` or `user.id` counts with no visit
  behind it. With no visit behind it (a payment confirmation, a job) an event still counts under its
  name, shows as `Server`, and never changes visits, funnels or conversions.
- When you name a person, the visit joins their contact and so do that browser's later visits, within
  about 5 minutes. A new person on the same browser replaces the old one from then on; what was already
  linked stays with the first person.
- Limits and errors: 50 events a request (`400` above); 33,000 API events a day per website, a
  technical limit, over which a request answers `429` with `code: "daily_limit_reached"` and `limit`
  (the day is UTC); `503` with `code: "try_again"` and `retryAfterSeconds` while events are being
  processed: send the same batch again, events with the same `insertId` never count twice; `409` when
  the website is not verified; `400` for an `Origin` header, a malformed body, or several websites with
  no `site` named.
- `POST /v1/analytics/identify` takes `{ "site"?, "visitorId"?, "user": {...} }`, answers `202` with
  `{ accepted: true }`, and links a person to a browser without an event (for example at login). `400`
  with `code: "no_identity"` when `user` has no `id`, `email` or `phone`.
- `GET /v1/analytics/events` (`site`, `from`, `to`; UTC days `YYYY-MM-DD`, default the last 30 days,
  at most 92): `{ from, to, events: [{ name, count, sessions, lastDay }] }`, busiest first, up to 100.
  `sessions` counts visits; an event with no visit behind it is in `count` only. Counts refresh every
  5 minutes and are kept for good.
- `GET /v1/analytics/events/:name` (same parameters): `{ name, from, to, count, people, trend, money,
  properties, sampled, recent }`. `people` is `null` for ranges over 31 days. `trend` is
  `[{ day, count, sessions }]` without the empty days. `money` is `[{ currency, valueMinor, events }]`.
  `properties` is `[{ key, values: [{ value, count }] }]`, read from the latest `sampled` events (at
  most 2,000). `recent` holds the newest 50: `{ id, name, occurredAt, source, userId, visitorId,
  sessionId, contactId, path, valueMinor, currency, properties }`, where `source` is `server` or
  `browser` and `sessionId` is `null` when no visit is behind the event. A name never sent answers
  count 0 and empty lists; a name that cannot be an event name is `404`. Single events are kept 90
  days; the daily counts stay.
- `GET /v1/analytics/funnels` (`site`, `steps`, `from`, `to`, `withinHours`): how many people got
  through 2 to 6 steps in order. `steps` is the step names separated by commas, for example
  `steps=page_view,add_to_cart,pay`; a step is an event name you sent or an action Slide records by
  itself (`page_view`, `product_view`, `add_to_cart`). `from` and `to` are UTC days `YYYY-MM-DD`
  (default the last 30 days) and cover at most 31 days (`400` above). `withinHours` is how long after
  one step the next still counts, 1 to 720, default 168 (7 days). Answers `{ from, to, withinHours,
  steps: [{ name, people, fromPrevious, fromFirst }] }`: each person counts once per step, only when
  they did it after the step before and inside the window. `fromPrevious` and `fromFirst` are a
  percentage (one decimal), or `null` where there is nobody to divide by (`fromPrevious` of the first
  step is always `null`). A person is a browser, so someone seen on two devices counts twice. It is
  counted from single events, so the answer is cached for 5 minutes, and `422` means the dates hold
  too much to read at once: send a shorter range.
- `GET /v1/analytics/cohorts` (`site`, `start`, `return`, `from`, `to`): weekly groups by the week
  (Monday, UTC) in which a person first did `start`, and what share of each group did `return` in the
  same week and the weeks after; `return` left out means the same event as `start`. `from` and `to`
  default to the 12 weeks ending at `to` and cover at most 91 days (`400` above). Answers `{ from, to,
  start, return, cohorts: [{ week, people, returned: [{ weeksLater, people, percent }] }] }`.
  `weeksLater` is 0 for the same week; a week in which nobody came back is left out of `returned`, so
  read it by `weeksLater` and not by position. A person is a browser. Cached for 5 minutes, and `422`
  as for funnels.
- `GET /v1/analytics/users/:userId/events` (`site`, `from`, `to`, `page`, `limit` up to 100): one
  person's events, newest first, across every browser they used; `{ data: [...], meta }` with the
  same row shape as `recent`. `:userId` is the `user.id` you sent.
- In the browser, with no API key: `window.slideAnalytics.track(name, props)` sends an event with at
  most 10 properties, and `window.slideAnalytics.getIds()` returns `{ visitorId, sessionId }` (both
  `null` when the visitor opted out). Pass them to your server and include them in the event you send
  there. The script does not ask for consent by itself: where you need consent, load it only after the visitor agrees. It stops for Do Not Track and for `slideAnalytics.disable()`. The site key
  is public, so a browser can name a person only with a signature from your server:
  `window.slideAnalytics.identify(userId, signature)`. Sign the user id (trimmed, at most 128
  characters) with your website's identify secret: the signature is the HMAC-SHA256 of the id, keyed
  with the secret, as 64 lower-case hex characters. In Node.js that is
  `slide.analytics.signUser(userId, secret)`, in Python `slide.analytics.sign_user(user_id, secret)`;
  neither makes a request. The secret is shown to people who can edit widgets, in Web Analytics,
  Developers, step 6. Keep it on your server: a secret in a page lets anyone attach any browser to
  any person. A signed call links a user id only. To link a contact, by email or phone, call
  `POST /v1/analytics/identify` from your server.

```bash
curl -X POST https://api.slide.cx/v1/analytics/events \
  -H "Authorization: Bearer $SLIDE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "events": [{ "name": "pay", "insertId": "order_10023", "user": { "id": "u_123" },
        "valueMinor": 199900, "currency": "INR", "properties": { "plan": "pro" } }] }'
```

### Voice
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/voice/agents` | `voice:agents:read` |
| GET | `/v1/voice/agents/:id` | `voice:agents:read` |
| GET | `/v1/voice/calls` | `voice:calls:read` |
| POST | `/v1/voice/calls/outbound` | `voice:calls:write` |
| GET | `/v1/voice/analytics` | `voice:calls:read` |
| GET | `/v1/voice/calls/:id` | `voice:calls:read` |
| GET | `/v1/voice/calls/:id/recording` | `voice:calls:read` |
| POST | `/v1/voice/agents` | `voice:agents:write` |
| PATCH | `/v1/voice/agents/:id` | `voice:agents:write` |
| DELETE | `/v1/voice/agents/:id` | `voice:agents:write` |

`POST /v1/voice/agents` creates a voice agent with the dashboard's rules and answers `201`. Body:
`name` (required, up to 120 characters), `persona` (required: the agent's instructions),
`description` and `firstMessage` (up to 500 each), `language` (up to 20), `multiLingual`,
`maxDurationSec` (30 to 3600), `maxTurns` (1 to 100), `tools` (at most 64), `mcpServerIds` and
`isActive`; the voice, speech and model settings take the values the dashboard offers (read an agent
to see them). An unknown field is ignored and a known field with a wrong value is a `400`. `PATCH`
takes any of the same fields. `DELETE` cannot be undone and answers `204`.

**How an AI agent call is billed.** Every call is charged in two parts: **telephony** (the call
itself plus its transcription), charged in 30-second steps, and **AI**, charged per second. Ring
time and calls nobody answers are free. On a call object, `billedAmountPaise` is the total taken
from your wallet, and `telephonyPaise` and `aiPaise` are its two parts, in paise (`null` on calls
billed before the parts were recorded).

`GET /v1/voice/analytics?from=2026-03-01&to=2026-03-31` returns `totalCalls`, `answeredCalls`
(answered or completed), `avgDurationSec`, `totalBilledPaise` (what the calls cost your wallet, in
INR paise: the sum of each call's `billedAmountPaise`), `totalCostUsd` (always `null`, kept for
compatibility), `callsByDay` (`[{ date, total, answered }]`, one entry per day with calls),
`callsByDirection` (`{ INBOUND, OUTBOUND }`) and `sentimentBreakdown`
(`{ POSITIVE, NEUTRAL, NEGATIVE }`; calls with no sentiment yet count as `NEUTRAL`).

### Voice External: App to App

Two people talk from inside your own app, for example a pro and a customer. Send two of your own
user ids; nothing has to be registered first. Calls that start or end on a real phone are in the
next section, **Voice External: App to Phone and Phone to App**.

```http
POST /v1/voice/app/calls
{ "type": "app", "from": "pro_991", "to": "cust_5521" }
```

**App-to-app calls are charged as telephony, per started minute of talk time with a 1-minute minimum**,
deducted from your wallet. See Pricing below.

#### How the pieces connect

```
  Your app  <------ your own API, push, socket ------>  Your backend
     |                                                        |    ^
     |                                         API key calls  |    |  signed webhooks
     |                                                        v    |
     |                                                     Slide API
     |
     +------ voice connection (voiceServerUrl + token) ----->  Slide voice server
```

| Link | What travels on it |
|---|---|
| Your backend → Slide API | Every API call, with your API key. Only your backend holds the key. |
| Slide → your backend | Webhooks: a call is ringing, answered, ended. |
| Your backend → your app | Your own channel (your push, your socket, your API). Slide has **no socket or push for your app**. When a webhook says a user's call is ringing, your backend tells that user's app. |
| Your app → Slide voice server | The audio, and the only connection your app makes to us. Your backend gets `voiceServerUrl` and `token` from the API and passes both to the app. |

`callId` ties it all together: it is in every webhook, every API response and every API path.
Always connect to the `voiceServerUrl` the API returns; never hard-code it. A token lets one
person join one call and is only needed to join: it expires after 5 minutes, the call does not.
After a network drop, get a new one from `POST /v1/voice/app/calls/:callId/token`.

#### One-time setup

| Step | Where |
|---|---|
| 1. API key with `voice:app:read` and `voice:app:write` (plus `webhooks:read`/`webhooks:write` to register a webhook by API) | Dashboard → API Keys |
| 2. Webhook subscribed to `voice.app.call.incoming`, `.answered`, `.completed`, `.missed`, `.rejected` | Dashboard → Webhooks, or the Webhooks API |
| 3. Money in your wallet: calls are charged per minute | Dashboard → Billing |

Keep the API key on your server. It can be pulled out of any mobile app and works for your whole
account; your app only ever receives a token, which works for one call.

#### Joining a call in your app

```bash
npm install @synquic/slide-voice
npm install @synquic/slide-voice-native   # React Native only: call registerSynquicVoice() first
```

```typescript
import { SynquicVoiceCall } from '@synquic/slide-voice';

// From YOUR backend: { callId, token, voiceServerUrl }
const session = await myApi.post('/calls/start', { to: 'cust_5521' });

const call = new SynquicVoiceCall();
call.on('connected', () => showCallScreen());
call.on('ended', () => {
  // Fires once, however the call ends: this user, the other person, or a phone hanging up.
  closeCallScreen();
  // Your backend calls POST /v1/voice/app/calls/:callId/end. Harmless if it already ended.
  myApi.post('/calls/' + session.callId + '/end');
});
await call.join(session);

hangUpButton.onclick = () => call.leave(); // triggers 'ended' above
```

Start a talk timer when your backend receives `voice.app.call.answered`, not when the app joins.

#### The call, step by step

1. Your backend: `POST /v1/voice/app/calls { "type": "app", "from": "pro_991", "to": "cust_5521" }`
   and gets `{ callId, token, voiceServerUrl }`. The pro's app joins and shows "Ringing".
2. Webhook `voice.app.call.incoming` (`to.externalId` = `cust_5521`). Your backend pushes
   `{ callId }` to that user's app, which rings.
3. Answer: your backend calls `POST /v1/voice/app/calls/:callId/accept { "asExternalId": "cust_5521" }`
   and passes the token back. The customer's app joins. Webhook `voice.app.call.answered`.
   Recording and billing start. Decline: `POST /v1/voice/app/calls/:callId/reject { "asExternalId": "cust_5521" }`.
4. Hang up: `POST /v1/voice/app/calls/:callId/end { "asExternalId": "..." }`. Webhook
   `voice.app.call.completed`, whose `durationSeconds` is the billed talk time.

Unanswered after 45 seconds: `voice.app.call.missed`, and the call costs nothing.

#### Requests

| Action | Request |
|---|---|
| Place a call | `POST /v1/voice/app/calls { "type": "app", "from", "to" }` (`409` if these two already have a live call) |
| Callee picks up | `POST /v1/voice/app/calls/:callId/accept { "asExternalId" }` |
| Callee declines | `POST /v1/voice/app/calls/:callId/reject { "asExternalId", "reason"? }` |
| New token after a network drop | `POST /v1/voice/app/calls/:callId/token { "asExternalId" }` |
| Hang up as an app user | `POST /v1/voice/app/calls/:callId/end { "asExternalId" }` |
| End from your backend | `POST /v1/voice/app/calls/:callId/end {}` |
| Call details / your call history | `GET /v1/voice/app/calls/:callId` / `GET /v1/voice/app/calls?identityExternalId=pro_991` |
| Recording (1 hour link) / transcript | `GET /v1/voice/app/calls/:callId/recording` / `GET /v1/voice/app/calls/:callId/transcript` |

An app losing its connection does not end the call, because that is not hanging up. When someone
hangs up in your app, call `end`: billing stops when the call ends. Calling it twice is safe.
Every answered call is recorded from pickup and transcribed a few seconds after it ends. Fetch
the recording once the call's `recordingReady` is `true`. The transcript's `state` is `ready`
(`turns` filled), `processing` (ask again shortly), `no_speech` (answered, nothing said, not
charged) or `not_answered`. Every call is also listed in Dashboard → Voice → Calls → App calls
with its recording, transcript and cost.

| `status` | Meaning |
|---|---|
| `RINGING` | Nobody has picked up yet |
| `ACCEPTED` | Picked up, both people connected |
| `COMPLETED` | A connected call ended. The only status that is charged |
| `CANCELLED` | Ended while still ringing |
| `REJECTED` | Declined |
| `MISSED` | Nobody answered in time |
| `FAILED` | The call could not be placed |

| `endReason` | Meaning |
|---|---|
| `completed` | An app user hung up a connected call |
| `caller_cancelled`, `callee_cancelled` | Hung up in the app while still ringing |
| `callee_rejected` | Declined, no reason given |
| `ring_timeout` | Not answered within 45 seconds |
| `ended_by_server` | Your backend ended it without `asExternalId` or a reason |
| `room_closed` | Everyone left without hanging up |
| your text | The `reason` you sent to `end` or `reject` |

#### Events

| Event | When | Your backend |
|---|---|---|
| `voice.app.call.incoming` | An app user is being rung | Tell that user's app to ring (`to.externalId`) |
| `voice.app.call.answered` | Picked up | Start the talk timer |
| `voice.app.call.completed` | Ended (`COMPLETED`, `CANCELLED` or `FAILED`) | Save the outcome and talk time |
| `voice.app.call.missed` | Nobody answered | Stop ringing screens |
| `voice.app.call.rejected` | Declined | Stop ringing screens |

```json
{
  "type": "voice.app.call.completed",
  "data": {
    "callId": "apc_7f31c9a2",
    "status": "COMPLETED",
    "from": "pro_991",
    "to": "cust_5521",
    "callerLegTransport": "WEBRTC",
    "calleeLegTransport": "WEBRTC",
    "durationSeconds": 92,
    "endReason": "completed",
    "endedAt": "2026-09-16T10:12:51.000Z"
  }
}
```

On `voice.app.call.incoming`, `from` and `to` are objects (`{ "externalId", "displayName", "role" }`);
on every other event they are plain strings. Reply `200` straight away and do the work after. A
failed delivery is retried, so skip repeats by event `id`.

#### Pricing

| | App to app |
|---|---|
| What is charged | Telephony: talk time, per minute, at your account's voice rate. The call's transcript is part of telephony |
| When the clock runs | From pickup (`voice.app.call.answered`) to hang-up. Ring time is free |
| Rounding | Each started minute counts as a full minute. A connected call is at least 1 minute |
| Free | Calls that are missed, declined, cancelled while ringing, or fail |
| Transcript | Part of telephony, per started minute of talk time, on its own billing line: `Call telephony (transcript)`. Not charged when nothing was said |
| How you pay | Deducted from your wallet when the call ends (Dashboard → Billing) |
| Where to see it | `costPaise` on every call object (talk time plus transcript, in paise; `null` until the call ends, `0` when not charged), the App calls tab in Dashboard → Voice → Calls, and one Billing line per charge |

Example: a call answered and hung up after 92 seconds (`durationSeconds: 92`) is charged as 2 minutes.
Billing lines read like `Voice call, app to app: 2 min (call 7f31c9a2)` and
`Call telephony (transcript), app to app: 2 min (call 7f31c9a2)`.

#### Bookings (optional)

Calls can also be placed on a booking, with `POST /v1/voice/app/calls
{ "externalRef", "fromExternalId" }` and no `type`. That needs the people and the
booking registered first, and adds two things: Slide rings registered devices by
push, and matches a customer who dials your number back to their open booking.
Integrations built on it keep working unchanged. You do not need it for app calls
or phone calls.

| Method | Path | Scope |
|---|---|---|
| POST | `/v1/voice/app/identities` | `voice:app:write` |
| GET | `/v1/voice/app/identities` | `voice:app:read` |
| GET | `/v1/voice/app/identities/:externalId` | `voice:app:read` |
| DELETE | `/v1/voice/app/identities/:externalId` | `voice:app:write` |
| POST | `/v1/voice/app/identities/:externalId/devices` | `voice:app:write` |
| DELETE | `/v1/voice/app/identities/:externalId/devices/:token` | `voice:app:write` |
| POST | `/v1/voice/app/sessions` | `voice:app:write` |
| GET | `/v1/voice/app/sessions` | `voice:app:read` |
| GET | `/v1/voice/app/sessions/:externalRef` | `voice:app:read` |
| DELETE | `/v1/voice/app/sessions/:externalRef` | `voice:app:write` |

### Voice External: App to Phone and Phone to App

Calls where at least one person is on a real phone. They use the same two fields as app to app:
`type` says where the other person picks up (`app` or `phone`), and `to` says who. Phones always
see your business number, never the other person's. Joining from the app, answering and hanging up
in the app work exactly as in **Voice External: App to App**.

| The call starts | It rings | Your backend sends |
|---|---|---|
| In your app | A phone | `POST /v1/voice/app/calls { "type": "phone", "from": "pro_991", "to": "+919876543210" }` |
| A phone dials your number | Your app | `POST /v1/voice/app/calls/:callId/connect { "type": "app", "to": "pro_991" }` |
| A phone dials your number | Another phone | `POST /v1/voice/app/calls/:callId/connect { "type": "phone", "to": "+919812345678" }` |

**Charged per minute, plus telephony for each phone leg** (each side that is on a phone). See Pricing below.

#### One-time setup

Everything in App to App setup, plus:

| Step | Needed for | Where |
|---|---|---|
| 1. Turn on phone calling | All three | Dashboard → Voice → Routing → How calls reach people, or `PATCH /v1/voice/app/settings { "pstnEnabled": true }` |
| 2. Send your business number to your app | Calls to your number | Dashboard → Voice → Routing → Your app, or `PATCH /v1/voice/app/numbers/:number { "routeTo": "app" }` |
| 3. Subscribe your webhook to `voice.app.call.inbound` | Calls to your number | Dashboard → Webhooks. Without it, calls to your number ring your team instead |

Until phone calling is on, anything that rings a phone is refused with `403`.

#### App to Phone

1. Your backend: `POST /v1/voice/app/calls { "type": "phone", "from": "pro_991", "to": "+919876543210" }`.
   The pro's app joins and hears it ringing.
2. The customer's phone rings, showing your business number.
3. Picked up: webhook `voice.app.call.answered`. Recording and billing start.
4. The pro hangs up with `end { "asExternalId": "pro_991" }`, or the customer hangs up the phone
   and the call ends by itself. Webhook `voice.app.call.completed`.

| What the phone did | Event | `status` | `endReason` |
|---|---|---|---|
| Picked up | `voice.app.call.answered` | `ACCEPTED` | |
| Hung up after talking | `voice.app.call.completed` | `COMPLETED` | `callee_hung_up` |
| Busy | `voice.app.call.rejected` | `REJECTED` | `busy` |
| Declined | `voice.app.call.rejected` | `REJECTED` | `declined` |
| Not answered in 40 seconds | `voice.app.call.missed` | `MISSED` | `no_answer` |
| Could not be rung | `voice.app.call.completed` | `FAILED` | `dial_failed` |

Errors: `400` invalid number (use international format) or no active number to call from, `403`
phone calling is off, `409` this app user already has a live call with this number.

#### Phone to App

1. A customer dials your business number and stays on the line.
2. Webhook `voice.app.call.inbound` with `callId`, `callerNumber` (their full number) and
   `calledNumber` (yours). Your backend looks up who they are.
3. Your backend: `POST /v1/voice/app/calls/:callId/connect { "type": "app", "to": "pro_991" }`.
4. From here it is the app-to-app flow from step 2: `voice.app.call.incoming` for `pro_991`, your
   push, `accept`, talk. If the customer hangs up, the call ends by itself.

The customer hears no ringing tone while the app rings.

#### Phone to Phone

Someone dials your business number and you connect them to another phone. It works in either
direction: a customer reaching their pro, or a pro reaching their customer.

1. The pro dials your number from their own phone.
2. Webhook `voice.app.call.inbound` with `callerNumber` = the pro's number. Your backend finds
   their current job.
3. Your backend: `POST /v1/voice/app/calls/:callId/connect { "type": "phone", "to": "+919876543210" }`.
   The pro hears it ringing; the customer's phone rings.
4. Picked up: `voice.app.call.answered`. Recording and billing start. Either person hangs up and
   the call ends for both: `voice.app.call.completed` with `endReason` `caller_hung_up` or `callee_hung_up`.

One webhook handler covers both kinds of call to your number:

```typescript
import { SlideClient, constructEvent } from '@synquic/slide';

const slide = new SlideClient({ apiKey: process.env.SLIDE_API_KEY! });

app.post('/slide/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  const event = constructEvent<any>(req.body, req.get('X-Slide-Signature') || '', process.env.SLIDE_WEBHOOK_SECRET!);
  res.sendStatus(200); // answer first, then act

  if (event.type === 'voice.app.call.inbound') {
    const { callId, callerNumber } = event.data;
    const job = await findOpenJobByPhone(callerNumber); // your own lookup

    if (!job) {
      await slide.voiceApp.endCall(callId, null, 'no_open_job'); // hang up on an unknown caller
    } else if (callerNumber === job.customerPhone) {
      await slide.voiceApp.connectCall(callId, { type: 'app', to: job.proId });        // ring the pro's app
    } else {
      await slide.voiceApp.connectCall(callId, { type: 'phone', to: job.customerPhone }); // ring the customer's phone
    }
  }

  if (event.type === 'voice.app.call.incoming') {
    await sendMyPush(event.data.to.externalId, { callId: event.data.callId }); // ring that user's app
  }
});
```

#### Connect a call to your number

`POST /v1/voice/app/calls/:callId/connect { "type": "app" | "phone", "to": "..." }` (`voice:app:write`).
Connect within `connectWithinSeconds` (45) of the call arriving, or the caller is disconnected
with `endReason: not_connected`. A phone you connect rings for whatever is left of those 45
seconds. Errors: `400` the call did not come in on your number, has ended, or the number is
invalid; `403` phone calling is off; `404` no such call; `409` already connected (only the first
connect counts).

#### Your numbers

```http
GET   /v1/voice/app/numbers
PATCH /v1/voice/app/numbers/%2B918031704204   { "routeTo": "app" }
```

| `routeTo` | Calls to the number |
|---|---|
| `app` | Your backend decides who picks up, through `voice.app.call.inbound`. Not subscribed to it? Calls ring your team instead. |
| `team` | Ring your team in the Slide dashboard. |
| `ai` | Answered by the voice agent assigned to the number. |

#### Hanging up a phone

A phone that hangs up ends the call by itself, and the other side is disconnected. Nobody on a
phone can use the API, so to cut a call short, end it from your backend with
`POST /v1/voice/app/calls/:callId/end {}` (no `asExternalId`). That is the only way to end a
phone-to-phone call early.

| `endReason` | Meaning |
|---|---|
| `caller_hung_up` | The person who dialled your number hung up |
| `callee_hung_up` | The phone that was rung hung up |
| `busy`, `declined`, `no_answer` | The phone that was rung was busy, declined, or not answered |
| `dial_failed` | The phone could not be rung |
| `not_connected` | A call to your number was not connected within 45 seconds |

#### Events

`voice.app.call.inbound` fires when someone dials your number and is waiting: call `connect`, or
`end` to hang up. The five call events are the same as app to app; `incoming` only fires when an
app is rung.

```json
{
  "type": "voice.app.call.inbound",
  "data": {
    "callId": "apc_9c02e4b1",
    "roomId": "voice-...",
    "status": "RINGING",
    "callerNumber": "+919876543210",
    "calledNumber": "+918031704204",
    "callerLegTransport": "PSTN",
    "connectWithinSeconds": 45,
    "startedAt": "2026-09-16T10:11:12.000Z"
  }
}
```

`callerLegTransport` and `calleeLegTransport` say how each side is connected: `WEBRTC` in your app,
`PSTN` on a phone. A person on a phone appears as a masked number such as `+91******3210`; the full
number is sent only once, as `callerNumber` on `voice.app.call.inbound`. A late
`voice.app.call.inbound` for a caller who already hung up gets `400` from `connect`, which is safe to ignore.

#### Pricing

| | App to phone | Phone to app | Phone to phone |
|---|---|---|---|
| Telephony for talk time, per minute | Yes | Yes | Yes |
| Telephony for each phone leg, per minute | 1 phone leg | 1 phone leg | 2 phone legs |

Every kind of call is charged as telephony, per minute of talk time from pickup to hang-up, at your
account's voice rate. On top of that, each side of the call that is on a phone adds telephony for
that phone leg, per minute. The App to App pricing rules apply: each started minute counts as a
full minute, a connected call is at least 1 minute, ring time and calls nobody answers cost nothing,
the transcript is part of telephony (its own billing line, `Call telephony (transcript)`), and the
total is deducted from your wallet when the call ends.

Example: a phone-to-phone call that talks for 3 minutes 10 seconds is charged 4 minutes of talk
time plus 4 minutes of telephony for each of the two phone legs.

#### Endpoints (both sections)

| Method | Path | Scope |
|---|---|---|
| POST | `/v1/voice/app/calls` | `voice:app:write` |
| GET | `/v1/voice/app/calls` | `voice:app:read` |
| GET | `/v1/voice/app/calls/:callId` | `voice:app:read` |
| POST | `/v1/voice/app/calls/:callId/connect` | `voice:app:write` |
| POST | `/v1/voice/app/calls/:callId/accept` | `voice:app:write` |
| POST | `/v1/voice/app/calls/:callId/reject` | `voice:app:write` |
| POST | `/v1/voice/app/calls/:callId/token` | `voice:app:write` |
| POST | `/v1/voice/app/calls/:callId/end` | `voice:app:write` |
| GET | `/v1/voice/app/calls/:callId/recording` | `voice:app:read` |
| GET | `/v1/voice/app/calls/:callId/transcript` | `voice:app:read` |
| GET | `/v1/voice/app/numbers` | `voice:app:read` |
| PATCH | `/v1/voice/app/numbers/:number` | `voice:app:write` |
| GET | `/v1/voice/app/settings` | `voice:app:read` |
| PATCH | `/v1/voice/app/settings` | `voice:app:write` |

### Workspace
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/wallet` | `billing:read` |
| GET | `/v1/organization` | `organization:read` |

Two small reads about the key's own workspace (neither takes a workspace from the request).
`GET /v1/wallet` answers `{ "balance": { "amountMinor": "123450", "currency": "INR", "formatted":
"₹1,234.50" }, "displayCurrency": "INR" | "USD" }`: the balance only, no transactions and no card or
payment details. `amountMinor` is a string in paise; the balance is always held in rupees, and
`displayCurrency` is what the workspace's screens show. A workspace that never held money answers
zero, not an error. `GET /v1/organization` answers `{ "id", "name", "plan", "enabledModules" }`:
`plan` is the plan name or `null`, `enabledModules` the module slugs switched on. No tax, address or
billing details.

### Webhooks (managing your own endpoints)
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/webhooks/events` | `webhooks:read` |
| GET | `/v1/webhooks` | `webhooks:read` |
| GET | `/v1/webhooks/:id` | `webhooks:read` |
| POST | `/v1/webhooks` | `webhooks:write` |
| PATCH | `/v1/webhooks/:id` | `webhooks:write` |
| DELETE | `/v1/webhooks/:id` | `webhooks:write` |
| POST | `/v1/webhooks/:id/rotate-secret` | `webhooks:write` |
| POST | `/v1/webhooks/:id/test` | `webhooks:write` |
| GET | `/v1/webhooks/:id/deliveries` | `webhooks:read` |
| GET | `/v1/webhooks/deliveries/:deliveryId` | `webhooks:read` |
| POST | `/v1/webhooks/deliveries/:deliveryId/replay` | `webhooks:write` |

---

## 6. Scopes: request least privilege

Ask for only what the integration genuinely uses. A leaked key scoped to `sms:logs:read`
cannot send anything.

```
contacts:read              contacts:write            contacts:block            contacts:delete
contacts:notes:read        contacts:notes:write
ads:read                   ads:write                 ads:publish
ads:google:read            ads:google:write
chat:conversations:read    chat:conversations:write
billing:read               organization:read
automations:read           automations:write         automations:executions:read
ai:models:read
agents:read                agents:write              agents:publish
whatsapp:send              whatsapp:logs:read        whatsapp:templates:read
whatsapp:templates:write   whatsapp:conversations:read whatsapp:campaigns:read  whatsapp:campaigns:write
email:send                 email:templates:read      email:contacts:read       email:contacts:write
sms:send                   sms:templates:read        sms:senders:read
sms:campaigns:read         sms:campaigns:write       sms:logs:read
rcs:send                   rcs:bots:read             rcs:templates:read
rcs:campaigns:read         rcs:campaigns:write       rcs:logs:read
otp:send                   otp:verify                otp:logs:read             otp:analytics:read
instagram:messages:read    instagram:messages:send   instagram:insights:read
shopify:products:read      shopify:orders:read       shopify:discounts:read
shopify:audit:read         shopify:audit:write
aeo:read                   aeo:write
analytics:events:read      analytics:events:write
voice:agents:read          voice:agents:write        voice:calls:read          voice:calls:write
voice:app:read             voice:app:write
webhooks:read              webhooks:write
```

These scopes spend money or change what customers get, so grant them deliberately:
`agents:publish` (a published agent answers customers on their next message), `ads:publish` (starts,
pauses and resizes live ad spend), `chat:conversations:write` (answers live chats under the key's
name and takes a chat over from the AI agent), `contacts:delete` (deletes a contact for good), `shopify:audit:write`
(paid audits, paid AI suggestions, writes to the store), `aeo:write` (paid checks, weekly checks of
active questions), `analytics:events:write` (adds data to your reports)
and every `:send` scope.

---

## 7. Outbound webhooks

Skip this section entirely if `DO I NEED WEBHOOKS` is `no`.

You register endpoints you own; Slide POSTs signed JSON to them as events occur. Many endpoints
per account are supported, capped at 50, so each consumer registers its own rather than sharing.

### Envelope

Identical for every event type. Route on `type`, never on body shape.

```json
{
  "id": "evt_9f2c4b1e7a3d5f8c0b2e4a6d8f1c3e5a",
  "type": "contact.created",
  "createdAt": "2026-08-20T10:11:12.000Z",
  "accountId": "org_9f2c4b1e",
  "livemode": true,
  "data": { }
}
```

`livemode` is `true` for real account activity and `false` for a test delivery you sent yourself
(the Send test button, or `POST /v1/webhooks/:id/test`), so a receiver can tell the two apart.

### Headers

| Header | Meaning |
|---|---|
| `X-Slide-Event-Id` | Stable across retries and across fan-out to multiple endpoints. **This is your idempotency key.** |
| `X-Slide-Event-Type` | Same as the envelope `type`. |
| `X-Slide-Signature` | HMAC signature. Always verify. |
| `X-Slide-Webhook-Id` | Which of your endpoints this delivery went to. |
| `X-Slide-Delivery-Attempt` | Attempt counter, starting at 1. |

### Signature verification

`X-Slide-Signature: t=1755683472,v1=<hex>` where `<hex>` is
`HMAC_SHA256(secret, "<t>.<raw request body>")`, hex encoded. The signing secret is shown once,
when the endpoint is created, and is prefixed `whsec_`.

**Verify against the raw request body bytes, before any JSON parsing.** Re-serialising a parsed
object reorders keys and changes whitespace, which changes the hash and makes every legitimate
delivery look forged. This is by a wide margin the most common integration failure. In Express,
that means `express.raw` on this route, not `express.json`.

The header may carry more than one `v1=` value during a secret rotation. Accept the delivery if
any of them matches.

```typescript
const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.SLIDE_WEBHOOK_SECRET; // whsec_...

app.post('/webhooks/slide', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.get('X-Slide-Signature') || '';
  const rawBody = req.body.toString('utf8');

  let timestamp = null;
  const candidates = [];
  for (const part of header.split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't') timestamp = Number(value);
    else if (key === 'v1') candidates.push(value);
  }
  if (!timestamp || candidates.length === 0) return res.status(400).send('malformed');

  // Reject stale AND far-future timestamps, so a captured delivery cannot be
  // replayed indefinitely and a receiver with a slow clock still works.
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return res.status(400).send('stale');

  const expected = crypto.createHmac('sha256', SECRET)
    .update(`${timestamp}.${rawBody}`, 'utf8').digest();
  const ok = candidates.some((c) => {
    const given = Buffer.from(c, 'hex');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
  if (!ok) return res.status(401).send('bad signature');

  const event = JSON.parse(rawBody);

  // Acknowledge FIRST, then do slow work. The 10s timeout is a failure.
  res.status(200).send('ok');
  void handleEventIdempotently(event, req.get('X-Slide-Event-Id'));
});
```

### Retries and failure handling

Return any `2xx` to acknowledge. Slide times out after **10 seconds** and treats that as a
failure, so acknowledge before doing slow work. Non-2xx responses are retried up to **7 total
attempts** spanning about 8 hours 45 minutes:

| Attempt | Sent after |
|---|---|
| 1 | Immediately |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 6 hours |

After the last attempt the delivery is marked exhausted, and can be inspected and replayed from
the delivery log via `GET /v1/webhooks/:id/deliveries` and
`POST /v1/webhooks/deliveries/:deliveryId/replay`.

Two behaviours to design for:

- An endpoint that fails **15 deliveries in a row** is automatically disabled. Fixing the
  receiver and re-enabling it clears the failure count.
- Returning **`410 Gone`** is an explicit unsubscribe: the endpoint is deleted immediately with
  no further retries. This is the REST Hooks convention. Do not return 410 casually.

Because `X-Slide-Event-Id` is stable across retries, store processed ids and skip duplicates.
That is the correct fix for "my server timed out but had already done the work".

### Event catalogue

```
contact.created              contact.updated             contact.lifecycle_changed
form.submitted
whatsapp.message.received    whatsapp.message.status
instagram.message.received
sms.message.received
email.message.delivered      email.message.opened        email.message.clicked
email.message.bounced        email.message.complained
voice.call.completed         voice.call.analysed
voice.app.call.inbound       voice.app.call.incoming     voice.app.call.answered
voice.app.call.completed     voice.app.call.missed       voice.app.call.rejected
voice.app.session.expired
shopify.order.created        shopify.order.fulfilled
otp.sent                     otp.verified                otp.failed
```

The three `otp.*` events cover every OTP widget in the workspace. `data.widgetId` says which widget
fired and `data.identifier` is the phone number. The one-time code is never part of a payload, and
a demo credential produces no event.

| Event | Fires when | `data` |
|---|---|---|
| `otp.sent` | A one-time code is sent. `isResend` is `true` for a resend of the same request, including the automatic SMS that follows a WhatsApp code reported as not delivered. | `requestId`, `widgetId`, `identifier`, `channel` (`sms` or `whatsapp`), `isResend`, `sentAt` |
| `otp.verified` | A user enters the correct code and the request is verified. | `requestId`, `widgetId`, `identifier`, `verifiedAt` |
| `otp.failed` | A verification is blocked after too many wrong codes. | `requestId`, `widgetId`, `identifier`, `reason` (`max_attempts_exceeded`), `failedAt` |

`voice.call.analysed` follows `voice.call.completed` for the same call by a few seconds, with the
same `callId`. It is sent once per call, only when the agent has analysis switched on and the other
person spoke. A test call from the agent builder sends nothing.

| Event | Fires when | `data` |
|---|---|---|
| `voice.call.analysed` | The analysis of an AI voice call is ready. | `callId`, `direction` (`INBOUND` or `OUTBOUND`), `from`, `to`, `status`, `durationSeconds`, `summary` (null unless the agent is set to write one), `sentiment` (`POSITIVE`, `NEUTRAL`, `NEGATIVE` or null), `outcome` (`{ key, label }` from the agent's own list, or null), `criteria` (a list of `{ key, label, pass }`), `fields` (an object: each field the agent collects, null when the caller did not state it), `endedAt`, `analysedAt` |

Call `GET /v1/webhooks/events` at runtime for the live catalogue with sample payloads, rather
than hardcoding this list, if you are building a UI that lets a user pick events.

### No-code routes

If the integration does not need custom code, these already exist and may be the better answer:
the **n8n** community node `n8n-nodes-slide-synquic` (trigger node per event, plus action nodes),
direct webhooks for Make / Pipedream / your own server, and a **Zapier** app pending directory
listing.

---

## 8. Real-time events over WebSocket

For live call monitoring and streaming transcripts, Slide runs Socket.IO on the same host as the
REST API. This is a separate channel from webhooks: use webhooks for durable server-to-server
delivery, WebSocket for live UI.

This stream powers the Slide dashboard and only accepts a signed-in dashboard user. Your backend
and your own apps cannot connect to it: they receive events through webhooks (section 7). For calls
in your app, see "How the pieces connect" under Voice App.

```typescript
import { io } from 'socket.io-client';

const socket = io('https://slide.synquic.com', { transports: ['websocket'] });

socket.emit('joinOrganization', 'your-org-id');   // org room: call lifecycle events
socket.emit('joinCall', 'call-id');               // per-call room: live transcript

socket.on('voice:call.initiated', (d) => { /* ... */ });
socket.on('voice:transcript.chunk', (d) => { /* d.role, d.content */ });

socket.emit('leaveCall', 'call-id');
socket.emit('leaveOrganization', 'your-org-id');
```

Voice lifecycle events are emitted to the organisation room `org_{orgId}`. Always leave rooms
you no longer need.

---

## 9. Official SDKs

Prefer these over raw HTTP. Both wrap every endpoint in a resource-based API and are fully typed.

**TypeScript / Node 18+**: `npm install @synquic/slide`

```typescript
import { SlideClient } from '@synquic/slide';

const slide = new SlideClient({ apiKey: process.env.SLIDE_API_KEY! });

await slide.messages.send({ to: '+919876543210', channel: 'whatsapp', /* ... */ });
await slide.sms.send({ /* ... */ });
await slide.otp.send({ /* ... */ });
await slide.rcs.send({ /* ... */ });
```

Resources include `messages`, `contacts`, `whatsapp`, `email`, `sms`, `rcs`, `otp`, `instagram`,
`shopify`, `voice`, `analytics` and `webhooks`. The SDK also ships a webhook signature verifier, so use that
instead of hand-rolling the HMAC if you are in TypeScript.

**Python 3.9+**: `pip install synquic-slide`. Sync and async clients, type hints via TypedDict,
context manager support, and the same webhook verification helper.

**Website events** need `@synquic/slide` 0.6 or `synquic-slide` 0.4. `track` returns at once and sends
in batches of up to 50, retrying with the same `insertId`, so it never slows your request and a retry
never counts twice. A serverless function must call `flush()` before it returns.

```typescript
slide.analytics.track({ name: 'pay', visitorId, sessionId, user: { id: 'u_123' },
  valueMinor: 199900, currency: 'INR', properties: { plan: 'pro' } });
await slide.analytics.flush();   // serverless only
```

```python
slide.analytics.track("pay", visitor_id=visitor_id, session_id=session_id, user_id="u_123",
                      value_minor=199900, currency="INR", properties={"plan": "pro"})
slide.analytics.flush()          # serverless only
```

---

## 10. MCP: driving Slide from an AI assistant

Slide ships an official **MCP server**, so an MCP client (Claude Code, Claude Desktop, Cursor,
or your own agent) can read and act on Slide data directly. Two ways to run it, same tools:

### Option A: local, over stdio

Runs on your machine, so the API key never leaves it.

```json
{
  "mcpServers": {
    "slide": {
      "command": "npx",
      "args": ["-y", "@synquic/slide-mcp"],
      "env": { "SLIDE_API_KEY": "sk_live_..." }
    }
  }
}
```

### Option B: hosted, over Streamable HTTP

Nothing to install. Point an MCP client at `https://api.slide.cx/mcp` and authenticate
with the same API key:

```
POST https://api.slide.cx/mcp
Authorization: Bearer sk_live_...
Content-Type: application/json
```

The protocol revision is `2025-06-18`. The server issues an `Mcp-Session-Id` on `initialize`
and never opens server-initiated streams, so a `GET` on that URL answers `405` by design.

### Writes are off by default

Read tools are always available. Tools that send messages, launch campaigns, place calls or
block contacts are **withheld unless you explicitly arm them**, because an accidental
`send_message` reaches a real customer and cannot be recalled.

- stdio: set `SLIDE_MCP_ENABLE_WRITES=true` in the `env` block. Only the exact string `true`
  arms them.
- hosted: send the header `X-Slide-MCP-Writes: true`.

Calling a withheld tool returns a message that says so, rather than "unknown tool", so a missing
flag is not mistaken for a typo.

### What the tools can do

96 tools in all.

58 tools, always on:

- 32 reads of the messaging surface: contacts; templates, campaigns, logs and stats across
  WhatsApp, email, SMS, RCS and OTP; WhatsApp message and conversation lookup; Instagram profile,
  conversations and insights; Shopify products, collections, order status, customer order history
  and discount validation; voice agents, calls, recordings and analytics; webhook endpoints, the
  event catalogue and delivery logs.
- 25 reads of the rest of the platform: automations (list, get, run history, node catalogue),
  contact notes, the AI model catalogue, AI agents (agents, versions, tool catalogue, runs,
  performance), the Shopify store audit (audits, price, issues, fixes, apply progress), AI search
  (summary, answers), website events, website funnels and cohorts, and ads (`read_ads`: connection,
  accounts, campaigns, ad sets, ads, daily results, summary and leads, in one tool with a `view`).
- `slide_api_request`, which reaches any endpoint of this API: `GET` always, any other method only
  when writes are armed. Routes without a named tool are reached this way, for example: live chat
  conversations, tag and segment lists, the wallet balance and the organization
  (`GET`), and the contact update and delete, campaign create, ad start, pause and budget changes
  (writes, each still needing its own scope; the ad changes need `ads:publish`).

38 write tools, opt-in:

- 15 for messaging: `send_message` (the unified endpoint, with fallback), the per-channel sends,
  `send_otp` and `verify_otp`, `set_contact_blocked`, campaign create, launch and cancel,
  `place_outbound_call`, `create_webhook_endpoint` and `replay_webhook_delivery`.
- 23 for the rest of the platform: automations (create, update, switch on and off, delete),
  contact notes (add, delete), WhatsApp template submission, voice agents (create, update,
  delete), AI agents (create, edit a draft, publish, roll back), the store audit (start an audit,
  suggest, edit, apply and undo fixes, change an issue's status), AI search (run a check, add a
  question) and sending a website event. Several of these spend from the wallet or change what customers get.

### The security property worth knowing

Every tool call goes over the real `/v1` HTTP surface carrying your own key. It passes through
the same key guard, scope guard, rate limiter and IP allow-list a REST client hits. **An MCP
session can never reach anything the key could not already reach**, and a key scoped to
`contacts:read` will get a `403` from a send tool exactly as it would from `curl`.

### A separate feature, easy to confuse

Slide is also an MCP *client*: under **Settings → MCP Servers** you can register remote MCP
servers you own, and Slide's own voice and AI agents will call their tools during a
conversation. That is the opposite direction from everything above. Configuration there is a
`serverUrl`, an optional encrypted `apiKey`, and a name; the slug is derived from the server's
advertised name or its hostname and is immutable. Tools reach the agent as
`mcp__<slug>__<tool>`, capped at 64 characters, with at most 48 tools per server per run.

## 11. Build it

Now write the integration. Structure it as:

1. **Config module**: reads `SLIDE_API_KEY` (and `SLIDE_WEBHOOK_SECRET` if webhooks are in
   scope) from the environment, fails loudly at startup if absent. No key literals anywhere.
2. **Client wrapper**: one place that constructs the SDK client or the fetch wrapper, applies
   the timeout, and applies the `Retry-After`-aware backoff from section 2.
3. **Feature code**: whatever `WHAT I WANT TO DO` describes, using One API for sends where it
   fits.
4. **Webhook receiver** (only if in scope): raw body, signature verified, acknowledge fast,
   process idempotently by `X-Slide-Event-Id`.
5. **Tests**: at minimum, a signature verification test with a known-good fixture, and a test
   that a non-2xx from Slide surfaces as a typed error rather than a silent success.

## Definition of done

Report on each of these explicitly. Say "not verified" where that is the truth.

```
□ No API key, webhook secret, or org id is hardcoded anywhere in the diff
□ Every Slide call has a timeout and a handled error path
□ 429 handling honours Retry-After, with jittered exponential backoff as fallback
□ The key's scopes are the minimum the integration actually uses, and are listed in the README
□ Sends that need reliability use One API with an explicit fallback chain, and read attempts[]
□ Webhook receiver verifies the signature against RAW bytes, before JSON parsing
□ Webhook receiver rejects timestamps drifting more than 300 seconds
□ Webhook receiver acknowledges within 10 seconds and processes asynchronously
□ Webhook handler is idempotent, keyed on X-Slide-Event-Id
□ Webhook handler never returns 410 unless it genuinely wants to be deleted
□ Pagination is respected on every list call: nothing assumes one page is all the data
□ The integration was exercised against real API responses, not only type-checked
```
