◆ Sapport for developers API reference Error catalog OpenAPI
Sections

#The AI Seller in Your Own Channel

Status: Planned. The api channel and the server conversation routes are described in the public API design and do not work yet. This guide shows the target scheme; the field names of request and response bodies are preliminary (server conversations API). Do not build a production integration on it until the routes are released: follow the changelog.

Purpose: connect your own communication channel to the Sapport AI seller: your messenger, mobile app, chatbot, or messaging system. The customer writes in your product, your server passes the turn to Sapport, the AI replies, and the reply goes back to the customer through you.

#Contents

  1. When to use it
  2. Overview
  3. What you will need
  4. Step 1. Key, scopes, and webhook
  5. Step 2. The external customer ID
  6. Step 3. An incoming message and the reply
  7. Step 4. Delivering the reply to the customer
  8. Step 5. Handoff to an operator and return to the AI
  9. Wallet payment and limits
  10. Full example: Node.js and Express
  11. Full example: Python and FastAPI
  12. Pre-launch checks
  13. Notes
  14. Open questions

#When to use it

It is not suitable if you only need a chat on a website: use the widget. If you need your own chat interface in a browser without your own server, use the client Chat API.

#Overview

Customer ──► your channel ──► your server
                              │  POST /conversations/{id}/messages
                              │  (Idempotency-Key, ?wait=15)
                              ▼
                          Sapport: AI seller
                              │
          ┌───────────────────┴──────────────────┐
          │ 200: reply ready within ?wait        │ 202: operation "in progress"
          ▼                                      ▼
   you send the reply                    message.created webhook
   to the customer at once               ► you fetch the message
                                          and send it to the customer

The AI reply runs only asynchronously: the request returns 202 and an operation envelope, and ?wait=15 lets you wait for the same job. All replies, including operator replies from the Sapport dashboard, reach you in a message.created event. Treat the webhook as the main delivery path, and a reply within wait as a convenient shortcut.

#What you will need

WhatWhy
A plan with the api_access flagwithout it, feature_not_in_plan
An integration key with the scopes conversations:read, conversations:write, ai_seller:invokereading the conversation, accepting a message, invoking the AI
A publicly reachable HTTPS address for your serverreceiving webhooks (address requirements)
A positive wallet balancean AI reply is a paid operation
An AI seller agent assigned to the api channelchosen in the dashboard when you create the integration; otherwise ai_reply.reason = no_agent
For RU: the key's IP allowlist, the hosting country of your system, and the basis for the transferrequired for scopes with PII; if the country is outside the Russian Federation, scopes with PII are not granted and the error is 403 pii_transfer_not_allowed (Cells and data)

#Step 1. Key, scopes, and webhook

  1. In the dashboard, create an integration and a key (Authentication). Grant only the scopes you need. For testing, use a test key: it does not charge money (test keys).
  2. Create a subscription to the message.created and conversation.handoff_requested events (optionally operation.completed) pointing to your server's address (webhooks). The signing secret is shown once: save it in your own secret store.
  3. The api channel is created in the dashboard together with the integration: one integration is one api channel. Choose the AI seller agent for the channel there too. The channel ID is returned by GET /me (channel_id).

Keep the key only on the server: in an environment variable or a secret store. Do not put it in a mobile app or on a page.

#Step 2. The external customer ID

For each customer of your channel, choose a stable external ID: the user ID in your system, not a phone number or an email. The platform uses it to find or create the contact and the conversation.

curl -sS -X POST "$SAPPORT_BASE_URL/conversations" \
  -H "Authorization: Bearer $SAPPORT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"external_customer_id":"client-4815","name":"Anna"}'

Save the mapping client-4815 → cnv_EXAMPLE01 on your side so you do not create a conversation again for every message. If the mapping is lost, a repeated call with the same external ID should return the same conversation (per the design, the conversation is found or created by external ID).

Pass only the data you are allowed to pass, and only on a lawful basis. Conversations contain customers' personal data; for the RU cell, when you connect, you specify the hosting country of your system (PII is passed only to recipients in the Russian Federation) and the basis for the transfer, and PII disclosures go into a log that the tenant can see (Cells and data).

#Step 3. An incoming message and the reply

For each customer turn:

  1. Build an Idempotency-Key. Make it derived from your turn (for example, the message ID in your channel), so that a retry after a failure uses the same key.
  2. Call POST /conversations/{id}/messages?wait=15.
  3. Handle the result:
ResultWhat to do
200, ai_reply.status = repliedsend the reply.content text to the customer
200, ai_reply.status = skippedsee the reasons for no reply
202, ai_reply.status = queued, with operation in the bodywait for the message.created webhook or poll GET /operations/{id}; repeating the request with the same key returns the same operation
402top up the wallet; a customer message can be accepted separately without an AI reply (conversations:write only)
429, 503 (service_unavailable, upstream_unavailable)wait for Retry-After and retry with the same key
other 4xxfix the request (error catalog)

Do not send the customer your own message twice: a reply can arrive both synchronously and by webhook. Remember the IDs of messages you have already delivered.

