Metadata-Version: 2.5
Name: synquic-slide
Version: 0.4.0
Summary: Official Python SDK for the Slide API
Project-URL: Homepage, https://slide.synquic.com
Project-URL: Documentation, https://slide.synquic.com
Author-email: Synquic <p@synquic.com>
License: MIT
Keywords: api,crm,email,otp,rcs,sdk,slide,sms,synquic,voice,whatsapp
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx>=0.27.0
Description-Content-Type: text/markdown

# sdk-python

`synquic-slide` is the official Python SDK for the **Slide by Synquic** API, a multi-channel customer engagement platform. One client covers WhatsApp Business messaging, SMS, RCS, OTP verification, transactional email, Instagram DMs, one send call across those channels, AI voice calls, Shopify lookups, unified contacts, automations and outbound webhooks. It targets **server-side Python 3.9+** and ships a synchronous client and an identical async one, both built on `httpx`. It authenticates with a secret API key, so it belongs on your backend, never in a client application.

## Installation

```bash
pip install synquic-slide
```

## Quick start

Responses are plain dictionaries (typed as `TypedDict`), so read them with `["key"]`.

```python
import os
from synquic_slide import SlideClient

slide = SlideClient(api_key=os.environ["SLIDE_API_KEY"])

# WhatsApp template message
msg = slide.whatsapp.send_template(
    to="+919876543210",
    template_name="order_shipped",
    language_code="en",
    components=[{"type": "body", "parameters": [{"type": "text", "text": "#1042"}]}],
)
print(msg["wamid"], msg["conversationId"])

# Transactional email from a saved template
slide.email.send(
    recipient={"to": "customer@example.com", "firstName": "Priya"},
    template_id="tmpl_order_confirm",
    from_name="Acme Store",
    from_email="orders@acme.com",
    variables={"orderId": "#1042"},
)

# Paginated reads always return {"data": [...], "meta": {...}}
page = slide.contacts.list(page=1, limit=50, search="priya")
print(len(page["data"]), page["meta"]["totalPages"])

# Outbound AI voice call. Failures raise SlideValidationError, SlideAuthError, etc.
call = slide.voice.initiate_outbound_call(
    agent_id="agent_abc123",
    to_number="+919876543210",
    call_context={"orderId": "#1042"},
)
print(call["id"], call["status"])

slide.close()   # or use `with SlideClient(api_key=...) as slide:`
```

The async client exposes exactly the same resources and method names:

```python
import asyncio, os
from synquic_slide import AsyncSlideClient

async def main():
    async with AsyncSlideClient(api_key=os.environ["SLIDE_API_KEY"]) as slide:
        agents = await slide.voice.list_agents(page=1, limit=20)
        print([a["name"] for a in agents["data"]])

asyncio.run(main())
```

Verifying an incoming webhook delivery. Pass the **raw** body, never a re-serialised dict:

```python
import os
from flask import request
from synquic_slide import construct_event, SlideSignatureVerificationError

@app.post("/webhooks/slide")
def slide_webhook():
    try:
        event = construct_event(
            request.get_data(),                        # not request.json
            request.headers["X-Slide-Signature"],
            os.environ["SLIDE_WEBHOOK_SECRET"],
        )
    except SlideSignatureVerificationError:
        return "bad signature", 400
    enqueue(event)          # event["id"] is stable across retries: dedupe on it
    return "", 200
```

### One send call, contacts and automations

```python
# One send call for any channel, with a fallback
sent = slide.messages.send(
    to="+919876543210",
    channel="whatsapp",
    fallback_channels=["sms"],
    message="Your order #1042 has shipped.",
    template={"name": "order_shipped", "languageCode": "en"},
    sms={"senderId": "ACMEIN"},
)
print(sent["channelUsed"], sent["messageId"], sent["attempts"])

# A new automation is always created paused. Going live is its own call.
catalog = slide.automations.node_catalog()   # trigger types, node types, their settings
automation = slide.automations.create(
    name="Order follow-up",
    trigger={"type": "WEBHOOK"},
    steps=[{"order": 0, "type": "SMART_DELAY", "delayMinutes": 5}],
)
slide.automations.activate(automation["id"])

# The AI models your workspace can use, with their prices
models = slide.ai.list_models()["data"]
```

