Guides
Bring your own AI
Your model answers; heysteff brings the channels (WhatsApp, Instagram, Messenger, SMS and email), the inbox and the team. When your AI does not know what to say, it hands the conversation to a person and stays quiet until it gets it back.
Before you start#
- Turn off the heysteff agent (a developer account starts with it off): otherwise both would answer.
- An API key with
messages:write,conversations:readandconversations:write. Ansk_test_key works the same on sandbox conversations. - A webhook subscribed to
message:received.
1. Receive the message#
Every customer message reaches your webhook. Its data carries everything you need to answer without another call:
{
"id": "5d1f0c7e-8a2b-4c3d-9e4f-0a1b2c3d4e5f",
"event": "message:received",
"ts": "2026-11-04T14:03:52.104Z",
"data": {
"conversation_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"message_id": "4e3d2c1b-0a9f-4e8d-7c6b-5a4f3e2d1c0b",
"channel": "whatsapp_cloud",
"channel_connection_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
"external_user_id": "56912345678",
"contact_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"bot_paused": false,
"bot_paused_by": null,
"sandbox": false,
"content": "Can I get a refund for order 1042?",
"content_type": "text"
}
}| Field | What for |
|---|---|
channel, external_user_id | Pass them as-is as channel and to of POST /v1/messages. |
channel_connection_id | The number or account it came in through: pass it to answer from the same one. |
bot_paused, bot_paused_by | When true, a person (or a flow) has the conversation: your AI stays quiet. |
sandbox | It is a sandbox conversation (answer it with your sk_test_ key). |
message:sent carries the same fields. The existing ones (channel_type, media_url, filename…) do not change.
2. Answer#
When bot_paused is false, give the text to your model and send the reply. Use the event's message_id in the Idempotency-Key: if the webhook arrives twice, the reply goes out once.
curl https://app.heysteff.com/api/v1/messages \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-4e3d2c1b" \
-d '{
"channel": "whatsapp_cloud",
"to": "56912345678",
"channel_connection_id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
"content": "Hi, order 1042 shipped yesterday. Anything else I can help with?"
}'3. When it does not know: hand it to a person#
POST /v1/conversations/{id}/escalate opens an escalation in the inbox, pauses the bot (bot_paused_by = api_escalation), reopens the conversation if it was archived and notifies the team with a summary and its urgency. Both fields are optional:
| Field | What it does |
|---|---|
reason | Why it goes to a person, in words (up to 300 characters). The team reads it in the inbox. |
urgency | high, medium or low. If you send it, it sticks; otherwise it is computed. |
curl https://app.heysteff.com/api/v1/conversations/$CONVERSATION_ID/escalate \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "reason": "Wants a refund and I cannot approve it.", "urgency": "high" }'{
"data": {
"escalation_id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"already_open": false,
"conversation": {
"id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"channel": "whatsapp_cloud",
"external_user_id": "56912345678",
"status": "open",
"bot_paused": true,
"bot_paused_by": "api_escalation",
"escalation": { "id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e", "reason": "api:handoff", "urgency": "high", "taken": false, "created_at": "2026-11-04T14:03:55.010Z" }
}
}
}It is idempotent: with an escalation already open it returns 200 with "already_open": true and does not open another, but the new reason is added to the case. A personal workspace has no team: it returns 422 personal_workspace_no_escalation, and the right move is to pause the bot.
4. While the team has it#
Customer messages keep reaching your webhook, with bot_paused: true: your AI stays quiet. To check the state without waiting for an event:
curl https://app.heysteff.com/api/v1/conversations/$CONVERSATION_ID \
-H "Authorization: Bearer $HEYSTEFF_API_KEY"It returns bot_paused, bot_paused_by, the open escalation (with its urgency and whether someone took it), the assignee and last_inbound_at (on WhatsApp Cloud free-form text only goes out within the following 24 hours).
5. Give it back to your AI#
When the team is done, they tap Reactivate bot or Resolve in the inbox, or your system does it:
curl https://app.heysteff.com/api/v1/conversations/$CONVERSATION_ID/bot \
-H "Authorization: Bearer $HEYSTEFF_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "paused": false }'{"paused": false} resumes the bot and closes the open escalation. If a heysteff flow is still running the conversation, its pause is kept and the response carries "kept_flow_pause": true. With {"paused": true} you pause without escalating (bot_paused_by = api), for example when your system takes over by itself; an existing pause is not overwritten.
curl -X POST https://app.heysteff.com/api/v1/conversations/$CONVERSATION_ID/resolve \
-H "Authorization: Bearer $HEYSTEFF_API_KEY"resolve is the inbox Resolve: it closes the escalations, reactivates the bot and archives the conversation. The customer's next message reopens it.
All together with the SDK#
import express from 'express';
import { Heysteff, verifyWebhook, type MessageWebhookData } from '@heysteff/sdk';
const heysteff = new Heysteff({ apiKey: process.env.HEYSTEFF_API_KEY });
const app = express();
app.post('/webhooks/heysteff', express.raw({ type: 'application/json' }), async (req, res) => {
const event = await verifyWebhook<MessageWebhookData>({
payload: req.body,
headers: req.headers,
secret: process.env.HEYSTEFF_WEBHOOK_SECRET!,
});
res.sendStatus(200); // answer fast, process afterwards
if (event.event !== 'message:received') return;
const msg = event.data;
// A person on the team has the conversation: your AI stays quiet.
if (msg.bot_paused) return;
const answer = await myModel(msg.content ?? ''); // { text, confident }
if (!answer.confident) {
await heysteff.conversations.escalate(msg.conversation_id, {
reason: 'I can't tell whether this order can be refunded.',
});
return;
}
await heysteff.messages.send(
{
channel: msg.channel,
to: msg.external_user_id,
...(msg.channel_connection_id ? { channel_connection_id: msg.channel_connection_id } : {}),
content: answer.text,
},
{ idempotencyKey: `reply-${msg.message_id}` },
);
});The SDK also has conversations.get, conversations.pauseBot, conversations.resumeBot and conversations.resolve. From the terminal: heysteff conversations get|escalate|pause|resume|resolve <id> (CLI).
Who paused the bot#
| bot_paused_by | What happened |
|---|---|
api_escalation | Your integration handed the conversation to a person. |
api | Your integration paused the bot without escalating. |
human_agent | Someone on the team replied from the inbox or escalated it by hand. |
human_deactivated, human_temporary | Someone on the team turned the bot off (the temporary one comes back by itself: bot_paused_until). |
flow:<id> | A heysteff flow is running the conversation. |
kanban_column | The conversation entered a funnel column that pauses the bot. |
Errors#
| Code | HTTP | What happened |
|---|---|---|
invalid_id | 400 | The conversation id is not a UUID. |
validation_error | 400 | The body does not validate (for example, an unknown urgency). |
forbidden | 403 | The key lacks conversations:write (or conversations:read to read). |
not_found | 404 | The conversation is not in this workspace, or not a sandbox one with a test key. |
personal_workspace_no_escalation | 422 | Personal workspace: pause the bot instead of escalating. |