#The AI Seller in Your Own Channel
Status: Planned. The
apichannel 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
- When to use it
- Overview
- What you will need
- Step 1. Key, scopes, and webhook
- Step 2. The external customer ID
- Step 3. An incoming message and the reply
- Step 4. Delivering the reply to the customer
- Step 5. Handoff to an operator and return to the AI
- Wallet payment and limits
- Full example: Node.js and Express
- Full example: Python and FastAPI
- Pre-launch checks
- Notes
- Open questions
#When to use it
- You have your own channel that is not among the built-in ones: a corporate messenger, an app, an SMS gateway, an integration with a partner platform.
- You need to decide for yourself where to keep the correspondence on your side, how to show replies, and how to identify customers.
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
| What | Why |
|---|---|
A plan with the api_access flag | without it, feature_not_in_plan |
An integration key with the scopes conversations:read, conversations:write, ai_seller:invoke | reading the conversation, accepting a message, invoking the AI |
| A publicly reachable HTTPS address for your server | receiving webhooks (address requirements) |
| A positive wallet balance | an AI reply is a paid operation |
An AI seller agent assigned to the api channel | chosen 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 transfer | required 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
- 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).
- Create a subscription to the
message.createdandconversation.handoff_requestedevents (optionallyoperation.completed) pointing to your server's address (webhooks). The signing secret is shown once: save it in your own secret store. - The
apichannel is created in the dashboard together with the integration: one integration is oneapichannel. Choose the AI seller agent for the channel there too. The channel ID is returned byGET /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:
- 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. - Call
POST /conversations/{id}/messages?wait=15. - Handle the result:
| Result | What to do |
|---|---|
200, ai_reply.status = replied | send the reply.content text to the customer |
200, ai_reply.status = skipped | see the reasons for no reply |
202, ai_reply.status = queued, with operation in the body | wait for the message.created webhook or poll GET /operations/{id}; repeating the request with the same key returns the same operation |
402 | top 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 4xx | fix 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.
- Verify the signature on the raw body (verification algorithm).
- Check that
Sapport-Event-Idhas not been processed yet. - The
message.createdpayload is always "thin": IDs without the message text. Request the message withGET /conversations/{conversation_id}/messagesand pick the one you need. - 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. - Respond
2xxquickly 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
| Situation | What happens | What your server does |
|---|---|---|
The AI requested an operator, or the daily limit (daily_limit) is exhausted | conversation.handoff_requested arrives; the AI stops replying | show the conversation to your operator, tell the customer |
| An operator on your side takes the conversation | POST /conversations/{id}/takeover | then call POST …/operator-messages |
| An operator writes to the customer | POST /conversations/{id}/operator-messages | deliver the message to the customer; message.created arrives |
| An operator returns the conversation to the AI | POST /conversations/{id}/release | the customer's next turns get an AI reply again |
| The conversation is finished | POST /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
- What is paid. The AI reply (
ai_seller:invoke). Accepting a customer message, operator replies, takeover, return, and closing are free. - Where the money comes from. The tenant's wallet. The
operationenvelope of a completed operation containsusage: {cost: {amount, currency}, model}in the wallet's currency (cost in the response). - A retry does not charge a second time. The same
Idempotency-Keyreturns the same operation. - Spend protection. A reserve before execution, a spend limit per tenant and per integration, no more than 5 concurrent paid operations per tenant, a maximum cost per operation (by default the equivalent of 100 RUB), and a wallet circuit breaker (paid operations). The values are set in the dashboard, and the current ones are visible in
GET /me. - The daily limit of AI replies. When it is exhausted, the message is saved, an AI reply is neither requested nor paid for, the conversation gets
handoff_pending, andai_replyequals{status: "skipped", reason: "daily_limit"}. - Rate limits (starting values). Reads 600/min, writes 120/min, paid 30/min. This is the lower bound of throughput: at about 30 customer turns per minute per key, throttle the flow with a queue on your side, and use several integrations only by agreement with the tenant.
- Money errors.
insufficient_funds(402) andspend_limit_reached(402): keep accepting customer messages, and either postpone the reply to the customer or hand it to an operator.
#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
- With a test key, send a turn and confirm that the reply arrived and no money was charged.
- Send a turn twice with the same
Idempotency-Key: the second reply must match, and there must be no duplicate in the conversation. - Send the same key with different text: expect
409 idempotency_conflict. - Turn on takeover (
takeover) and confirm thatai_replyequals{status: "skipped", reason: "operator_active"}. - 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.
- Check the handling of
402,429,503, and a lost connection during?wait=15(after a drop, find the result withGET /operations/{id}). - Make sure that the key and the webhook secret do not end up in logs or the repository.
#Notes
- One channel, one role. The
apichannel does not get the platform's shared site prompt: the AI replies according to your agent's settings and your tenant's knowledge base. - Event order is not guaranteed. Rely on message IDs and creation time.
- A webhook beats polling. Polling
GET …/messagesis fine for reconciliation, but it uses up read limits. - Different cells, different keys and addresses. An RU key does not work on EN and ID, and the reverse (Authentication).
- Customer texts are PII. Do not write them to logs unless necessary.
#Open questions
- The final names of the response fields (
reply,message, thesendervalues) will be available only in OpenAPI. Technical. - The parameters for filtering messages by time and for page size, for a quick reconciliation after a webhook. Technical.