```python
# Create a contact, or update the one with the same phone or email.
# The API key needs the permission contacts:write.
contact = slide.contacts.upsert(
    phone="+919876543210",
    email="priya@example.com",
    name="Priya Sharma",
    tags=["Newsletter"],
)
print(contact["id"], contact["created"])

# Find one person: phone and email match exactly, search is a "contains" match
found = slide.contacts.list(email="priya@example.com")
same = slide.contacts.get(contact["id"])
```

The SDK makes every call once and never retries on its own, except the batches of `analytics.track` and `analytics.track_now`, which carry an `insertId` so a retry cannot count twice. Do not retry a send blindly either: a timeout does not mean the message was not sent, and a second send reaches a real person twice.

`contacts.upsert` needs an API key with the permission `contacts:write` (and `contacts.list` / `contacts.get` need `contacts:read`). A key that only has the email list permission cannot create contacts: it is refused with `SlideScopeError` (403). A key's permissions cannot be edited, so create a new API key that has `contacts:write`.

Sending the same `contacts.upsert` again after it has answered, also after a timeout or a server error, gives the same contact and creates nothing twice. Two calls for the same person made at the same moment can be refused with `SlideError` status 409, and `error.body["code"]` says what to do: on `CONTACT_UPSERT_IN_PROGRESS` nothing was written, so wait `error.body["retryAfter"]` seconds and send it again; on `PHONE_IN_USE` send it again once.

### Website events from your server

`create_contact` needs a key that also holds `contacts:write`. A contact that matches is left unchanged. An event carries at most 20 properties.

See your events in the dashboard under **Web Analytics, Events**, for the website you sent them to. Counts refresh every 5 minutes.

**Send `session_id` 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, with only a `visitor_id` or a user, counts with no visit behind it. It still counts under its name.


Send a named event (`pay`, `view_button_clicked`, anything) from your server, with the person it belongs to. Slide ties it to the visit, counts it by name, and shows it in Web Analytics. The API key needs `analytics:events:write` (and `analytics:events:read` to read events back). Your website must be verified in Web Analytics first.

```python
# In the browser, read the ids of this visit and send them to your server with your own request:
#   const { visitorId, sessionId } = window.slideAnalytics.getIds()

# On your server:
slide = SlideClient(
    api_key=os.environ["SLIDE_API_KEY"],
    analytics_on_error=lambda error, events: print("Slide events lost:", len(events), error),
)

@app.post("/checkout/paid")
def paid():
    slide.analytics.track(
        "pay",
        visitor_id=request.json["visitorId"],      # joins the event to the visit
        session_id=request.json["sessionId"],
        user_id=current_user.id,                   # or user={"id": ..., "email": ...}
        value_minor=199900,                        # paise or cents, a whole number
        currency="INR",
        properties={"plan": "pro", "method": "upi", "order_id": "A-10023456"},
    )
    return "", 204
```

`track` returns at once: the event waits in a queue and a background thread sends up to 50 at a time, when 50 are queued or after 5 seconds (`analytics_flush_interval`). It has to batch: an API key allows 60 requests a minute by default and every request uses one call of your daily limit. The thread is a daemon: it never keeps your program alive.

**Serverless functions (AWS Lambda, Google Cloud Functions, Vercel) must call `slide.analytics.flush()` before they return.** A function can be frozen before the timer fires, and the queued events are lost. A script that ends right after `track` loses them too: call `flush()`, or use the client as a context manager (`with SlideClient(...) as slide:`), which flushes when it closes.

```python
def handler(event, context):
    slide.analytics.track("signup", user_id=event["userId"])
    slide.analytics.flush()          # before returning
    return {"statusCode": 200}
```

Every event needs a `visitor_id`, or a `user_id` / `user` with an `id`, `email` or `phone`; `session_id` needs `visitor_id`. `user_id="u_1"` is shorthand for `user={"id": "u_1"}`. Without a visit to join (a payment webhook, a cron job) the event still counts under its name; it just does not belong to a visit. Put personal data in `user`, never in `properties`: a property that looks like an email, a phone number or a password is left off and named in `droppedProperties`. `value_minor` needs a `currency`. `time` (epoch seconds or milliseconds, an ISO string or a `datetime`, naive meaning UTC) can be up to 7 days old and defaults to the moment of the call.