#Step 4. Delivering the reply to the customer

A subscription to message.created calls your address for every new message in the conversation: from the customer, the AI, and the operator.

  1. Verify the signature on the raw body (verification algorithm).
  2. Check that Sapport-Event-Id has not been processed yet.
  3. The message.created payload is always "thin": IDs without the message text. Request the message with GET /conversations/{conversation_id}/messages and pick the one you need.
  4. For a customer message, the event has ai_reply: {status, reason?}. Skip customer messages (you sent them yourself) and deliver the AI and operator replies to your customer.
  5. Respond 2xx quickly and queue the rest of the work.

The subscription's include_pii flag adds PII to conversation.* events, but not the message text: message.created stays "thin" (Webhooks).

#Step 5. Handoff to an operator and return to the AI

SituationWhat happensWhat your server does
The AI requested an operator, or the daily limit (daily_limit) is exhaustedconversation.handoff_requested arrives; the AI stops replyingshow the conversation to your operator, tell the customer
An operator on your side takes the conversationPOST /conversations/{id}/takeoverthen call POST …/operator-messages
An operator writes to the customerPOST /conversations/{id}/operator-messagesdeliver the message to the customer; message.created arrives
An operator returns the conversation to the AIPOST /conversations/{id}/releasethe customer's next turns get an AI reply again
The conversation is finishedPOST /conversations/{id}/close

While an operator is handling the conversation, the AI does not reply to the customer's turns, and the POST …/messages response contains ai_reply: {status: "skipped", reason: "operator_active"}. The message is still accepted and is visible to the operator.

#Wallet payment and limits

#Full example: Node.js and Express

The example brings together accepting turns from your channel and the Sapport webhook. The mapping store and the list of processed events are in memory; replace them with a database for production. The functions deliverToCustomer and onCustomerMessage plug into your channel.

import crypto from 'node:crypto';
import express from 'express';

const BASE = process.env.SAPPORT_BASE_URL;        // https://formula-cream.pro/api/public/v1
const KEY = process.env.SAPPORT_API_KEY;
const WEBHOOK_SECRET = process.env.SAPPORT_WEBHOOK_SECRET;
const TOLERANCE_SEC = 300;

const conversations = new Map();   // external customer ID -> conversation ID (replace with a DB)
const delivered = new Set();       // IDs of messages already sent to the customer
const seenEvents = new Set();      // Sapport-Event-Id

async function api(path, { method = 'GET', key, body } = {}) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      'Content-Type': 'application/json',
      ...(key ? { 'Idempotency-Key': key } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) {
    const err = new Error(`${data.code ?? res.status}: ${data.detail ?? ''}`);
    err.status = res.status;
    err.code = data.code;
    throw err;
  }
  return { status: res.status, data };
}

async function conversationFor(customerId, name) {
  if (conversations.has(customerId)) return conversations.get(customerId);
  const { data } = await api('/conversations', {
    method: 'POST',
    key: `conv-${customerId}`,
    body: { external_customer_id: customerId, name }
  });
  conversations.set(customerId, data.id);
  return data.id;
}

// Called by your channel on every customer turn.
// ourMessageId is the turn's ID in your channel.
export async function onCustomerMessage(customerId, name, ourMessageId, text) {
  const conversationId = await conversationFor(customerId, name);
  // The key is derived from the turn: a retry after a failure is safe
  const { status, data } = await api(`/conversations/${conversationId}/messages?wait=15`, {
    method: 'POST',
    key: `msg-${ourMessageId}`,
    body: { content: text }
  });
  if (status === 200 && data.ai_reply?.status === 'replied' && !delivered.has(data.reply.id)) {
    delivered.add(data.reply.id);
    await deliverToCustomer(customerId, data.reply.content);
  }
  // 202 (ai_reply.status = 'queued', data.operation): the reply arrives by the message.created webhook
  // ai_reply.status = 'skipped': the AI is not replying, the reason is in data.ai_reply.reason
}

// Plug this into your channel
async function deliverToCustomer(customerId, text) {
  console.log('→ to customer', customerId, text);
}

