developers
ES Sign up

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#

1. Receive the message#

Every customer message reaches your webhook. Its data carries everything you need to answer without another call:

JSON
{
  "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"
  }
}
FieldWhat for
channel, external_user_idPass them as-is as channel and to of POST /v1/messages.
channel_connection_idThe number or account it came in through: pass it to answer from the same one.
bot_paused, bot_paused_byWhen true, a person (or a flow) has the conversation: your AI stays quiet.
sandboxIt 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
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:

FieldWhat it does
reasonWhy it goes to a person, in words (up to 300 characters). The team reads it in the inbox.
urgencyhigh, medium or low. If you send it, it sticks; otherwise it is computed.
curl
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" }'
JSON
{
  "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
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
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
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#

TypeScript
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_byWhat happened
api_escalationYour integration handed the conversation to a person.
apiYour integration paused the bot without escalating.
human_agentSomeone on the team replied from the inbox or escalated it by hand.
human_deactivated, human_temporarySomeone 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_columnThe conversation entered a funnel column that pauses the bot.

Errors#

CodeHTTPWhat happened
invalid_id400The conversation id is not a UUID.
validation_error400The body does not validate (for example, an unknown urgency).
forbidden403The key lacks conversations:write (or conversations:read to read).
not_found404The conversation is not in this workspace, or not a sandbox one with a test key.
personal_workspace_no_escalation422Personal workspace: pause the bot instead of escalating.