To see what the API did with each event, send a batch yourself:

```python
result = slide.analytics.track_now(
    [{"name": "pay", "user_id": "u_123", "value_minor": 199900, "currency": "INR"}],
)
print(result["accepted"], result["rejected"], result["droppedProperties"])   # rejected: [{index, code, message}]
```

**What is retried.** A 503 and a dropped connection are tried again, up to 3 tries in all, with the same `insertId` on every event, so nothing is counted twice; the wait is the `retryAfterSeconds` the server gives, else 0.5 s then 1 s. A 400, 401, 403, 409 or 429 (`error.body["code"]` is `daily_limit_reached` when the website is over its daily limit) is never repeated. In the background nothing raises: a batch that failed, events the API refused, and events dropped from a full queue (5,000 events, oldest first) are reported to `analytics_on_error(error, events)` and nowhere else. Without it they are lost silently. `track_now` raises the error instead.

```python
slide.analytics.identify(visitor_id=visitor_id, user_id="u_123")                       # at login
names = slide.analytics.list_events(from_date="2026-10-01", to_date="2026-10-31")
pay = slide.analytics.get_event("pay")                                                 # trend, people, money, properties
mine = slide.analytics.get_user_events("u_123", limit=20)
```

On `AsyncSlideClient` the same calls exist; `track` is still a plain method (no `await`), and `await slide.analytics.flush()` sends what is queued. Called with no event loop running, `track` only queues.

#### Funnels and cohorts

Read two reports of your website events from your server. Both need an API key with `analytics:events:read`.

```python
# Of the people who viewed a page, how many added to the cart, then paid, each step within 3 days of the one before:
funnel = slide.analytics.get_funnel(
    ["page_view", "add_to_cart", "pay"],       # 2 to 6; an event name or a built-in action; a list or "a,b,c"
    from_date="2026-10-01",
    to_date="2026-10-31",                      # at most 31 days
    within_hours=72,                           # 1 to 720, default 168 (7 days)
)
for step in funnel["steps"]:
    print(step["name"], step["people"], step["fromPrevious"], step["fromFirst"])
# page_view 200 None 100 / add_to_cart 50 25 25 / pay 10 20 5      (fromPrevious and fromFirst are percents)

# Retention: people grouped by the week they first signed up, and how many paid in each later week:
cohorts = slide.analytics.get_cohorts("signup", return_event="pay")["cohorts"]     # return_event defaults to the start event
# [{"week": "2026-10-05", "people": 40, "returned": [{"weeksLater": 0, "people": 8, "percent": 20}, ...]}]
```

A step is the name of an event you send (`pay`) or one Slide records by itself (`page_view`, `product_view`, `add_to_cart`). Steps count in order and each person counts once per step, however often they repeat it. Weeks start on Monday (UTC). A cohort range is at most 91 days (the last 12 weeks when you send none). `return_event` is sent to the API as `return`, which is a Python keyword. A request the API refuses (one step, too many days) raises `SlideValidationError`. Both calls exist on `AsyncSlideClient` with `await`.

#### Name the person in the browser, signed

A web page can call `window.slideAnalytics.identify(userId, signature)` to tell Slide who is browsing, but only with a signature your server made: the website key is public, so an unsigned name would let anyone attach a stranger's browsing to one of your customers. Sign the user id on your server with your website's identify secret, then render both into the page for the logged-in user:

Find the secret in the dashboard under **Web Analytics, Developers**. It needs permission to edit the website.

```python
# On your server. Keep the secret in an environment variable, never in a page or a repository.
signature = slide.analytics.sign_user(request.user.id, os.environ["SLIDE_IDENTIFY_SECRET"])
return render(request, "app.html", {"user_id": request.user.id, "signature": signature})

# In the page:
#   window.slideAnalytics.identify(userId, signature)
```

`sign_user` makes no request and needs no API key: it is HMAC-SHA256 of the user id with the secret, as 64 lower-case hex characters. It is a plain method on `AsyncSlideClient` too (no `await`). It signs the id without surrounding spaces, as Slide reads it, and raises `ValueError` for an empty id, an id over 128 characters or an empty secret. The browser can name a user id this way and nothing else: an email or a phone still has to come from your server (`analytics.identify`, `analytics.track`).

