#The AI Seller in Your Own Channel
Status: Available for the scheme "create a conversation, customer message, AI reply, webhook" and for cards. Planned: step 5, the operator reply from your system, takeover, return to the AI, and closing (server conversations API).
Purpose: connect your own communication channel to the Walker Formulation Academy AI seller: your messenger, mobile app, chatbot, or messaging system. The customer writes in your product, your server passes the turn to Walker Formulation Academy, 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)
▼
Platform: 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 Walker Formulation Academy 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 connect the channel; otherwise ai_reply.reason = no_agent |
| A connected chat channel | Settings → API → "Connect a chat channel"; without it, 409 channel_not_configured |
| 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). A subscription has no secret: deliveries are signed by the platform, and you verify the signature with a public key from the JWKS set (signature). - Connect the chat channel: Settings → API → "Connect a chat channel". One integration is one
apichannel; choose the AI seller agent there too. The channel ID is returned byGET /me(channel_id). Until the channel is connected, writing to conversations returns409 channel_not_configured.
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 "$API_BASE_URL/conversations" \
-H "Authorization: Bearer $API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"external_customer_id":"client-4815","name":"Anna","language":"ru"}'
Idempotency-Key is required. If the customer already has an active conversation, the response is that same conversation (200); otherwise a new one is created (201) and a conversation.created event arrives. Save the mapping client-4815 → conversation ID on your side so you do not call the route for every message: a lost mapping is restored by the same call. The external ID is isolated per integration: the same external_customer_id under another integration is a different customer. The name, email, and phone are written only into empty fields of the contact.
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 | the response contains only reply.id, without text: read the message with GET /conversations/{id}/messages?fields=sender,content and send the text to the customer |
201 or 200, ai_reply.status = skipped | see the reasons for no reply; 201 with not_requested means you did not ask for an AI reply (ai_reply: none, or the ai_seller:invoke scope is missing) |
200, ai_reply.status = queued_for_review | the AI reply became a draft; it goes to the customer after an operator approves it |
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 (wallet_suspended, spend_limit_reached) | top up the wallet or raise the limit; a customer message can be accepted separately without an AI reply: ai_reply: none |
409 channel_not_configured | connect the chat channel in the dashboard |
429 (rate_limited, concurrency_limit), 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
Webhook-Event-Idhas not been processed yet. - The
message.createdpayload is always "thin": IDs without the message text, attachments, or cards. Request the message withGET /conversations/{conversation_id}/messagesand thefields=you need (content,attachments,cards) and pick the one you need. Withoutfields, the text and attachments do not arrive: they are personal fields (Minimization and field selection). - 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.
A message can carry product cards (cards) and attachments (attachments; the links are temporary). A card from your system is sent with POST /conversations/{id}/cards (cards).
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
Status: partly Planned. The
conversation.handoff_requestedevent and theoperator_activereason work. Thetakeover,operator-messages,release, andcloseroutes do not work yet.
| 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 not charged. - 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 integration and no more than 5 per tenant (beyond that,
429 concurrency_limit), 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 inGET /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.
spend_limit_reachedandwallet_suspended(402): the request is rejected before anything is written. A shortage of funds for the AI reply itself givesai_reply.reason=insufficient_funds: the message is saved, so 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 Walker Formulation Academy 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. The webhook signature is verified with the jose library against the platform's public keys (algorithm).
import express from 'express';
import { base64url, compactVerify, createRemoteJWKSet } from 'jose';
const BASE = process.env.API_BASE_URL; // https://formula-cream.pro/api/public/v1
const KEY = process.env.API_KEY;
const JWKS = createRemoteJWKSet(new URL(`${BASE}/.well-known/jwks.json`));
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(); // Webhook-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);
// 201: a new conversation, 200: an existing one was found; in both cases data.id
const { data } = await api('/conversations', {
method: 'POST',
key: `conv-${customerId}`,
body: { external_customer_id: customerId, name, language: 'ru' }
});
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, ai_reply: 'auto' }
});
if (status === 200 && data.ai_reply?.status === 'replied' && !delivered.has(data.reply.id)) {
// The reply text is not here: we read the message with a separate request.
await deliverMessage(customerId, conversationId, data.reply.id);
}
// 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
}
// We read the text only with fields=content: the disclosure goes into the disclosure log.
async function deliverMessage(customerId, conversationId, messageId) {
const { data } = await api(`/conversations/${conversationId}/messages?limit=20&fields=sender,content`);
const m = data.data.find((x) => x.id === messageId);
if (!m || delivered.has(m.id)) return;
delivered.add(m.id);
await deliverToCustomer(customerId, m.content);
}
// Plug this into your channel
async function deliverToCustomer(customerId, text) {
console.log('→ to customer', customerId, text);
}
const app = express();
app.post('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const eventId = req.get('Webhook-Event-Id') ?? '';
const timestamp = req.get('Webhook-Timestamp') ?? '';
const m = /^([\w-]+)\.\.([\w-]+)$/.exec(req.get('Webhook-Signature') ?? '');
if (!eventId || !m || !/^\d{1,12}$/.test(timestamp)
|| Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SEC) return res.sendStatus(400);
// The payload is detached from the signature: we assemble it ourselves and insert it between the dots.
const payload = Buffer.concat([Buffer.from(`${eventId}.${timestamp}.`), req.body]);
try {
await compactVerify(`${m[1]}.${base64url.encode(payload)}.${m[2]}`, JWKS, { algorithms: ['EdDSA'] });
} catch {
return res.sendStatus(400);
}
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, sender } = event.data; // data: IDs, no text
if (sender === 'customer') return; // we do not echo the customer's own turns back
const customerId = [...conversations].find(([, id]) => id === conversation_id)?.[0];
if (!customerId) return;
const { data } = await api(`/conversations/${conversation_id}/messages?limit=10&fields=sender,content`);
for (const m of data.data) {
if (m.sender === 'customer' || delivered.has(m.id) || !m.content) continue;
delivered.add(m.id);
await deliverToCustomer(customerId, m.content);
}
}
app.listen(3000);
#Full example: Python and FastAPI
The signature is verified with the PyJWT library (the PyJWS class, the payload is not JSON) against the platform's public keys.
import base64
import json
import os
import time
import httpx
import jwt
import requests
from fastapi import FastAPI, HTTPException, Request
from jwt import PyJWK, PyJWS
BASE = os.environ["API_BASE_URL"] # https://formula-cream.pro/api/public/v1
KEY = os.environ["API_KEY"]
JWKS_URL = BASE + "/.well-known/jwks.json"
TOLERANCE = 300
conversations: dict[str, str] = {} # external customer ID -> conversation ID (replace with a DB)
delivered: set[str] = set()
seen_events: set[str] = set()
_jwks = {"keys": [], "at": 0.0}
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:
# 201: a new conversation, 200: an existing one was found
_, data = await api(
"POST", "/conversations", key=f"conv-{customer_id}",
json={"external_customer_id": customer_id, "name": name, "language": "ru"},
)
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 deliver_new_messages(customer_id: str, conversation_id: str):
# We read the text only with fields=content: the disclosure goes into the disclosure log.
_, data = await api(
"GET", f"/conversations/{conversation_id}/messages",
params={"limit": 10, "fields": "sender,content"},
)
for m in data["data"]:
if m["sender"] == "customer" or m["id"] in delivered or not m.get("content"):
continue
delivered.add(m["id"])
await deliver_to_customer(customer_id, m["content"])
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": "auto"},
)
ai_reply = (data or {}).get("ai_reply", {})
if status == 200 and ai_reply.get("status") == "replied":
await deliver_new_messages(customer_id, conversation_id) # the response has only reply.id
# 202 (queued, data["operation"]): wait for the message.created webhook
# skipped: the AI is not replying, the reason is in ai_reply["reason"]
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def key_for(kid: str):
for attempt in (0, 1):
if attempt == 1 or time.time() - _jwks["at"] > 300:
_jwks.update(keys=requests.get(JWKS_URL, timeout=5).json()["keys"], at=time.time())
for jwk in _jwks["keys"]:
if jwk.get("kid") == kid:
return PyJWK.from_dict(jwk).key
return None
def verify(event_id: str, timestamp: str, signature: str, body: bytes) -> bool:
parts = signature.split(".")
if not event_id or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE:
return False
if len(parts) != 3 or parts[1] != "":
return False
try:
header = json.loads(base64.urlsafe_b64decode(parts[0] + "=" * (-len(parts[0]) % 4)))
key = key_for(header.get("kid", ""))
if key is None:
return False
payload = f"{event_id}.{timestamp}.".encode() + body
PyJWS().decode(f"{parts[0]}.{b64url(payload)}.{parts[2]}", key, algorithms=["EdDSA"])
return True
except (jwt.PyJWTError, ValueError):
return False
@app.post("/webhooks")
async def handle_webhook(request: Request):
raw = await request.body() # the raw body
h = request.headers
event_id = h.get("Webhook-Event-Id", "")
if not verify(event_id, h.get("Webhook-Timestamp", ""), h.get("Webhook-Signature", ""), raw):
raise HTTPException(400, "bad signature")
if event_id in seen_events:
return {"ok": True}
seen_events.add(event_id)
event = json.loads(raw)
if event.get("type") == "message.created" and event["data"].get("sender") != "customer":
conversation_id = event["data"]["conversation_id"]
customer_id = next((c for c, i in conversations.items() if i == conversation_id), None)
if customer_id:
await deliver_new_messages(customer_id, conversation_id)
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. - Send a turn with
ai_reply: noneand confirm thatai_replyequals{status: "skipped", reason: "not_requested"}and no money was charged. Once takeover is available, checkoperator_activetoo. - 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,409 channel_not_configured,429(includingconcurrency_limit),503, and a lost connection during?wait=15(after a drop, find the result withGET /operations/{id}). - Make sure that the access key does not end up in logs or the repository, and that customer texts are not written to logs.
#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. - Texts and attachments are not in events. Events are thin, and
fields=does not return personal fields by default: request only what you need. - 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
- Step 5 (operator reply, takeover, return to the AI, closing): after the routes are released. Technical.
- Sending customer attachments through the server API. Technical.