TL;DR
- Answers come from your policies. The agent searches a knowledge base with your shipping, returns and FAQ documents before it replies.
- Order status comes from your store. A
get_order_statustool calls your HTTPS endpoint, which verifies the signature and returns the real status. - You can test without messaging customers.
POST /v2/agents/{agentId}/testreturns the reply and the tool the model chose; on thezavuprovider a test is billed like a live reply. - Start on Telegram, switch channels later. Telegram connects with one API call; WhatsApp and Instagram connect from Channels in the dashboard, with the same agent.
- Replying from the inbox does not pause the agent. Only a flow's
transferstep does, until someone on your team replies.
Most questions an online store gets are the same five: where is my order, how much is shipping, can I return this, do you have it in another size, how long does delivery take. An AI chatbot for ecommerce is worth building when it answers those from your real policies and your real order data, not from whatever the model guesses.
This tutorial builds that over the Zavu API, end to end, with plain HTTP. At the end you have:
- A chat channel connected (Telegram to start, WhatsApp or Instagram when you go live).
- An AI agent that answers from a knowledge base holding your shipping policy, returns policy and FAQ.
- A
get_order_statustool: the agent calls your endpoint, you verify the signature, and you answer with the order's status from your store. - A way to test the whole thing without messaging a single customer.
- A keyword that hands the conversation to a person, and the agent staying quiet until that person answers.
If you prefer to click through the dashboard instead, the support agent walkthrough does the Telegram version with no code. This post is the API version, with your order data in the loop.
What you need
- A Zavu account and an API key. Every call below sends
Authorization: Bearer $ZAVUDEV_API_KEY. - A channel your customers write to. Each one has a different setup cost:
- Telegram: a bot token from @BotFather. One API call, no review, no Meta account. The fastest way to have something real to test. - WhatsApp: a WhatsApp Business Account through Meta's signup, connected from Channels in the dashboard. The most friction of any channel, and for many stores in Latin America the one customers actually use. - Instagram: also connected through Meta, from Channels in the dashboard.
- An HTTPS endpoint on your side that can look up an order. It has to be public: Zavu refuses a tool whose URL is not a public HTTPS address.
- Your policies as text. Shipping, returns, payment methods, the questions you answer every week.
The SDKs (@zavudev/sdk, zavudev) call API v1. API v2 is called over HTTP, so the examples use curl and fetch. Two pieces of this build have no v2 endpoint yet, knowledge bases and tools, and live on v1 under /v1/agents/{agentId}/... with the same key and the same agent id. The migration guide lists what stays on v1. Each step below says which version it uses.
1. Connect a channel (v2)
Start with Telegram. Create a bot with @BotFather, copy the token, and connect it:
terminalcurl -X POST https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "telegram", "botToken": "123456789:AA..."}'
The 201 lists the entries of the address you connected. A Telegram bot's address is @botname:
JSON{ "items": [ { "address": "@acme_store_bot", "type": "telegram", "active": true, "v1SenderId": "kd7..." } ] }
If Telegram rejects the token, nothing is created and you get 400 invalid_bot_token, with Telegram's own words in details.telegramSaid. Copy the token again from @BotFather.
Check active. A connection that would cost money on your plan starts switched off; turn it on with POST /v2/channels/{address}/{type}/activate, and the response tells you what it charged in chargedCents and monthlyCents. Details in Addresses and channels.
WhatsApp and Instagram are not connected with this endpoint. Connect them from Channels in the dashboard; once connected they appear in GET /v2/channels and the rest of this tutorial works the same, with the phone number or @username as the address.
2. Create the agent (v2)
terminalcurl -X POST https://api.zavu.dev/v2/agents \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme store assistant", "provider": "zavu", "model": "openai/gpt-4o-mini", "systemPrompt": "You answer customers of Acme, an online store. Answer only from the knowledge base and the get_order_status tool. If the answer is not there, say you do not know and offer a person. To check an order, ask for the order number and the email used at checkout. Never invent a delivery date. Keep replies short: this is a chat." }'
name, provider, model and systemPrompt are required, and v2 names every one that is missing in a single 400. provider is one of openai, anthropic, google, mistral or zavu.
zavu is the managed provider: no API key of yours, and each reply is charged to your Zavu balance per token. With any other provider you send apiKey and the tokens cost your provider account instead. On zavu, model is written as provider/model, for example openai/gpt-4o-mini; the dashboard's model picker lists the catalog in force.
Two things in the response matter. The agent is created disabled, and its channels is empty: it answers nobody yet. Keep the id.
JavaScriptconst res = await fetch("https://api.zavu.dev/v2/agents", { method: "POST", headers: { Authorization:, "Content-Type": "application/json", }, body: JSON.stringify({ name: "Acme store assistant", provider: "zavu", model: "openai/gpt-4o-mini", systemPrompt: SYSTEM_PROMPT, }), }); const { agent } = await res.json(); console.log(agent.id, agent.enabled); // "kh7...", falseBearer${process.env.ZAVUDEV_API_KEY}
The system prompt is where most chatbots go wrong. "Answer only from the knowledge base" and "never invent a delivery date" are the two lines that keep an ecommerce bot from promising things your warehouse cannot do.
3. Load your policies into a knowledge base (v1)
A knowledge base is a set of documents the agent searches before it answers. Create one on the agent, then add a document per topic:
terminalcurl -X POST https://api.zavu.dev/v1/agents/$AGENT_ID/knowledge-bases \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "Store policies", "description": "Shipping, returns, payments and FAQ"}'
terminalcurl -X POST https://api.zavu.dev/v1/agents/$AGENT_ID/knowledge-bases/$KB_ID/documents \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Shipping policy", "content": "# Shipping\n\nOrders placed before 2pm ET ship the same business day.\nStandard shipping in the continental US takes 3 to 5 business days and is free over $50.\nExpress takes 1 to 2 business days and costs $15.\nWe do not ship to PO boxes." }'
Repeat for returns, payment methods and an FAQ. One topic per document retrieves better than one long document with everything. A document is processed after it is created; its isProcessed turns true once it is split and searchable.
If you sell on Shopify, you already have this text: it is in your store's policy pages. Paste it as it is and fix what is stale. The agent will repeat it to customers exactly as written.
4. Give it a get_order_status tool (v1)
The knowledge base answers "how long does shipping take". It cannot answer "where is order 1042". For that the agent calls a tool: an HTTPS endpoint of yours that Zavu calls with arguments the model fills in.
terminalcurl -X POST https://api.zavu.dev/v1/agents/$AGENT_ID/tools \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "get_order_status", "description": "Look up the status of an order. Call it when the customer asks where their order is or when it will arrive. Requires the order number and the email used at checkout.", "webhookUrl": "https://shop.example.com/zavu/tools/order-status", "parameters": { "type": "object", "properties": { "order_number": { "type": "string", "description": "Order number, e.g. 1042" }, "email": { "type": "string", "description": "Email used at checkout" } }, "required": ["order_number", "email"] } }'
The 201 carries tool.webhookSecret. It is returned only on create, never on a later read. Store it. If you lose it, rotate it with POST /v1/agents/{agentId}/tools/{toolId}/webhook/secret.
What your endpoint receives
Zavu sends a POST with this JSON body:
JSON{ "tool": "get_order_status", "arguments": { "order_number": "1042", "email": "dana@example.com" }, "context": { "conversationId": "js72...", "channel": "telegram", "contactPhone": "...", "messageId": "jd7..." }, "timestamp": 1760889600000 }
and these headers: X-Zavu-Tool (the tool name), X-Zavu-Timestamp, and X-Zavu-Signature, the hex-encoded HMAC-SHA256 of the raw body with the tool's secret. No prefix, no timestamp mixed in. contactPhone is the identifier the customer wrote from: a phone number on WhatsApp, the chat id on Telegram.
The endpoint
JavaScriptimport express from "express"; import crypto from "node:crypto"; const app = express(); const SECRET = process.env.ZAVU_TOOL_SECRET; // Raw body: the signature is over the exact bytes Zavu sent. app.post("/zavu/tools/order-status", express.raw({ type: "application/json" }), async (req, res) => { const expected = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex"); const received = req.get("X-Zavu-Signature") ?? ""; const ok = received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)); if (!ok) return res.status(401).json({ error: "bad signature" }); const { arguments: args } = JSON.parse(req.body.toString("utf8")); // Your store: Shopify Admin API, your platform's API, or your own database. const order = await findOrder(args.order_number); // Do not confirm an order to someone who cannot prove it is theirs. if (!order || order.email.toLowerCase() !== String(args.email).toLowerCase()) { return res.json({ found: false }); } res.json({ found: true, status: order.status, // "paid" | "shipped" | "delivered" | ... carrier: order.carrier ?? null, trackingUrl: order.trackingUrl ?? null, estimatedDelivery: order.estimatedDelivery ?? null, }); }); app.listen(3000);
The same check in Python:
Pythonimport hmac, hashlib def valid(raw_body: bytes, signature: str, secret: str) -> bool: expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature or "")
What you return is what the agent reads, so return facts, not prose: a status, a carrier, a tracking link, a date when you have one. A non-2xx response reaches the agent as a failed call and it answers around the error, so a found: false with 200 gives a better reply than a 404. Keep the endpoint fast: the customer is waiting for the reply while it runs.
Asking for the email is a choice, not a requirement of Zavu. On Telegram you have no phone number to match against the order, and an order number alone is easy to guess.
5. Connect the agent and turn it on (v2)
terminalcurl -X POST https://api.zavu.dev/v2/agents/$AGENT_ID/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"address": "@acme_store_bot", "type": "telegram"}'
address and type are one entry of GET /v2/channels. An address answers with one agent at a time: if another agent already holds it, the 400 names that agent. An address the project does not have is 404 not_found. One-way SMS is refused with 409 channel_cannot_receive, because nobody can reply to it and the agent would never answer.
Then enable it:
terminalcurl -X PATCH https://api.zavu.dev/v2/agents/$AGENT_ID \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"enabled": true}'
6. Test it without messaging anyone (v2)
POST /v2/agents/{agentId}/test runs the agent's prompt, model and knowledge base against a message and returns the reply instead of delivering it. No message goes out, no execution is logged.
terminalcurl -X POST https://api.zavu.dev/v2/agents/$AGENT_ID/test \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "the email is dana@example.com", "history": [ { "role": "user", "content": "where is my order 1042?" }, { "role": "assistant", "content": "I can check that. What email did you use at checkout?" } ] }'
The response:
JSON{ "success": true, "text": "...", "knowledgeChunksUsed": 0, "plannedToolCalls": [ { "name": "get_order_status", "arguments": "{\"order_number\":\"1042\",\"email\":\"dana@example.com\"}" } ], "executedToolCalls": [], "warnings": [ "The model was offered 1 tool (get_order_status) but none ran, since running them would reach your real handlers. ..." ], "inputTokens": 812, "outputTokens": 41, "latencyMs": 1430, "cost": 0.000146 }
Read three fields:
plannedToolCalls: the tool the model chose and the arguments it filled in. By default nothing runs, so the reply stops at that choice.knowledgeChunksUsed: ask "do you ship to PO boxes?" and this should be above zero. Zero means the answer did not come from your documents.warnings: what a dry run cannot prove. A disabled agent, an agent connected to no channel, tools offered but not run, flows that were not evaluated, contact metadata that does not exist in a test.
To run the whole loop, the model calling your real endpoint and answering with its result, add "executeTools": true. Your endpoint is called for real, and executedToolCalls lists what ran with ok and any error. Use a test order.
A test run on a zavu agent is billed like a live reply, at the same per-token price, and the response's cost says how much. With an empty balance it is refused with 402 insufficient_balance; on a bring-your-own-key agent it costs your provider.
Then write to the bot from your own Telegram account. That is the first real conversation.
7. Watch conversations and take over
In the dashboard, every chat the agent handles appears in Conversations, next to messages from every other channel. You can open a thread and reply yourself.
Be precise about what that reply does. Replying from Conversations does not pause the agent. The customer's next message is answered by the agent again. The agent stays quiet only while a handoff is open, and a handoff opens when a flow reaches a transfer step.
So give customers a way to ask for a person. Flows are v1:
terminalcurl -X POST https://api.zavu.dev/v1/agents/$AGENT_ID/flows \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Talk to a person", "trigger": { "type": "keyword", "keywords": ["human", "agent", "talk to a person"] }, "steps": [ { "id": "handoff", "type": "transfer", "config": { "message": "A person from our team will reply here shortly." } } ], "enabled": true, "priority": 10 }'
A keyword trigger is a case-insensitive substring match. When it fires, the customer gets the message, the session is marked transferred, and the agent stops answering that contact. Their next messages still arrive in Conversations. The moment someone on your team replies, from Conversations or from the API, the handoff ends and the agent takes the thread back.
Nobody is notified for you: there is no queue and no assignment. If your team does not watch Conversations, put a tool step before the transfer that pings your own Slack or helpdesk. For the difference between this and a scripted bot, see AI agent vs chatbot.
What can go wrong
| What you see | Why | What to do |
|---|---|---|
400 invalid_bot_token | Telegram rejected the token | Copy it again from @BotFather |
400 naming name, provider, model, systemPrompt | Missing fields on create | Send all four |
400 "already answered by the agent ..." | The address has another agent | Disconnect it there, or use another address |
409 channel_cannot_receive | You connected one-way SMS | Use a channel customers can reply on |
402 insufficient_balance on test | Zavu model, empty balance | Add funds, or use your own provider key |
| Agent answers but invents order dates | It is not calling the tool | Check plannedToolCalls; sharpen the tool description |
knowledgeChunksUsed: 0 | Documents not processed, or not relevant | Check isProcessed; split long documents by topic |
v2 errors are flat, {code, message, details}. The errors reference lists them.
Going to production with your AI chatbot for ecommerce
- Move to the channel your customers use. Connect WhatsApp or Instagram from Channels, then
POST /v2/agents/{agentId}/channelswith that address. Same agent, same knowledge base, same tool. The same setup works as an AI customer service chatbot for a Shopify store on whichever channel its customers write from. - WhatsApp costs. Meta charges for service messages past 1,000 free ones per number each month, and stops delivering them if the WhatsApp Business Account has no payment method. Here is how that works.
- SMS in the US. If you add two-way SMS on a US number, sending to +1 numbers needs an approved 10DLC campaign; until then sends are refused with
403 ten_dlc_required. Telegram and email need none. See what A2P 10DLC is. - Agent cost. On
zavu, every reply is charged to your balance per token. Watch it in the agent's executions and stats (v1:GET /v1/agents/{agentId}/executionsand/stats, or the agent's page in the dashboard). - Limits. Daily and monthly sending limits depend on your plan and verification. The sending limits page has the numbers.
- Keep policies current. When your return window changes, update the document (
PATCHon the document reprocesses it). The bot repeats what the knowledge base says, including what is out of date.
To see where this fits in the new API, read the Zavu 2.0 announcement and the v2 overview.