### Chat inside your own app

Your server makes one call: a short-lived chat token for a user it has already signed in. Your app opens the chat with that token. The API key never leaves your server.

Pilot: `slide.chat` is not in the version of this package on the registry yet. During the pilot you receive a build that has it with your pilot access; until then the token call is one plain `POST /v1/chat/tokens` with your key.

```python
import os
from flask import Flask, jsonify, request
from synquic_slide import SlideClient, construct_event, SlideSignatureVerificationError

app = Flask(__name__)
slide = SlideClient(api_key=os.environ["SLIDE_API_KEY"])   # needs the scope chat:tokens:write

# 1. Your app asks YOUR server for a token, behind your own sign-in.
@app.get("/me/chat-token")
@require_sign_in
def chat_token():
    result = slide.chat.create_token(
        user_id=current_user.id,      # your own stable id for the user
        name=current_user.name,       # optional: how the team sees them
        role="patient",               # optional
        app="patient",                # optional: only when you have more than one app
    )
    return jsonify(token=result["token"])   # to refresh, the app simply asks again

# 2. A reply is waiting for a user who has not seen it: send your own push.
@app.post("/webhooks/slide-chat")
def chat_webhook():
    try:
        event = construct_event(
            request.get_data(),
            request.headers.get("X-Slide-Signature", ""),
            os.environ["SLIDE_CHAT_WEBHOOK_SECRET"],
        )
    except SlideSignatureVerificationError:
        return "bad signature", 400
    if event["type"] == "chat.message.unread":
        data = event["data"]      # never carries the text; de-duplicate on data["messageId"]
        enqueue_push(data["userId"], data["app"], badge=data["unreadCount"])
    return "", 200
```

Optional, when a user signs out or asks to be erased: `slide.chat.revoke_user(user_id, app=...)` and `slide.chat.delete_user(user_id, app=...)` (scope `chat:users:write`).

## API reference

Every method below exists twice: on `SlideClient` (blocking) and on `AsyncSlideClient` (awaitable, same name). Arguments are keyword-only unless shown positionally. List methods also accept `page` (default 1) and `limit` (default varies by endpoint, 10 to 50).

