Agent Bots — Webhook & AI Automation in Ndoto
Automate conversations in Ndoto using webhook bots or built-in AI agents. Learn how to create, configure, and assign agent bots to inboxes with real code examples.
Agent Bots
Agent bots automate customer conversations so your team can focus on the conversations that need a human touch. You can build a custom webhook bot that integrates with any external service, or use the built-in AI agent powered by a knowledge base.
Manage your bots from Settings → Agent Bots. Create a new bot using the Add Bot button.
Bot Types
Webhook Bot
A webhook bot receives conversation events via HTTP POST to a URL you provide. Your server handles the event and can reply back to the conversation using the Ndoto API. This gives you full control — connect any AI model, CRM, database, or custom logic.
AI Agent Bot
The AI agent bot auto-responds to incoming messages using your knowledge base and a configurable system prompt. No external server required — it runs inside Ndoto.
Configuration options:
| Field | Description |
|---|---|
| System Prompt | Instructions that shape the bot's tone, scope, and behaviour |
| Temperature | Controls response creativity (0 = precise, 1 = more varied) |
| Max Exchanges Before Handoff | How many back-and-forth messages before the bot escalates to a human |
| Handoff Keywords | Phrases a customer can say to trigger immediate human handoff (e.g. "speak to agent") |
Creating a Bot
- Go to Settings → Agent Bots
- Click Add Bot
- Choose a bot type: Webhook or AI Agent
- For webhook bots: enter a name and a valid webhook URL (
https://...) - For AI agent bots: configure the system prompt and handoff rules
- Click Create Bot
After creation, an access token is shown once. Copy it and store it securely — it authenticates incoming API calls from your bot back to Ndoto.
Assigning a Bot to an Inbox
- Go to Settings → Inboxes
- Click Settings on the inbox you want to configure
- Go to the Configuration tab
- Under Select an agent bot, pick the bot from the dropdown
- Click Update
One bot can be assigned to multiple inboxes. An inbox can only have one active bot at a time.
Webhook Bot — Developer Guide
How it works
When a conversation event occurs in an inbox that has your bot assigned, Ndoto sends an HTTP POST request to your webhook URL with a JSON payload. Your server processes it and can reply using the Ndoto REST API.
Customer sends message
↓
Ndoto fires POST to your webhook URL
↓
Your server processes the message
↓
Your server calls Ndoto API to send a reply
↓
Customer sees the replyWebhook Payload — message_created
This is the most common event. Fired every time a new message arrives in a conversation assigned to your bot.
{
"event": "message_created",
"id": 1234,
"content": "Hello, I need help with my order",
"content_type": "text",
"message_type": "incoming",
"created_at": 1741526400,
"private": false,
"source_id": null,
"additional_attributes": {},
"content_attributes": {},
"sender": {
"id": 56,
"name": "Jane Doe",
"email": "[email protected]",
"phone_number": "+260966123456",
"avatar": "https://app.usendoto.com/rails/active_storage/...",
"identifier": null,
"type": "contact"
},
"inbox": {
"id": 3,
"name": "Website Chat",
"channel": "Channel::WebWidget"
},
"conversation": {
"id": 99,
"account_id": 1,
"status": "open",
"assignee": null,
"meta": {
"sender": {
"id": 56,
"name": "Jane Doe",
"type": "contact"
},
"channel": "Channel::WebWidget"
}
},
"account": {
"id": 1,
"name": "Acme Support"
}
}Webhook Payload — conversation_resolved
{
"event": "conversation_resolved",
"id": 99,
"status": "resolved",
"account_id": 1,
"meta": {
"sender": {
"id": 56,
"name": "Jane Doe",
"type": "contact"
},
"channel": "Channel::WebWidget"
},
"account": {
"id": 1,
"name": "Acme Support"
}
}Sending a Reply
To reply to a message, call the Ndoto API using the bot's access token and the conversation ID from the payload.
POST https://app.usendoto.com/api/v1/accounts/{account_id}/conversations/{conversation_id}/messagesHeaders:
api_access_token: YOUR_BOT_ACCESS_TOKEN
Content-Type: application/jsonBody:
{
"content": "Hi Jane! I can help with your order. Can you share your order number?",
"message_type": "outgoing",
"private": false
}Complete Example — Node.js (Express)
A minimal webhook bot that receives messages and replies using the Ndoto API.
import express from 'express';
import axios from 'axios';
const app = express();
app.use(express.json());
const NDOTO_BASE_URL = 'https://app.usendoto.com';
const BOT_ACCESS_TOKEN = 'your_bot_access_token_here';
app.post('/webhook', async (req, res) => {
const { event, content, conversation, account } = req.body;
// Only handle incoming customer messages
if (event !== 'message_created') {
return res.sendStatus(200);
}
// Ignore messages sent by the bot itself (outgoing)
if (req.body.message_type === 'outgoing') {
return res.sendStatus(200);
}
const conversationId = conversation.id;
const accountId = account.id;
try {
await axios.post(
`${NDOTO_BASE_URL}/api/v1/accounts/${accountId}/conversations/${conversationId}/messages`,
{
content: `You said: "${content}". How can I help you further?`,
message_type: 'outgoing',
private: false,
},
{
headers: {
api_access_token: BOT_ACCESS_TOKEN,
'Content-Type': 'application/json',
},
}
);
} catch (error) {
console.error('Failed to send reply:', error.message);
}
res.sendStatus(200);
});
app.listen(3000, () => console.log('Bot webhook listening on port 3000'));Complete Example — Python (Flask)
from flask import Flask, request, jsonify
import requests
app = Flask(__name__)
NDOTO_BASE_URL = "https://app.usendoto.com"
BOT_ACCESS_TOKEN = "your_bot_access_token_here"
@app.route("/webhook", methods=["POST"])
def webhook():
data = request.json
# Only handle incoming customer messages
if data.get("event") != "message_created":
return "", 200
if data.get("message_type") == "outgoing":
return "", 200
conversation_id = data["conversation"]["id"]
account_id = data["account"]["id"]
customer_message = data.get("content", "")
reply = f'Thanks for reaching out! You said: "{customer_message}". Our team will get back to you shortly.'
requests.post(
f"{NDOTO_BASE_URL}/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages",
json={
"content": reply,
"message_type": "outgoing",
"private": False,
},
headers={
"api_access_token": BOT_ACCESS_TOKEN,
"Content-Type": "application/json",
},
)
return "", 200
if __name__ == "__main__":
app.run(port=3000)Complete Example — Ruby (Sinatra)
require 'sinatra'
require 'json'
require 'net/http'
require 'uri'
NDOTO_BASE_URL = 'https://app.usendoto.com'
BOT_ACCESS_TOKEN = 'your_bot_access_token_here'
post '/webhook' do
payload = JSON.parse(request.body.read)
# Only handle incoming customer messages
return status 200 unless payload['event'] == 'message_created'
return status 200 if payload['message_type'] == 'outgoing'
conversation_id = payload['conversation']['id']
account_id = payload['account']['id']
content = payload['content']
uri = URI("#{NDOTO_BASE_URL}/api/v1/accounts/#{account_id}/conversations/#{conversation_id}/messages")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri)
request['api_access_token'] = BOT_ACCESS_TOKEN
request['Content-Type'] = 'application/json'
request.body = {
content: "Thanks! You said: \"#{content}\". We'll be with you shortly.",
message_type: 'outgoing',
private: false
}.to_json
http.request(request)
status 200
endSending Private Notes
Private notes are visible only to agents — not the customer. Useful for logging, tagging, or leaving context for human agents.
await axios.post(
`${NDOTO_BASE_URL}/api/v1/accounts/${accountId}/conversations/${conversationId}/messages`,
{
content: 'Bot could not resolve this query. Customer is asking about order #4521.',
message_type: 'outgoing',
private: true, // 👈 only agents see this
},
{ headers: { api_access_token: BOT_ACCESS_TOKEN } }
);Resolving a Conversation
To mark a conversation as resolved after the bot handles it:
await axios.patch(
`${NDOTO_BASE_URL}/api/v1/accounts/${accountId}/conversations`,
{ id: conversationId, status: 'resolved' },
{ headers: { api_access_token: BOT_ACCESS_TOKEN } }
);Assigning to a Human Agent
To hand off a conversation to a human agent:
await axios.patch(
`${NDOTO_BASE_URL}/api/v1/accounts/${accountId}/conversations/${conversationId}/assignments`,
{ assignee_id: agentUserId },
{ headers: { api_access_token: BOT_ACCESS_TOKEN } }
);All Webhook Events
| Event | Description |
|---|---|
message_created | New message in a conversation |
message_updated | A message was edited |
conversation_opened | A resolved conversation was reopened |
conversation_resolved | A conversation was resolved |
webwidget_triggered | A web widget session started |
Tips
- Always return HTTP
200immediately — Ndoto retries if your server takes too long or returns an error - Check
message_type !== 'outgoing'to avoid infinite loops where your bot replies to its own messages - Store the bot access token in an environment variable, never hardcode it
- Use
private: truemessages to leave notes for human agents without the customer seeing them
Real-World Example — WhatsApp Loan Application Bot
A webhook bot is ideal for collecting structured data over a conversation. This example walks through a loan application flow over WhatsApp: the bot asks questions one at a time, builds up the application, confirms with the customer, and hands off to a human agent when done.
How it works
Customer sends WhatsApp message
↓
Ndoto receives it and fires POST to your webhook
↓
Bot asks questions step by step (name, ID, income, loan amount…)
↓
Bot summarises the application and asks for confirmation
↓
Bot posts a private note for the loan officer and hands off
↓
Loan officer sees the full conversation and takes overWhat you need
- A WhatsApp inbox connected via Settings → Inboxes
- A Webhook Bot created in Settings → Agent Bots and assigned to that inbox
- A server (Node.js example below) with Redis to track conversation state
The bot keeps track of state
Because each webhook call is stateless, you need to store where each conversation is in the flow. Redis works well — store the current step and collected answers keyed by conversation ID.
import express from 'express';
import axios from 'axios';
import Redis from 'ioredis';
const app = express();
app.use(express.json());
const redis = new Redis();
const NDOTO_BASE_URL = 'https://app.usendoto.com';
const BOT_ACCESS_TOKEN = process.env.BOT_ACCESS_TOKEN;
const STEPS = [
{ key: 'full_name', question: 'Welcome! 👋 To apply for a loan, I need a few details.\n\nWhat is your *full name*?' },
{ key: 'nrc_number', question: 'Thank you! What is your *NRC / National ID number*?' },
{ key: 'employment', question: 'Are you *employed, self-employed, or a business owner*?' },
{ key: 'income', question: 'What is your *monthly income* (in ZMW)?' },
{ key: 'loan_amount', question: 'How much would you like to *borrow* (in ZMW)?' },
{ key: 'loan_purpose', question: 'What is the *purpose* of the loan?' },
];
async function getState(conversationId) {
const data = await redis.get(`loan:${conversationId}`);
return data ? JSON.parse(data) : { step: 0, answers: {} };
}
async function saveState(conversationId, state) {
await redis.set(`loan:${conversationId}`, JSON.stringify(state), 'EX', 86400);
}
async function sendMessage(accountId, conversationId, content, isPrivate = false) {
await axios.post(
`${NDOTO_BASE_URL}/api/v1/accounts/${accountId}/conversations/${conversationId}/messages`,
{ content, message_type: 'outgoing', private: isPrivate },
{ headers: { api_access_token: BOT_ACCESS_TOKEN } }
);
}
app.post('/webhook', async (req, res) => {
res.sendStatus(200); // always respond immediately
const { event, message_type, content, conversation, account } = req.body;
if (event !== 'message_created' || message_type !== 'incoming') return;
const conversationId = conversation.id;
const accountId = account.id;
const state = await getState(conversationId);
if (state.step < STEPS.length) {
// Save previous answer then ask next question
if (state.step > 0) {
state.answers[STEPS[state.step - 1].key] = content;
}
await sendMessage(accountId, conversationId, STEPS[state.step].question);
state.step += 1;
await saveState(conversationId, state);
} else if (state.step === STEPS.length) {
// Save last answer and show summary
state.answers[STEPS[state.step - 1].key] = content;
state.step += 1;
await saveState(conversationId, state);
const summary = `
✅ *Loan Application Summary*
👤 Name: ${state.answers.full_name}
🪪 NRC: ${state.answers.nrc_number}
💼 Employment: ${state.answers.employment}
💰 Monthly Income: ZMW ${state.answers.income}
💵 Loan Amount: ZMW ${state.answers.loan_amount}
📋 Purpose: ${state.answers.loan_purpose}
Is this correct? Reply *YES* to submit or *NO* to start over.
`.trim();
await sendMessage(accountId, conversationId, summary);
} else {
// Confirmation step
const reply = content.trim().toUpperCase();
if (reply === 'YES') {
await sendMessage(accountId, conversationId,
'🎉 Your application has been submitted! A loan officer will contact you shortly.'
);
// Private note visible only to agents
await sendMessage(accountId, conversationId,
'📋 *New loan application received.* Please review the details above and follow up with the customer.',
true
);
await redis.del(`loan:${conversationId}`);
} else if (reply === 'NO') {
await redis.del(`loan:${conversationId}`);
await sendMessage(accountId, conversationId,
"No problem! Let's start over. What is your *full name*?"
);
await saveState(conversationId, { step: 1, answers: {} });
} else {
await sendMessage(accountId, conversationId,
'Please reply *YES* to submit your application or *NO* to start over.'
);
}
}
});
app.listen(3000, () => console.log('Loan bot listening on port 3000'));What the loan officer sees in Ndoto
- The full WhatsApp conversation appears in their inbox
- A private note flags the submission: "New loan application received. Please review the details above."
- They assign the conversation to themselves and contact the customer directly
- Add a Label (e.g.
loan-application) via the API for easy inbox filtering
Extending this pattern
| Feature | How |
|---|---|
| Save to your own database | INSERT the answers in the YES handler before sending the confirmation |
| Email the loan officer | Send an email from your webhook server in the YES handler |
| Reject ineligible applicants early | Check income against a minimum threshold and reply with a polite decline |
| Support multiple languages | Detect the language of the first message and serve questions in that language |
| Resume interrupted applications | The Redis state persists for 24 hours — customers can continue where they left off |
This same pattern works for any structured data collection: insurance quotes, support tickets, onboarding forms, appointment bookings, and more.
System Bots
Bots with a System badge are globally available across all accounts and are managed by a platform administrator. They cannot be edited or deleted from the account settings.
Frequently Asked Questions
Can a bot and a human agent handle the same inbox? Yes. When a bot is assigned to an inbox, it handles conversations automatically. A human agent can take over any conversation — once assigned, the bot stops responding to that conversation.
How do I avoid the bot replying to its own messages?
Check the message_type field in the payload. Incoming messages from customers have message_type: "incoming". Your bot's replies will have message_type: "outgoing" — ignore those to avoid infinite loops.
Where do I find the bot's access token after creation? Go to Settings → Agent Bots, click the edit icon on the bot, then use Reset Access Token to generate a new one. The original token is only shown once at creation time.
Can I assign a bot to a specific conversation instead of an entire inbox? Yes. When viewing a conversation, you can assign a bot directly to it from the conversation details panel. This overrides the inbox-level bot assignment for that conversation.
Does the webhook receive messages from the bot itself?
Yes — all messages fire the message_created event. Always filter by message_type === "incoming" to only process customer messages.
Related Docs
Ndoto Product Guide — Features & How They Work
A complete guide to Ndoto's product features — conversations, channels, Akili AI, automation, contacts, Help Center, reports, and teams. Learn how every part of Ndoto works.
How AI Credits Work in Akili
Understand how Akili AI credits are consumed, how billing works, what each AI action costs, and what happens when credits run out.