const app = express();
app.post('/sapport', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
  const header = req.get('Sapport-Signature') || '';
  const parts = header.split(',').map((p) => p.trim());
  const t = Number(parts.find((p) => p.startsWith('t='))?.slice(2));
  const sigs = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
  if (!t || !sigs.length || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) return res.sendStatus(400);

  const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(`${t}.`).update(req.body).digest();
  const ok = sigs.some((hex) => {
    const got = Buffer.from(hex, 'hex');
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
  if (!ok) return res.sendStatus(400);

  const eventId = req.get('Sapport-Event-Id');
  if (seenEvents.has(eventId)) return res.sendStatus(200);
  seenEvents.add(eventId);

  res.sendStatus(200);                       // respond quickly
  const event = JSON.parse(req.body.toString('utf8'));
  handle(event).catch((e) => console.error('handle', event.id, e.message));
});

async function handle(event) {
  if (event.type !== 'message.created') return;
  const { conversation_id } = event.data;   // data: IDs, no text
  const { data } = await api(`/conversations/${conversation_id}/messages?limit=10`);
  const customerId = [...conversations].find(([, id]) => id === conversation_id)?.[0];
  if (!customerId) return;
  for (const m of data.data) {
    if (m.sender === 'customer' || delivered.has(m.id)) continue;   // the sender values are illustrative
    delivered.add(m.id);
    await deliverToCustomer(customerId, m.content);
  }
}

app.listen(3000);

#Full example: Python and FastAPI

import hashlib
import hmac
import os
import time

import httpx
from fastapi import FastAPI, HTTPException, Request

BASE = os.environ["SAPPORT_BASE_URL"]            # https://formula-cream.pro/api/public/v1
KEY = os.environ["SAPPORT_API_KEY"]
WEBHOOK_SECRET = os.environ["SAPPORT_WEBHOOK_SECRET"].encode()
TOLERANCE = 300

conversations: dict[str, str] = {}               # external customer ID -> conversation ID (replace with a DB)
delivered: set[str] = set()
seen_events: set[str] = set()

app = FastAPI()
client = httpx.AsyncClient(
    base_url=BASE, headers={"Authorization": f"Bearer {KEY}"}, timeout=30
)


async def api(method: str, path: str, key: str | None = None, **kw):
    headers = {"Idempotency-Key": key} if key else {}
    res = await client.request(method, path, headers=headers, **kw)
    data = res.json() if res.content else {}
    if res.status_code >= 400:
        raise RuntimeError(f'{data.get("code", res.status_code)}: {data.get("detail", "")}')
    return res.status_code, data


async def conversation_for(customer_id: str, name: str | None) -> str:
    if customer_id not in conversations:
        _, data = await api(
            "POST", "/conversations", key=f"conv-{customer_id}",
            json={"external_customer_id": customer_id, "name": name},
        )
        conversations[customer_id] = data["id"]
    return conversations[customer_id]


async def deliver_to_customer(customer_id: str, text: str):
    print("-> to customer", customer_id, text)   # plug this into your channel


async def on_customer_message(customer_id: str, name: str | None, our_message_id: str, text: str):
    """Called by your channel on every customer turn."""
    conversation_id = await conversation_for(customer_id, name)
    status, data = await api(
        "POST", f"/conversations/{conversation_id}/messages",
        key=f"msg-{our_message_id}", params={"wait": 15}, json={"content": text},
    )
    ai_reply = (data or {}).get("ai_reply", {})
    reply = (data or {}).get("reply")
    if status == 200 and ai_reply.get("status") == "replied" and reply["id"] not in delivered:
        delivered.add(reply["id"])
        await deliver_to_customer(customer_id, reply["content"])
    # 202 (queued, data["operation"]): wait for the message.created webhook
    # skipped: the AI is not replying, the reason is in ai_reply["reason"]


def verify(raw: bytes, header: str) -> bool:
    parts = [p.strip() for p in header.split(",")]
    t = next((p[2:] for p in parts if p.startswith("t=")), None)
    sigs = [p[3:] for p in parts if p.startswith("v1=")]
    if not t or not sigs or abs(time.time() - int(t)) > TOLERANCE:
        return False
    expected = hmac.new(WEBHOOK_SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)


@app.post("/sapport")
async def sapport_webhook(request: Request):
    raw = await request.body()                   # the raw body
    if not verify(raw, request.headers.get("Sapport-Signature", "")):
        raise HTTPException(400, "bad signature")
    event_id = request.headers.get("Sapport-Event-Id", "")
    if event_id in seen_events:
        return {"ok": True}
    seen_events.add(event_id)

    event = await request.json()
    if event.get("type") == "message.created":
        conversation_id = event["data"]["conversation_id"]
        customer_id = next((c for c, i in conversations.items() if i == conversation_id), None)
        if customer_id:
            _, data = await api("GET", f"/conversations/{conversation_id}/messages", params={"limit": 10})
            for m in data["data"]:
                if m["sender"] == "customer" or m["id"] in delivered:   # the sender values are illustrative
                    continue
                delivered.add(m["id"])
                await deliver_to_customer(customer_id, m["content"])
    return {"ok": True}

To run it: uvicorn app:app --port 3000. For receiving in production, put an HTTPS proxy in front of the server.

#Pre-launch checks

  1. With a test key, send a turn and confirm that the reply arrived and no money was charged.
  2. Send a turn twice with the same Idempotency-Key: the second reply must match, and there must be no duplicate in the conversation.
  3. Send the same key with different text: expect 409 idempotency_conflict.
  4. Turn on takeover (takeover) and confirm that ai_reply equals {status: "skipped", reason: "operator_active"}.
  5. Stop your webhook receiver, then start it again: missed deliveries are retried on a schedule of 8 retries, the latest after 24 hours (delivery and retries); anything undelivered is visible in the delivery log and can be resent.
  6. Check the handling of 402, 429, 503, and a lost connection during ?wait=15 (after a drop, find the result with GET /operations/{id}).
  7. Make sure that the key and the webhook secret do not end up in logs or the repository.

#Notes

#Open questions