| Method | Parameters | Returns |
|---|---|---|
| `SlideClient(...)` / `AsyncSlideClient(...)` | `api_key: str`, `base_url: str = "https://slide.synquic.com/api"`, `timeout: float = 30.0` | client instance, raises `ValueError` if `api_key` is empty |
| `client.close()` | none | `None`, awaitable on the async client. Both clients are context managers |
| `contacts.list(...)` | `search: str \| None` ("contains" match), `phone: str \| None` and `email: str \| None` (exact match), `include_blocked: bool \| None` | `PaginatedResponse[Contact]`, contacts of the contacts hub |
| `contacts.upsert(...)` | `phone=None`, `email=None` (at least one of the two), `name=None`, `first_name=None`, `last_name=None`, `company=None`, `tags: list[str] \| None`, `custom_fields: dict \| None` | `UpsertContactResponse`: the `Contact` plus `created`, `emailContact`, `emailUnsubscribed`, `unmatchedCustomFields`, `warnings`. Matched by phone, then email. Needs `contacts:write` |
| `contacts.get(contact_id)` | positional id | `Contact` |
| `email.send(...)` | `recipient: dict`, `template_id`, `from_name`, `from_email`, `subject=None`, `reply_to=None`, `variables: dict \| None` | `SendEmailResponse` |
| `email.list_templates()` / `email.get_template(template_id)` | none / positional `template_id` | `dict` / `EmailTemplate` |
| `email.list_contacts(...)` | `search: str \| None` | `PaginatedResponse[EmailContact]` |
| `email.upsert_contact(...)` | `email`, `first_name=None`, `last_name=None`, `phone=None`, `company=None`, `tags: list \| None`, `custom_fields: dict \| None` | `EmailContact`. Email list only: to create or update a contact use `contacts.upsert` |
| `whatsapp.send_template(...)` | `to`, `template_name`, `language_code`, `components: list \| None` | `SendWhatsAppTemplateResponse` |
| `whatsapp.upload_header_media(...)` | `file: bytes`, `filename`, `mime_type`, `expected_format: "IMAGE" \| "VIDEO" \| "DOCUMENT" \| None` | `dict` |
| `whatsapp.list_logs(...)` | `direction: "inbound" \| "outbound" \| None`, `status=None`, `from_date=None`, `to_date=None` (ISO dates, not addresses) | `PaginatedResponse[WhatsAppLog]` |
| `whatsapp.list_templates(...)` | `status=None`, `category=None` | `PaginatedResponse[WhatsAppTemplate]` |
| `whatsapp.get_conversation_by_phone(...)` | `phone`, `limit=50`, oldest message first | `WhatsAppConversationByPhone` |
| `whatsapp.list_campaigns(...)` | `status=None` | `PaginatedResponse[dict]` |
| `whatsapp.get_campaign(campaign_id)` / `whatsapp.get_campaign_analytics(campaign_id)` | positional id | `dict` |
| `whatsapp.create_campaign(...)` | `template_id`, `phones: list[str]`, `name=None`, `template_components=None`, `variable_mapping=None`, `scheduled_at=None`, `schedule_timezone=None` | `dict` |
| `whatsapp.launch_campaign(campaign_id)` / `whatsapp.cancel_campaign(campaign_id)` | positional id | `dict` |
| `analytics.track(name, ...)` | `name`, `site=None`, `insert_id=None`, `time=None`, `visitor_id=None`, `session_id=None`, `user_id=None`, `user: dict \| None` (`id`, `email`, `phone`, `name`, `create_contact`), `value_minor=None`, `currency=None`, `path=None`, `properties: dict \| None`; needs `visitor_id` or a user | `None`. Queues; sends up to 50 per request from a daemon thread. Never raises. Needs `analytics:events:write` |
| `analytics.flush()` | none | `None`. Sends what is queued; never raises. Serverless functions must call it before returning. Awaitable on the async client |
| `analytics.track_now(events, site=None)` | `events: list[dict]` (at most 50, the fields of `track`), `site` | `TrackAnalyticsEventsResponse` = `accepted`, `capped`, `rejected` (`index`, `code`, `message`), `droppedProperties`. One request; a 503 or dropped connection is tried again |
| `analytics.identify(...)` | `visitor_id=None`, `user_id=None`, `user: dict \| None`, `site=None` | `IdentifyAnalyticsVisitorResponse` (`accepted`). Needs `analytics:events:write` |
| `analytics.list_events(...)` | `site=None`, `from_date=None`, `to_date=None` (`YYYY-MM-DD`) | `ListAnalyticsEventsResponse`: `from`, `to`, `events` (`name`, `count`, `sessions`, `lastDay`). Needs `analytics:events:read` |
| `analytics.get_event(name, ...)` | positional `name`, `site=None`, `from_date=None`, `to_date=None` | `AnalyticsEventDetail`: `count`, `people`, `trend`, `money`, `properties`, `recent` |
| `analytics.get_user_events(user_id, ...)` | positional `user_id`, `site=None`, `from_date=None`, `to_date=None`, `page=1`, `limit=50` | `PaginatedResponse[AnalyticsEventRow]`, newest first |
| `analytics.get_funnel(steps, ...)` | positional `steps` (a list or `"a,b,c"`, 2 to 6), `site=None`, `from_date=None`, `to_date=None` (a range of at most 31 days), `within_hours=None` (1 to 720) | `AnalyticsFunnel`: `from`, `to`, `withinHours`, `steps` (`name`, `people`, `fromPrevious`, `fromFirst`). Needs `analytics:events:read` |
| `analytics.get_cohorts(start, ...)` | positional `start`, `return_event=None`, `site=None`, `from_date=None`, `to_date=None` (a range of at most 91 days) | `AnalyticsCohorts`: `from`, `to`, `start`, `return`, `cohorts` (`week`, `people`, `returned`: `weeksLater`, `people`, `percent`). Needs `analytics:events:read` |
| `analytics.sign_user(user_id, secret)` | the user id and your website's identify secret | `str`: HMAC-SHA256 as 64 lower-case hex characters, for `window.slideAnalytics.identify(userId, signature)`. No request, no API key, a plain method on both clients; raises `ValueError` on an empty or over-long id or an empty secret |
| `instagram.get_profile()` | none | `InstagramProfile` |
| `instagram.list_conversations(...)` | pagination only | `PaginatedResponse[InstagramConversation]` |
| `instagram.send_message(...)` | `recipient_igsid`, `message` | `SendInstagramMessageResponse` |
| `instagram.get_insights(...)` | `period=None`, `since=None`, `until=None` | `dict` |
| `shopify.search_products(...)` | `q`, `limit=10` | `dict` |
| `shopify.get_product_types()` / `shopify.list_collections()` | none | `dict` |
| `shopify.get_product(product_id)` | positional id | `ShopifyProduct` |
| `shopify.get_collection_products(collection_id, ...)` | positional id, `limit=50` | `dict` |
| `shopify.get_order_status(...)` | `identifier` (order number or id) | `ShopifyOrder` or `list[ShopifyOrder]` |
| `shopify.get_customer_orders(...)` | `identifier` (phone or email) | `dict` |
| `shopify.validate_discount(...)` | `code` | `DiscountValidation` |
| `agents.list(...)` / `agents.get(agent_id)` | `q=None`, `audience=None`, pagination / positional id | `dict`. Needs `agents:read` |
| `agents.performance(agent_id, days=None)` | `days` 1 to 180, default 30 | `dict`: results its tools completed (bookings, orders, payments), cancellations apart, `conversionRate` (a percentage, `None` over no conversations), trend, cost per result in paise |
| `agents.list_runs(agent_id, ...)` / `agents.get_run(run_id)` | `status`, `outcome`, `channel`, `version_id`, `from_`, `to`, pagination | `dict`. Never what a customer wrote, what the agent replied or who the customer is |
| `agents.tool_catalog()` | none | `dict` |
| `agents.create(name=...)` / `agents.create_draft(agent_id)` / `agents.update_draft(agent_id, version_id, changes)` | keyword `name`, `description`, `audience` / id / dict of changes | `dict`. Needs `agents:write` |
| `agents.publish(agent_id, version_id)` / `agents.rollback(agent_id, version_id)` | ids | `dict`. Needs `agents:publish` |
| `voice.list_agents(...)` / `voice.get_agent(agent_id)` | pagination only / positional id | `PaginatedResponse[VoiceAgent]` / `VoiceAgent` |
| `voice.list_calls(...)` | `status=None`, `direction=None` | `PaginatedResponse[VoiceCall]` |
| `voice.initiate_outbound_call(...)` | `agent_id`, `to_number`, `call_context: dict \| None` | `InitiateOutboundCallResponse` |
| `voice.get_call(call_id)` / `voice.get_call_recording(call_id)` | positional id | `VoiceCall` / `dict` |
| `voice.get_analytics(...)` | `from_date`, `to_date` | `VoiceAnalytics` |
| `sms.send(...)` | `sender_id`, `to: list[str]`, `message`, `template_id=None`, `route=None` | `SendSmsResponse` |
| `sms.list_templates(...)` / `sms.list_senders(...)` | `status=None` / pagination only | `PaginatedResponse[dict]` |
| `sms.list_campaigns(...)` / `sms.get_campaign(campaign_id)` | pagination only / positional id | `PaginatedResponse[dict]` / `dict` |
| `sms.create_campaign(...)` | `name`, `message_body`, `sender_id=None`, `template_id=None`, `route=None`, `audience_type=None`, `audience_criteria=None`, `scheduled_at=None` | `dict` |
| `sms.launch_campaign(campaign_id)` / `sms.cancel_campaign(campaign_id)` | positional id | `SmsCampaignActionResponse` |
| `sms.list_logs(...)` | `direction=None`, `status=None`, `start=None`, `end=None` | `PaginatedResponse[dict]` |
| `sms.get_stats(...)` | `start=None`, `end=None` | `SmsStats` |
| `rcs.send(...)` | `to`, `template_id`, `rcs_variables: dict \| None`, `sms_fallback: dict \| None`, `ttl: int \| None` | `dict` |
| `rcs.list_bots()` / `rcs.list_templates(...)` | none / pagination only | `list[dict]` / `PaginatedResponse[dict]` |
| `rcs.send_campaign(...)` | `bot_id`, `template_id`, `campaign_name=None`, `numbers=None`, `country=None`, `remove_duplicate=None`, `ttl=None`, `schedule_campaign=None`, `scheduled_at=None`, `fallback=None`, `fallback_message=None` | `dict` |
| `rcs.list_campaigns(...)` / `rcs.list_logs(...)` | pagination only | `PaginatedResponse[dict]` |
| `rcs.get_stats(...)` | `start=None`, `end=None` | `RcsStats` |
| `otp.send(...)` | `widget_id`, `identifier` (a phone number) | `SendOtpResponse` (`requestId`, `channel`) |
| `otp.retry(...)` | `request_id`, `channel=None` (`"sms"` or `"whatsapp"`) | `SendOtpResponse` (`requestId`, `channel`) |
| `otp.verify(...)` / `otp.verify_token(...)` | `request_id` and `otp` / `access_token` | `VerifyOtpResponse` / `VerifyOtpTokenResponse` |
| `otp.list_logs(...)` | `widget_id=None`, `status=None` | `PaginatedResponse[dict]` |
| `otp.get_analytics(...)` | `widget_id=None` | `dict` |
| `messages.send(...)` | `to`, `channel` (`"whatsapp"`, `"sms"`, `"rcs"`, `"email"` or `"instagram"`), `fallback_channels: list \| None` (`"whatsapp"`, `"sms"`, `"rcs"`), `message=None`, `template: dict \| None` (`name`, `id`, `languageCode`, `variables`, `components`), `sms: dict \| None` (`senderId`, `route`), `email: dict \| None` (`subject`, `fromName`, `fromEmail`, `replyTo`, `firstName`, `lastName`) | `SendMessageResponse` (`status`, `channelUsed`, `messageId`, `attempts`). Made once, never retried |
| `automations.list(...)` / `automations.get(automation_id)` | pagination only / positional id | `PaginatedResponse[AutomationSummary]` / `Automation`, with its trigger and steps |
| `automations.node_catalog()` | none | `AutomationNodeCatalog`, every trigger type and node type with the settings each node reads |
| `automations.create(...)` | `name`, `description=None`, `trigger: dict \| None`, `steps: list[dict] \| None`, `keywords: list[dict] \| None`, `posts: list[dict] \| None`, `check_follower=None`, `node_layout: dict \| None` | `CreateAutomationResponse`, ALWAYS created paused |
| `automations.update(automation_id, ...)` | positional id, any create argument, plus `active=None` | `Automation`. `trigger`, `steps`, `keywords` and `posts` replace what is stored |
| `automations.activate(automation_id)` / `automations.pause(automation_id)` | positional id | `Automation` |
| `automations.delete(automation_id)` | positional id | `None` |
| `automations.list_executions(automation_id, ...)` | positional id, pagination only | `PaginatedResponse[AutomationExecution]`, newest run first |
| `automations.rerun_execution(automation_id, execution_id)` | both positional | `dict` (`{"ok": True}`), the steps run again for real |
| `ai.list_models()` | none | `dict` (`{"data": [AiModel, ...]}`), each model with the price your workspace is charged |
| `webhooks.list_event_types()` | none | `dict`, the catalogue plus a sample payload each |
| `webhooks.list(...)` / `webhooks.get(endpoint_id)` | pagination only / positional id | `PaginatedResponse[WebhookEndpoint]` / `WebhookEndpoint` |
| `webhooks.create(...)` | `url`, `events: list[str]` (`["*"]` for all), `description=None`, `is_active=None` | `dict` including `secret`, returned once only |
| `webhooks.update(endpoint_id, ...)` | positional id, `url=None`, `events=None`, `description=None`, `is_active=None` | `WebhookEndpoint` |
| `webhooks.delete(endpoint_id)` / `webhooks.rotate_secret(endpoint_id)` | positional id | `None` / `dict`, the previous secret stops working |
| `webhooks.send_test(endpoint_id, ...)` | positional id, `event_type=None` | `dict` |
| `webhooks.list_deliveries(endpoint_id, ...)` | positional id, `status=None` | `PaginatedResponse[WebhookDelivery]` |
| `webhooks.get_delivery(delivery_id)` / `webhooks.replay_delivery(delivery_id)` | positional id | `dict`, a replay reuses the original event id |
| `chat.create_token(...)` | `user_id`, `name=None`, `role=None`, `app=None`, `ttl_seconds=None` (300 to 86400, default 3600) | `ChatToken` (`token`, `expiresAt`, `user`). Call it again to refresh |
| `chat.revoke_user(user_id, ...)` | positional id, `app=None` | `ChatUserRevoked` (`revoked`, `notBefore`), every earlier token of that user stops working |
| `chat.delete_user(user_id, ...)` | positional id, `app=None` | `ChatUserDeleted` (`deleted`, `conversations`), erases the user's conversations; safe to call twice |

