How to Build a Two-Way SMS Chatbot with the senderZ API
A one-way messaging API sends messages. A two-way messaging API has conversations. The difference is a webhook endpoint that receives inbound messages and application logic that decides how to respond.
This guide walks through building a two-way chatbot using the senderZ API. The chatbot sends messages via iMessage or SMS, receives replies through webhooks, uses AI to generate contextual responses, and maintains conversation threading so every exchange stays connected. By the end, you will have a working chatbot that handles real conversations.
Architecture Overview
A two-way chatbot has three components:
- Outbound sending — Your application sends messages through the senderZ API.
- Inbound receiving — senderZ forwards incoming replies to your webhook endpoint.
- Response logic — Your application processes the inbound message and sends a reply.
The flow looks like this:
Your App → senderZ API → Recipient's phone (iMessage or SMS)
Recipient replies → senderZ → Your webhook → Your App processes → senderZ API → Reply sent
senderZ handles the transport layer — delivering messages via iMessage when available and SMS as fallback, managing phone number selection, enforcing compliance rules, and routing inbound messages to your webhook. Your application handles the conversation logic.
For an overview of how senderZ routes messages, see the send message documentation.
Step 1: Register a Webhook
To receive inbound messages, register a webhook URL with senderZ. When someone replies to a message you sent, senderZ sends a POST request to this URL with the message details.
Using curl
curl -X POST https://api.senderz.com/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/senderz",
"events": ["message.received", "message.delivered", "message.failed"],
"secret": "your_webhook_signing_secret"
}'
Using TypeScript
import { SenderZ } from "@senderz/sdk";
const client = new SenderZ({ apiKey: "YOUR_API_KEY" });
const webhook = await client.webhooks.create({
url: "https://your-app.com/webhooks/senderz",
events: ["message.received", "message.delivered", "message.failed"],
secret: "your_webhook_signing_secret",
});
The secret is used to sign webhook payloads with HMAC-SHA256. Always verify the signature before processing any webhook — see the security section below.
Register for message.received to get inbound messages, message.delivered for delivery confirmations, and message.failed for failure notifications. For more on webhook events, see the webhooks integration page.
Step 2: Handle Inbound Messages
When someone replies, senderZ sends a POST request to your webhook URL:
{
"event": "message.received",
"data": {
"id": "msg_abc123",
"from": "+15551234567",
"to": "+15559876543",
"body": "What time do you close today?",
"channel": "imessage",
"received_at": "2026-04-17T15:30:00Z",
"tenant_id": "tenant_xyz789"
},
"signature": "sha256=..."
}
Here is an Express handler that receives and verifies the webhook:
import express from "express";
import crypto from "crypto";
const app = express();
app.use(express.json());
const WEBHOOK_SECRET = process.env.SENDERZ_WEBHOOK_SECRET!;
function verifySignature(payload: string, signature: string): boolean {
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(`sha256=${expected}`),
Buffer.from(signature)
);
}
app.post("/webhooks/senderz", (req, res) => {
const signature = req.headers["x-senderz-signature"] as string;
const payload = JSON.stringify(req.body);
if (!verifySignature(payload, signature)) {
return res.status(401).json({ error: "Invalid signature" });
}
const { event, data } = req.body;
if (event === "message.received") {
handleInboundMessage(data);
}
res.status(200).json({ received: true });
});
Always return a 200 status immediately. Process the message asynchronously. If your endpoint returns a non-2xx status or times out (10 seconds), senderZ retries the webhook up to 3 times with exponential backoff.
Step 3: Conversation Threading
To maintain context across a conversation, track message history per phone number. Here is a simple in-memory conversation store:
interface Message {
role: "user" | "assistant";
content: string;
timestamp: number;
}
const conversations = new Map<string, Message[]>();
function getConversation(phoneNumber: string): Message[] {
if (!conversations.has(phoneNumber)) {
conversations.set(phoneNumber, []);
}
return conversations.get(phoneNumber)!;
}
function addMessage(
phoneNumber: string,
role: "user" | "assistant",
content: string
): void {
const conversation = getConversation(phoneNumber);
conversation.push({ role, content, timestamp: Date.now() });
// Keep last 20 messages for context window
if (conversation.length > 20) {
conversations.set(phoneNumber, conversation.slice(-20));
}
}
In production, store conversations in a database. The phone number is the conversation key — every message from +15551234567 belongs to the same thread, regardless of which senderZ phone number was used to send or receive.
Step 4: Add AI Response Generation
senderZ includes built-in AI reply suggestions through the /v1/ai/reply endpoint. Pass the conversation history and get back a contextual response:
Using curl
curl -X POST https://api.senderz.com/v1/ai/reply \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"conversation": [
{"role": "user", "content": "What time do you close today?"}
],
"persona": "friendly-support",
"max_length": 160
}'
Response
{
"data": {
"reply": "We're open until 6 PM today. Is there anything specific you'd like help with before then?",
"confidence": 0.92
}
}
Using TypeScript
const aiReply = await client.ai.reply({
conversation: getConversation(phoneNumber).map((m) => ({
role: m.role,
content: m.content,
})),
persona: "friendly-support",
max_length: 160,
});
The persona parameter controls the tone and style of the response. You can configure personas in the senderZ dashboard — each persona has its own system prompt, knowledge base, and behavioral rules.
The confidence score (0 to 1) tells you how confident the AI is in the response. For fully automated chatbots, you might auto-send responses above 0.8 confidence and queue lower-confidence responses for human review.
For more on AI agent capabilities, see the AI agents solutions page.
Step 5: Wire It All Together
Here is the complete chatbot server:
import express from "express";
import crypto from "crypto";
import { SenderZ } from "@senderz/sdk";
const app = express();
app.use(express.json());
const client = new SenderZ({ apiKey: process.env.SENDERZ_API_KEY! });
const WEBHOOK_SECRET = process.env.SENDERZ_WEBHOOK_SECRET!;
// Conversation store (use a database in production)
const conversations = new Map<string, { role: string; content: string }[]>();
function verifySignature(payload: string, signature: string): boolean {
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(`sha256=${expected}`),
Buffer.from(signature)
);
}
async function handleInboundMessage(data: {
from: string;
body: string;
channel: string;
}): Promise<void> {
const { from, body } = data;
// Check for STOP keyword — senderZ handles this automatically,
// but good practice to check on your end too
if (body.trim().toUpperCase() === "STOP") {
return; // senderZ processes the opt-out; do not reply
}
// Add user message to conversation
if (!conversations.has(from)) {
conversations.set(from, []);
}
const history = conversations.get(from)!;
history.push({ role: "user", content: body });
// Generate AI reply
const aiResponse = await client.ai.reply({
conversation: history,
persona: "friendly-support",
max_length: 300,
});
const replyText = aiResponse.reply;
// Send the reply
await client.messages.send({
to: from,
channel: "auto",
body: replyText,
});
// Store assistant message
history.push({ role: "assistant", content: replyText });
// Trim conversation to last 20 messages
if (history.length > 20) {
conversations.set(from, history.slice(-20));
}
}
app.post("/webhooks/senderz", async (req, res) => {
const signature = req.headers["x-senderz-signature"] as string;
const payload = JSON.stringify(req.body);
if (!verifySignature(payload, signature)) {
return res.status(401).json({ error: "Invalid signature" });
}
// Respond immediately
res.status(200).json({ received: true });
// Process asynchronously
if (req.body.event === "message.received") {
try {
await handleInboundMessage(req.body.data);
} catch (error) {
console.error("Failed to handle inbound message:", error);
}
}
});
app.listen(3000, () => console.log("Chatbot server running on :3000"));
Testing the Chatbot
Start the server and expose it to the internet using a tunnel (ngrok, cloudflared, or similar):
# Start the server
node server.js
# In another terminal, expose it
npx ngrok http 3000
Update your senderZ webhook URL to the tunnel URL:
curl -X POST https://api.senderz.com/v1/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok.io/webhooks/senderz",
"events": ["message.received"],
"secret": "your_webhook_signing_secret"
}'
Now send a test message to your senderZ phone number. The chatbot will receive it via webhook and reply automatically using AI-generated responses.
Human-in-the-Loop Escalation
Not every message should get an automated response. Add a confidence threshold and escalation path:
async function handleInboundMessage(data: {
from: string;
body: string;
}): Promise<void> {
const { from, body } = data;
const history = conversations.get(from) || [];
history.push({ role: "user", content: body });
const aiResponse = await client.ai.reply({
conversation: history,
persona: "friendly-support",
max_length: 300,
});
if (aiResponse.confidence >= 0.8) {
// Auto-reply for high-confidence responses
await client.messages.send({
to: from,
channel: "auto",
body: aiResponse.reply,
});
history.push({ role: "assistant", content: aiResponse.reply });
} else {
// Escalate to human — send notification to your team
await client.messages.send({
to: from,
channel: "auto",
body: "Great question — let me connect you with a team member. They'll get back to you shortly.",
});
// Notify your team via internal channel (Slack, email, dashboard)
await notifyTeam(from, body, aiResponse.reply, aiResponse.confidence);
}
conversations.set(from, history);
}
This pattern ensures customers always get a response — either an AI-generated answer or a human handoff — within seconds.
Compliance Considerations
senderZ handles TCPA compliance automatically for your chatbot:
- STOP processing: If a user texts STOP, senderZ blocks all future messages to that number. Your chatbot never needs to check manually — the opt-out is enforced at the platform level.
- START processing: If a user texts START after opting out, senderZ re-enables messaging to that number.
- Quiet hours: Marketing messages are blocked between 8 PM and 8 AM local time. Conversational replies (responses to user-initiated messages) are not subject to quiet hours.
- Consent logging: Every message sent and received is logged with timestamps for compliance audits.
Your chatbot should still avoid sending unsolicited messages. Only reply to user-initiated conversations unless the user has explicitly opted in to receive proactive messages.
For pricing information and plan-level features, see the pricing page.
Deploying to Production
For production chatbots, consider:
Use Cloudflare Workers for the webhook handler. senderZ runs on Cloudflare infrastructure, so deploying your webhook handler as a Cloudflare Worker minimizes latency between receiving the webhook and sending the reply. The round trip from webhook to AI response to outbound message can be under 2 seconds.
Store conversations in a database. Use D1 (Cloudflare’s SQLite), PostgreSQL, or Redis. Index by phone number and include timestamps for conversation expiration (clear conversations after 24 hours of inactivity).
Rate limit outbound replies. Prevent your chatbot from sending more than a reasonable number of messages per conversation per hour. This protects against loops (two chatbots replying to each other) and abuse.
Monitor delivery status. Register for message.failed webhook events. If a message fails to deliver, log it and potentially retry or escalate.
Frequently Asked Questions
Can the chatbot handle multiple conversations simultaneously?
Yes. Each conversation is keyed by the sender’s phone number, so the chatbot can handle hundreds of concurrent conversations. The senderZ API processes requests in parallel, and webhook events arrive independently for each conversation. The only bottleneck is your application’s processing capacity.
Does the chatbot work with both iMessage and SMS?
Yes. When a user sends an iMessage, the reply goes back via iMessage. When a user sends an SMS, the reply goes back via SMS. senderZ handles channel matching automatically — your chatbot code is identical regardless of the channel. The channel field in the webhook payload tells you which channel was used if you need it for analytics.
How do I train the AI persona for my business?
The AI persona is configured in the senderZ dashboard. You can provide a system prompt (describing your business, tone, and boundaries), a knowledge base (FAQ content, product details, policies), and behavioral rules (when to escalate, what topics to avoid). You can also submit training URLs through the /v1/ai/train endpoint to expand the AI’s knowledge base with your website content.
What happens if the webhook endpoint is down?
senderZ retries failed webhook deliveries up to 3 times with exponential backoff (1 minute, 5 minutes, 25 minutes). If all retries fail, the webhook event is logged in your senderZ dashboard for manual review. Your chatbot will miss those inbound messages, but they are not lost — you can retrieve them from the messages API.
Can I use my own AI model instead of senderZ’s built-in AI?
Yes. The /v1/ai/reply endpoint is optional. You can process inbound messages with any AI model — OpenAI, Anthropic, a local model, or custom logic — and use the senderZ API only for sending and receiving messages. The chatbot architecture works with any response generation approach.
Ready to build your two-way chatbot? Get your API key at senderZ.com, register a webhook, and start receiving inbound messages in under five minutes.
For the full webhook reference, see the webhooks documentation. For AI persona configuration, visit the AI agents solutions page. To learn how to send your first message, check the send message docs.