# 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`.

---

` 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://slide.synquic.com/api/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 a fixed set of scopes chosen at creation time; scopes cannot be changed later without
re-issuing the key.

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

- `rateLimitPerMinute` — the per-minute request ceiling for that key.
- `ipWhitelistEnabled` + `allowedIps` — requests from any other source IP are rejected `403`,
  and the rejecting response names the offending IP so the list can be corrected.

---

## 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://slide.synquic.com/api`. 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://slide.synquic.com/api/mcp`, without the `/v1` segment. See section 10.

### Contacts
| Method | Path | Scope |
|---|---|---|
| GET | `/v1/contacts` | `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` takes `page` (default 1), `limit` (default 50, max 100) and `search`
(fuzzy across name, username, email, phone).

### 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.

### WhatsApp
| Method | Path | Scope |
|---|---|---|
| POST | `/v1/whatsapp/send-template` | `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` |

### 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` |

### 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` |

### 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` |

### 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` |

### 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` |

### Voice App (in-app calling: identities, devices, sessions, 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` |
| 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` |
| GET | `/v1/voice/app/calls/:callId/recording` | `voice:app:read` |
| GET | `/v1/voice/app/calls/:callId/transcript` | `voice:app:read` |
| POST | `/v1/voice/app/calls/:callId/accept` | `voice:app:write` |
| POST | `/v1/voice/app/calls/:callId/token` | `voice:app:write` |
| POST | `/v1/voice/app/calls/:callId/reject` | `voice:app:write` |
| POST | `/v1/voice/app/calls/:callId/end` | `voice:app:write` |

### 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:block            contacts:notes:read       contacts:notes:write
automations:read           automations:write         automations:executions:read
ai:models:read
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
voice:agents:read          voice:agents:write        voice:calls:read          voice:calls:write
voice:app:read             voice:app:write
webhooks:read              webhooks:write
```

---

## 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",
  "data": { }
}
```

### 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.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
```

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.

```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` 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.

---

## 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://slide.synquic.com/api/mcp` and authenticate
with the same API key:

```
POST https://slide.synquic.com/api/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

32 read tools, always on: 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.

15 write tools, opt-in: `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`.

### 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
```