### Module-level exports

| Export | Signature |
|---|---|
| `verify_webhook_signature` | `(raw_body: str \| bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool`, constant-time, accepts several `v1` values during a rotation |
| `construct_event` | `(raw_body, signature_header, secret, tolerance_seconds=300) -> dict`, verifies then parses, raises rather than returning unverified data |
| `DEFAULT_TOLERANCE_SECONDS` | `300`, the maximum timestamp drift accepted |
| `SlideSignatureVerificationError` | raised by `construct_event` |

Failed requests raise `SlideValidationError` (400), `SlideAuthError` (401), `SlideScopeError` (403), `SlideNotFoundError` (404) or the base `SlideError` for any other status. All carry `.status_code`, `.body` and the API's message. Response `TypedDict`s such as `Contact`, `VoiceCall` and `WhatsAppLog` are importable from the package root.

## Configuration

| Argument | Type | Default | Notes |
|---|---|---|---|
| `api_key` | `str`, required, keyword-only | none | Sent as `Authorization: Bearer <key>`. Read it from an environment variable such as `SLIDE_API_KEY`, never hardcode it. To create or update contacts, create a new API key with `contacts:write` (and `contacts:read` to list or read them): a key that only has the email list permission cannot create contacts, and a key's permissions cannot be edited |
| `base_url` | `str` | `https://slide.synquic.com/api` | Trailing slash is normalised, and every resource path is appended as `v1/...` |
| `timeout` | `float` | `30.0` | Seconds, applied by the underlying HTTP client to every request |
| `analytics_flush_interval` | `float` | `5.0` | Seconds an event waits in the queue behind `analytics.track` before it is sent (a batch also goes out at 50 events) |
| `analytics_on_error` | `Callable[[Exception, list[dict]], Any] \| None` | `None` | The only place a background failure shows: called with the error (`SlideError`, an `httpx` error, `SlideEventsRejectedError` or `SlideEventQueueFullError`) and the events concerned. On the async client it may be an `async def` |
| `analytics_retry_delay` | `float` | `0.5` | Seconds before the first retry when the server gave no hint; it doubles |

