Slide API
Integrate email campaigns, Instagram messaging, WhatsApp campaigns, SMS, RCS, OTP verification, Shopify commerce, AI voice calling, and real-time WebSocket events into your own applications using the Slide REST API.
Introduction
The Slide API is a RESTful API over HTTPS. Every response body is JSON. Requests that include a body must set Content-Type: application/json.
| Property | Value |
|---|---|
| Base URL | https://api.slide.cx/v1 |
| Protocol | HTTPS only |
| Format | JSON (application/json) |
| API Version | v1 |
| Auth scheme | Bearer token (API key) |
2026-03-24T10:00:00.000Z). Pagination follows a consistent { data, meta } envelope./messages/send endpoint, or jump straight to the technical reference below.SDKs & Code Examples
Official client libraries for TypeScript/Node.js and Python. Both are fully typed, zero-configuration, and wrap every endpoint in a resource-based API. The latest releases add sms, rcs, and otp resources, the unified messages resource for the One API endpoint, plus WhatsApp conversation and campaign methods: client.messages.send(...), client.sms.send(...), client.otp.send(...), client.rcs.send(...), and more. If you prefer raw HTTP, jump to the Without an SDK section below.
TypeScript SDK with full type inference, ESM + CJS dual build, zero runtime dependencies. Works in Node.js 18+ and any modern runtime.
npm install @synquic/slide # or: pnpm add @synquic/slide # or: yarn add @synquic/slide
Python SDK with sync and async clients, full type hints via TypedDict, context manager support. Requires Python 3.9+ and httpx.
pip install synquic-slide
TypeScript SDK
Install the package and instantiate a client with your API key. All methods are fully typed and return typed response objects.
import { SlideClient } from '@synquic/slide'; const slide = new SlideClient({ apiKey: process.env.SLIDE_API_KEY! }); // Send a transactional email const sent = await slide.email.send({ templateId: 'tmpl_welcome', recipient: { to: 'user@example.com', firstName: 'Rahul' }, fromName: 'Acme Store', fromEmail: 'orders@acme.com', variables: { orderId: '#1042', total: '₹2,499' }, }); console.log(sent.messageId, sent.status); // Send a WhatsApp template const msg = await slide.whatsapp.sendTemplate({ to: '+919876543210', templateName: 'order_shipped', languageCode: 'en', }); console.log(msg.wamid); // Initiate an outbound AI voice call const call = await slide.voice.initiateOutboundCall({ agentId: 'va_01JXYZ...', toNumber: '+919876543210', callContext: { customerName: 'Priya', purpose: 'appointment_confirmation' }, }); console.log(call.id, call.status); // vc_01J... INITIATED // List contacts with pagination const contacts = await slide.contacts.list({ page: 1, limit: 50 }); console.log(contacts.meta.total, contacts.data.length); // Shopify - validate discount code const discount = await slide.shopify.validateDiscount({ code: 'SAVE20' }); if (discount.valid) { console.log(`${discount.value}% off, expires ${discount.expiresAt}`); } // Instagram - get profile and send a DM const profile = await slide.instagram.getProfile(); await slide.instagram.sendMessage({ recipientIgsid: '123456789', message: 'Thanks for reaching out!', }); // SMS, RCS, and OTP await slide.sms.send({ senderId: 'ACMEIN', to: ['919876543210'], message: 'Your order has shipped!', }); const otp = await slide.otp.send({ widgetId: 'wgt_01J...', identifier: '+919876543210' }); await slide.otp.verify({ requestId: otp.requestId, otp: '123456' }); await slide.rcs.send({ to: '919876543210', templateId: 'rcst_01J...' }); // WhatsApp conversations and campaigns const history = await slide.whatsapp.getConversationByPhone({ phone: '919876543210', limit: 50 }); const campaigns = await slide.whatsapp.listCampaigns({ page: 1, limit: 20 });
Error Handling
import { SlideClient, SlideAuthError, SlideScopeError, SlideValidationError, SlideNotFoundError, SlideError, } from '@synquic/slide'; const slide = new SlideClient({ apiKey: process.env.SLIDE_API_KEY! }); try { await slide.email.send({ /* ... */ }); } catch (err) { if (err instanceof SlideAuthError) { console.error('Invalid API key - check SLIDE_API_KEY'); } else if (err instanceof SlideScopeError) { console.error('Missing scope:', err.message); // e.g. "email:send" } else if (err instanceof SlideValidationError) { console.error('Bad request:', err.body); } else if (err instanceof SlideNotFoundError) { console.error('Resource not found'); } else if (err instanceof SlideError) { console.error(`HTTP ${err.statusCode}:`, err.message); } }
Python SDK
The Python SDK ships two client classes: SlideClient (synchronous, backed by httpx.Client) and AsyncSlideClient (async, backed by httpx.AsyncClient). Both support context managers.
Sync Client
import os from synquic_slide import SlideClient slide = SlideClient(api_key=os.environ["SLIDE_API_KEY"]) # Send a transactional email slide.email.send( recipient={"to": "user@example.com", "firstName": "Rahul"}, template_id="tmpl_welcome", from_name="Acme Store", from_email="orders@acme.com", variables={"orderId": "#1042", "total": "₹2,499"}, ) # Send a WhatsApp template msg = slide.whatsapp.send_template( to="+919876543210", template_name="order_shipped", language_code="en", ) print(msg["wamid"]) # Initiate an outbound AI voice call call = slide.voice.initiate_outbound_call( agent_id="va_01JXYZ...", to_number="+919876543210", call_context={"customerName": "Priya", "purpose": "appointment_confirmation"}, ) print(call["id"], call["status"]) # vc_01J... INITIATED # List contacts contacts = slide.contacts.list(page=1, limit=50) print(contacts["meta"]["total"]) # Validate a Shopify discount code discount = slide.shopify.validate_discount(code="SAVE20") if discount["valid"]: print(f'{discount["value"]}% off, expires {discount["expiresAt"]}') # SMS, RCS, and OTP slide.sms.send(sender_id="ACMEIN", to=["919876543210"], message="Your order has shipped!") otp = slide.otp.send(widget_id="wgt_01J...", identifier="+919876543210") slide.otp.verify(request_id=otp["requestId"], otp="123456") slide.rcs.send(to="919876543210", template_id="rcst_01J...")
Async Client
import asyncio import os from synquic_slide import AsyncSlideClient async def main(): async with AsyncSlideClient(api_key=os.environ["SLIDE_API_KEY"]) as slide: # Fetch contacts and send a WhatsApp message concurrently contacts, agents = await asyncio.gather( slide.contacts.list(page=1, limit=20), slide.voice.list_agents(), ) print(f'{contacts["meta"]["total"]} contacts, {agents["meta"]["total"]} agents') call = await slide.voice.initiate_outbound_call( agent_id=agents["data"][0]["id"], to_number="+919876543210", call_context={"customerName": "Priya"}, ) print(call["id"], call["status"]) asyncio.run(main())
Error Handling
from synquic_slide import ( SlideClient, SlideAuthError, SlideScopeError, SlideNotFoundError, SlideValidationError, SlideError, ) slide = SlideClient(api_key="sk_live_...") try: slide.email.send(...) except SlideAuthError: print("Invalid API key - check SLIDE_API_KEY") except SlideScopeError as e: print(f"Missing scope: {e}") # e.g. "email:send" except SlideValidationError as e: print(f"Bad request: {e.body}") except SlideNotFoundError: print("Resource not found") except SlideError as e: print(f"HTTP {e.status_code}: {e}")
Without an SDK
The Slide API is a standard HTTP REST API - any HTTP client works.
curl
export SLIDE_API_KEY="sk_live_YOUR_API_KEY" export SLIDE_BASE="https://api.slide.cx/v1" # Send an email using a template curl -X POST "$SLIDE_BASE/email/send" \ -H "Authorization: Bearer $SLIDE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "YOUR_TEMPLATE_ID", "recipient": { "to": "hello@example.com", "firstName": "Alex" }, "variables": { "company": "Acme" }, "fromName": "Acme", "fromEmail": "hi@acme.com" }' # Initiate an outbound voice call curl -X POST "$SLIDE_BASE/voice/calls/outbound" \ -H "Authorization: Bearer $SLIDE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "agentId": "va_01JXYZ...", "toNumber": "+919876543210" }'
Node.js fetch
const BASE = 'https://api.slide.cx/v1'; const KEY = process.env.SLIDE_API_KEY!; const headers = { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json', }; // Send email const emailRes = await fetch(`${BASE}/email/send`, { method: 'POST', headers, body: JSON.stringify({ templateId: 'YOUR_TEMPLATE_ID', recipient: { to: 'user@example.com', firstName: 'Alex' }, variables: { company: 'Acme Inc' }, fromName: 'Acme Inc', fromEmail: 'hi@acme.com', }), }); const email = await emailRes.json(); console.log(email); // { messageId, status, to, templateId, templateName } // Search Shopify products const prodRes = await fetch(`${BASE}/shopify/products/search?q=sneakers&limit=5`, { headers }); const products = await prodRes.json(); console.log(products.data); // [{ id, title, vendor, variants }, ...]
Authentication
Every API request must include a valid API key in the Authorization header as a Bearer token. Keys are prefixed with sk_live_ for production.
To create an API key, navigate to Dashboard → API Keys or visit slide.synquic.com/dashboard/api-keys.
# Include in every request curl https://api.slide.cx/v1/email/send \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json"
Scopes
Each API key is issued with a specific set of scopes that determine what operations it can perform. Attempting an operation without the required scope returns 403 Forbidden.
| Scope | Description |
|---|---|
| contacts:read | List and search the unified contacts database |
| contacts:block | Block or unblock a contact across every channel |
| email:send | Send transactional or campaign emails |
| email:templates:read | List and retrieve email templates |
| instagram:messages:read | Read Instagram conversations and profile |
| instagram:messages:send | Send Instagram direct messages |
| instagram:insights:read | Read Instagram analytics and insights |
Rate Limits
Each API key has a configurable per-minute limit, rateLimitPerMinute. The default is 60 requests per minute, and it can be set anywhere from 1 to 10,000 per key in Admin Settings → API Keys (IP and rate settings).
Rate limit state is tracked on a sliding 60 second window, per key and per client IP. Two servers calling the API with the same key from different IPs each get their own window.
| Header | Description |
|---|---|
X-RateLimit-Limit | The configured requests-per-minute limit for this key |
X-RateLimit-Remaining | Requests remaining in the current 60 second window |
Retry-After | Seconds to wait before retrying (on 429 responses only) |
X-RateLimit-Limit and X-RateLimit-Remaining are returned on every request. When the limit is exceeded, the API returns HTTP 429 Too Many Requests with a Retry-After header:
{
"statusCode": 429,
"error": "Too Many Requests",
"message": "Rate limit exceeded. Try again in 23 seconds.",
"retryAfter": 23
}Recommended Client Behavior
On a 429 response, honor the Retry-After header and wait that many seconds before retrying. For repeated 429s, apply exponential backoff with jitter. Never retry in a tight loop.
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; // Honor Retry-After, fall back to exponential backoff with jitter 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'); }
POST /v1/otp/send and POST /v1/otp/retry) additionally throttle per identifier: each phone number is limited to a maximum number of sends per hour, configured on the OTP widget. This applies on top of the key's per-minute limit.IP Whitelisting & Key Security
Every API key can be locked down to a fixed set of source IP addresses. Set allowedIps (IPv4 addresses or ranges, up to 20) when you create a key, or later with Edit on the key in API Keys. An empty list allows any IP. When a list is set, requests from any other IP are rejected with 403 Forbidden, and the offending IP is included in the error message so you can update the list.
{
"statusCode": 403,
"error": "Forbidden",
"message": "Request from IP 203.0.113.42 is not allowed for this API key"
}x-forwarded-for). An address you put in that header yourself is ignored, so a key stays locked to the true origin IP. If your servers reach us through a proxy or NAT, allow its public address.Key Security Model
| Feature | Description |
|---|---|
| Environments | Keys are issued as live (sk_live_) or test (sk_test_) and are scoped to that environment |
| Expiry | Keys support an optional expiresAt date, expired keys are rejected with 401 |
| One-time display | The plaintext key is shown exactly once at creation, only a SHA-256 hash is stored |
| Usage logs | Every request is logged per key: endpoint, HTTP status, source IP, and duration |
Best Practices
| Practice | Why |
|---|---|
| Grant least-privilege scopes | A leaked key with only sms:logs:read cannot send messages |
| Rotate keys periodically | Create a new key, migrate traffic, then revoke the old one |
| Use a separate key per integration | Per-key usage logs make it easy to trace and revoke a single consumer |
| Enable IP whitelisting on server keys | Even a leaked key is useless outside your infrastructure |
Error Codes
All errors follow the same JSON envelope. The message field is human-readable and safe to display to end users.
{
"statusCode": 400,
"error": "Bad Request",
"message": "recipient.to must be a valid email address"
}| HTTP Code | Meaning | Common Cause |
|---|---|---|
| 200 OK | Request succeeded | Standard successful response |
| 201 Created | Resource created | Contact or resource was created |
| 400 Bad Request | Invalid input | Missing required field, bad format |
| 401 Unauthorized | Missing or invalid API key | No Authorization header |
| 403 Forbidden | Insufficient scope | Key does not have required scope |
| 404 Not Found | Resource not found | Invalid ID or path |
| 409 Conflict | Duplicate resource | Contact with email already exists |
| 422 Unprocessable | Validation failed | Field present but value invalid |
| 429 Too Many Requests | Rate limit exceeded | Exceeded the key's configured per-minute limit |
| 500 Internal Error | Server error | Unexpected error contact support |
Contacts API
Read and write your organization's unified contact database. Contacts are shared across all channels: WhatsApp, Instagram, email, SMS and every integration.
List Contacts
/v1/contactsList contacts, most recently active first, with an optional search across name, phone and email, or an exact lookup by phone or email.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number (1-indexed) |
limit | number | 50 | Results per page (max 100) |
search | string | Contacts whose name, phone or email contains this text (any case). Up to 200 characters. | |
phone | string | Exact match on the phone number, read the way POST /v1/contacts reads it. A value that is not a phone number matches nobody. | |
email | string | Exact match on the email address, ignoring case. With phone, the contact must match both. | |
includeBlocked | string | false | true lists blocked contacts too. They are left out otherwise. |
curl "https://api.slide.cx/v1/contacts?page=1&limit=50&search=john" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
const params = new URLSearchParams({ page: '1', limit: '50', search: 'john' }); const res = await fetch(`https://api.slide.cx/v1/contacts?${params}`, { headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY' }, }); const { data, meta } = await res.json();
Response
{
"data": [
{
"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"
}
],
"meta": {
"total": 312,
"page": 1,
"limit": 50,
"totalPages": 7
}
}customFields is keyed by the field id of each custom field, and every value is a string.
Get a Contact
/v1/contacts/:idOne contact, in the same shape as a list row. The id may be the contact id or an Instagram contact id from an older list answer; both open the same contact. A contact of another workspace is a 404.
curl "https://api.slide.cx/v1/contacts/4b1d2c3e-..." \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Create or Update a Contact
/v1/contactsCreate or update ONE contact, matched by phone first, then by email. Answers 201 when the contact was created and 200 when it was updated; the body says which.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | One of phone or email | With the country code, for example +919876543210. Up to 32 characters. A number is accepted too. |
email | string | One of phone or email | Up to 254 characters. |
name | string | No | Up to 128 characters. When absent, firstName and lastName are joined. |
firstName | string | No | Up to 63 characters. |
lastName | string | No | Up to 63 characters. |
company | string | No | Up to 128 characters. Kept on the email list entry, as a contact has no company field. |
tags | string[] | No | Tag names, at most 50, each up to 32 characters. A tag that does not exist yet is created. Tags are added, never removed. |
customFields | object | No | Keyed by a custom field id or its display name, at most 100 keys. Values: a string up to 2,048 characters, a number, a boolean or null. Keys that match no field are listed in unmatchedCustomFields. |
A blank string counts as not sent, so an empty field never wipes a value. The call never changes a contact's subscription or consent.
curl -X POST "https://api.slide.cx/v1/contacts" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"phone": "+919876543210", "name": "John Doe", "tags": ["VIP"], "customFields": {"city": "Pune"}}'
Response
{
"id": "4b1d2c3e-...",
"unifiedContactId": "4b1d2c3e-...",
"name": "John Doe",
"phone": "+919876543210",
"tags": [{ "id": "...", "name": "VIP", "color": "#..." }],
"customFields": { "city": "Pune" },
"created": true,
"emailContact": null,
"emailUnsubscribed": false,
"unmatchedCustomFields": [],
"warnings": []
}The answer is the contact (every field of a list row; some are left out above) plus what the call did. emailUnsubscribed is true when the address has opted out of email anywhere in the workspace; this call reports it and never changes it. warnings may hold COMPANY_NEEDS_EMAIL (company was sent without an email, so it was not kept). Errors: 400 IDENTIFIER_REQUIRED with neither phone nor email, 400 INVALID_PHONE when the phone cannot be read as an international number, 409 PHONE_IN_USE when the number already belongs to another contact, and 409 CONTACT_UPSERT_IN_PROGRESS with a Retry-After header while another request is writing the same contact.
Update a Contact
/v1/contacts/:idChange the name, email, phone, lifecycle stage or tags of ONE contact. A field you leave out stays as it is. The answer is the contact, in the same shape as GET /v1/contacts/:id.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Up to 128 characters. |
email | string | No | A valid email address, up to 254 characters. |
phone | string | No | With the country code, for example +919876543210. Up to 32 characters. |
lifecycleStageId | string or null | No | The id of a lifecycle stage of this workspace, or null to clear the stage. |
tags | string[] | No | Tag names, at most 50, each up to 32 characters. This REPLACES the contact’s tags with exactly these names; a name that does not exist yet is created, and [] removes every tag. |
Send at least one field. A blank string counts as not sent. Email and phone cannot be cleared, because a contact is matched by them. Notes, consent and blocking are not changed here: they have their own routes. Errors: 400 NOTHING_TO_UPDATE, 400 INVALID_PHONE, 400 UNKNOWN_LIFECYCLE_STAGE, 404 for a contact of another workspace, and 409 CONTACT_VALUE_IN_USE when the phone or email already belongs to another contact.
POST /v1/contacts, which only adds tags, tags here replaces them. Read the contact first if you want to keep the tags it has.curl -X PATCH "https://api.slide.cx/v1/contacts/4b1d2c3e-..." \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "John A. Doe", "tags": ["VIP", "Pune"]}'
Delete a Contact
/v1/contacts/:idDelete ONE contact for good, with what the dashboard deletes with it: its channel records and conversations. Answers 204 with no body. A contact of another workspace is a 404.
curl -X DELETE "https://api.slide.cx/v1/contacts/4b1d2c3e-..." \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Tags and Segments
The workspace's tags and saved segments, for building a tag list or a filter in your own tool. Both take page and limit (50 a page, 100 at most).
/v1/contacts/tagsEvery tag of the workspace, A to Z, with how many contacts carry it.
{
"data": [
{
"id": "9c1f...",
"name": "VIP",
"color": "#8b5cf6",
"contactCount": 42,
"createdAt": "2026-03-02T08:00:00.000Z"
}
],
"meta": { "total": 12, "page": 1, "limit": 50, "totalPages": 1 }
}/v1/contacts/segmentsEvery saved segment, newest first. size is the member count at the last sync (lastSyncedAt), not a live count. The rule behind a segment is not returned.
{
"data": [
{
"id": "5a7e...",
"name": "Pune, last 30 days",
"size": 318,
"lastSyncedAt": "2026-10-30T06:00:00.000Z",
"createdAt": "2026-09-01T10:00:00.000Z",
"updatedAt": "2026-10-30T06:00:00.000Z"
}
],
"meta": { "total": 4, "page": 1, "limit": 50, "totalPages": 1 }
}Block a Contact
/v1/contacts/{id}/blockBlock a contact across every channel. Requires the contacts:block scope, not contacts:read.
409 CONTACT_BLOCKED. Message history is preserved and unblocking restores everything.Body Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
reason | string | No | Up to 500 chars. Shown in the dashboard and returned in the 409 body of every refused send. |
curl -X POST "https://api.slide.cx/v1/contacts/cnt_abc123/block" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"reason": "Spam"}'
Response
{
"success": true,
"contactId": "cnt_abc123",
"isBlocked": true
}Idempotent, blocking an already-blocked contact returns 200, never an error, so retries and duplicated webhook deliveries are safe. The id accepts either the unifiedContactId or the id returned by GET /v1/contacts, both resolve to the same contact.
GET /v1/whatsapp/conversations/by-phone returns 403 with code CONTACT_BLOCKED, not a 404, so your integration can tell "blocked" apart from "no such conversation".Unblock a Contact
/v1/contacts/{id}/unblockRestore a blocked contact. Conversations reappear in every inbox with their full history.
curl -X POST "https://api.slide.cx/v1/contacts/cnt_abc123/unblock" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Contact Notes
Free-text notes on a contact, shown on the contact in the dashboard. A note added with a key has no named author and shows on the contact's history as added by the API.
| Endpoint | Scope | What it does |
|---|---|---|
GET /v1/contacts/:id/notes | contacts:notes:read | The newest 50 notes, newest first. |
POST /v1/contacts/:id/notes | contacts:notes:write | Adds a note. Body: { "body": "..." }, 1 to 4,000 characters after trimming. Answers 201 with the note. |
DELETE /v1/contacts/:id/notes/:noteId | contacts:notes:write | Deletes a note. Answers 204 with no body. |
// GET /v1/contacts/:id/notes
{
"data": {
"notes": [
{
"id": "...",
"body": "Prefers a call after 6 pm",
"createdAt": "2026-10-20T09:12:00.000Z",
"updatedAt": "2026-10-20T09:12:00.000Z",
"author": null
}
]
}
}author is { id, name, email } for a note a team member wrote in the dashboard. A contact of another workspace is a 404.
Live Chat Conversations API
Read the live chat conversations of your workspace: the chats from your website and from your own app, with their messages. The same conversations, filters and order as the live chat Inbox in the dashboard. Answer a chat in text, and hand it back to your AI agent when you are done.
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.List Conversations
/v1/conversationsConversations, most recent message first, with the same filters as the Inbox.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number (1-indexed) |
limit | number | 20 | Results per page (max 100) |
isOpen | string | true for open chats only, false for closed ones only. | |
assignedToId | string | A teammate id, or the word unassigned. | |
unread | string | true for chats with messages nobody has read. | |
needsPerson | string | true for chats an AI agent handed to a person that nobody has answered yet. | |
contactId | string | Only the conversations of one contact (an id from /v1/contacts). | |
search | string | Text to look for. Up to 200 characters. |
curl "https://api.slide.cx/v1/conversations?isOpen=true&needsPerson=true" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "7d2c0b1e-...",
"source": "website",
"displayName": "Visitor from Pune",
"visitor": { "name": "Asha", "email": "asha@example.com", "phone": null },
"contactId": "4b1d2c3e-...",
"contact": { "id": "4b1d2c3e-...", "name": "Asha Rao", "email": "asha@example.com", "phone": "+919876543210" },
"contactClaimedByVisitor": true,
"lastMessageAt": "2026-10-30T07:41:00.000Z",
"lastMessageBody": "Can I change my delivery address?",
"lastMessageDirection": "inbound",
"unreadCount": 2,
"isOpen": true,
"closedAt": null,
"handledBy": "AGENT",
"pageUrl": "https://acme.example/orders",
"pageTitle": "Your orders",
"assignedToId": null,
"assignedAt": null,
"needsPerson": true,
"handedOffAt": "2026-10-30T07:42:00.000Z",
"preChatAnswers": { "Order number": "A-1042" },
"createdAt": "2026-10-30T07:38:00.000Z",
"updatedAt": "2026-10-30T07:42:00.000Z"
}
],
"meta": { "total": 1, "page": 1, "limit": 20, "totalPages": 1 }
}source is website or app. visitor and preChatAnswers are what the visitor typed, and none of it is verified; when contactClaimedByVisitor is true, the link to contact rests only on a phone number or email the visitor gave. handledBy is TEAM, AGENT (an AI agent) or null.
Get a Conversation
/v1/conversations/:idOne conversation, in the same shape as a list row plus contactBlocked, visitorBlocked and visitorOnline. The id is a UUID; a conversation of another workspace is a 404.
List Messages
/v1/conversations/:id/messagesThe messages of one conversation, newest first. Takes page and limit (30 a page unless asked, 100 at most).
{
"data": [
{
"id": "b41e...",
"conversationId": "7d2c0b1e-...",
"direction": "inbound",
"senderType": "VISITOR",
"sender": null,
"body": "Can I change my delivery address?",
"readAt": null,
"createdAt": "2026-10-30T07:41:00.000Z",
"attachments": [
{ "id": "f02a...", "kind": "image", "mimeType": "image/jpeg", "fileName": "label.jpg", "sizeBytes": 183422, "durationMs": null }
]
}
],
"meta": { "total": 14, "page": 1, "limit": 30, "totalPages": 1 }
}direction is inbound (from the visitor) or outbound. senderType is VISITOR, USER (a teammate), API_KEY (a reply written with an API key), AGENT (an AI agent) or SYSTEM, and sender names the teammate or agent when there is one. For API_KEY the sender id is null and the name is the name of the key. Attachments say what the file is, never where to download it.
Reply to a Conversation
/v1/conversations/:id/replyAnswer a chat in text. The visitor sees the name of the API key as the sender. It takes the chat over from any AI agent on it, until you hand it back. Answers 200.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
body | string | Yes | The reply, 1 to 2,000 characters after trimming. Line breaks and tabs are allowed, other control characters are not. |
clientRequestId | string | Yes | 8 to 64 characters of letters, digits, underscore and hyphen. You make it, and reuse it on a retry: one id stores and sends one reply. |
curl -X POST "https://api.slide.cx/v1/conversations/7d2c0b1e-.../reply" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"body": "Yes, we can change it. What is the new address?", "clientRequestId": "reply-0001-order-A1042"}'
Response
{
"message": {
"id": "b41f...",
"conversationId": "7d2c0b1e-...",
"direction": "outbound",
"senderType": "API_KEY",
"sender": { "id": null, "name": "Acme Support Bot" },
"body": "Yes, we can change it. What is the new address?",
"readAt": null,
"createdAt": "2026-10-30T07:45:00.000Z",
"attachments": []
},
"duplicate": false,
"delivery": { "visitorOnline": true, "email": "NOT_SENT", "reason": "VISITOR_ONLINE" }
}Sending the same clientRequestId again returns the stored reply with duplicate: true and sends nothing a second time, so a retry after a timeout is safe. delivery.email is QUEUED when the reply will also follow by email, otherwise NOT_SENT with a reason: VISITOR_ONLINE, NO_VISITOR_EMAIL, NO_SENDER, ADDRESS_SUPPRESSED or UNAVAILABLE.
Errors: 400 for an invalid body, 403 when the contact or the visitor is blocked, 404 for a conversation of another workspace, or 404 CHAT_NOT_AVAILABLE when live chat is switched off (nothing is written), and 409 when the visitor has another open conversation to reply in instead.
Hand a Conversation Back
/v1/conversations/:id/hand-backGive the chat back to its AI agent, which answers the next message from the visitor. The same as Hand back to agent in the Inbox. Takes no body. Answers 200.
curl -X POST "https://api.slide.cx/v1/conversations/7d2c0b1e-.../hand-back" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
{
"id": "7d2c0b1e-...",
"handledBy": "agent",
"agent": { "id": "a1b2c3d4-...", "name": "Maya" },
"canHandBack": false,
"handedOffAt": null
}handledBy says who answers now: agent (an AI agent), person (the team) or none (no AI agent has this chat). canHandBack is true when a hand-back would still change something. With no AI agent on the conversation the call changes nothing and answers a handledBy of none. A conversation of another workspace is a 404.
Messages API
The Messages API is a single, unified endpoint for sending on any channel, WhatsApp, SMS, RCS, Email, or Instagram, through one request contract. Pick a primary channel, optionally add an ordered fallbackChannels chain for phone-based channels, and the API takes care of routing and delivery. This is purely additive: every existing per-channel endpoint documented below (/whatsapp/send-template, /sms/send, /rcs/send, /email/send, /instagram/messages) continues to work exactly as before, unchanged. Use whichever fits your integration: the unified endpoint for cross-channel routing and fallback, or a dedicated endpoint for channel-specific control.
<channel>:send scope for whichever channel(s) you specify in channel and fallbackChannels, for example whatsapp:send together with sms:send for a WhatsApp request with an SMS fallback.How It Works
- 1Every request lists a primary
channeland, optionally, an orderedfallbackChannelschain. - 2Before anything is attempted, the API validates that your key holds the
sendscope for every channel in the chain, and that the required fields for each are present, so you get a fast, cheap 400/403 instead of a partial send. - 3The primary channel is attempted first through the exact same underlying send logic as its dedicated endpoint (same billing, same delivery logs).
- 4If it fails, the API moves to the next channel in
fallbackChannels, in order, until one succeeds or the chain is exhausted. - 5The response always includes an
attemptsarray, so you can see exactly which channel(s) were tried and why any of them failed, not just the final outcome.
Send a Message (Any Channel)
/v1/messages/sendSend a message on any supported channel through one request contract, with an optional ordered fallback chain across phone-based channels.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
to | string | Yes | Recipient identifier: E.164 phone number (whatsapp/sms/rcs), email address (email), or Instagram IGSID (instagram) |
channel | string | Yes | Primary channel: whatsapp, sms, rcs, email, or instagram |
fallbackChannels | string[] | No | Ordered fallback chain, tried in order if the primary channel fails. Phone-based channels only (whatsapp, sms, rcs); cannot be combined with email or instagram |
message | string | Conditional | Free-text body. Required for instagram, and required for sms (sms always needs a message body) |
template.name | string | Conditional | WhatsApp template name. Required when channel or a fallback channel is whatsapp |
template.id | string | Conditional | RCS or Email template ID. Required for rcs, required for email |
template.languageCode | string | Conditional | WhatsApp language code, e.g. "en". Required for whatsapp |
template.variables | object | No | Template merge variables (maps to RCS rcsVariables / Email merge variables) |
template.components | array | No | Advanced: raw WhatsApp template components array |
sms.senderId | string | Conditional | Required when channel or a fallback channel is sms |
sms.route | string | No | SMS route override |
email.subject | string | No | Override the template subject |
email.fromName | string | Conditional | Sender display name. Required when channel is email |
email.fromEmail | string | Conditional | Sender email address. Required when channel is email |
email.replyTo | string | No | Reply-to email address |
email.firstName | string | No | Maps to {{first_name}} variable |
email.lastName | string | No | Maps to {{last_name}} variable |
Example 1: Simple WhatsApp Send
curl -X POST https://api.slide.cx/v1/messages/send \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+919876543210", "channel": "whatsapp", "template": { "name": "order_confirmation", "languageCode": "en", "variables": { "1": "John", "2": "ORD-12345" } } }'
const response = await fetch('https://api.slide.cx/v1/messages/send', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ to: '+919876543210', channel: 'whatsapp', template: { name: 'order_confirmation', languageCode: 'en', variables: { '1': 'John', '2': 'ORD-12345' }, }, }), }); const data = await response.json(); // { status: "sent", channelUsed: "whatsapp", messageId: "wamid.HBg...", attempts: [...] }
Example 2: WhatsApp with SMS Fallback
curl -X POST https://api.slide.cx/v1/messages/send \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "+919876543210", "channel": "whatsapp", "fallbackChannels": ["sms"], "message": "Your order ORD-12345 has shipped!", "template": { "name": "order_shipped", "languageCode": "en" }, "sms": { "senderId": "ACMEIN" } }'
import requests response = requests.post( 'https://api.slide.cx/v1/messages/send', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'to': '+919876543210', 'channel': 'whatsapp', 'fallbackChannels': ['sms'], 'message': 'Your order ORD-12345 has shipped!', 'template': { 'name': 'order_shipped', 'languageCode': 'en', }, 'sms': { 'senderId': 'ACMEIN' }, } ) data = response.json() # {'status': 'sent', 'channelUsed': 'whatsapp', 'messageId': 'wamid.HBg...', 'attempts': [...]}
Response
{
"status": "sent",
"channelUsed": "whatsapp",
"messageId": "wamid.HBg...",
"attempts": [
{ "channel": "whatsapp", "status": "sent", "messageId": "wamid.HBg..." }
]
}Error Responses
| Status | Error | Description |
|---|---|---|
| 400 | Missing required field(s) | Lists exactly which field(s) are missing for which channel, e.g. template.id, email.fromEmail |
| 403 | Insufficient scope | The API key is missing the <channel>:send scope required for the requested channel(s) |
| 502 | All channels failed to deliver the message | The primary channel and every fallback channel in the chain failed; see the attempts array for the per-channel error |
// 400: missing required field(s)
{
"message": "Missing required field(s) for channel \"email\": template.id, email.fromEmail"
}
// 403: insufficient scope
{
"message": "Insufficient scope. Required: whatsapp:send"
}
// 502: all channels failed
{
"message": "All channels failed to deliver the message",
"attempts": [
{ "channel": "whatsapp", "status": "failed", "error": "..." },
{ "channel": "sms", "status": "failed", "error": "..." }
]
}Email API
The Email API lets you send transactional emails and manage templates programmatically. All email operations are scoped per organization.
Send Email
/v1/email/sendSend a transactional email using a pre-built template. Raw HTML is not accepted create templates in the dashboard and reference them by template ID. Each send deducts 1 email credit from your wallet. A Recommended products block in the template is removed here, because its picks are written per person when a campaign is sent.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
templateId | string | Yes | Template ID (get from GET /email/templates or the dashboard Templates page) |
recipient.to | string | Yes | Recipient email address |
recipient.firstName | string | No | Maps to {{first_name}} variable |
recipient.lastName | string | No | Maps to {{last_name}} variable |
variables | object | No | Key-value pairs for template variables, e.g. {"company": "Acme"} |
subject | string | No | Override the template subject (uses template subject if omitted) |
fromName | string | Yes | Sender display name |
fromEmail | string | Yes | Sender email (must match verified SMTP or domain) |
replyTo | string | No | Reply-to email address |
Important: Raw HTML is not supported in the API. Create your email templates in the dashboard (Email > Templates) using the visual editor or HTML code editor, then use the template ID here. This ensures all emails are tracked, branded, and include required unsubscribe links. Each send deducts 1 email credit from your wallet balance.
curl -X POST https://api.slide.cx/v1/email/send \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "recipient": { "to": "user@example.com", "firstName": "John" }, "variables": { "company": "Acme Corp", "order_id": "ORD-12345" }, "fromName": "Your Company", "fromEmail": "hello@yourdomain.com" }'
const response = await fetch('https://api.slide.cx/v1/email/send', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ templateId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', recipient: { to: 'user@example.com', firstName: 'John', }, variables: { company: 'Acme Corp', order_id: 'ORD-12345', }, fromName: 'Your Company', fromEmail: 'hello@yourdomain.com', }), }); const data = await response.json(); // { messageId: "msg_01J...", status: "sent", to: "user@example.com", // templateId: "a1b2c3d4...", templateName: "Order Confirmation" }
import requests response = requests.post( 'https://api.slide.cx/v1/email/send', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'templateId': 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'recipient': { 'to': 'user@example.com', 'firstName': 'John', }, 'variables': { 'company': 'Acme Corp', 'order_id': 'ORD-12345', }, 'fromName': 'Your Company', 'fromEmail': 'hello@yourdomain.com', } ) data = response.json() # {'messageId': 'msg_01J...', 'status': 'sent', 'to': 'user@example.com', # 'templateId': 'a1b2c3d4...', 'templateName': 'Order Confirmation'}
Response
{
"messageId": "msg_01JXYZ...",
"status": "sent",
"to": "user@example.com",
"templateId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"templateName": "Order Confirmation"
}Error Responses
| Status | Error | Description |
|---|---|---|
| 400 | Template not found | The templateId does not exist or does not belong to your organization |
| 400 | Insufficient email credits | Your wallet has no email credits remaining. Purchase more in Settings > Wallet |
| 400 | No verified SMTP connection | Set up and verify an SMTP connection first in Email > SMTP |
| 400 | Template has no HTML content | The template is empty. Edit it in the dashboard first |
List Email Templates
/v1/email/templatesRetrieve all email templates for the organization.
curl https://api.slide.cx/v1/email/templates \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
const res = await fetch('https://api.slide.cx/v1/email/templates', { headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY' }, }); const { data, meta } = await res.json();
Response
{
"data": [
{
"id": "tpl_01JXYZ...",
"name": "Welcome Email",
"subject": "Welcome to {{company_name}}",
"createdAt": "2026-01-15T09:00:00.000Z",
"updatedAt": "2026-02-10T12:30:00.000Z"
}
],
"meta": {
"total": 1,
"page": 1,
"limit": 20,
"totalPages": 1
}
}Get Email Template
/v1/email/templates/:idRetrieve a single email template by its ID, including the full HTML body.
curl https://api.slide.cx/v1/email/templates/tpl_01JXYZ \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
const res = await fetch('https://api.slide.cx/v1/email/templates/tpl_01JXYZ', { headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY' }, }); const template = await res.json();
Response
{
"id": "tpl_01JXYZ...",
"name": "Welcome Email",
"subject": "Welcome to {{company_name}}",
"html": "<h1>Welcome!</h1><p>Thanks for joining {{company_name}}.</p>",
"text": "Welcome! Thanks for joining {{company_name}}.",
"createdAt": "2026-01-15T09:00:00.000Z",
"updatedAt": "2026-02-10T12:30:00.000Z"
}List Email Contacts
/v1/email/contactsList the organization's email contacts with pagination and optional search.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number (1-indexed) |
limit | number | 50 | Results per page (max 100) |
search | string | Optional search by name or email |
curl "https://api.slide.cx/v1/email/contacts?page=1&limit=50" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "ec_01JXYZ...",
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"subscribed": true,
"createdAt": "2026-02-01T09:00:00.000Z"
}
],
"meta": { "total": 312, "page": 1, "limit": 50, "totalPages": 7 }
}Create Email Contact
/v1/email/contactsCreate a new email contact. Returns 409 Conflict if a contact with the same email already exists.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Contact email address |
firstName | string | No | First name |
lastName | string | No | Last name |
curl -X POST https://api.slide.cx/v1/email/contacts \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "john@example.com", "firstName": "John", "lastName": "Doe" }'
Response
{
"id": "ec_01JXYZ...",
"email": "john@example.com",
"firstName": "John",
"lastName": "Doe",
"subscribed": true,
"createdAt": "2026-04-08T12:30:00.000Z"
}Instagram API
The Instagram API provides access to your connected Instagram Business account including profile data, conversations, message sending, and analytics. Requires an active Instagram integration in your organization.
404 Not Found.Get Instagram Profile
/v1/instagram/profileRetrieve your connected Instagram Business account profile information.
curl https://api.slide.cx/v1/instagram/profile \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
const res = await fetch('https://api.slide.cx/v1/instagram/profile', { headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY' }, }); const profile = await res.json();
Response
{
"id": "17841400455970661",
"username": "yourbusiness",
"name": "Your Business Name",
"biography": "Official business account",
"followersCount": 12450,
"followsCount": 310,
"mediaCount": 89,
"profilePictureUrl": "https://example.com/profile.jpg",
"website": "https://yourbusiness.com"
}List Conversations
/v1/instagram/conversationsRetrieve a paginated list of Instagram DM conversations.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number (1-indexed) |
limit | number | 20 | Conversations per page (max 50) |
curl "https://api.slide.cx/v1/instagram/conversations?page=1&limit=20" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
import requests res = requests.get( 'https://api.slide.cx/v1/instagram/conversations', headers={'Authorization': 'Bearer sk_live_YOUR_API_KEY'}, params={'page': 1, 'limit': 20} ) data = res.json()
Response
{
"data": [
{
"id": "conv_01JXYZ...",
"senderId": "112233445566778",
"senderUsername": "johndoe",
"senderName": "John Doe",
"lastMessage": "Thanks for the info!",
"lastMessageAt": "2026-03-24T08:15:00.000Z",
"unreadCount": 2
}
],
"meta": {
"total": 87,
"page": 1,
"limit": 20,
"totalPages": 5
}
}Send Instagram Message
/v1/instagram/messagesSend a direct message to an Instagram user by their page-scoped IGSID.
recipientIgsid must be a valid page-scoped Instagram user ID (IGSID), not a username. You can retrieve IGSIDs from GET /v1/instagram/conversations or via the Instagram inbox.Request Body
| Field | Type | Required | Description |
|---|---|---|---|
recipientIgsid | string | Yes | Page-scoped Instagram user ID (IGSID) |
message | string | Yes | Message text content (max 1000 chars) |
curl -X POST https://api.slide.cx/v1/instagram/messages \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "recipientIgsid": "112233445566778", "message": "Hi! Thanks for reaching out. How can we help you today?" }'
const res = await fetch('https://api.slide.cx/v1/instagram/messages', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ recipientIgsid: '112233445566778', message: 'Hi! Thanks for reaching out. How can we help you today?', }), }); const result = await res.json(); // { messageId: 'igmsg_01JXYZ...', status: 'sent' }
import requests res = requests.post( 'https://api.slide.cx/v1/instagram/messages', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'recipientIgsid': '112233445566778', 'message': 'Hi! Thanks for reaching out.', } ) print(res.json()) # {'messageId': 'igmsg_01JXYZ...', 'status': 'sent'}
Get Instagram Insights
/v1/instagram/insightsRetrieve Instagram account analytics and performance metrics.
curl https://api.slide.cx/v1/instagram/insights \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
const res = await fetch('https://api.slide.cx/v1/instagram/insights', { headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY' }, }); const insights = await res.json();
Response
{
"followerCount": 12450,
"followsDelta": 23,
"reach": 4821,
"profileViews": 1340,
"impressions": 9123,
"websiteClicks": 89,
"period": "last_7_days",
"updatedAt": "2026-03-24T00:00:00.000Z"
}Ads API
Read the ads account your workspace connected to Slide: its accounts, campaigns, ad sets, ads, results and lead-form leads. Make a campaign, and start, pause or resize spend. These are the same figures and the same changes as the Ads page of the dashboard.
403. Without a connected account every route but GET /v1/ads/status answers 409 ADS_NOT_CONNECTED. Reading needs ads:read, making a campaign needs ads:write, and changing live spend needs ads:publish.List routes take page and limit (50 a page, 100 at most) and answer { data, meta: { total, page, limit, totalPages } }. Ids are the ones the ad platform issues: an ad account id is act_ and digits, every other id is digits. Budgets are in minor units (paise or cents).
Connection Status
/v1/ads/statusWhether an ads account is connected, as whom, and when the sign-in expires. Works before anything is connected.
{
"connected": true,
"connectedAs": "Priya Shah",
"tokenExpiresAt": "2026-12-20T09:00:00.000Z",
"lastRefreshedAt": "2026-10-30T06:00:00.000Z",
"adAccountCount": 2,
"pageCount": 3
}List Ad Accounts
/v1/ads/accountsThe ad accounts of the connected login. insightRows says how many days of results Slide holds for each, so you can open on an account that has some.
{
"data": [
{
"id": "act_1234567890",
"name": "Acme Store",
"currency": "INR",
"timezoneName": "Asia/Kolkata",
"accountStatus": 1,
"businessName": "Acme Pvt Ltd",
"syncEnabled": true,
"insightRows": 412,
"newestDataAt": "2026-10-30T00:00:00.000Z",
"rowsInWindow": 87
}
],
"meta": { "total": 2, "page": 1, "limit": 50, "totalPages": 1 }
}rowsInWindow counts result rows in the last 30 days. A positive newestDataAt with rowsInWindow of 0 means the account is quiet, not broken.
List Campaigns
/v1/ads/accounts/:adAccountId/campaignsThe campaigns of one ad account. Budgets, bids and the spend cap are minor units.
curl "https://api.slide.cx/v1/ads/accounts/act_1234567890/campaigns?limit=20" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
{
"data": [
{
"id": "120210000000001",
"name": "Diwali sale",
"status": "ACTIVE",
"effectiveStatus": "ACTIVE",
"objective": "OUTCOME_LEADS",
"buyingType": "AUCTION",
"bidStrategy": "LOWEST_COST_WITHOUT_CAP",
"dailyBudgetMinor": 50000,
"lifetimeBudgetMinor": null,
"budgetRemainingMinor": 31200,
"spendCapMinor": null,
"startTime": "2026-10-20T00:00:00+0530",
"stopTime": null,
"createdTime": "2026-10-19T11:02:00+0530",
"updatedTime": "2026-10-29T18:40:00+0530"
}
],
"meta": { "total": 9, "page": 1, "limit": 20, "totalPages": 1 }
}List Ad Sets
/v1/ads/campaigns/:campaignId/adsetsThe ad sets of one campaign: budget, bid, what they optimise for, and their dates.
Each row has id, name, campaignId, status, effectiveStatus, dailyBudgetMinor, lifetimeBudgetMinor, budgetRemainingMinor, bidAmountMinor, bidStrategy, billingEvent, optimizationGoal, destinationType, startTime, endTime, createdTime and updatedTime. A value the platform does not send is null.
List Ads
/v1/ads/adsets/:adsetId/adsThe ads of one ad set, each with its creative when it has one.
{
"data": [
{
"id": "120210000000003",
"name": "Carousel A",
"adsetId": "120210000000002",
"campaignId": "120210000000001",
"status": "ACTIVE",
"effectiveStatus": "ACTIVE",
"creative": {
"id": "120210000000009",
"name": "Carousel A creative",
"title": "Festive offers",
"body": "Up to 30% off this week",
"callToActionType": "SHOP_NOW",
"imageUrl": "https://...",
"thumbnailUrl": "https://..."
},
"createdTime": "2026-10-19T11:05:00+0530",
"updatedTime": "2026-10-29T18:40:00+0530"
}
],
"meta": { "total": 3, "page": 1, "limit": 50, "totalPages": 1 }
}Results by Day
/v1/ads/insightsDaily results from the figures Slide holds, oldest day first, at the level you ask for. With no dates it is the last 30 days.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
level | string | campaign | account, campaign, adset or ad. |
since | ISO 8601 date | 30 days before until | First day. |
until | ISO 8601 date | now | Last day. |
adAccountId | string | Only this ad account, for example act_1234567890. | |
campaignId | string | Only this campaign. |
{
"data": [
{
"level": "campaign",
"adAccountId": "act_1234567890",
"campaignId": "120210000000001",
"campaignName": "Diwali sale",
"adsetId": null,
"adsetName": null,
"adId": null,
"adName": null,
"dateStart": "2026-10-29T00:00:00.000Z",
"dateStop": "2026-10-29T00:00:00.000Z",
"impressions": 18420,
"reach": 15210,
"frequency": "1.21",
"clicks": 530,
"inlineLinkClicks": 412,
"uniqueClicks": 498,
"spend": "4120.55",
"cpc": "7.77",
"cpm": "223.69",
"ctr": "2.88",
"leads": 34,
"costPerLead": "121.19",
"videoViews": 0,
"postEngagement": 96,
"revenue": null,
"purchases": null
}
],
"meta": {
"since": "2026-10-01T00:00:00.000Z",
"until": "2026-10-30T00:00:00.000Z",
"returned": 30,
"truncated": false,
"cap": 5000
}
}Counts are numbers; money and ratios (spend, frequency, cpc, cpm, ctr, costPerLead) are strings in the account's currency, so no digit is lost. revenue and purchases are null when the ad platform reported no purchases, never 0. One answer holds at most 5,000 rows; when it was cut, meta.truncated is true, so narrow the dates.
Results Summary
/v1/ads/insights/summaryOne set of totals and ratios over a date range, from the campaign rows. Takes since, until and adAccountId, like the daily results. With no adAccountId it adds up every ad account of the workspace.
{
"data": {
"revenue": null,
"purchases": null,
"roas": null,
"impressions": 512300,
"reach": 301200,
"clicks": 14230,
"leads": 880,
"spend": "98210.40",
"ctr": "2.78",
"cpc": "6.90",
"cpl": "111.60",
"cpm": "191.71"
},
"meta": { "since": "2026-10-01T00:00:00.000Z", "until": "2026-10-30T00:00:00.000Z" }
}revenue, purchases and roas are null unless the ad platform reported purchases. cpc, cpl and cpm are null when there is nothing to divide by.
Recent Leads
/v1/ads/leadsThe newest lead-form submissions from your ads, newest first. Takes limit only (20 unless asked, 100 at most); there is no paging.
{
"data": [
{
"id": "ev_...",
"occurredAt": "2026-10-30T07:41:00.000Z",
"contactId": "4b1d2c3e-...",
"phone": "+919876543210",
"email": "john@example.com",
"fullName": "John Doe",
"formId": "5550001",
"formName": "Diwali offer form",
"campaignId": "120210000000001",
"campaignName": "Diwali sale",
"adsetId": "120210000000002",
"adsetName": "Pune 25-40",
"adId": "120210000000003",
"adName": "Carousel A",
"pageName": "Acme Store",
"platform": "facebook",
"status": null
}
]
}The lead is also a contact: contactId opens it with GET /v1/contacts/:id. Any field the form did not carry is null.
Create a Campaign
/v1/ads/accounts/:adAccountId/campaignsCreate a campaign in one ad account. It is ALWAYS created paused, with no way to ask otherwise: starting it is a separate call that needs ads:publish. Answers 201.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Up to 256 characters. |
objective | string | No | Upper-case words joined by an underscore, for example OUTCOME_LEADS. Default OUTCOME_TRAFFIC. |
specialAdCategories | string[] | No | At most 10, upper-case words joined by an underscore. The ad platform requires them for housing, credit, employment and the like. |
curl -X POST "https://api.slide.cx/v1/ads/accounts/act_1234567890/campaigns" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Winter launch", "objective": "OUTCOME_LEADS"}'
{ "id": "120210000000042", "status": "PAUSED" }Start or Pause
/v1/ads/:entity/:id/statusStart (ACTIVE) or pause (PAUSED) a campaign, an ad set or an ad. :entity is campaign, adset or ad; :id is its numeric id. This changes live spend.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
status | string | Yes | ACTIVE or PAUSED, in any case. |
curl -X POST "https://api.slide.cx/v1/ads/campaign/120210000000042/status" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "PAUSED"}'
{ "id": "120210000000042", "status": "PAUSED", "previousStatus": "ACTIVE", "changed": true }Asking for the status the object already has changes nothing and answers changed: false, so a retried call is safe.
Change a Budget
/v1/ads/:entity/:id/budgetChange the daily or the lifetime budget of a campaign or an ad set, in minor units (paise or cents). Send exactly one of dailyMinor and lifetimeMinor. This changes live spend.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
dailyMinor | integer | One of the two | The new daily budget, at least 100 minor units. |
lifetimeMinor | integer | One of the two | The new lifetime budget, at least 1. |
curl -X POST "https://api.slide.cx/v1/ads/campaign/120210000000042/budget" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dailyMinor": 75000}'
{ "id": "120210000000042", "budget": "daily", "previousMinor": 50000, "newMinor": 75000 }Sending both fields, or neither, is a 400, because the ad platform keeps one budget type and ignores the other. previousMinor is what the budget was a moment before, or null if it had none. Each change is recorded against the key that made it.
Google Ads API
Read the Google Ads account your workspace connected to Slide: its customers, campaigns, ad groups, ads, conversion actions, daily results and lead forms. Add a lead form, or remove one. These are the same figures as the Google Ads page of the dashboard.
403. Without a connected account every route but GET /v1/google-ads/status answers 409 ADS_NOT_CONNECTED. Reading needs ads:google:read, and adding or removing a lead form needs ads:google:write. These are not the scopes of the Ads API above, which is for Meta Ads.List routes 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 linked accounts of your workspace is a 404. Other Google ids are digits. Money is in full units of the currency of the account, not in minor units.
Connection Status
/v1/google-ads/statusWhether a Google Ads account is connected, as whom, and when the sign-in expires. Works before anything is connected.
{
"connected": true,
"connectedAs": "priya@acme.example",
"tokenExpiresAt": "2026-12-20T09:00:00.000Z",
"lastRefreshedAt": "2026-10-30T06:00:00.000Z",
"customerCount": 2,
"leadFormCount": 1
}List Customers
/v1/google-ads/customersThe Google Ads customers (accounts) linked to your workspace. Manager accounts come first, then by name.
curl "https://api.slide.cx/v1/google-ads/customers" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
{
"data": [
{
"customerId": "1234567890",
"name": "Acme Store",
"currencyCode": "INR",
"timeZone": "Asia/Kolkata",
"isManager": false,
"isTestAccount": false
}
],
"meta": { "total": 1, "page": 1, "limit": 50, "totalPages": 1 }
}List Campaigns
/v1/google-ads/customers/:customerId/campaignsThe campaigns of one customer, by name. dailyBudget is in full units of the currency of the account.
{
"data": [
{
"id": "20100000001",
"name": "Winter sale",
"status": "ENABLED",
"channelType": "SEARCH",
"startDate": "2026-10-01",
"endDate": null,
"dailyBudget": 500
}
],
"meta": { "total": 1, "page": 1, "limit": 50, "totalPages": 1 }
}List Ad Groups
/v1/google-ads/customers/:customerId/ad-groupsThe ad groups of one customer, by name. Add campaignId (digits) to see the ad groups of one campaign only.
{
"data": [
{
"id": "30200000001",
"name": "Winter boots",
"status": "ENABLED",
"type": "SEARCH_STANDARD",
"campaignId": "20100000001",
"campaignName": "Winter sale"
}
],
"meta": { "total": 1, "page": 1, "limit": 50, "totalPages": 1 }
}List Ads
/v1/google-ads/customers/:customerId/adsThe ads of one customer, by name. Add adGroupId (digits) to see the ads of one ad group only.
{
"data": [
{
"id": "40300000001",
"name": "Winter boots, free delivery",
"type": "RESPONSIVE_SEARCH_AD",
"status": "ENABLED",
"adGroupId": "30200000001",
"adGroupName": "Winter boots"
}
],
"meta": { "total": 1, "page": 1, "limit": 50, "totalPages": 1 }
}List Conversion Actions
/v1/google-ads/customers/:customerId/conversion-actionsThe conversion actions of one customer, by name: what Google Ads counts as a result.
{
"data": [
{ "id": "50400000001", "name": "Lead form submit", "type": "GOOGLE_HOSTED", "category": "SUBMIT_LEAD_FORM" }
],
"meta": { "total": 1, "page": 1, "limit": 50, "totalPages": 1 }
}Results by Day
/v1/google-ads/insightsThe daily figures Slide has stored, oldest day first, at the level you ask for. Money is in full units of the currency of the customer.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
customerId | string | Only one customer (10 digits). Without it, every linked customer. | |
level | string | customer, campaign, ad_group or ad. | |
startDate | string | First day, ISO 8601 (for example 2026-10-01). | |
endDate | string | Last day, ISO 8601. | |
page | number | 1 | Page number (1-indexed, up to 200). |
limit | number | 100 | Rows per page (max 500). |
curl "https://api.slide.cx/v1/google-ads/insights?customerId=1234567890&level=campaign&startDate=2026-10-01&endDate=2026-10-07" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
{
"data": [
{
"level": "campaign",
"customerId": "1234567890",
"campaignId": "20100000001",
"campaignName": "Winter sale",
"adGroupId": null,
"adGroupName": null,
"adId": null,
"adName": null,
"date": "2026-10-01T00:00:00.000Z",
"currencyCode": "INR",
"impressions": 4210,
"clicks": 188,
"cost": "2350.4",
"averageCpc": "12.5",
"averageCpm": "558.3",
"ctr": "0.044655",
"conversions": "9",
"conversionsValue": "13500",
"costPerConversion": "261.155556",
"leads": 9,
"costPerLead": "261.155556",
"videoViews": 0,
"engagements": 0
}
],
"meta": { "total": 1, "page": 1, "limit": 100, "totalPages": 1 }
}Counts (impressions, clicks, leads, videoViews, engagements) are numbers. Money and ratios are strings, so no digit is lost. The ids and names that do not belong to the level you asked for are null. Slide holds the days it has fetched, so a day that is not stored yet is not in the answer.
List Lead Forms
/v1/google-ads/lead-formsThe lead forms added to Slide, newest first, with how many leads each brought. Never the key of a form.
{
"data": [
{
"id": "9c1e2f3a-...",
"formAssetId": "6700000001",
"formName": "Winter sale enquiry",
"campaignId": "20100000001",
"campaignName": "Winter sale",
"customerId": "1234567890",
"customerName": "Acme Store",
"leadCount": 14,
"lastLeadAt": "2026-10-30T07:10:00.000Z",
"isActive": true,
"createdAt": "2026-10-02T09:00:00.000Z"
}
],
"meta": { "total": 1, "page": 1, "limit": 50, "totalPages": 1 }
}The leads themselves arrive as contacts in your workspace, so read them with GET /v1/contacts.
Add a Lead Form
/v1/google-ads/lead-formsAdd a lead form to Slide, so its leads arrive in your workspace. Every field is optional. Answers 201 with the form as the list shows it.
Body Parameters
| Field | Type | Required | Description |
|---|---|---|---|
formAssetId | string | No | The id of the lead form in Google Ads. Up to 64 characters. |
formName | string | No | A name to recognise it by. Up to 200 characters. |
campaignId | string | No | The campaign it belongs to. Up to 64 characters. |
campaignName | string | No | The name of that campaign. Up to 200 characters. |
customerId | string | No | One of your linked customers (10 digits, from GET /v1/google-ads/customers). |
curl -X POST "https://api.slide.cx/v1/google-ads/lead-forms" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"formName": "Winter sale enquiry", "customerId": "1234567890"}'
The answer has the same fields as a row of the list, with leadCount at 0. Slide makes the key of the form and does not return it: the person who pastes it into the lead form in Google Ads reads it in Slide, under Integrations. A customerId that is not one of your linked customers is a 404.
Remove a Lead Form
/v1/google-ads/lead-forms/:idRemove a lead form from Slide. The leads it already brought stay as contacts. Answers 204 with no body. The id is a UUID; a form of another workspace is a 404.
curl -X DELETE "https://api.slide.cx/v1/google-ads/lead-forms/9c1e2f3a-..." \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
WhatsApp API
Send WhatsApp template messages, retrieve message logs, and list approved templates. All messages are sent through your connected WhatsApp Business account and billed per Meta's category-based pricing (Marketing, Utility, Authentication, Service).
Send Template Message
/v1/whatsapp/send-templateSend an approved WhatsApp template message to a phone number. Supports all template types: text-only, media headers, quick-reply buttons, call-to-action buttons, and dynamic variables.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
to | string | Yes | Recipient phone number in E.164 format without + (e.g. "919876543210") |
templateName | string | Yes | Name of the approved template (e.g. "order_confirmation") |
languageCode | string | Yes | Template language code (e.g. "en", "en_US", "hi") |
components | array | No | Template components with dynamic variables (Meta Cloud API format) |
async | boolean | No | true answers 202 with { "requestId", "status": "queued" } before the message is handed to WhatsApp; read the outcome with GET /v1/whatsapp/send-status/:requestId. Default false: 200 with the wamid. |
Components Format
Components follow the Meta Cloud API format. Each component specifies a type and parameters:
| Component Type | Usage | Example |
|---|---|---|
| header | Image, video, or document header | { "type": "header", "parameters": [{ "type": "image", "image": { "link": "https://..." } }] } |
| body | Dynamic variables in the template body | { "type": "body", "parameters": [{ "type": "text", "text": "John" }] } |
| button | Dynamic URL suffix or copy code | { "type": "button", "sub_type": "url", "index": "0", "parameters": [{ "type": "text", "text": "ORDER123" }] } |
# Send a simple text template with one body variable curl -X POST https://api.slide.cx/v1/whatsapp/send-template \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "919876543210", "templateName": "order_confirmation", "languageCode": "en", "components": [ { "type": "body", "parameters": [ { "type": "text", "text": "John" }, { "type": "text", "text": "ORD-12345" }, { "type": "text", "text": "₹2,499" } ] } ] }'
const response = await fetch('https://api.slide.cx/v1/whatsapp/send-template', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ to: '919876543210', templateName: 'order_confirmation', languageCode: 'en', components: [ { type: 'body', parameters: [ { type: 'text', text: 'John' }, { type: 'text', text: 'ORD-12345' }, { type: 'text', text: '₹2,499' }, ], }, ], }), }); const data = await response.json(); // { wamid: "wamid.HBgM...", conversationId: "conv_01J...", status: "sent" }
import requests response = requests.post( 'https://api.slide.cx/v1/whatsapp/send-template', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'to': '919876543210', 'templateName': 'order_confirmation', 'languageCode': 'en', 'components': [ { 'type': 'body', 'parameters': [ {'type': 'text', 'text': 'John'}, {'type': 'text', 'text': 'ORD-12345'}, {'type': 'text', 'text': '₹2,499'}, ], }, ], } ) data = response.json() # {'wamid': 'wamid.HBgM...', 'conversationId': 'conv_01J...', 'status': 'sent'}
Response
{
"wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSMjYxMDgx...",
"conversationId": "conv_01JXYZ...",
"status": "sent"
}400 Bad Request.Template Examples
// Text-only template (no variables)
{
"to": "919876543210",
"templateName": "welcome_message",
"languageCode": "en"
}// Template with image header + body variables
{
"to": "919876543210",
"templateName": "promo_offer",
"languageCode": "en",
"components": [
{
"type": "header",
"parameters": [
{ "type": "image", "image": { "link": "https://example.com/promo.jpg" } }
]
},
{
"type": "body",
"parameters": [
{ "type": "text", "text": "John" },
{ "type": "text", "text": "20%" }
]
}
]
}// OTP / Verification template (body + copy-code button)
{
"to": "919876543210",
"templateName": "verification",
"languageCode": "en",
"components": [
{
"type": "body",
"parameters": [{ "type": "text", "text": "654321" }]
},
{
"type": "button",
"sub_type": "url",
"index": "0",
"parameters": [{ "type": "text", "text": "654321" }]
}
]
}Get an Async Send's Status
/v1/whatsapp/send-status/:requestIdThe outcome of a send made with async: true. Kept for 48 hours; after that, and for an id of another workspace, the answer is 404. The message log and the whatsapp.message.status webhook stay the lasting record.
{
"requestId": "...",
"status": "sent",
"to": "919876543210",
"templateName": "order_confirmation",
"queuedAt": "2026-10-25T07:00:00.000Z",
"wamid": "wamid.HBgM...",
"conversationId": "...",
"completedAt": "2026-10-25T07:00:01.000Z"
}status is queued, sent or failed. wamid and conversationId appear once WhatsApp has accepted the message; a failed send carries error (plain words) and errorCode (the HTTP status the same send would have answered).
Get One Message's Status
/v1/whatsapp/messages/:wamidWhere one message stands, by its wamid. A send is only accepted at once; WhatsApp reports the real outcome later, and a failure carries WhatsApp's own explanation.
{
"wamid": "wamid.HBgM...",
"status": "failed",
"direction": "outbound",
"type": "template",
"templateName": "order_confirmation",
"templateCategory": "UTILITY",
"to": "919876543210",
"sentAt": "2026-10-25T07:00:01.000Z",
"failure": {
"code": 131026,
"label": "Not delivered",
"reason": "...",
"hint": "...",
"title": "Message undeliverable"
},
"retryCount": 0,
"retryOfWamid": null
}status is pending, sent, delivered, read or failed. failure is null unless the message failed. retryCount counts automatic resends after a passing failure, and retryOfWamid is set when this message is itself such a resend. An unknown wamid is a 404.
Get Message Logs
/v1/whatsapp/logsRetrieve paginated WhatsApp message logs for the organization. Includes inbound, outbound, delivery status, billing details, and message source attribution.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 50 | Results per page (max 100) |
direction | string | all | "inbound" or "outbound" |
status | string | all | "sent", "delivered", "read", or "failed" |
from | string | Start date (YYYY-MM-DD) | |
to | string | End date (YYYY-MM-DD) |
curl "https://api.slide.cx/v1/whatsapp/logs?page=1&limit=20&direction=outbound" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
const res = await fetch( 'https://api.slide.cx/v1/whatsapp/logs?page=1&limit=20&direction=outbound', { headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY' } } ); const { data, meta } = await res.json();
Response
{
"data": [
{
"id": "msg_01JXYZ...",
"wamid": "wamid.HBgM...",
"direction": "outbound",
"type": "template",
"status": "delivered",
"content": { "templateName": "order_confirmation", "languageCode": "en" },
"templateName": "order_confirmation",
"templateCategory": "UTILITY",
"isTemplate": true,
"isBillable": true,
"billingAmountPaise": 12,
"source": "API",
"sourceName": "Production Key",
"timestamp": "2026-04-08T12:30:00.000Z",
"conversation": {
"id": "conv_01J...",
"customerWaId": "919876543210",
"customerName": "John Doe"
}
}
],
"meta": { "total": 142, "page": 1, "limit": 20, "totalPages": 8 }
}Source Values
| Source | Description |
|---|---|
MANUAL | Sent by a user from the Slide dashboard |
BOT | Sent by a WhatsApp automation/bot |
AUTOMATION | Sent by an automation workflow |
CAMPAIGN | Sent as part of a bulk campaign |
API | Sent via this Developer API |
INBOUND | Received from a customer |
SYSTEM | Platform system message (e.g. OTP verification) |
List Templates
/v1/whatsapp/templatesRetrieve all WhatsApp message templates for the organization, including their approval status, category, and language.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number |
limit | integer | 25 | Results per page |
status | string | all | "APPROVED", "PENDING", or "REJECTED" |
category | string | all | "MARKETING", "UTILITY", or "AUTHENTICATION" |
curl "https://api.slide.cx/v1/whatsapp/templates?status=APPROVED" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
const res = await fetch( 'https://api.slide.cx/v1/whatsapp/templates?status=APPROVED', { headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY' } } ); const templates = await res.json();
Response
{
"data": [
{
"id": "tpl_01JXYZ...",
"name": "order_confirmation",
"category": "UTILITY",
"language": "en",
"status": "APPROVED",
"components": [
{
"type": "BODY",
"text": "Hi {{1}}, your order {{2}} of {{3}} has been confirmed!"
}
],
"createdAt": "2026-01-10T08:00:00.000Z"
},
{
"id": "tpl_02ABCD...",
"name": "verification",
"category": "AUTHENTICATION",
"language": "en",
"status": "APPROVED",
"components": [
{
"type": "BODY",
"text": "Your verification code is {{1}}"
},
{
"type": "BUTTONS",
"buttons": [{ "type": "URL", "text": "Copy Code", "url": "https://..." }]
}
],
"createdAt": "2026-01-05T10:00:00.000Z"
}
],
"meta": { "total": 12, "page": 1, "limit": 25, "totalPages": 1 }
}Submit a Template
/v1/whatsapp/templatesSubmit a new message template to WhatsApp for review. It cannot be sent until its status is APPROVED: poll GET /v1/whatsapp/templates for the result.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Lowercase letters, digits and underscores only, for example order_shipped. |
language | string | Yes | Language code, for example en or en_US. One template per name and language. |
category | string | Yes | MARKETING, UTILITY or AUTHENTICATION. |
components | array | Yes | The template DEFINITION: HEADER, BODY, FOOTER and BUTTONS blocks (and CAROUSEL cards), with {{1}}, {{2}} placeholders in the text. Not the same as the values you send with send-template. |
components[].mediaUrl | string | No | On an IMAGE, VIDEO or DOCUMENT header (a carousel card's too): 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. |
curl -X POST https://api.slide.cx/v1/whatsapp/templates \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "order_shipped", "language": "en", "category": "UTILITY", "components": [ { "type": "BODY", "text": "Hi {{1}}, your order {{2}} has shipped.", "example": { "body_text": [["Asha", "#1042"]] } } ] }'
Response (201)
{
"id": "...",
"metaTemplateId": "...",
"name": "order_shipped",
"language": "en",
"category": "UTILITY",
"status": "PENDING",
"components": [ ... ],
"parameterFormat": "POSITIONAL",
"lastSyncedAt": "2026-10-25T07:00:00.000Z",
"createdAt": "2026-10-25T07:00:00.000Z",
"updatedAt": "2026-10-25T07:00:00.000Z",
"_note": "Submitted for review. It cannot be sent until its status is APPROVED. Poll GET /v1/whatsapp/templates."
}A template WhatsApp refuses is a 400 with its reason. The workspace needs a connected WhatsApp number.
Upload Header Media
/v1/whatsapp/header-media/uploadUpload an image, video, or document to use as a template header. Returns a media handle you can reference in the header component of a template send.
Request Body
Send as multipart/form-data with a single file field.
curl -X POST https://api.slide.cx/v1/whatsapp/header-media/upload \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -F "file=@promo.jpg"
Response
{
"mediaId": "wamedia_01JXYZ...",
"url": "https://slide.synquic.com/media/wamedia_01JXYZ.jpg",
"mimeType": "image/jpeg"
}Get Conversation by Phone
/v1/whatsapp/conversations/by-phoneRetrieve the message history exchanged with a specific phone number. Returns the last N messages (max 50) in chronological order, oldest first.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
phone | string | Customer phone number in international format without + (required, e.g. "919876543210") | |
limit | number | 50 | Number of most recent messages to return (max 50) |
curl "https://api.slide.cx/v1/whatsapp/conversations/by-phone?phone=919876543210&limit=50" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
const res = await fetch( 'https://api.slide.cx/v1/whatsapp/conversations/by-phone?phone=919876543210&limit=50', { headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY' } } ); const conversation = await res.json(); // conversation.data is chronological: oldest message first
import requests res = requests.get( 'https://api.slide.cx/v1/whatsapp/conversations/by-phone', headers={'Authorization': 'Bearer sk_live_YOUR_API_KEY'}, params={'phone': '919876543210', 'limit': 50} ) conversation = res.json()
Response
{
"conversationId": "conv_01JXYZ...",
"customerWaId": "919876543210",
"customerName": "John Doe",
"data": [
{
"id": "msg_01JXYZ...",
"wamid": "wamid.HBgM...",
"direction": "outbound",
"type": "template",
"status": "read",
"content": { "templateName": "order_confirmation" },
"templateName": "order_confirmation",
"mediaUrl": null,
"mediaMimeType": null,
"errorCode": null,
"errorMessage": null,
"timestamp": "2026-04-08T12:30:00.000Z",
"createdAt": "2026-04-08T12:30:00.000Z"
},
{
"id": "msg_02ABCD...",
"wamid": "wamid.HBgN...",
"direction": "inbound",
"type": "text",
"status": "received",
"content": { "body": "Thanks! When will it arrive?" },
"templateName": null,
"mediaUrl": null,
"mediaMimeType": null,
"errorCode": null,
"errorMessage": null,
"timestamp": "2026-04-08T12:31:12.000Z",
"createdAt": "2026-04-08T12:31:12.000Z"
}
]
}List Campaigns
/v1/whatsapp/campaignsRetrieve a paginated list of WhatsApp campaigns with their status and headline counts.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
status | string | all | "DRAFT", "SCHEDULED", "RUNNING", "COMPLETED", or "CANCELLED" |
curl "https://api.slide.cx/v1/whatsapp/campaigns?page=1&limit=20&status=COMPLETED" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "wac_01JXYZ...",
"name": "April Promo",
"status": "COMPLETED",
"templateId": "tpl_01JXYZ...",
"templateName": "promo_offer",
"totalRecipients": 5000,
"sentCount": 4980,
"deliveredCount": 4870,
"scheduledAt": null,
"createdAt": "2026-04-01T09:00:00.000Z"
}
],
"meta": { "total": 6, "page": 1, "limit": 20, "totalPages": 1 }
}Get Campaign
/v1/whatsapp/campaigns/:idRetrieve a single campaign with its full configuration and current status.
curl "https://api.slide.cx/v1/whatsapp/campaigns/wac_01JXYZ" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Get Campaign Analytics
/v1/whatsapp/campaigns/:id/analyticsRetrieve delivery, read, click, reply, and conversion metrics for a campaign.
curl "https://api.slide.cx/v1/whatsapp/campaigns/wac_01JXYZ/analytics" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"campaignId": "wac_01JXYZ...",
"totalRecipients": 5000,
"sent": 4980,
"delivered": 4870,
"read": 3120,
"clicked": 640,
"replied": 212,
"converted": 87,
"failed": 20,
"deliveryRate": 97.79,
"readRate": 64.07,
"clickRate": 13.14
}Create Campaign
/v1/whatsapp/campaignsCreate a DRAFT campaign with a manual phone list audience. The campaign does not send until you call the launch endpoint (or the scheduled time is reached after launch).
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | Campaign name (auto-generated if omitted) |
templateId | string | Yes | ID of an approved WhatsApp template |
phones | string[] | Yes | 1 to 10,000 phone numbers in international format without + (e.g. "919876543210") |
templateComponents | array | No | Template components with static variable values (Meta Cloud API format) |
variableMapping | object | No | Map template variables to per-recipient contact fields |
scheduledAt | string | No | ISO 8601 timestamp to schedule the send |
scheduleTimezone | string | No | IANA timezone for scheduledAt (e.g. "Asia/Kolkata") |
curl -X POST https://api.slide.cx/v1/whatsapp/campaigns \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "April Promo", "templateId": "tpl_01JXYZ...", "phones": ["919876543210", "919876543211"], "scheduledAt": "2026-04-15T10:00:00.000Z", "scheduleTimezone": "Asia/Kolkata" }'
const res = await fetch('https://api.slide.cx/v1/whatsapp/campaigns', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'April Promo', templateId: 'tpl_01JXYZ...', phones: ['919876543210', '919876543211'], scheduledAt: '2026-04-15T10:00:00.000Z', scheduleTimezone: 'Asia/Kolkata', }), }); const campaign = await res.json(); // { id: 'wac_01J...', status: 'DRAFT', totalRecipients: 2, ... }
import requests res = requests.post( 'https://api.slide.cx/v1/whatsapp/campaigns', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'name': 'April Promo', 'templateId': 'tpl_01JXYZ...', 'phones': ['919876543210', '919876543211'], } ) campaign = res.json() print(campaign['id'], campaign['status']) # wac_01J... DRAFT
Response
{
"id": "wac_01JXYZ...",
"name": "April Promo",
"status": "DRAFT",
"templateId": "tpl_01JXYZ...",
"totalRecipients": 2,
"scheduledAt": "2026-04-15T10:00:00.000Z",
"scheduleTimezone": "Asia/Kolkata",
"createdAt": "2026-04-10T09:00:00.000Z"
}Launch Campaign
/v1/whatsapp/campaigns/:id/launchLaunch a DRAFT campaign. Sending is queued and processed asynchronously, the endpoint returns 202 Accepted immediately. If scheduledAt is set, the campaign moves to SCHEDULED and sends at that time.
curl -X POST "https://api.slide.cx/v1/whatsapp/campaigns/wac_01JXYZ/launch" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"status": "queued",
"campaignId": "wac_01JXYZ..."
}Cancel Campaign
/v1/whatsapp/campaigns/:id/cancelCancel a scheduled or running campaign. Messages not yet sent are stopped and the wallet is refunded for all unsent recipients.
curl -X POST "https://api.slide.cx/v1/whatsapp/campaigns/wac_01JXYZ/cancel" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"success": true,
"campaignId": "wac_01JXYZ...",
"refundedRecipients": 1240
}SMS API
Send transactional and promotional SMS, manage DLT templates and sender IDs, run bulk campaigns, and query delivery logs and stats. All sends go through your organization's registered DLT sender IDs and approved templates.
Send SMS
/v1/sms/sendSend an SMS to one or more recipients using a registered sender ID.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
senderId | string | Yes | Registered sender ID (e.g. "ACMEIN") |
to | string[] | Yes | Recipient phone numbers (min 1) |
message | string | Yes | Message text (must match the approved DLT template) |
templateId | string | No | DLT template ID for the message |
route | string | No | "transactional" or "promotional" (default "transactional") |
curl -X POST https://api.slide.cx/v1/sms/send \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "senderId": "ACMEIN", "to": ["919876543210", "919876543211"], "message": "Your order ORD-12345 has shipped. Track it at https://acme.com/track", "route": "transactional" }'
const res = await fetch('https://api.slide.cx/v1/sms/send', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ senderId: 'ACMEIN', to: ['919876543210'], message: 'Your order ORD-12345 has shipped.', route: 'transactional', }), }); const result = await res.json(); // { status: 'sent', recipients: 1, externalIds: ['ext_...'] }
import requests res = requests.post( 'https://api.slide.cx/v1/sms/send', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'senderId': 'ACMEIN', 'to': ['919876543210'], 'message': 'Your order ORD-12345 has shipped.', 'route': 'transactional', } ) print(res.json()) # {'status': 'sent', 'recipients': 1, 'externalIds': ['ext_...']}
Response
{
"status": "sent",
"recipients": 2,
"externalIds": ["ext_01JXYZ...", "ext_02ABCD..."]
}List SMS Templates
/v1/sms/templatesRetrieve a paginated list of the organization's DLT SMS templates.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
status | string | all | Filter by template status (e.g. "APPROVED", "PENDING") |
curl "https://api.slide.cx/v1/sms/templates?page=1&limit=20&status=APPROVED" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "smst_01JXYZ...",
"name": "Order Shipped",
"dltTemplateId": "1107160000000012345",
"body": "Your order {#var#} has shipped. Track it at {#var#}",
"route": "transactional",
"status": "APPROVED",
"createdAt": "2026-02-01T09:00:00.000Z"
}
],
"meta": { "total": 8, "page": 1, "limit": 20, "totalPages": 1 }
}List Sender IDs
/v1/sms/sendersRetrieve a paginated list of the organization's registered SMS sender IDs.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
curl "https://api.slide.cx/v1/sms/senders?page=1&limit=20" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "smss_01JXYZ...",
"senderId": "ACMEIN",
"entityId": "1101230000000067890",
"status": "APPROVED",
"createdAt": "2026-01-20T09:00:00.000Z"
}
],
"meta": { "total": 2, "page": 1, "limit": 20, "totalPages": 1 }
}List SMS Campaigns
/v1/sms/campaignsRetrieve a paginated list of SMS campaigns.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
curl "https://api.slide.cx/v1/sms/campaigns?page=1&limit=20" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "smsc_01JXYZ...",
"name": "Weekend Sale",
"status": "COMPLETED",
"route": "promotional",
"senderId": "ACMEIN",
"totalRecipients": 12000,
"sentCount": 11940,
"deliveredCount": 11512,
"scheduledAt": null,
"createdAt": "2026-03-28T09:00:00.000Z"
}
],
"meta": { "total": 4, "page": 1, "limit": 20, "totalPages": 1 }
}Get SMS Campaign
/v1/sms/campaigns/:idRetrieve a single SMS campaign with its full configuration and current counts.
curl "https://api.slide.cx/v1/sms/campaigns/smsc_01JXYZ" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Create SMS Campaign
/v1/sms/campaignsCreate a DRAFT SMS campaign. The campaign does not send until you call the launch endpoint.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Campaign name |
messageBody | string | Yes | Message text (must match the approved DLT template) |
senderId | string | No | Registered sender ID to send from |
templateId | string | No | DLT template ID for the message |
route | string | No | "transactional" or "promotional" |
audienceType | string | No | Audience selection mode (e.g. all contacts, segment, manual list) |
audienceCriteria | object | No | Audience filter criteria for the selected audienceType |
scheduledAt | string | No | ISO 8601 timestamp to schedule the send |
curl -X POST https://api.slide.cx/v1/sms/campaigns \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Weekend Sale", "messageBody": "Flat 20% off this weekend! Shop now: https://acme.com/sale", "senderId": "ACMEIN", "route": "promotional", "scheduledAt": "2026-04-12T04:30:00.000Z" }'
const res = await fetch('https://api.slide.cx/v1/sms/campaigns', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Weekend Sale', messageBody: 'Flat 20% off this weekend! Shop now: https://acme.com/sale', senderId: 'ACMEIN', route: 'promotional', }), }); const campaign = await res.json(); // { id: 'smsc_01J...', status: 'DRAFT', ... }
import requests res = requests.post( 'https://api.slide.cx/v1/sms/campaigns', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'name': 'Weekend Sale', 'messageBody': 'Flat 20% off this weekend! Shop now: https://acme.com/sale', 'senderId': 'ACMEIN', 'route': 'promotional', } ) campaign = res.json() print(campaign['id'], campaign['status']) # smsc_01J... DRAFT
Response
{
"id": "smsc_01JXYZ...",
"name": "Weekend Sale",
"status": "DRAFT",
"route": "promotional",
"senderId": "ACMEIN",
"scheduledAt": "2026-04-12T04:30:00.000Z",
"createdAt": "2026-04-10T09:00:00.000Z"
}Launch SMS Campaign
/v1/sms/campaigns/:id/launchLaunch a DRAFT campaign. Sending is queued and processed asynchronously, the endpoint returns 202 Accepted immediately.
curl -X POST "https://api.slide.cx/v1/sms/campaigns/smsc_01JXYZ/launch" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"status": "queued",
"campaignId": "smsc_01JXYZ..."
}Cancel SMS Campaign
/v1/sms/campaigns/:id/cancelCancel a scheduled or running SMS campaign. Messages not yet sent are stopped.
curl -X POST "https://api.slide.cx/v1/sms/campaigns/smsc_01JXYZ/cancel" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"success": true,
"campaignId": "smsc_01JXYZ..."
}Get SMS Logs
/v1/sms/logsRetrieve paginated SMS message logs with direction, status, and date filters.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 50 | Results per page (max 100) |
direction | string | all | "inbound" or "outbound" |
status | string | all | Filter by delivery status (e.g. "sent", "delivered", "failed") |
start | string | Start date (ISO 8601) | |
end | string | End date (ISO 8601) |
curl "https://api.slide.cx/v1/sms/logs?page=1&limit=50&direction=outbound&status=delivered" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "smsl_01JXYZ...",
"externalId": "ext_01JXYZ...",
"direction": "outbound",
"route": "transactional",
"status": "delivered",
"body": "Your order ORD-12345 has shipped.",
"senderId": "ACMEIN",
"templateId": "smst_01JXYZ...",
"source": "API",
"sentAt": "2026-04-08T12:30:00.000Z",
"deliveredAt": "2026-04-08T12:30:04.000Z",
"failedAt": null,
"errorCode": null,
"errorMessage": null,
"createdAt": "2026-04-08T12:30:00.000Z",
"phone": "919876543210",
"customerName": "John Doe"
}
],
"meta": { "total": 1420, "page": 1, "limit": 50, "totalPages": 29 }
}Get SMS Stats
/v1/sms/statsRetrieve daily aggregated SMS delivery stats for a date range.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
start | string | Start date (ISO 8601) | |
end | string | End date (ISO 8601) |
curl "https://api.slide.cx/v1/sms/stats?start=2026-04-01&end=2026-04-30" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"dataPoints": [
{ "date": "2026-04-01", "sent": 480, "delivered": 462, "failed": 18 },
{ "date": "2026-04-02", "sent": 512, "delivered": 498, "failed": 14 }
],
"totals": {
"sent": 992,
"delivered": 960,
"failed": 32,
"deliveryRate": 96.77
}
}RCS API
Send rich RCS Business Messaging content, cards, carousels, and suggested actions, with automatic SMS fallback for handsets that do not support RCS. Manage bots, templates, campaigns, and delivery logs.
Send RCS Message
/v1/rcs/sendSend a single rich RCS message using an approved template, with an optional SMS fallback for non-RCS handsets.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
to | string | Yes | Recipient phone number |
templateId | string | Yes | ID of an approved RCS template |
rcsVariables | object | No | Key-value map of template variable values |
smsFallback | object | No | SMS fallback: { sender, message, templateId, route }, sent if the handset does not support RCS |
ttl | string | No | Time-to-live for the message before fallback or expiry (e.g. "3600s") |
curl -X POST https://api.slide.cx/v1/rcs/send \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "to": "919876543210", "templateId": "rcst_01JXYZ...", "rcsVariables": { "name": "John", "orderId": "ORD-12345" }, "smsFallback": { "sender": "ACMEIN", "message": "Your order ORD-12345 has shipped.", "route": "transactional" }, "ttl": "3600s" }'
const res = await fetch('https://api.slide.cx/v1/rcs/send', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ to: '919876543210', templateId: 'rcst_01JXYZ...', rcsVariables: { name: 'John', orderId: 'ORD-12345' }, smsFallback: { sender: 'ACMEIN', message: 'Your order ORD-12345 has shipped.' }, }), }); const result = await res.json();
import requests res = requests.post( 'https://api.slide.cx/v1/rcs/send', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'to': '919876543210', 'templateId': 'rcst_01JXYZ...', 'rcsVariables': {'name': 'John', 'orderId': 'ORD-12345'}, 'smsFallback': {'sender': 'ACMEIN', 'message': 'Your order ORD-12345 has shipped.'}, } ) print(res.json())
List RCS Bots
/v1/rcs/botsRetrieve the RCS bots provisioned for your organization.
curl "https://api.slide.cx/v1/rcs/bots" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "rcsb_01JXYZ...",
"name": "Acme Store",
"status": "APPROVED",
"createdAt": "2026-02-15T09:00:00.000Z"
}
]
}List RCS Templates
/v1/rcs/templatesRetrieve a paginated list of RCS templates.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
curl "https://api.slide.cx/v1/rcs/templates?page=1&limit=20" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Create RCS Campaign
/v1/rcs/campaignsCreate and dispatch a bulk RCS campaign. The bulk send is accepted and processed asynchronously, the endpoint returns 202 Accepted.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
botId | string | Yes | ID of the RCS bot to send from |
templateId | string | Yes | ID of an approved RCS template |
campaignName | string | No | Campaign name |
numbers | string[] | No | Recipient phone numbers |
country | string | No | Recipient country code (default "IN") |
removeDuplicate | boolean | No | Deduplicate the recipient list before sending |
ttl | string | No | Time-to-live before fallback or expiry |
scheduleCampaign | boolean | No | Schedule instead of sending immediately |
scheduledAt | string | No | ISO 8601 timestamp for the scheduled send |
fallback | boolean | No | Enable SMS fallback for non-RCS handsets |
fallbackMessage | string | No | SMS fallback message text |
curl -X POST https://api.slide.cx/v1/rcs/campaigns \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "botId": "rcsb_01JXYZ...", "templateId": "rcst_01JXYZ...", "campaignName": "April Launch", "numbers": ["919876543210", "919876543211"], "country": "IN", "removeDuplicate": true, "fallback": true, "fallbackMessage": "New arrivals are live! Shop now: https://acme.com" }'
const res = await fetch('https://api.slide.cx/v1/rcs/campaigns', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ botId: 'rcsb_01JXYZ...', templateId: 'rcst_01JXYZ...', campaignName: 'April Launch', numbers: ['919876543210', '919876543211'], removeDuplicate: true, }), }); // 202 Accepted, bulk send queued
import requests res = requests.post( 'https://api.slide.cx/v1/rcs/campaigns', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'botId': 'rcsb_01JXYZ...', 'templateId': 'rcst_01JXYZ...', 'campaignName': 'April Launch', 'numbers': ['919876543210', '919876543211'], } ) print(res.status_code) # 202
List RCS Campaigns
/v1/rcs/campaignsRetrieve a paginated list of RCS campaigns.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
curl "https://api.slide.cx/v1/rcs/campaigns?page=1&limit=20" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Get RCS Logs
/v1/rcs/logsRetrieve paginated RCS message logs including delivery status and fallback outcomes.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 50 | Results per page (max 100) |
curl "https://api.slide.cx/v1/rcs/logs?page=1&limit=50" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Get RCS Stats
/v1/rcs/statsRetrieve aggregated RCS delivery stats for a date range.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
start | string | Start date (ISO 8601) | |
end | string | End date (ISO 8601) |
curl "https://api.slide.cx/v1/rcs/stats?start=2026-04-01&end=2026-04-30" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
OTP API
Server-side phone verification: send one-time passwords by SMS or on WhatsApp, verify them, and prove verification to your backend with single-use access tokens. OTP length, expiry, resend cooldown, and delivery channels are configured on an OTP widget in the dashboard, the API references that widget by widgetId. SMS and WhatsApp are the only delivery channels. WhatsApp codes are sent from your own connected WhatsApp number with one of your approved authentication templates, so a widget can use WhatsApp only when your workspace has both.
400), and it publishes otp.sent with channel: "sms". A widget with WhatsApp and no second channel sends nothing more to such a number. A code is charged as one WhatsApp authentication message or one SMS, and no contact is created for it.Send OTP
/v1/otp/sendSend a one-time password to a phone number using an OTP widget's configuration. Returns a requestId used for retry and verification.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
widgetId | string | Yes | ID of the OTP widget configured in the dashboard |
identifier | string | Yes | Phone number to verify (e.g. "+919876543210") |
curl -X POST https://api.slide.cx/v1/otp/send \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "widgetId": "wgt_01JXYZ...", "identifier": "+919876543210" }'
const res = await fetch('https://api.slide.cx/v1/otp/send', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ widgetId: 'wgt_01JXYZ...', identifier: '+919876543210' }), }); const { requestId } = await res.json();
import requests res = requests.post( 'https://api.slide.cx/v1/otp/send', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={'widgetId': 'wgt_01JXYZ...', 'identifier': '+919876543210'} ) request_id = res.json()['requestId']
Response
{
"requestId": "otpreq_01JXYZ...",
"channel": "sms"
}channel is the channel the code went out on: "sms" or "whatsapp".
Retry OTP
/v1/otp/retryResend the OTP for an existing request. Respects the widget's resend cooldown and maximum resend count. A resend goes out on the widget's second channel when it has one, otherwise on its first.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
requestId | string | Yes | The requestId returned by POST /otp/send |
channel | string | No | Send this resend on "sms" or "whatsapp" first. Honoured only when the widget sends on that channel. Any other value is not an error: the code goes out on the widget's own channel and the response says so. |
curl -X POST https://api.slide.cx/v1/otp/retry \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "requestId": "otpreq_01JXYZ...", "channel": "whatsapp" }'
Response
{
"requestId": "otpreq_01JXYZ...",
"channel": "whatsapp",
"requestedChannel": "whatsapp",
"requestedChannelAvailable": true
}| Field | Type | Description |
|---|---|---|
channel | string | The channel the code went out on: "sms" or "whatsapp" |
requestedChannel | string | Present only when the request named a channel: the value you sent |
requestedChannelAvailable | boolean | Present only when the request named a channel: false when this widget does not send on it |
notice | string | Present only when the named channel was not the one used: why, in words |
200: the code is sent on the widget's own channel, requestedChannelAvailable is false, and notice explains it. Read channel to know where the code went.Verify OTP
/v1/otp/verifyVerify the OTP entered by the user. On success, returns a JWT accessToken that proves the verification. Returns 400 if the code is invalid, expired, or the request is blocked after too many attempts.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
requestId | string | Yes | The requestId returned by POST /otp/send |
otp | string | Yes | The one-time password entered by the user |
curl -X POST https://api.slide.cx/v1/otp/verify \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "requestId": "otpreq_01JXYZ...", "otp": "123456" }'
const res = await fetch('https://api.slide.cx/v1/otp/verify', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ requestId: 'otpreq_01JXYZ...', otp: '123456' }), }); const { accessToken } = await res.json(); // Pass accessToken to your backend, then consume it with POST /otp/verify-token
import requests res = requests.post( 'https://api.slide.cx/v1/otp/verify', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={'requestId': 'otpreq_01JXYZ...', 'otp': '123456'} ) access_token = res.json()['accessToken']
Response
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}Verify Access Token
/v1/otp/verify-tokenConfirm server-side that an accessToken represents a real, recent verification. The token is single-use: this call consumes it, a second call with the same token fails.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
accessToken | string | Yes | The JWT returned by POST /otp/verify |
curl -X POST https://api.slide.cx/v1/otp/verify-token \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "accessToken": "eyJhbGciOiJIUzI1NiIs..." }'
Response
{
"verified": true,
"identifier": "+919876543210",
"verifiedAt": "2026-04-08T12:31:12.000Z"
}accessToken to your backend. Your backend calls POST /v1/otp/verify-token once to confirm and consume it before trusting the phone number.Get OTP Logs
/v1/otp/logsRetrieve paginated OTP request logs, optionally filtered by widget and status.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
widgetId | string | all | Filter by OTP widget |
status | string | all | Filter by request status (e.g. "sent", "verified", "expired", "blocked") |
page | number | 1 | Page number |
limit | number | 50 | Results per page (max 100) |
curl "https://api.slide.cx/v1/otp/logs?widgetId=wgt_01JXYZ&status=verified&page=1&limit=50" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "otpreq_01JXYZ...",
"widgetId": "wgt_01JXYZ...",
"identifier": "+919876543210",
"channel": "sms",
"status": "verified",
"attempts": 1,
"resends": 0,
"sentAt": "2026-04-08T12:30:00.000Z",
"verifiedAt": "2026-04-08T12:31:12.000Z"
}
],
"meta": { "total": 940, "page": 1, "limit": 50, "totalPages": 19 }
}Get OTP Analytics
/v1/otp/analyticsRetrieve success and failure rates and delivery analytics for an OTP widget.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
widgetId | string | all | Filter analytics to a single OTP widget |
curl "https://api.slide.cx/v1/otp/analytics?widgetId=wgt_01JXYZ" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"totalSent": 5230,
"totalVerified": 4870,
"totalExpired": 240,
"totalBlocked": 46,
"successRate": 93.12,
"channelBreakdown": {
"sms": { "sent": 4100, "verified": 3810 },
"whatsapp": { "sent": 1130, "verified": 1060 }
}
}Shopify API
The Shopify API provides read-only access to products, collections, orders, and discount codes from your connected Shopify store. Requires an active Shopify integration in your organization.
404 Not Found.Search Products
/v1/shopify/products/searchFull-text search across product title, tags, vendor, and type.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
q | string | Search query (required) | |
limit | number | 10 | Max results (max 50) |
curl "https://api.slide.cx/v1/shopify/products/search?q=sneakers&limit=5" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "gid://shopify/Product/123456",
"title": "Classic Sneakers",
"vendor": "Nike",
"productType": "Footwear",
"tags": ["sneakers", "sale"],
"variants": [
{ "id": "gid://shopify/ProductVariant/789", "title": "Size 10", "price": "89.99", "inventoryQuantity": 24 }
]
}
],
"meta": { "count": 1, "query": "sneakers" }
}List Product Types
/v1/shopify/products/typesGet all unique product types (categories) sorted A Z.
curl "https://api.slide.cx/v1/shopify/products/types" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": ["Accessories", "Footwear", "T-Shirts", "Watches"],
"meta": { "count": 4 }
}Get Product
/v1/shopify/products/:idGet full product detail including all variants, options, and description.
curl "https://api.slide.cx/v1/shopify/products/gid://shopify/Product/123456" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
List Collections
/v1/shopify/collectionsList all custom and smart collections sorted A Z.
curl "https://api.slide.cx/v1/shopify/collections" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{ "id": "gid://shopify/Collection/111", "title": "Summer Sale", "handle": "summer-sale", "productsCount": 24 }
],
"meta": { "count": 1 }
}Get Collection Products
/v1/shopify/collections/:id/productsList products in a specific collection.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | number | 50 | Max results (max 250) |
curl "https://api.slide.cx/v1/shopify/collections/gid://shopify/Collection/111/products?limit=10" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Get Order Status
/v1/shopify/orders/statusLook up an order by order number (e.g. #1001) or customer email.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
identifier | string | Order number (e.g. "#1001") or customer email (required) |
curl "https://api.slide.cx/v1/shopify/orders/status?identifier=%231001" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"orderNumber": "#1001",
"financialStatus": "paid",
"fulfillmentStatus": "fulfilled",
"totalPrice": "129.99",
"currency": "INR",
"lineItems": [
{ "title": "Classic Sneakers", "quantity": 1, "price": "89.99" },
{ "title": "Sports Socks", "quantity": 2, "price": "20.00" }
],
"tracking": {
"carrier": "BlueDart",
"number": "BD123456789",
"url": "https://bluedart.com/track/BD123456789"
},
"createdAt": "2026-03-20T10:30:00.000Z"
}Get Customer Orders
/v1/shopify/customers/ordersGet a customer's order history by phone number or email.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
identifier | string | Phone (E.164 or local) or email (required) |
curl "https://api.slide.cx/v1/shopify/customers/orders?identifier=%2B919876543210" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
const res = await fetch( 'https://api.slide.cx/v1/shopify/customers/orders?identifier=user@example.com', { headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY' } } ); const { customer, orders } = await res.json();
Validate Discount Code
/v1/shopify/discounts/validateValidate a discount or promo code in real time.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
code | string | Discount code to validate (required) |
curl "https://api.slide.cx/v1/shopify/discounts/validate?code=SAVE20" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"valid": true,
"code": "SAVE20",
"type": "percentage",
"value": 20,
"minimumOrderAmount": "500.00",
"expiresAt": "2026-06-30T23:59:59.000Z",
"usageCount": 142,
"usageLimit": 1000
}Shopify Store Audit API
Audit the connected Shopify store: a score per section, the problems found, and fixes Slide can suggest and write to the store for you. The same audit as the store audit in the dashboard's Shopify reports. Starting an audit, its price and fix suggestions need a connected store (404 otherwise).
402 with code (INSUFFICIENT_BALANCE or WALLET_LOCKED) and reason.Latest Audit
/v1/shopify/auditThe newest audit with its section results. run is null before the first audit. A running audit shows the sections finished so far.
{
"run": {
"id": "...",
"shopDomain": "acme.myshopify.com",
"status": "COMPLETED",
"trigger": "MANUAL",
"source": "API",
"overallScore": 72,
"checksVersion": "...",
"chargedPaise": 0,
"errorMessage": null,
"startedAt": "2026-10-25T07:00:00.000Z",
"finishedAt": "2026-10-25T07:04:10.000Z",
"createdAt": "2026-10-25T07:00:00.000Z",
"weights": null,
"sectionResults": [
{ "section": "performance", "status": "...", "score": 64, "critical": 0, "high": 2, "medium": 3, "low": 1, "checks": [ ... ], "finishedAt": "2026-10-25T07:01:30.000Z" }
]
}
}A run's status is QUEUED, RUNNING, COMPLETED, PARTIAL, FAILED or CANCELLED. Sections: performance, technical_seo, ai_visibility, conversion, ux_mobile, product_pages, cart_checkout, tracking, theme, apps, store_setup, catalog, retention, security, compliance.
Price of the Next Audit
/v1/shopify/audit/quoteWhether the next audit is free or what it costs. Ask before starting one.
interface StoreAuditQuote { isFree: boolean; // true while this month's free audit is unused pricePaise: number; // what the next audit costs the wallet, in paise priceUsd: number; // the same price in US dollars currency: 'INR'; freeResetsOn: string; // ISO time the next free audit becomes available (00:00 India time on the 1st) }
List Audits, Get One
| Endpoint | Scope | What it does |
|---|---|---|
GET /v1/shopify/audit/runs | shopify:audit:read | Every audit, newest first, without section results. page (default 1) and limit (default 20, max 100); answers { data, meta }. |
GET /v1/shopify/audit/runs/:id | shopify:audit:read | One audit with its section results, in the shape of the latest audit above. 404 for an unknown id. |
Start an Audit
/v1/shopify/audit/runsStarts an audit in the background and answers 202 at once. When it is not free, the wallet is charged first. Read progress with GET /v1/shopify/audit/runs/:id.
{ "runId": "...", "status": "QUEUED", "isFree": true, "charged": false, "pricePaise": 0 }402 when the wallet cannot pay (the body adds pricePaise), 409 when an audit is already running, 404 when no store is connected. Takes no body.
Issues
/v1/shopify/audit/issuesEvery problem the audits found, most recently seen first, each with its newest finding and a fix plan (who fixes it, about how long it takes, what it changes).
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | OPEN, IN_PROGRESS, FIXED or IGNORED | |
section | string | One of the section ids above | |
severity | string | critical, high, medium or low | |
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
{
"data": [
{
"id": "...",
"checkId": "...",
"section": "product_pages",
"problemKey": "...",
"title": "...",
"severity": "high",
"impact": 4,
"effort": 2,
"status": "OPEN",
"assigneeUserId": null,
"firstSeenRunId": "...",
"lastSeenRunId": "...",
"firstSeenAt": "2026-09-02T07:00:00.000Z",
"lastSeenAt": "2026-10-25T07:04:10.000Z",
"resolvedAt": null,
"priority": 2,
"quickWin": true,
"fixPlan": { "owner": "merchant", "ownerWords": "...", "minutes": { "min": 10, "max": 20 }, "per": "product", "totalMinutes": { "min": 490, "max": 980 }, "timeWords": "...", "expectedResult": "...", "estimate": "catalogue" },
"latestFinding": { "id": "...", "runId": "...", "severity": "high", "affected": [ ... ], "affectedCount": 49, "evidence": { ... }, "fixGuide": { ... }, "createdAt": "2026-10-25T07:04:10.000Z" }
}
],
"meta": { "total": 31, "page": 1, "limit": 20, "totalPages": 2 }
}impact (how much fixing it helps) and effort (how much work the fix is) run from 1 to 5. priority is impact divided by effort, and a quick win is impact 3 or more with effort 2 or less.
| Endpoint | Scope | What it does |
|---|---|---|
GET /v1/shopify/audit/issues/:id | shopify:audit:read | One issue: latestFinding in full, history (one line per finding without its evidence, newest first, up to 50) and fixes (the first page of its suggested fixes, as GET .../fixes answers it). |
PATCH /v1/shopify/audit/issues/:id | shopify:audit:write | Changes the status only: { "status": "OPEN" | "IN_PROGRESS" | "FIXED" | "IGNORED" }. A later audit reopens a FIXED issue it finds again; IGNORED is never changed by an audit. |
Suggested Fixes
For issues Slide can fix, ask for suggestions, review or edit each one, then apply them. Nothing in the store changes until you apply, and an applied fix can be undone.
| Endpoint | Scope | What it does |
|---|---|---|
POST /v1/shopify/audit/issues/:id/fixes/suggest | shopify:audit:write | Makes suggestions in the background (202). 422 with reason no_fix when Slide cannot suggest a change for this issue; 409 while suggestions are already being made; 402 when AI suggestions cannot be paid for. |
GET /v1/shopify/audit/issues/:id/fixes | shopify:audit:read | The suggestions, oldest first, with generation (the progress of the latest suggest request) and access (whether the store granted the permissions each kind of change needs). status filters, page and limit page. |
PATCH /v1/shopify/audit/fixes/:id | shopify:audit:write | Edits a suggestion before it is applied: { "value": ... } (text, a list of tags, or { "value", "unit" } for a weight) or { "clear": true }. Changes nothing in the store. |
POST /v1/shopify/audit/fixes/apply | shopify:audit:write | Writes the chosen suggestions to the store in the background: { "fixIds": ["..."] }, 1 to 200. Answers 202 with { batchId, count, skipped: [{ id, reason }] }. |
GET /v1/shopify/audit/fixes/batches/:batchId | shopify:audit:read | Progress of an apply: { batchId, total, counts (by status), done, stale, items }. |
POST /v1/shopify/audit/fixes/:id/undo | shopify:audit:write | Puts the value back that was in the store before the fix. 409 when the value was changed in Shopify since it was applied, or when the fix is not applied. |
// One suggestion, as every fixes route returns it
{
"id": "...",
"issueId": "...",
"runId": "...",
"batchId": null,
"kind": "...",
"kindTitle": "Product SEO title",
"resourceType": "...",
"resourceId": "...",
"parentResourceId": null,
"resourceLabel": "Linen shirt",
"field": "...",
"beforeValue": "Linen shirt",
"suggestedValue": "Linen shirt for men, breathable summer fit",
"appliedValue": null,
"source": "...",
"status": "SUGGESTED",
"error": null,
"scopeFeature": "...",
"accessGranted": true,
"limits": { ... },
"needsValue": false,
"canEdit": true,
"canDismiss": true,
"canApply": true,
"canUndo": false,
"appliedAt": null,
"revertedAt": null,
"createdAt": "2026-10-25T07:10:00.000Z",
"updatedAt": "2026-10-25T07:10:00.000Z"
}A suggestion's status is SUGGESTED, EDITED, APPLYING, APPLIED, SKIPPED_CHANGED (the value changed in Shopify after it was suggested, so it was not written), FAILED, REVERTED or DISMISSED.
AI Search API
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 every workspace, with or without Shopify. The same data as AI Search in the dashboard.
402 with code and reason (insufficient_balance or wallet_locked).Summary
/v1/aeo/summaryThe latest answer per question and assistant over the last 30 days, with the totals: how often your brand is named, its average position, competitor mentions and how often your website is cited.
{
"label": "API-sampled; answers people see in the apps can differ.",
"window": { "since": "2026-09-25T07:00:00.000Z", "until": "2026-10-25T07:00:00.000Z", "days": 30 },
"configured": true,
"brand": { "names": ["Acme"], "domains": ["acme.com"], "competitors": [{ "name": "Globex", "domain": "globex.com" }] },
"assistants": { "available": [{ "provider": "...", "label": "...", "model": "..." }], "notAvailable": [ ... ] },
"notChecked": [{ "name": "...", "reason": "..." }],
"activeRun": null,
"totals": {
"answers": 40,
"mentioned": 22,
"mentionRate": 0.55,
"averagePosition": 2.4,
"competitorShare": 0.7,
"competitorMentions": [{ "name": "Globex", "answers": 18 }],
"grounded": 30,
"groundedCitingStore": 9,
"citationShare": 0.3,
"byProvider": [{ "provider": "...", "label": "...", "answers": 10, "mentioned": 6, "mentionRate": 0.6, "averagePosition": 2, "grounded": 8, "groundedCitingStore": 3 }]
},
"prompts": [{ "id": "...", "prompt": "best linen shirts in India", "market": "India", "answers": [ ... ] }],
"lastSampledAt": "2026-10-24T07:00:00.000Z",
"latestRun": { "batchId": "...", "answers": 40, "chargedPaise": ..., "sampledAt": "2026-10-24T07:00:00.000Z" },
"note": "Active questions are checked every week on each AI assistant, and each check is charged from your wallet by AI usage."
}Each assistant is identified by provider (an id) and label (the name people know). Rates are fractions from 0 to 1, and null when there is nothing to divide. latestRun.chargedPaise is what the newest check cost the wallet, in paise.
Mention Trend
/v1/aeo/trendOne point per finished check, oldest first, each with its answers, mentions and mention rate, overall and per assistant. A check still running is left out until it finishes.
| Parameter | Type | Default | Description |
|---|---|---|---|
days | number | 90 | How many days back, 7 to 365 |
{
"label": "API-sampled; answers people see in the apps can differ.",
"window": { "since": "2026-07-27T07:00:00.000Z", "until": "2026-10-25T07:00:00.000Z", "days": 90 },
"data": [
{ "batchId": "...", "sampledAt": "2026-10-24T07:00:00.000Z", "answers": 40, "mentioned": 22, "mentionRate": 0.55,
"byAssistant": [{ "provider": "...", "label": "...", "answers": 10, "mentioned": 6 }] }
],
"capped": false,
"runningBatchId": null
}At most the newest 100 checks are returned; capped is true when there were more.
Answers
/v1/aeo/answersEvery stored answer, newest first.
| Parameter | Type | Default | Description |
|---|---|---|---|
promptId | string | Only answers to this question | |
provider | string | Only answers from this assistant: a provider id as the summary lists it | |
batchId | string | Only answers from this check | |
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
{
"data": [
{
"id": "...",
"promptId": "...",
"batchId": "...",
"promptText": "best linen shirts in India",
"market": "India",
"provider": "...",
"providerLabel": "...",
"model": "...",
"sampledAt": "2026-10-24T07:00:00.000Z",
"grounded": true,
"brandMentioned": true,
"position": 2,
"competitors": ["Globex"],
"products": ["Linen shirt"],
"citations": ["https://acme.com/linen"],
"ownDomainCited": true,
"excerpt": "...",
"chargedPaise": ...
}
],
"meta": { "total": 120, "page": 1, "limit": 20, "totalPages": 6 },
"label": "API-sampled; answers people see in the apps can differ."
}Questions and Brand Profile
| Endpoint | Scope | What it does |
|---|---|---|
GET /v1/aeo/prompts | aeo:read | Every question (at most 100 per workspace, so one page is the whole list): { data: [{ id, prompt, market, active, createdAt, updatedAt }], activeCount, maxActive, note, meta }. Active questions first. |
POST /v1/aeo/prompts | aeo:write | Adds a question: { "prompt": "...", "market"?: "..." }. The question is 5 to 300 characters, the market up to 60. A new question is active, so it is checked, and charged, every week; at most 20 are active at once. Answers 201 with { prompt, note }. |
PATCH /v1/aeo/prompts/:id | aeo:write | Changes a question: any of prompt, market (null clears it) and active (false stops the weekly check). Answers { prompt, note }. |
GET /v1/aeo/settings | aeo:read | The brand profile: { configured, brandNames, domains, competitors, market, language, updatedAt, suggestions, limits }. suggestions holds values to review while nothing is saved, and is null after that. |
PUT /v1/aeo/settings | aeo:write | Replaces the brand profile: brandNames (1 to 10), domains (1 to 10 public website domains), competitors (up to 10, each { name, domain? }), market and language. Answers the saved profile, as GET does. |
Run a Check
/v1/aeo/runAsks every active question of each available AI assistant now, in the background, and answers 202. Charged from the wallet by AI usage. Takes no body.
{
"batchId": "...",
"status": "QUEUED",
"questions": 6,
"assistants": [{ "provider": "...", "label": "...", "model": "..." }],
"notAvailable": [ ... ],
"label": "API-sampled; answers people see in the apps can differ."
}400 until a brand profile and at least one active question exist, 402 when the wallet cannot pay, 409 with reason: "active_run" and activeBatchId while a check is running. Read the results with GET /v1/aeo/summary or GET /v1/aeo/answers?batchId=... once it finishes.
Website Events API
Send a named event, such as pay or view_button_clicked, from your own server or any language that can make an HTTPS request, and read the events back. Events appear in the Events tab of Web Analytics, for the website you sent them to, tied to the visit behind them when you pass that on. Your website sends events from the browser with slideAnalytics.track(); this API is for everything that happens on your side.
Origin header comes from a web page, where anyone can read the key, so it is refused with 400. In a web page use slideAnalytics.track().Every route works on one website of the key's workspace. Name it with site (its domain, id or site key), or leave it out when the workspace has one website. The website must be verified, or the answer is 409. Each request counts as one API call against your plan, so send events in batches of up to 50.
Send Events
/v1/analytics/eventsSend up to 50 events in one request. Each event is checked on its own: a bad event is listed in rejected with the reason, and the good ones are kept. Answers 202.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
site | string | No | The website: its domain, id or site key. Needed only when the workspace has several websites. |
events | object[] | Yes | 1 to 50 events, described below. |
Event Fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | What happened. Starts with a letter, up to 40 characters of Latin letters, digits, spaces and _ . : -. It is stored lower case with underscores, so "View Button Clicked" becomes view_button_clicked. A website can use up to 500 different names a day, so put changing values in properties, never in the name. |
user | object | If no visitorId | Who it was: id, email, phone, name and createContact. At least one of id, email or phone. See below. |
visitorId | string | If no user | The browser, from slideAnalytics.getIds() on your page. 6 to 64 letters, digits, dot, hyphen or underscore. |
sessionId | string | No | The visit, from slideAnalytics.getIds(). It needs the visitorId of the same browser. With it, the event joins that visit. |
time | number or string | No | When it happened, as epoch seconds, epoch milliseconds or an ISO date. Defaults to now. At most 7 days back; older times are rejected, never moved to today. |
insertId | string | No | Your own id for this event, up to 128 characters. Sending the same insertId on the same event name never counts twice, which makes a retry safe. Generated when you leave it out, so without it a retry counts again. |
valueMinor | integer | No | An amount in paise or cents, for example 199900 for Rs 1,999. Needs currency. |
currency | string | With valueMinor | A 3-letter code such as INR or USD. |
properties | object | No | Free details such as plan or method. Up to 20 keys; values are text, numbers or true/false. Text longer than 200 characters is cut; an object or a list is dropped. |
path | string | No | The page it relates to, for example /checkout. Defaults to /. |
Put who it was in user, never in properties. Properties whose key or value looks like personal data (an email address, a phone number, a password, a token, a card) are dropped and listed in droppedProperties. Keys that end in _id or _ref, such as order_id, keep their digits.
curl -X POST https://api.slide.cx/v1/analytics/events \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "site": "example.com", "events": [{ "name": "pay", "insertId": "order_10023", "user": { "id": "u_123", "email": "asha@example.com" }, "valueMinor": 199900, "currency": "INR", "properties": { "plan": "pro", "order_id": "A-10023456" } }] }'
const res = await fetch('https://api.slide.cx/v1/analytics/events', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SLIDE_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ events: [{ name: 'pay', insertId: 'order_10023', user: { id: 'u_123' }, valueMinor: 199900, currency: 'INR' }], }), }); const result = await res.json(); // { accepted: 1, capped: 0, rejected: [], droppedProperties: [] }
import os import requests res = requests.post( "https://api.slide.cx/v1/analytics/events", headers={"Authorization": f"Bearer {os.environ['SLIDE_API_KEY']}"}, json={"events": [{"name": "pay", "insertId": "order_10023", "user": {"id": "u_123"}, "valueMinor": 199900, "currency": "INR"}]}, timeout=10, ) print(res.json()) # {"accepted": 1, "capped": 0, "rejected": [], "droppedProperties": []}
Response (202)
{
"accepted": 2,
"capped": 0,
"rejected": [
{ "index": 2, "code": "invalid_value", "message": "valueMinor must be a whole number of paise or cents, with a 3-letter currency." }
],
"droppedProperties": [{ "index": 0, "keys": ["customer_email"] }]
}accepted is the number of events taken in. capped is the number held back by the website's overall daily limits. rejected lists each refused event by its index in your array, with a code and a readable message. An event is never dropped silently: check rejected in your code.
| Rejection code | Meaning |
|---|---|
invalid_event | The event is not an object. |
invalid_name | name is missing, or has no usable letters. |
invalid_time | time is not epoch seconds, epoch milliseconds or an ISO date. |
future_time | time is in the future. |
too_old | time is more than 7 days old. |
no_identity | The event has neither a visitorId nor a user with an id, email or phone. |
invalid_id | visitorId or sessionId is not 6 to 64 letters, digits, dot, hyphen or underscore, or a sessionId came without its visitorId. |
invalid_value | valueMinor is not a whole number, or currency is not 3 letters. |
too_many_names | The website already used 500 different event names today. |
needs_contacts_scope | The event asks for createContact, and the key does not hold contacts:write. |
daily_limit_reached | The website has used its daily limit of API events. This event was not taken in. |
Limits and Errors
| Limit or status | What it means and what to do |
|---|---|
| 50 events a request | More than 50 in one request is a 400. Split the batch. |
| 33,000 API events a day per website | A technical limit that keeps reports fast. Over it, a request answers 429 with code daily_limit_reached and limit. The count restarts at midnight UTC. |
| 7 days back | Events older than 7 days are rejected with too_old. |
| 500 event names a day per website | Counted separately for this API and for the browser. A new name past it is rejected with too_many_names. Names already used keep working. |
| 20 properties an event | Any more are left off and listed in droppedProperties. |
429 daily_limit_reached | Nothing in the request was taken in. Wait until the next UTC day, or send fewer events. |
503 try_again | Events are being processed. The body has retryAfterSeconds. Send the same batch again after that time: events with the same insertId never count twice. Without insertId a resend counts again. |
409 | The website is not verified yet. Verify it in Web Analytics first. |
400 | The body is malformed, the request had an Origin header (a web page), or the workspace has several websites and no site was named. |
The user Object
| Field | Type | Description |
|---|---|---|
id | string | Your own id for the person. Up to 128 characters. Events with the same id belong to one person, in every browser they use. |
email | string | Matches an existing contact by email. |
phone | string | Matches an existing contact by phone. |
name | string | Used only when a contact is created. |
createContact | boolean | Default false. When true and no contact matches the email or phone, a contact is created, if the key also holds contacts:write. A contact that matches is left unchanged. It is created under the normal contact rules, which count toward your contact allowance and can start automations. Leave it off unless you want that. |
A contact is matched by email or phone and linked to the visitor. A visitor already tied to a different contact is left alone. Personal details are kept in the contacts hub and never in the event itself.
How 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 visit of the browser last linked to user.id, by the same rule. An event with no visit behind it (a payment confirmation, a scheduled job) still counts under its name and shows as Server in the Events tab, but it never changes visits, funnels or conversions.
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, carrying only a visitorId or a user.id, counts with no visit behind it.
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.
Link a Person to a Visit
/v1/analytics/identifyTell Slide who a browser belongs to, for example when someone logs in, without sending an event. Earlier and later events of that visitor then belong to the person. Answers 202.
| Field | Type | Required | Description |
|---|---|---|---|
site | string | No | The website, as for sending events. |
visitorId | string | No | The browser, from slideAnalytics.getIds(). Without it the person has no browser yet and later events with the same user.id are grouped under them. |
user | object | Yes | The user object above. At least one of id, email or phone, or the answer is 400 no_identity. |
curl -X POST https://api.slide.cx/v1/analytics/identify \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "visitorId": "v_7d3e91aa0c12", "user": { "id": "u_123", "email": "asha@example.com" } }'
{ "accepted": true }List Events
/v1/analytics/eventsEvery event name of a date range, busiest first (up to 100), with how often it happened, in how many visits, and the last day it happened. Counts are refreshed every 5 minutes and kept for good.
| Parameter | Type | Default | Description |
|---|---|---|---|
site | string | The website, as for sending events | |
from | string | 29 days before to | First day, YYYY-MM-DD (UTC) |
to | string | today | Last day, YYYY-MM-DD (UTC). A range covers at most 92 days. |
{
"from": "2026-10-03",
"to": "2026-11-01",
"events": [
{ "name": "pay", "count": 412, "sessions": 388, "lastDay": "2026-11-01" },
{ "name": "view_button_clicked", "count": 96, "sessions": 90, "lastDay": "2026-10-31" }
]
}sessions counts visits that included the event. An event sent from a server with no visit behind it is in count and not in sessions.
Get One Event
/v1/analytics/events/:nameOne event over a date range: a daily trend, the number of people, money by currency, the most common property values and the latest events. Takes the same site, from and to parameters. A name that was never sent answers a count of 0 and empty lists; a name that cannot be an event name answers 404.
{
"name": "pay",
"from": "2026-10-03",
"to": "2026-11-01",
"count": 412,
"people": 377,
"trend": [{ "day": "2026-10-31", "count": 21, "sessions": 20 }],
"money": [{ "currency": "INR", "valueMinor": 82188800, "events": 412 }],
"properties": [{ "key": "plan", "values": [{ "value": "pro", "count": 301 }, { "value": "starter", "count": 111 }] }],
"sampled": 412,
"recent": [
{
"id": "...",
"name": "pay",
"occurredAt": "2026-11-01T09:14:03.000Z",
"source": "server",
"userId": "u_123",
"visitorId": "v_7d3e91aa0c12",
"sessionId": "s_8f2k1a9c",
"contactId": null,
"path": "/checkout",
"valueMinor": 199900,
"currency": "INR",
"properties": { "plan": "pro", "order_id": "A-10023456" }
}
]
}people is null for ranges longer than 31 days. trend leaves out days with no events. sessionId is null when the event has no visit behind it. source is server for this API and browser for slideAnalytics.track(). properties is read from the latest sampled events (at most 2,000), and recent holds the newest 50. Single events are kept for 90 days; the daily counts in trend and count are kept for good.
One Person's Events
/v1/analytics/users/:userId/eventsThe events of one person, newest first, across every browser they used and the events your server sent with their user id. :userId is the id you sent in user.id.
| Parameter | Type | Default | Description |
|---|---|---|---|
site | string | The website, as for sending events | |
from | string | 29 days before to | First day, YYYY-MM-DD (UTC) |
to | string | today | Last day, YYYY-MM-DD (UTC) |
page | number | 1 | Page number |
limit | number | 50 | Results per page (max 100) |
Answers { data: [...], meta: { total, page, limit, totalPages } }, where each row has the same shape as an item of recent above.
Funnels
/v1/analytics/funnelsHow many people got through 2 to 6 steps, in order. A step is an event name you sent or an action Slide records by itself: page_view, product_view or add_to_cart.
| Parameter | Type | Default | Description |
|---|---|---|---|
site | string | The website, as for sending events | |
steps | string | Required. The step names in order, separated by commas, 2 to 6: steps=page_view,add_to_cart,pay | |
from | string | 29 days before to | First day, YYYY-MM-DD (UTC). The range covers at most 31 days (400 above) |
to | string | today | Last day, YYYY-MM-DD (UTC) |
withinHours | number | 168 | How long after one step the next still counts, 1 to 720 hours |
{
"from": "2026-10-03",
"to": "2026-11-01",
"withinHours": 168,
"steps": [
{ "name": "page_view", "people": 5210, "fromPrevious": null, "fromFirst": 100 },
{ "name": "add_to_cart", "people": 640, "fromPrevious": 12.3, "fromFirst": 12.3 },
{ "name": "pay", "people": 188, "fromPrevious": 29.4, "fromFirst": 3.6 }
]
}Each person counts once per step, only when they did it after the step before and inside the window. fromPrevious and fromFirst are percentages with 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. The answer is counted from single events and cached for 5 minutes. 422 means the dates hold too much to read at once: send a shorter range.
Cohorts
/v1/analytics/cohortsWeekly groups by the week a person first did one event, and the share of each group that did another event, or the same one, in the weeks after. Weeks start on Monday (UTC).
| Parameter | Type | Default | Description |
|---|---|---|---|
site | string | The website, as for sending events | |
start | string | Required. The event, or built-in action, that puts a person in a week's group | |
return | string | start | The event that counts as coming back |
from | string | 83 days before to | First day, YYYY-MM-DD (UTC). The range covers at most 91 days (400 above) |
to | string | today | Last day, YYYY-MM-DD (UTC) |
{
"from": "2026-08-10",
"to": "2026-11-01",
"start": "signup_done",
"return": "pay",
"cohorts": [
{
"week": "2026-10-19",
"people": 120,
"returned": [
{ "weeksLater": 0, "people": 30, "percent": 25 },
{ "weeksLater": 2, "people": 12, "percent": 10 }
]
}
]
}weeksLater is 0 for the week the person first did start. A week in which nobody came back is left out of returned (week 1 above), so read it by weeksLater and not by position. A person is a browser. Cached for 5 minutes; 422 as for funnels.
Node and Python
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.
import { SlideClient } from '@synquic/slide'; const slide = new SlideClient({ apiKey: process.env.SLIDE_API_KEY! }); slide.analytics.track({ name: 'pay', visitorId, // from slideAnalytics.getIds() in the browser sessionId, user: { id: 'u_123' }, valueMinor: 199900, // paise or cents, a whole number currency: 'INR', properties: { plan: 'pro', method: 'upi' }, }); await slide.analytics.flush(); // serverless functions only
import os from synquic_slide import SlideClient slide = SlideClient(api_key=os.environ["SLIDE_API_KEY"]) slide.analytics.track( "pay", visitor_id=visitor_id, # from slideAnalytics.getIds() in the browser session_id=session_id, user_id="u_123", value_minor=199900, # paise or cents, a whole number currency="INR", properties={"plan": "pro", "method": "upi"}, ) slide.analytics.flush() # serverless functions only
In the Browser
With the Slide analytics snippet on your page, the browser sends events itself and hands you the ids of the visit, so an event your server sends afterwards joins that visit. The browser needs no API key.
// Name an action on the page. Details are free text, numbers or true/false. window.slideAnalytics.track('view_button_clicked', { plan: 'pro' }); // Hand this visit to your server. Both are null when the visitor has opted out. const { visitorId, sessionId } = window.slideAnalytics.getIds();
Send visitorId and sessionId to your server with your own request, then include them in the event you send there. An event from the browser carries at most 10 properties, and 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. 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, under Developers, step 6.
// On your server, for the signed-in person only const signature = slide.analytics.signUser(userId, process.env.SLIDE_IDENTIFY_SECRET!); // In the page, with the userId and signature your server rendered window.slideAnalytics.identify(userId, signature);
POST /v1/analytics/identify from your server.AI Agents API
Read and manage the AI agents that answer your customers on WhatsApp, Instagram, website chat, SMS and email: what each one got done, every run it made, and its versions. A key only ever reaches the agents of its own workspace: another workspace's agent is a 404, exactly like an id that does not exist.
List agents
/v1/agentsThe workspace's agents, newest first, with their live and draft versions.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number (1-indexed) |
limit | number | 50 | Results per page (max 100) |
search | string | Agents whose name or description contains this text, any case. Up to 120 characters. | |
audience | string | CUSTOMER, PATIENT, STAFF or ACCOUNT | |
isActive | boolean | Only active (true) or inactive (false) agents |
curl "https://api.slide.cx/v1/agents?limit=10" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "8f0c2a4e-...",
"name": "Booking Assistant",
"description": "Books home services and takes payment",
"audience": "CUSTOMER",
"isActive": true,
"versionCount": 12,
"boundStepCount": 1,
"publishedVersion": { "id": "...", "version": 12, "publishedAt": "2026-10-24T09:12:00.000Z" },
"draftVersion": null,
"createdAt": "2026-09-13T08:00:00.000Z",
"updatedAt": "2026-10-24T09:12:00.000Z"
}
],
"meta": { "total": 1, "page": 1, "limit": 10, "totalPages": 1 }
}Get an agent
/v1/agents/:idOne agent, with its versions summarised. Read a version's full content with GET /v1/agents/:id/versions/:versionId.
List an agent's versions
/v1/agents/:id/versionsEvery version of the agent, newest first, summarised: its number, status, model, limits, how many tools and knowledge items it holds and how many runs it answered.
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | DRAFT, PUBLISHED or ARCHIVED | |
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
{
"data": [
{
"id": "...",
"version": 12,
"status": "PUBLISHED",
"modelRef": "...",
"maxIterations": 6,
"maxTurns": null,
"hasMemory": true,
"publishedAt": "2026-10-24T09:12:00.000Z",
"publishedBy": { "id": "...", "name": "Asha Rao" },
"toolCount": 5,
"knowledgeCount": 2,
"runCount": 1794,
"createdAt": "2026-10-24T08:40:00.000Z",
"updatedAt": "2026-10-24T09:12:00.000Z"
}
],
"meta": { "total": 12, "page": 1, "limit": 20, "totalPages": 1 }
}Get one version
/v1/agents/:id/versions/:versionIdOne version in full: its instructions, model, tools and their settings, knowledge, hand-off and session settings, and whether it can still be edited (only a DRAFT can).
{
"id": "...",
"version": 13,
"status": "DRAFT",
"publishedAt": null,
"publishedBy": null,
"createdAt": "2026-10-25T06:00:00.000Z",
"updatedAt": "2026-10-25T06:10:00.000Z",
"editable": true,
"content": {
"systemPrompt": "You book home services ...",
"modelRef": "...",
"maxIterations": 6,
"maxTurns": null,
"exitKeyword": null,
"hasMemory": true,
"tools": [{ "toolKey": "...", "requiresVerification": false, "needsApproval": false, "config": null }],
"knowledgeIds": ["..."],
"handoffPolicy": null,
"sessionPolicy": null
}
}Credentials are removed from every tool's config; a config with nothing else left is null. modelRef is the slug of a model from GET /v1/ai/models.
What an agent got done
/v1/agents/:id/performanceThe bookings, orders, payments and other results the agent's tools completed, cancellations counted apart, the share of its conversations with a result, a daily trend and the AI cost per result. Counted from successful tool calls only; nothing is estimated.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
days | number | 30 | How many days back, 1 to 180 |
curl "https://api.slide.cx/v1/agents/8f0c2a4e-.../performance?days=30" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"agentId": "8f0c2a4e-...",
"from": "2026-09-25T07:00:00.000Z",
"to": "2026-10-25T07:00:00.000Z",
"objective": { "label": "Complete actions for customers", "measure": "actions completed through its tools" },
"outcomes": { "runs": 1900, "answeredWithoutPerson": 1500, "handedOff": 310, "resolved": 1100, "failed": 12 },
"conversations": 1794,
"convertedConversations": 140,
"conversionRate": 7.8,
"totals": { "conversions": 152, "reversals": 32, "changes": 13 },
"byLabel": [
{ "toolKey": "mcp__homeservices__book_service", "label": "Book service", "kind": "CONVERSION", "count": 152 },
{ "toolKey": "mcp__homeservices__cancel_booking", "label": "Cancel booking", "kind": "REVERSAL", "count": 32 }
],
"trend": [{ "date": "2026-10-24", "conversions": 6, "reversals": 1 }],
"recent": [{ "runId": "...", "label": "Book service", "kind": "CONVERSION", "at": "2026-10-24T18:02:11.000Z", "channel": "WHATSAPP" }],
"costPaise": 288000,
"costPerConversionPaise": 1895,
"sampled": false,
"source": "Successful agent tool calls ..."
}conversionRate is a percentage, and null when the agent handled no conversations in the window. objective is the goal every agent has, worked out from its tools, and outcomes counts its runs by how they ended: answered without a person, handed to the team, resolved, failed.List an agent's runs
/v1/agents/:id/runsOne run per customer message the agent answered, newest first: how it ended, which version answered, which tools it used, and each failed tool call with the error it returned.
| Parameter | Type | Default | Description |
|---|---|---|---|
status | string | RUNNING, COMPLETED or FAILED | |
outcome | string | AUTOMATED, HANDED_OFF, FAILED, ABANDONED or UNCLASSIFIED | |
channel | string | WHATSAPP, INSTAGRAM, SMS, EMAIL, VOICE, WEB or APP | |
versionId | string | Only runs answered by this version | |
from | ISO 8601 | Runs that started at or after this moment | |
to | ISO 8601 | Runs that started before this moment | |
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 50) |
Get one run
/v1/agents/runs/:runIdOne run in full: every tool call in order, with its input, a short preview of its result, how long it took and, when it failed, the error the tool returned.
Tool catalogue
/v1/agents/tool-catalogThe tools an agent may hold, by audience: the built-ins and the workspace's own active tools (custom__...). Use it before adding tools to a draft.
Create, edit and publish
| Endpoint | Scope | What it does |
|---|---|---|
POST /v1/agents | agents:write | Creates an agent and its draft version 1. A draft reaches no customer. |
POST /v1/agents/:id/versions | agents:write | Opens a draft from the live version (one draft at a time). |
PATCH /v1/agents/:id/versions/:versionId | agents:write | Edits the draft: instructions, model, tools, knowledge, session settings (for example followUps: false switches follow-ups off). |
POST /v1/agents/:id/versions/:versionId/publish | agents:publish | Makes the draft the live version, after every check the dashboard runs. Optional body { "expectedUpdatedAt" }: when sent, a draft changed since you read it is not published (409 AGENT_VERSION_CHANGED). |
POST /v1/agents/:id/versions/:versionId/rollback | agents:publish | Publishes a copy of an earlier version as a new version; history is kept. |
Automations API
Read, build and run the automations of your workspace: the flows that answer and message your customers on every channel. The same automations as the dashboard's automation builder.
POST /v1/automations therefore always creates it paused, whatever active you send, and going live is a separate activate call. Activation is refused when a step needs an integration that is not connected.Node Catalogue
/v1/automations/node-catalogEvery trigger type and every step (node) type, with the config fields each one reads. Call it before creating or updating an automation: a step whose type or config is wrong saves without error and then does nothing.
{
"triggers": [ ... ],
"nodes": [ ... ],
"unattributedFields": [ ... ],
"meta": {
"triggerCount": ...,
"nodeCount": ...,
"nodesWithDocumentedConfig": ...,
"note": "configFields are the persisted columns each node reads. Fields under unattributedFields are settable but the schema does not record which node owns them."
}
}The catalogue is generated from the engine's own schema, so it is always what the engine accepts.
List Automations
/v1/automationsThe workspace's automations, newest first: name, description, whether it is live, its trigger type and how many steps and keywords it has.
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 25 | Results per page (max 100) |
{
"data": [
{
"id": "...",
"name": "Order shipped",
"description": null,
"active": true,
"trigger": { "type": "SHOPIFY_EVENT" },
"_count": { "steps": 3, "keywords": 0 },
"createdAt": "2026-09-13T08:00:00.000Z",
"updatedAt": "2026-10-24T09:12:00.000Z"
}
],
"meta": { "total": 8, "page": 1, "limit": 25, "totalPages": 1 }
}Get an Automation
/v1/automations/:idOne automation in full: its trigger with its settings, every step with its config, keywords and linked posts. 404 for an automation of another workspace.
Create an Automation
/v1/automationsCreates an automation, always paused. Counts toward your plan's automation limit (403 when it is reached). Answers 201 with the automation and a _note saying it was created paused.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Shown in the dashboard. |
description | string | No | Up to 500 characters. |
trigger | object | No | { "type": "...", ...its config fields }. The type and its fields come from the node catalogue. |
steps | array | No | [{ "order": 0, "type": "...", ...its config fields }], in run order. Types and fields come from the node catalogue. |
keywords | array | No | The keywords that start a keyword-triggered flow. |
posts | array | No | The posts a comment-triggered flow listens to. |
curl -X POST https://api.slide.cx/v1/automations \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome", "trigger": { "type": "..." }, "steps": [{ "order": 0, "type": "...", "message": "Hi there" }] }'
Change, Switch On and Off, Delete
| Endpoint | Scope | What it does |
|---|---|---|
PATCH /v1/automations/:id | automations:write | Changes the automation; send the fields to change, in the create shape. |
POST /v1/automations/:id/activate | automations:write | Puts it live. Refused when a step needs an integration that is not connected. |
POST /v1/automations/:id/pause | automations:write | Stops it. Nothing is deleted. |
DELETE /v1/automations/:id | automations:write | Deletes it. Answers 204 with no body. |
POST /v1/automations/:id/executions/:execId/rerun | automations:write | Runs the automation again with the trigger data of an earlier run. Answers { "ok": true } at once; the new run appears in the run history. |
Run History
/v1/automations/:id/executionsEvery run of one automation, newest first, with the result of each step.
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 25 | Results per page (max 100) |
{
"data": [
{
"id": "...",
"triggerType": "SHOPIFY_EVENT",
"status": "COMPLETED",
"context": { ... },
"stepResults": [{ "order": 0, "type": "SEND_WHATSAPP", "status": "ok", "durationMs": 412 }],
"errorMessage": null,
"startedAt": "2026-10-24T09:12:00.000Z",
"completedAt": "2026-10-24T09:12:01.000Z",
"durationMs": 1020
}
],
"meta": { "total": 214, "page": 1, "limit": 25, "totalPages": 9 }
}AI Models API
/v1/ai/modelsThe AI models your workspace can choose for its agents, by capability label, priced at what your workspace is charged.
{
"data": [
{
"slug": "...",
"label": "...",
"description": "...",
"isDefault": true,
"supportsTools": true,
"supportsVision": false,
"pricing": { "inputInrPer1M": ..., "outputInrPer1M": ..., "displayCurrency": "INR", "inputPer1M": ..., "outputPer1M": ... },
"voice": null
}
]
}Prices are per million tokens, in rupees (inputInrPer1M, outputInrPer1M) and in your display currency (inputPer1M, outputPer1M). voice is set on models a voice agent can use, with billedRatePaisePerMin. The slug of a model is what an agent takes as its modelRef. There is no write side: which models exist is decided by Slide.
Voice API
The Voice API provides programmatic access to AI voice calling manage agents, initiate outbound calls, retrieve call history with transcripts, and access recordings. Powered by enterprise SIP infrastructure.
List Voice Agents
/v1/voice/agentsList all AI voice agents configured for your organization.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number (1-indexed) |
limit | number | 20 | Results per page (max 100) |
curl "https://api.slide.cx/v1/voice/agents?page=1&limit=10" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "va_01JXYZ...",
"name": "Sales Assistant",
"description": "Handles inbound sales inquiries",
"language": "en-US",
"sttProvider": "DEEPGRAM",
"ttsProvider": "ELEVENLABS",
"llmProvider": "OPENAI",
"llmModel": "gpt-4o-mini",
"isActive": true,
"createdAt": "2026-03-01T10:00:00.000Z"
}
],
"meta": { "total": 3, "page": 1, "limit": 10, "totalPages": 1 }
}Get Voice Agent
/v1/voice/agents/:idGet full agent configuration including persona, language, and provider settings.
curl "https://api.slide.cx/v1/voice/agents/va_01JXYZ" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
List Voice Calls
/v1/voice/callsList voice calls with optional filtering by status and direction.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Results per page (max 100) |
status | string | Filter: INITIATED, RINGING, ANSWERED, COMPLETED, FAILED, NO_ANSWER, BUSY, CANCELLED | |
direction | string | Filter: INBOUND or OUTBOUND |
curl "https://api.slide.cx/v1/voice/calls?status=COMPLETED&direction=OUTBOUND&limit=10" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": [
{
"id": "vc_01JXYZ...",
"agentId": "va_01JXYZ...",
"direction": "OUTBOUND",
"callerNumber": "+911234567890",
"calledNumber": "+919876543210",
"status": "COMPLETED",
"durationSeconds": 142,
"sentiment": "POSITIVE",
"summary": "Customer confirmed appointment for Thursday.",
"wasConfirmed": true,
"billedAmountPaise": 3200,
"startedAt": "2026-03-24T10:00:00.000Z",
"endedAt": "2026-03-24T10:02:22.000Z"
}
],
"meta": { "total": 56, "page": 1, "limit": 10, "totalPages": 6 }
}Initiate Outbound Call
/v1/voice/calls/outboundInitiate an AI-powered outbound voice call to a phone number.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
agentId | string | Yes | ID of the voice agent to use |
toNumber | string | Yes | Phone number to call (E.164 format preferred) |
callContext | object | No | Key-value context passed to the AI agent during the call |
curl -X POST "https://api.slide.cx/v1/voice/calls/outbound" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "agentId": "va_01JXYZ...", "toNumber": "+919876543210", "callContext": { "customerName": "Rahul Sharma", "appointmentDate": "2026-04-10", "purpose": "appointment_confirmation" } }'
const res = await fetch('https://api.slide.cx/v1/voice/calls/outbound', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, body: JSON.stringify({ agentId: 'va_01JXYZ...', toNumber: '+919876543210', callContext: { customerName: 'Rahul Sharma', purpose: 'appointment_confirmation' }, }), }); const call = await res.json(); // { id: 'vc_01J...', status: 'INITIATED', direction: 'OUTBOUND', ... }
import requests res = requests.post( 'https://api.slide.cx/v1/voice/calls/outbound', headers={ 'Authorization': 'Bearer sk_live_YOUR_API_KEY', 'Content-Type': 'application/json', }, json={ 'agentId': 'va_01JXYZ...', 'toNumber': '+919876543210', 'callContext': {'customerName': 'Rahul Sharma', 'purpose': 'appointment_confirmation'}, } ) call = res.json() print(call['id'], call['status']) # vc_01J... INITIATED
When no call is placed
| Status | code | When | What to do |
|---|---|---|---|
| 409 | VOICE_NUMBER_BUSY | Another call to this number is in progress, or waiting to be tried again. Always on: one call at a time per number in your workspace. | Wait until that call ends, then try again |
| 429 | VOICE_DAILY_CALL_CAP | This agent already started as many calls to this number as its "Calls to one person" limit allows in 24 hours. A retry of an unanswered call does not count. | Do not retry now. Try again tomorrow, or raise the limit on the agent |
| 429 | VOICE_LINES_BUSY | Every line your workspace may use for outbound calls is busy | Wait for a call to finish, then retry |
Each body carries code and a message sentence. Two different reasons answer 429, so read code, not the status alone.
Get Call Details
/v1/voice/calls/:idGet full call details including transcription, sentiment, and cost breakdown.
curl "https://api.slide.cx/v1/voice/calls/vc_01JXYZ" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"id": "vc_01JXYZ...",
"agentId": "va_01JXYZ...",
"direction": "OUTBOUND",
"status": "COMPLETED",
"callerNumber": "+911234567890",
"calledNumber": "+919876543210",
"callerName": "Rahul Sharma",
"durationSeconds": 142,
"sentiment": "POSITIVE",
"summary": "Customer confirmed the appointment for Thursday 10 AM.",
"wasConfirmed": true,
"wasBooked": false,
"transcription": [
{ "role": "agent", "content": "Hello! This is a call from Acme Corp regarding your appointment.", "timestamp": "2026-03-24T10:00:03.000Z" },
{ "role": "user", "content": "Yes, I was expecting this call.", "timestamp": "2026-03-24T10:00:08.000Z" }
],
"telephonyPaise": 1500,
"aiPaise": 1700,
"billedAmountPaise": 3200,
"startedAt": "2026-03-24T10:00:00.000Z",
"endedAt": "2026-03-24T10:02:22.000Z"
}billedAmountPaise is the total taken from your wallet, and telephonyPaise and aiPaise are its two parts, in paise. Calls billed before the parts were recorded return null for both and show only the total.Get Call Recording
/v1/voice/calls/:id/recordingGet a pre-signed URL for the call recording audio. URL expires in 1 hour.
curl "https://api.slide.cx/v1/voice/calls/vc_01JXYZ/recording" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"data": {
"url": "https://s3.ap-south-1.amazonaws.com/recordings/vc_01JXYZ.ogg?X-Amz-Expires=3600&..."
}
}404 if the call has no recording.Get Voice Analytics
/v1/voice/analyticsGet aggregated voice call metrics for a date range.
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
from | string | Start date (ISO 8601, required) | |
to | string | End date (ISO 8601, required) |
curl "https://api.slide.cx/v1/voice/analytics?from=2026-03-01&to=2026-03-31" \ -H "Authorization: Bearer sk_live_YOUR_API_KEY"
Response
{
"totalCalls": 234,
"answeredCalls": 198,
"avgDurationSec": 127,
"totalCostUsd": null,
"totalBilledPaise": 748800,
"callsByDay": [
{ "date": "2026-03-01", "total": 8, "answered": 7 },
{ "date": "2026-03-02", "total": 11, "answered": 9 }
],
"callsByDirection": {
"INBOUND": 60,
"OUTBOUND": 174
},
"sentimentBreakdown": {
"POSITIVE": 142,
"NEUTRAL": 74,
"NEGATIVE": 18
}
}| Field | Meaning |
|---|---|
answeredCalls | Calls that were answered or completed. |
avgDurationSec | Average call length in seconds, over calls that have a duration. |
totalBilledPaise | What the calls in the range cost your wallet, in INR paise (100 paise = ₹1): the sum of each call’s billedAmountPaise. |
totalCostUsd | Always null. Kept so existing integrations keep parsing the response; use totalBilledPaise. |
callsByDay | One entry per day that had calls (UTC date), with total and answered counts. Two days shown above. |
sentimentBreakdown | Calls with no sentiment yet are counted as NEUTRAL. |
Create, Update and Delete Voice Agents
| Endpoint | Scope | What it does |
|---|---|---|
POST /v1/voice/agents | voice:agents:write | Creates a voice agent with the same rules as the dashboard. Answers 201 with the new agent. |
PATCH /v1/voice/agents/:id | voice:agents:write | Changes an agent: send only the fields to change. |
DELETE /v1/voice/agents/:id | voice:agents:write | Deletes the agent. It cannot be undone. Answers 204 with no body. |
Body
| Field | Type | Required on create | Description |
|---|---|---|---|
name | string | Yes | Up to 120 characters. |
persona | string | Yes | Instructions for the agent: who it is and how it should handle the call. |
description | string | No | Up to 500 characters. |
firstMessage | string | No | What the agent says first. Up to 500 characters. |
language | string | No | Language code, up to 20 characters. |
multiLingual | boolean | No | The multilingual switch of the dashboard. |
maxDurationSec | integer | No | Longest call, 30 to 3600 seconds. |
maxTurns | integer | No | Most turns in one call, 1 to 100. |
tools | array | No | Tools the agent may use, at most 64. |
mcpServerIds | string[] | No | Ids of MCP servers registered in Settings whose tools the agent may call. |
isActive | boolean | No | Whether the agent is active. |
The voice, speech and model settings take the values the dashboard offers; read an agent with GET /v1/voice/agents/:id to see them. A field this API does not know is ignored, and a known field with a wrong value is a 400.
Voice External: App to App
Two people talk from inside your own app, for example a pro and a customer. You send two of your own user ids; nothing has to be registered with us first. Calls that start or end on a real phone are in the next section, Voice External: App to Phone and Phone to App.
POST /v1/voice/app/calls
{ "type": "app", "from": "pro_991", "to": "cust_5521" }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 notifications, 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, the same way it tells it anything else. |
| Your app → Slide voice server | The audio. It is 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. Pass it to your app with the token. Always connect to the voiceServerUrl the API returns. Never hard-code it, because it can change. 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 | Dashboard → API Keys |
| 2. Webhook endpoint subscribed to voice.app.call.incoming, .answered, .completed, .missed and .rejected | Dashboard → Webhooks, or the Webhooks API |
| 3. Money in your wallet: calls are charged per minute | Dashboard → Billing |
Joining a Call in Your App
The same code runs in the browser and in React Native. On React Native also install @synquic/slide-voice-native and call registerSynquicVoice() at the very top of your entry file, or calls connect with no audio.
npm install @synquic/slide-voice # React Native only npm install @synquic/slide-voice-native
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); // Hang-up button hangUpButton.onclick = () => call.leave(); // triggers 'ended' above
When the other person hangs up, declines, or never answers, we close the call and ended fires in your app. Start a talk timer when your backend receives voice.app.call.answered, not when the app joins.
The Call, Step by Step
1. Pro taps Call Your app asks your backend.
Your backend POST /v1/voice/app/calls
{ "type": "app", "from": "pro_991", "to": "cust_5521" }
<-- { callId, token, voiceServerUrl }
Pro's app joins with that token and shows "Ringing".
2. Webhook voice.app.call.incoming (to.externalId = "cust_5521") --> your backend
Your backend pushes { callId } to cust_5521's app, which rings.
3. Customer taps Answer
Your backend POST /v1/voice/app/calls/:callId/accept { "asExternalId": "cust_5521" }
<-- { token, voiceServerUrl }
Customer's app joins. Both are talking. Recording starts. Billing starts.
Webhook voice.app.call.answered
(or Decline) Your backend POST /v1/voice/app/calls/:callId/reject { "asExternalId": "cust_5521" }
4. Either taps End Your backend POST /v1/voice/app/calls/:callId/end { "asExternalId": "..." }
Webhook voice.app.call.completed (durationSeconds = billed talk time)If nobody answers within 45 seconds you get voice.app.call.missed, and the call costs nothing.
Place a Call
/v1/voice/app/callsStart a call from your app. Returns the caller's join token.
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | app to ring another app user. (phone rings a phone number: see App to Phone.) |
from | string | Yes | Your id for the person calling. They talk from the app. |
to | string | Yes | Your id for the person being called. |
{
"callId": "apc_7f31c9a2",
"roomId": "app-org_1a2b-7f31c9a2",
"status": "RINGING",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"voiceServerUrl": "wss://...",
"tokenExpiresIn": 300,
"ringTimeoutSeconds": 45,
"callee": { "externalId": "cust_5521", "displayName": null }
}| Error | When |
|---|---|
| 400 | Missing fields, or from and to are the same person. |
| 409 | These two people already have a live call. |
Answer or Decline
/v1/voice/app/calls/:callId/acceptThe app user picks up and receives their join token. Retrying is safe.
/v1/voice/app/calls/:callId/rejectThe app user declines a ringing call.
| Field | Type | Required | Description |
|---|---|---|---|
asExternalId | string | Yes | The user being rung: to.externalId from voice.app.call.incoming. |
reason | string | No | Decline only. Returned as endReason. |
Hang Up
/v1/voice/app/calls/:callId/endEnd the call for everyone on it. Safe to call twice.
| Field | Type | Required | Description |
|---|---|---|---|
asExternalId | string | No | The app user hanging up. Leave it out to end the call from your backend. |
reason | string | No | Returned as endReason. |
/v1/voice/app/calls/:callId/tokenA new join token after a network drop. Body: { asExternalId }.
An app losing its connection does not end the call, because that is not hanging up. When someone hangs up in your app, call this endpoint: billing stops when the call ends.
| Status | Meaning |
|---|---|
RINGING | Nobody has picked up yet. |
ACCEPTED | Picked up. Both people are connected. |
COMPLETED | A connected call ended. The only status that is charged. |
CANCELLED | Ended while still ringing. Never connected. |
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 it was still ringing. |
callee_rejected | Declined, with 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 own text | The reason you sent to end or reject. |
Calls involving a phone have a few more reasons, listed in Hanging Up a Phone.
Webhooks
| Event | When | What your backend does |
|---|---|---|
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 | The call ended (COMPLETED, CANCELLED or FAILED). | Stop any ringing screen. Save the outcome and talk time. |
voice.app.call.missed | Nobody answered. | Stop any ringing screen. |
voice.app.call.rejected | Declined. | Stop any ringing screen. |
voice.app.session.expired | Bookings only: a booking reached its expiry. | Nothing required. |
{
"id": "evt_01JXYZ...",
"type": "voice.app.call.completed",
"createdAt": "2026-09-16T10:12:51.000Z",
"data": {
"callId": "apc_7f31c9a2",
"sessionRef": null,
"roomId": "app-org_1a2b-7f31c9a2",
"status": "COMPLETED",
"from": "pro_991",
"to": "cust_5521",
"callerLegTransport": "WEBRTC",
"calleeLegTransport": "WEBRTC",
"durationSeconds": 92,
"endReason": "completed",
"endedAt": "2026-09-16T10:12:51.000Z"
}
}voice.app.call.incoming, from and to are objects with externalId; on every other event they are plain strings. Reply 200 straight away and do the work after. A failed delivery is retried, so the same event can arrive twice: skip repeats by event id.Recordings and Transcripts
/v1/voice/app/calls/:callId/recordingA link to the call audio. Expires in one hour and is generated fresh each time.
/v1/voice/app/calls/:callId/transcriptWho said what, generated from the finished recording.
Every answered call is recorded from the moment it is picked up and transcribed a few seconds after it ends. Every call object carries recordingReady: fetch the recording once it is true. Your call history is at GET /v1/voice/app/calls?identityExternalId=pro_991, and every call is also listed in Dashboard → Voice → Calls → App calls with its recording, transcript and cost.
| Transcript state | Meaning |
|---|---|
ready | turns holds who said what. |
processing | Still being made. Ask again in a few seconds. |
no_speech | The call was answered but nothing was said. Not charged. |
not_answered | Never answered, so there is nothing to transcribe. |
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. See 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)
You do not need anything in this section, for app calls or phone calls. Calls can also be placed on a booking, with { "externalRef", "fromExternalId" } and no type. That needs the people and the booking registered first, and adds two things: we ring registered devices by push, and match a customer who dials your number back to their open booking. Integrations built on it keep working unchanged.
Create or Update an Identity
/v1/voice/app/identitiesRegister one participant, keyed by your own id. Idempotent: safe to replay for a full roster sync.
Body
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | Yes | Your id for this person. Stable for the life of the account. |
role | string | Yes | PRO or CUSTOMER |
displayName | string | No | Shown as the caller name on push notifications. |
phone | string | No | E.164. Stored encrypted, used only for the PSTN fallback leg. Omit it if you would rather we never hold it. |
curl -X POST https://api.slide.cx/v1/voice/app/identities \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "externalId": "pro_991", "role": "PRO", "displayName": "Ramesh K." }'
Get an Identity
/v1/voice/app/identities/:externalIdIncludes reachability, so you can hide the call button when a call could not ring.
{
"id": "vai_3c81",
"externalId": "pro_991",
"role": "PRO",
"displayName": "Ramesh K.",
"isActive": true,
"lastSeenAt": "2026-08-22T10:09:00.000Z",
"deviceCount": 2,
"reachable": true
}Register a Device
/v1/voice/app/identities/:externalId/devicesOptional. Register a push target so we can ring the device directly.
isVoip: true. A backgrounded app can only raise the native incoming-call screen from a VoIP push. Register a plain notification token and the device can be messaged but not rung.| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | FCM registration token, or APNs VoIP token. |
platform | string | Yes | ios or android |
isVoip | boolean | No | Defaults to true on iOS. |
Create a Calling Session
/v1/voice/app/sessionsPair a Pro and a Customer for one booking. This is what authorises them to call each other.
| Field | Type | Required | Description |
|---|---|---|---|
externalRef | string | Yes | Your booking / job / order id. Unique per organization. |
proExternalId | string | Yes | Must already exist with role PRO. |
customerExternalId | string | Yes | Must already exist with role CUSTOMER. |
expiresAt | string | No | ISO 8601. Defaults to 24 hours out, capped at 90 days. |
metadata | object | No | Echoed back on every webhook for this session. |
externalRef. Replaying it extends the booking instead of creating a second session, and reactivates one you previously revoked.End a Session
/v1/voice/app/sessions/:externalRefThe booking finished or was cancelled. No new calls can be placed; a call already in progress is left to finish.
Your People, Sessions and Calls
| Endpoint | Scope | What it does |
|---|---|---|
GET /v1/voice/app/identities | voice:app:read | Your identities: { data: [{ id, externalId, role, displayName, isActive, lastSeenAt, createdAt }], meta }. Optional role (PRO or CUSTOMER), page and limit (default 20, max 100). |
DELETE /v1/voice/app/identities/:externalId | voice:app:write | Offboards the person: their devices and sessions go with them, and any phone number held for them is destroyed. Answers 204. |
DELETE /v1/voice/app/identities/:externalId/devices/:token | voice:app:write | Removes one push target. An unknown token is not an error. Answers 204. |
GET /v1/voice/app/sessions | voice:app:read | Your sessions. Optional status (ACTIVE, EXPIRED or REVOKED), page and limit (default 20, max 100). |
GET /v1/voice/app/sessions/:externalRef | voice:app:read | One session, with callCount: how many calls were placed on it. |
GET /v1/voice/app/calls | voice:app:read | Your call history, newest first. Optional sessionRef, identityExternalId and status filters, page and limit (default 20, max 100). |
GET /v1/voice/app/calls/:callId | voice:app:read | One call. |
// A session
{
"id": "...",
"externalRef": "BK-8842",
"status": "ACTIVE",
"pro": { "externalId": "pro_991", "displayName": "Ramesh K." },
"customer": { "externalId": "cust_5521", "displayName": "Asha R." },
"expiresAt": "2026-10-26T07:00:00.000Z",
"metadata": null,
"createdAt": "2026-10-25T07:00:00.000Z",
"callCount": 2
}
// A call
{
"callId": "...",
"sessionRef": "BK-8842",
"roomId": "...",
"status": "COMPLETED",
"from": { "externalId": "pro_991", "displayName": "Ramesh K." },
"to": { "externalId": "cust_5521", "displayName": "Asha R." },
"callerLegTransport": "WEBRTC",
"calleeLegTransport": "PSTN",
"startedAt": "2026-10-25T07:10:00.000Z",
"answeredAt": "2026-10-25T07:10:09.000Z",
"endedAt": "2026-10-25T07:13:19.000Z",
"durationSeconds": 190,
"endReason": "completed",
"costPaise": ...,
"recordingReady": true
}callerLegTransport and calleeLegTransport are WEBRTC for a leg carried by your app and PSTN for a real phone. costPaise is what the call cost your wallet (talk time plus its transcript), null until it ends and 0 when nobody answered. The status and endReason values are listed under Hang Up, above.
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. Read App to App first: joining from the app, answering and hanging up work the same way here.
| 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" } |
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. Reaching the phone network is a licensed activity, so it stays off until you turn it on, and each change is recorded.
App to Phone
1. Pro taps Call Your backend POST /v1/voice/app/calls
{ "type": "phone", "from": "pro_991", "to": "+919876543210" }
<-- { callId, token, voiceServerUrl }
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. Hang up Pro taps End: your backend POST /v1/voice/app/calls/:callId/end { "asExternalId": "pro_991" }
Customer hangs up the phone: the call ends by itself, and the pro's app gets 'ended'.
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 |
| Error | When |
|---|---|
| 400 | The number is not valid (use international format), or your account has no active number to call from. |
| 403 | Phone calling is off. See One-Time Setup, step 1. |
| 409 | This app user already has a live call with this number. |
Phone to App
A customer dials your business number and the pro answers in your app.
1. Customer dials your number. They stay on the line while you decide.
2. Webhook voice.app.call.inbound
{ "callId": "apc_9c02e4b1", "callerNumber": "+919876543210", "calledNumber": "+918031704204" }
Your backend looks up who +919876543210 is and which pro they need.
3. Your backend POST /v1/voice/app/calls/apc_9c02e4b1/connect { "type": "app", "to": "pro_991" }
4. Webhook voice.app.call.incoming (to.externalId = "pro_991") --> your backend
Your backend pushes { callId } to pro_991's app, which rings.
5. Pro taps Answer Your backend POST /v1/voice/app/calls/:callId/accept { "asExternalId": "pro_991" }
<-- { token, voiceServerUrl }
Pro's app joins. Both are talking. Recording and billing start.
6. Hang up Customer hangs up: the call ends by itself.
Pro taps End: your backend POST /v1/voice/app/calls/:callId/end { "asExternalId": "pro_991" }
Webhook voice.app.call.completedSteps 4 to 6 are exactly the same as app to app. 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 dialling to reach their pro, or a pro dialling to reach their customer.
1. Pro dials your number from their own phone.
2. Webhook voice.app.call.inbound { "callId": "apc_9c02e4b1", "callerNumber": "+919812345678", ... }
Your backend sees +919812345678 is pro_991, whose current job is with a customer.
3. Your backend POST /v1/voice/app/calls/apc_9c02e4b1/connect { "type": "phone", "to": "+919876543210" }
The pro hears it ringing. The customer's phone rings, showing your business number.
4. Picked up Webhook voice.app.call.answered. Recording and billing start.
5. Hang up Either person hangs up: the call ends for both.
Webhook voice.app.call.completed (endReason caller_hung_up or callee_hung_up)One webhook handler covers both kinds of call to your number:
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
/v1/voice/app/calls/:callId/connectSay who picks up a call that arrived on your business number.
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | app to ring an app user, phone to ring a phone number. |
to | string | Yes | Your id for the app user, or a phone number in international format. |
callId comes from the voice.app.call.inbound webhook. Connect within connectWithinSeconds (45) of the call arriving; after that the caller is disconnected with endReason: not_connected. A phone you connect rings for whatever is left of those 45 seconds. Returns the call.
| Error | When |
|---|---|
| 400 | The call did not come in on your number, it has already ended, or the phone number is not valid. |
| 403 | type is phone and phone calling is off. |
| 404 | No such call on your account. |
| 409 | The call is already connected. Only the first connect counts. |
Your Numbers
/v1/voice/app/numbersYour business numbers and where calls to each one go.
/v1/voice/app/numbers/:numberChange where calls to one number go. Body: { "routeTo": "app" }.
| routeTo | Calls to the number |
|---|---|
app | Your backend decides who picks up, through voice.app.call.inbound. If you are not subscribed to that webhook, calls ring your team instead. |
team | Ring your team in the Slide dashboard. |
ai | Answered by the voice agent assigned to the number. |
curl -X PATCH https://api.slide.cx/v1/voice/app/numbers/%2B918031704204 \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "routeTo": "app" }' { "number": "+918031704204", "routeTo": "app" }
Call Delivery Settings
/v1/voice/app/settingsHow calls reach your people: whether a call may reach a real phone, and whether the app or the phone rings first.
{
"pstnEnabled": false,
"transport": "appFirst",
"fallbackAfterSeconds": 20,
"ringTimeoutSeconds": 45,
"outboundReady": true
}/v1/voice/app/settingsChanges either setting and answers the settings as GET does. The same switch as Dashboard, Voice, Routing, How calls reach people.
| Field | Type | Required | Description |
|---|---|---|---|
pstnEnabled | boolean | No | Whether calls may reach the public phone network. Off until you turn it on; the change is recorded against the key. |
transport | string | No | appFirst (default) rings the app and tries the phone only if nobody answers within fallbackAfterSeconds. phoneFirst rings the real phone straight away and needs pstnEnabled. |
ringTimeoutSeconds is when an unanswered call is given up on. outboundReady is false while the workspace has no active number to dial out from, and turning phone delivery on is then refused with a 400. Turning it off without sending transport also moves phoneFirst back to appFirst.
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: POST /v1/voice/app/calls/:callId/end {}, with no asExternalId. That is the only way to end a phone-to-phone call early. Answer, decline and app hang-up work as in App to App.
| 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 was not answered. |
dial_failed | The phone could not be rung. |
not_connected | A call to your number was not connected within 45 seconds. |
Webhooks
| Event | When | What your backend does |
|---|---|---|
voice.app.call.inbound | Someone dialled your number and is waiting. | Call connect, or end to hang up. |
| The five call events | Same as App to App. | incoming only fires when an app is rung. |
{
"id": "evt_01JXYZ...",
"type": "voice.app.call.inbound",
"createdAt": "2026-09-16T10:11:12.000Z",
"data": {
"callId": "apc_9c02e4b1",
"roomId": "voice-...",
"status": "RINGING",
"callerNumber": "+919876543210",
"calledNumber": "+918031704204",
"callerLegTransport": "PSTN",
"connectWithinSeconds": 45,
"startedAt": "2026-09-16T10:11:12.000Z"
}
}{
"id": "evt_01JXYZ...",
"type": "voice.app.call.answered",
"createdAt": "2026-09-16T10:11:19.000Z",
"data": {
"callId": "apc_9c02e4b1",
"sessionRef": null,
"roomId": "voice-...",
"status": "ACCEPTED",
"from": "+91******3210",
"to": "pro_991",
"callerLegTransport": "PSTN",
"calleeLegTransport": "WEBRTC",
"answeredAt": "2026-09-16T10:11:19.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 once, as callerNumber on voice.app.call.inbound, because you need it to decide who picks up. 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 same rules as App to App pricing 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.
WebSocket Events
Slide provides real-time event streaming via Socket.IO for live call monitoring and transcript streaming. Connect to the WebSocket server to receive voice call lifecycle events and live transcription.
Connection
Connect using the Socket.IO client library. The WebSocket server runs on the same host as the REST API.
import { io } from 'socket.io-client'; const socket = io('https://slide.synquic.com', { transports: ['websocket'], autoConnect: true, }); // Join your organization room to receive events socket.emit('joinOrganization', 'your-org-id'); // Subscribe to a specific call for live transcript socket.emit('joinCall', 'call-id'); // Unsubscribe when done socket.emit('leaveCall', 'call-id'); socket.emit('leaveOrganization', 'your-org-id');
import socketio sio = socketio.Client() sio.connect('https://slide.synquic.com', transports=['websocket']) # Join organization room sio.emit('joinOrganization', 'your-org-id') # Listen for events @sio.on('voice:call.initiated') def on_call_initiated(data): print(f"Call started: {data['callId']}") @sio.on('voice:transcript.chunk') def on_transcript(data): print(f"[{data['role']}]: {data['content']}")
Voice Call Events
Voice call lifecycle events are emitted to the organization room (org_{orgId}). Subscribe by emitting joinOrganization with your org ID.
| Event | Payload | Description |
|---|---|---|
voice:call.initiated | { callId, agentId, direction, calledNumber, status } | New call created and dispatched |
voice:call.ringing | { callId, status } | Call is ringing (voice room started) |
voice:call.answered | { callId, status, answeredAt } | Call was answered by the recipient |
voice:call.ended | { callId, status, durationSeconds, sentiment, summary } | Call completed or failed |
// Listen for call lifecycle events socket.on('voice:call.initiated', (data) => { console.log('Call started:', data.callId, data.direction); }); socket.on('voice:call.answered', (data) => { console.log('Call answered:', data.callId); // Join call room for live transcript socket.emit('joinCall', data.callId); }); socket.on('voice:call.ended', (data) => { console.log('Call ended:', data.callId, data.sentiment, data.summary); socket.emit('leaveCall', data.callId); });
Live Transcript Streaming
Real-time transcript chunks are emitted to the call room (call_{callId}). Subscribe by emitting joinCall with the call ID. Each chunk contains a single utterance from the AI agent or the human caller.
| Event | Payload | Description |
|---|---|---|
voice:transcript.chunk | { role, content, timestamp } | Single transcript utterance |
| Field | Type | Description |
|---|---|---|
role | "agent" | "user" | Who spoke the AI agent or the human caller |
content | string | Transcribed text of the utterance |
timestamp | string | ISO 8601 timestamp of when it was spoken |
// Subscribe to live transcript for a specific call socket.emit('joinCall', 'vc_01JXYZ...'); socket.on('voice:transcript.chunk', (chunk) => { const speaker = chunk.role === 'agent' ? 'AI' : 'Caller'; console.log(`[${speaker}] ${chunk.content}`); // [AI] Hello! This is a call from Acme Corp regarding your appointment. // [Caller] Yes, I was expecting this call. }); // Clean up when done socket.emit('leaveCall', 'vc_01JXYZ...');
GET /v1/voice/calls/:id in the transcription field.Webhooks
Everything above is you calling us. Webhooks are the other direction: we POST a signed JSON payload to a URL you own whenever something happens on your account, so you do not have to poll. Register endpoints at slide.synquic.com/dashboard/webhooks, or over this API with a key holding the webhooks:write scope.
An account can hold up to 50 endpoints, and each one independently chooses which events it wants. That is what makes tools like Zapier work correctly: every Zap subscribes its own URL when you turn it on and removes it when you turn it off, so several can be live at once without interfering with each other.
Zapier, n8n & direct
Three ways to consume these events. All three drive the same endpoints below, so an account can run any combination at once: each connection registers its own webhook endpoint and they never interfere.
| Integration | How it connects | Status |
|---|---|---|
| n8n | Install n8n-nodes-slide-synquic from Settings → Community Nodes. A trigger node for every event and an action node for sending. | Available now |
| Direct webhooks | Register an endpoint yourself and receive signed POSTs. Works with Make, Pipedream, or your own server. | Available now |
| Zapier | Triggers for every event plus actions for sending messages and managing contacts. | In review |
Payload format
Every delivery has the same envelope regardless of event type. The event-specific data is always under data, and type tells you what it is, so you can route on one field rather than sniffing the body shape.
{
"id": "evt_9f2c4b1e7a3d5f8c0b2e4a6d8f1c3e5a",
"type": "contact.created",
"createdAt": "2026-08-20T10:11:12.000Z",
"accountId": "org_9f2c4b1e",
"livemode": true,
"data": {
"contact": {
"id": "ct_9f2c4b1e",
"name": "Priya Sharma",
"email": "priya@example.com",
"phone": "+919876543210",
"tags": ["newsletter"],
"lifecycleStageId": "stg_lead",
"createdAt": "2026-08-20T10:11:12.000Z"
},
"source": "widget"
}
}Headers
| Header | Description |
|---|---|
X-Slide-Event-Id | Unique id for this event. Stable across retries, so use it to make your handler idempotent. |
X-Slide-Event-Type | The event type, identical to the envelope’s type field. |
X-Slide-Signature | HMAC signature, described below. Always verify it. |
X-Slide-Webhook-Id | Which of your endpoints this delivery was sent to. |
X-Slide-Delivery-Attempt | Attempt counter, starting at 1. |
X-Slide-Event-Id. Retries of a delivery reuse it too. Storing processed ids and skipping duplicates is the correct way to handle a retry after your server timed out but had already done the work.Verifying the signature
Your endpoint URL is not a secret, so anyone who learns it could post to it. Every delivery is signed with the secret shown once when you created the endpoint. The header looks like this:
X-Slide-Signature: t=1755683472,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t is the Unix timestamp of the delivery and v1 is HMAC-SHA256 over the string "{t}.{raw request body}", hex encoded, keyed with your signing secret.
express.raw, not express.json, on this route.const crypto = require('crypto'); const express = require('express'); const app = express(); const SECRET = process.env.SLIDE_WEBHOOK_SECRET; // whsec_... // express.raw, NOT express.json: we need the exact bytes we were sent. app.post('/webhooks/slide', express.raw({ type: 'application/json' }), (req, res) => { const header = req.get('X-Slide-Signature') || ''; const rawBody = req.body.toString('utf8'); const parts = Object.fromEntries( header.split(',').map((p) => { const i = p.indexOf('='); return [p.slice(0, i).trim(), p.slice(i + 1).trim()]; }), ); // Reject anything older than 5 minutes, so a captured delivery cannot be // replayed indefinitely. The timestamp is inside the signed string, so it // cannot be edited without breaking the signature. const drift = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t)); if (!parts.t || drift > 300) return res.status(400).send('stale'); const expected = crypto .createHmac('sha256', SECRET) .update(`${parts.t}.${rawBody}`, 'utf8') .digest(); const received = Buffer.from(parts.v1 || '', 'hex'); if ( received.length !== expected.length || !crypto.timingSafeEqual(received, expected) ) { return res.status(400).send('bad signature'); } const event = JSON.parse(rawBody); // Acknowledge FIRST, then do the work. We time out after 10 seconds. res.sendStatus(200); handleEvent(event).catch(console.error); });
import hashlib, hmac, time def verify_slide_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: """raw_body must be the exact bytes received, never a re-serialised dict.""" parts = dict( p.strip().split("=", 1) for p in header.split(",") if "=" in p ) timestamp = parts.get("t") signature = parts.get("v1") if not timestamp or not signature: return False if abs(int(time.time()) - int(timestamp)) > tolerance: return False expected = hmac.new( secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature)
Retries and failure handling
Return any 2xx status to acknowledge a delivery. Acknowledge before doing slow work: we time out after 10 seconds and treat that as a failure. Anything that is not a 2xx is retried on this schedule, up to 7 attempts spanning about 8 hours 45 minutes.
| Attempt | Sent after |
|---|---|
| 1 | Immediately |
| 2 | 10 seconds later |
| 3 | 1 minute later |
| 4 | 5 minutes later |
| 5 | 30 minutes later |
| 6 | 2 hours later |
| 7 | 6 hours later |
After the final attempt the delivery is marked exhausted. You can inspect and replay it from the delivery log, in the dashboard or over the API.
Returning 410 Gone is treated as an explicit unsubscribe: we delete the endpoint immediately rather than retrying. This is the REST Hooks convention and is how Zapier cleans up after a Zap is turned off.
Event catalogue
Subscribe to "*" to receive everything, including events added later. Call GET /v1/webhooks/events for this list with a sample payload attached to each entry.
| Event | Fires when |
|---|---|
contact.created | A contact is added to the unified contacts hub, from any channel or import. |
contact.updated | An existing contact changes. data.changedFields lists exactly what changed. |
contact.lifecycle_changed | A contact moves between lifecycle stages, for example Lead to Customer. |
form.submitted | A visitor submits a form, popup, or landing page. |
whatsapp.message.received | An inbound WhatsApp message arrives from a customer. |
whatsapp.message.status | A message you sent is marked sent, delivered, read, or failed. |
instagram.message.received | An inbound Instagram direct message arrives. |
sms.message.received | An inbound SMS arrives on one of your numbers. |
voice.call.completed | A voice call finishes, with duration, outcome, and summary. |
voice.call.analysed | The analysis of an AI voice call is ready, a few seconds after the call ends, with the same data.callId. Carries the summary, the sentiment, data.outcome (key and label, or null), data.criteria and data.fields. Sent only when the agent has analysis switched on and the other person spoke. |
email.message.delivered | An email is accepted by the recipient mail server. |
email.message.opened | A recipient opens an email. |
email.message.clicked | A recipient clicks a link. data.url is the destination. |
email.message.bounced | An email bounces. The address is added to your suppression list. |
email.message.complained | A recipient marks an email as spam. |
shopify.order.created | A new order is placed in your connected store. |
shopify.order.fulfilled | An order is fulfilled, with tracking details when available. |
otp.sent | A one-time code is sent to a phone number. data.channel is sms or whatsapp, and data.isResend is true for a resend. |
otp.verified | A user enters the correct one-time code and the request is verified. |
otp.failed | A verification is blocked after too many wrong codes. data.reason is max_attempts_exceeded. |
The three otp.* events cover every OTP widget in the workspace: data.widgetId says which one 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.
Registering an endpoint
/v1/webhooksRegister an endpoint and choose which events it receives. Returns the signing secret once.
curl -X POST https://api.slide.cx/v1/webhooks \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/slide", "events": ["contact.created", "whatsapp.message.received"], "description": "Production CRM sync" }'
{
"id": "whe_4f8a2c1e",
"url": "https://example.com/webhooks/slide",
"description": "Production CRM sync",
"events": ["contact.created", "whatsapp.message.received"],
"isActive": true,
"status": "ACTIVE",
"createdVia": "api",
"consecutiveFailures": 0,
"lastSuccessAt": null,
"createdAt": "2026-08-20T10:11:12.000Z",
"secret": "whsec_XQ8vN2pLm4kR7tY1wZ3aB6cD9eF0gH5j"
}secret appears in this response and in the rotate response, and nowhere else, ever. Store it before you close the connection. If you lose it, rotate to get a new one and update your receiver, since rotating invalidates the old secret immediately.The URL must be publicly reachable and use https. Private, loopback, and link-local addresses are rejected, and the destination is re-checked on every delivery, so a hostname that later resolves to an internal address stops being delivered to.
Managing endpoints
/v1/webhooks/eventsThe event catalogue, with a sample payload for each event.
/v1/webhooksList your endpoints, paginated.
/v1/webhooks/:idRetrieve one endpoint, including its current health.
/v1/webhooks/:idChange the URL, the subscribed events, or enable and disable the endpoint.
/v1/webhooks/:idUnsubscribe. Returns 204 with no body.
/v1/webhooks/:id/rotate-secretIssue a new signing secret. The old one stops working immediately.
/v1/webhooks/:id/testQueue a test delivery. Returns 202. Pass eventType to receive that event's sample payload instead of a generic ping.
/v1/webhooks/:id/deliveriesDelivery log for one endpoint, newest first. Filter with ?status=FAILED.
/v1/webhooks/deliveries/:deliveryIdOne delivery in full, including the exact payload that was sent.
/v1/webhooks/deliveries/:deliveryId/replayRe-send a delivery. Reuses the original event id, so your idempotency check still applies.
Connecting Zapier
Zapier and other REST Hooks tools drive the endpoints above directly. A typical trigger wires up like this:
| Zapier step | What it calls |
|---|---|
| Authentication | API key in an Authorization: Bearer header, with webhooks:read and webhooks:write. |
| Trigger event list | GET /v1/webhooks/events |
| Subscribe (Zap on) | POST /v1/webhooks with the Zap’s target URL and the chosen event. |
| Unsubscribe (Zap off) | DELETE /v1/webhooks/:id |
| Perform list (test) | POST /v1/webhooks/:id/test, or read the delivery log for a recent real event. |
Workspace API
Two small reads about the workspace the key belongs to: its wallet balance, and who it is. Neither route takes a workspace from the request; you always get your own.
Wallet Balance
/v1/walletThe wallet balance, and nothing else: no transactions, no top-ups, no card or payment details.
{
"balance": { "amountMinor": "123450", "currency": "INR", "formatted": "₹1,234.50" },
"displayCurrency": "INR"
}amountMinor is a string in paise, so a large balance never loses a digit. The balance is always held in rupees; displayCurrency is the currency your workspace's screens show (INR or USD). A workspace that has never held money answers a balance of zero, not an error.
Organization
/v1/organizationThe workspace the key belongs to: its id, name, plan name and the modules switched on for it. No tax, address or billing details.
{
"id": "6f387e9a-...",
"name": "Acme Store",
"plan": "Pro",
"enabledModules": ["email", "whatsapp"]
}plan is null for a workspace with no subscription. enabledModules are the module slugs switched on for the workspace.
Scopes Reference
All available scopes and the operations they unlock. Scopes are chosen per API key at creation time and can be changed later with Edit on the key in the dashboard, without re-issuing it.
POST /messages/send endpoint (see the Messages API section above) does not have its own scope. It reuses whichever <channel>:send scope below matches the channel(s) you pass in channel / fallbackChannels - there is nothing extra to request here.| Scope | Module | Allowed Operations |
|---|---|---|
| contacts:read | Contacts | GET /contacts, GET /contacts/:id, GET /contacts/tags, GET /contacts/segments |
| contacts:write | Contacts | POST /contacts (create or update one contact), PATCH /contacts/:id |
| contacts:block | Contacts | POST /contacts/:id/block, POST /contacts/:id/unblock |
| contacts:notes:read | Contacts | GET /contacts/:id/notes |
| contacts:notes:write | Contacts | POST /contacts/:id/notes, DELETE /contacts/:id/notes/:noteId |
| contacts:delete | Contacts | DELETE /contacts/:id (cannot be undone) |
| ads:read | Ads | GET /ads/status, accounts, campaigns, ad sets, ads, insights, insights/summary, leads |
| ads:write | Ads | POST /ads/accounts/:adAccountId/campaigns (always created paused) |
| ads:publish | Ads | POST /ads/:entity/:id/status, POST /ads/:entity/:id/budget (change live spend) |
| ads:google:read | Google Ads | GET /google-ads/status, customers, campaigns, ad-groups, ads, conversion-actions, insights, lead-forms |
| ads:google:write | Google Ads | POST /google-ads/lead-forms, DELETE /google-ads/lead-forms/:id (adds or removes a lead form; changes no campaign) |
| chat:conversations:read | Live chat | GET /conversations, GET /conversations/:id, GET /conversations/:id/messages |
| chat:conversations:write | Live chat | POST /conversations/:id/reply (as the key, takes the chat over from an AI agent), POST /conversations/:id/hand-back |
| billing:read | Workspace | GET /wallet |
| organization:read | Workspace | GET /organization |
| automations:read | Automations | GET /automations, GET /automations/:id, GET /automations/node-catalog |
| automations:write | Automations | POST /automations, PATCH /automations/:id, activate, pause, DELETE /automations/:id, re-run |
| automations:executions:read | Automations | GET /automations/:id/executions |
| ai:models:read | AI | GET /ai/models |
| webhooks:read | Webhooks | GET /webhooks, GET /webhooks/events, GET /webhooks/:id, GET /webhooks/:id/deliveries |
| webhooks:write | Webhooks | POST /webhooks, PATCH /webhooks/:id, DELETE /webhooks/:id, rotate-secret, test, replay |
| email:send | POST /email/send | |
| email:templates:read | GET /email/templates, GET /email/templates/:id | |
| email:contacts:read | GET /email/contacts | |
| email:contacts:write | POST /email/contacts | |
| instagram:messages:read | GET /instagram/profile, GET /instagram/conversations | |
| instagram:messages:send | POST /instagram/messages | |
| instagram:insights:read | GET /instagram/insights | |
| whatsapp:send | POST /whatsapp/send-template, POST /whatsapp/header-media/upload, GET /whatsapp/send-status/:requestId | |
| whatsapp:logs:read | GET /whatsapp/logs, GET /whatsapp/messages/:wamid | |
| whatsapp:templates:read | GET /whatsapp/templates | |
| whatsapp:templates:write | POST /whatsapp/templates | |
| whatsapp:conversations:read | GET /whatsapp/conversations/by-phone | |
| whatsapp:campaigns:read | GET /whatsapp/campaigns, GET /whatsapp/campaigns/:id, campaign analytics | |
| whatsapp:campaigns:write | POST /whatsapp/campaigns, launch, cancel | |
| sms:send | SMS | POST /sms/send |
| sms:templates:read | SMS | GET /sms/templates |
| sms:senders:read | SMS | GET /sms/senders |
| sms:campaigns:read | SMS | GET /sms/campaigns, GET /sms/campaigns/:id |
| sms:campaigns:write | SMS | POST /sms/campaigns, launch, cancel |
| sms:logs:read | SMS | GET /sms/logs, GET /sms/stats |
| rcs:send | RCS | POST /rcs/send |
| rcs:bots:read | RCS | GET /rcs/bots |
| rcs:templates:read | RCS | GET /rcs/templates |
| rcs:campaigns:read | RCS | GET /rcs/campaigns |
| rcs:campaigns:write | RCS | POST /rcs/campaigns |
| rcs:logs:read | RCS | GET /rcs/logs, GET /rcs/stats |
| otp:send | OTP | POST /otp/send, POST /otp/retry |
| otp:verify | OTP | POST /otp/verify, POST /otp/verify-token |
| otp:logs:read | OTP | GET /otp/logs |
| otp:analytics:read | OTP | GET /otp/analytics |
| shopify:products:read | Shopify | GET /shopify/products/*, GET /shopify/collections/* |
| shopify:orders:read | Shopify | GET /shopify/orders/status, GET /shopify/customers/orders |
| shopify:discounts:read | Shopify | GET /shopify/discounts/validate |
| shopify:audit:read | Shopify store audit | GET /shopify/audit, quote, runs, issues, suggested fixes, apply progress |
| shopify:audit:write | Shopify store audit | Start an audit (charged after the free one each month), AI fix suggestions (charged), edit, apply and undo fixes, change an issue status |
| aeo:read | AI search | GET /aeo/summary, trend, answers, prompts, settings |
| aeo:write | AI search | POST /aeo/run (charged), POST /aeo/prompts, PATCH /aeo/prompts/:id, PUT /aeo/settings |
| analytics:events:read | Website events | GET /analytics/events, GET /analytics/events/:name, GET /analytics/users/:userId/events |
| analytics:events:write | Website events | POST /analytics/events (up to 50 events), POST /analytics/identify |
| agents:read | AI Agents | GET /agents, GET /agents/:id, versions, runs, performance, tool catalogue |
| agents:write | AI Agents | POST /agents, drafts (POST and PATCH versions) |
| agents:publish | AI Agents | POST .../publish, POST .../rollback |
| voice:agents:read | Voice | GET /voice/agents, GET /voice/agents/:id |
| voice:calls:read | Voice | GET /voice/calls, GET /voice/calls/:id, recordings, analytics |
| voice:calls:write | Voice | POST /voice/calls/outbound |
| voice:agents:write | Voice | POST /voice/agents, PATCH /voice/agents/:id, DELETE /voice/agents/:id |
| voice:app:read | Voice external | GET /voice/app/identities, sessions, calls, recordings, transcripts, numbers, settings |
| voice:app:write | Voice external | Identities, devices and sessions; place, connect, answer, decline and end calls; call tokens; number routing; settings |
Ready to integrate?
Create your API key from the dashboard and start building in minutes.