Walker Formulation Academy for developers API reference Error catalog OpenAPI
Sections

#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

  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)
                              ▼
                          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

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 connect the channel; otherwise ai_reply.reason = no_agent
A connected chat channelSettings → 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 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). 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).
  3. Connect the chat channel: Settings → API → "Connect a chat channel". One integration is one api channel; choose the AI seller agent there too. The channel ID is returned by GET /me (channel_id). Until the channel is connected, writing to conversations returns 409 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:

  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 = repliedthe 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 = skippedsee 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_reviewthe AI reply became a draft; it goes to the customer after an operator approves it
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
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_configuredconnect 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 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 Webhook-Event-Id has not been processed yet.
  3. The message.created payload is always "thin": IDs without the message text, attachments, or cards. Request the message with GET /conversations/{conversation_id}/messages and the fields= you need (content, attachments, cards) and pick the one you need. Without fields, the text and attachments do not arrive: they are personal fields (Minimization and field selection).
  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.

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_requested event and the operator_active reason work. The takeover, operator-messages, release, and close routes do not work yet.

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 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

  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. Send a turn with ai_reply: none and confirm that ai_reply equals {status: "skipped", reason: "not_requested"} and no money was charged. Once takeover is available, check operator_active too.
  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, 409 channel_not_configured, 429 (including concurrency_limit), 503, and a lost connection during ?wait=15 (after a drop, find the result with GET /operations/{id}).
  7. 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

#Open questions