Webhook signing secrets are separate from the API key: `webhooks.create()` returns one once, and that is what `construct_event` and `verify_webhook_signature` expect (for example from `SLIDE_WEBHOOK_SECRET`).

## Requirements

- Python **>= 3.9**
- `httpx >= 0.27.0`, the only declared dependency
- `typing_extensions` is imported by `synquic_slide.types`, and normally arrives transitively with `httpx`. Install it explicitly if your environment prunes transitive dependencies

## Development

The project builds with the hatchling backend declared in `pyproject.toml`:

```bash
pip install -e .
python -m build      # produces the wheel and sdist
```

The `chat` resource and the `chat.message.unread` event have tests in `tests/test_chat.py`, on the standard library's `unittest` with the HTTP layer replaced, so nothing leaves the machine:

```bash
python3 -B -m unittest discover -s tests -v
```

The `messages`, `automations`, `ai` and `contacts` resources are covered the same way (`tests/test_messages.py`, `tests/test_automations.py`, `tests/test_ai.py`, `tests/test_contacts.py`): every method is checked on both clients for its HTTP method, path, query and body. `tests/test_analytics.py` also holds the queue: batching at 50, the interval, `flush()`, retries with the same `insertId`s, no retry on a 400, 401, 403, 409 or 429, `analytics_on_error`, and the 5,000 event bound.

No linter or formatter configuration is checked in to this repository.

## Licence

MIT.
