VocaLoop

WhatsApp Reply Loop API

A WhatsApp reply loop collects information one question at a time, in the chat. Your agent asks a question, VocaLoop sends it to the person on WhatsApp, they type the answer as a normal reply, and the answer comes back to your agent. There is no form and no link — it is a conversation. One call can also run a whole plan — several questions sent one after another, each reply triggering the next — from a prompt, from ready-made questions, or from the plan published in the Playground (see Run a plan).

It is a separate flow from the Secure Link Loop API (which sends a generated form behind a link) and the WhatsApp Form Loop API (which sends that form to fill in inside WhatsApp), and never interferes with either. Sending hangs off /api/external/send-wa-loop; the status is read from the Secure Link Loop API's one status endpoint:

EndpointWhat it doesAuth
POST /api/external/send-wa-loopAsk a question — or run a plan (prompt, questions, or the published plan) — on WhatsApp. Returns the request_id (see Run a plan)API key with Send loops
GET /api/external/loop/{id}Where that question stands and, once answered, the reply (the same endpoint that reads a secure-link loop)API key with Read loop status

Both are what your code calls, with a project API key created inside the project the loop belongs to, sent in either header:

-H "X-API-Key: vlk_..."          # or: -H "Authorization: Bearer vlk_..."

Base URL: your app's public URL (locally http://localhost:3000). A key belongs to one project and reaches only that project — a request id from any other project answers 404, indistinguishable from an id that doesn't exist.

Replies reach VocaLoop through Meta's webhook, which must point at your deployment — see Receiving replies below. Until it is configured, questions go out but no answer ever arrives.

Ask a question

curl -X POST http://localhost:3000/api/external/send-wa-loop \
  -H "X-API-Key: vlk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "recipient": "+14155550100",
    "question": "What is the best email address to reach you on?",
    "expiry_minutes": 60,
    "meta": { "field": "email" }
  }'
FieldTypeNotes
recipientstring, requiredThe person's phone with country code (E.164, e.g. +14155550100). Anything else answers 422
questionstringOne question, sent verbatim as the WhatsApp message (up to 1024 characters) under the question template’s instructions. Leave it out to run a plan instead — prompt, questions, or neither for the published plan, see Run a plan
fieldoptional stringWhat the question collects (email, full_name …) — the key to store the answer under; echoed on the status call
step, totaloptional integers, togetherThe question's place in a conversation of several, e.g. 2 of 3. With both set, the first question (step 1) goes out on the question template — the greeting and instructions, then the question — and every later one as plain text, just the question (see message layout); send a later one only after the previous answer arrived. Without them the question goes out on its own, on the template
expiry_minutesoptional integerHow long to wait for the reply. Omitted = the server default (WA_LOOP_DEFAULT_EXPIRY_MINUTES, 5 minutes — the person is on a call). When the window passes without a reply the loop ends: the question becomes expired, a plan run stops at it, a live call is told the caller did not reply (wa_loop.expired webhook event), and a later message is not matched to it
metaoptional objectUp to 20 string tags echoed back on the status call — for instance which field the question fills, so your code knows where to put the answer

The question goes out through the account's WhatsApp channel (the server's system Meta credentials, WHATSAPP_* in the root .env), which must be enabled on the Integrations page — a disabled channel answers 400 before anything is sent. Each question is one invocation on the account's credits (402 when the balance is out).

Response — keep the request_id, it is the reference for the reply:

{
  "request_id": "7c2a…",
  "project_id": "1f00…",
  "project_name": "Lead Capture",
  "recipient": "+14155550100",
  "question": "What is the best email address to reach you on?",
  "status": "pending",
  "error": null,
  "expires_at": "2026-09-15T10:12:00Z",
  "meta": { "field": "email" }
}

status is pending once WhatsApp accepted the message and failed when it was rejected outright (provider detail in error — typically the template is not approved yet). WhatsApp can also refuse a message after accepting it; that failure arrives through the webhook and turns the question failed with the reason in error, so always read the status rather than assuming a pending question was delivered.

Every question is a template message — never free-form text. Free-form WhatsApp messages only deliver inside the person's 24-hour reply window, so VocaLoop sends each question as the Meta-approved template vocaloop_question_v2 (body: “Hello! This is {{1}}. We need one quick detail from you. Please reply to this message with your answer. — {{2}} — Sent via VocaLoop.” — {{1}} your brand name, {{2}} the question). If Meta rejects the template send (not approved yet, paused, renamed) the question comes back failed with Meta's reason; nothing else is tried. Submit the template once per WhatsApp Business Account with scripts/whatsapp-templates.sh push vocaloop_question_v2 (… status for its review state); the name is WHATSAPP_QUESTION_TEMPLATE in the root .env, and a server without it answers 503.

Run a plan

A plan is several questions sent one after another: the first goes out at once, on the question template; every reply sends the next question automatically, and the last reply completes the run. Leave question out of the body and pick one of three ways to say which questions:

WayBodyWhat happens
Prompt"prompt": "the customer's full name, the email for the invoice and their delivery address"The AI plans one short question per item, with a key name for each answer (full_name, email, …), and the run starts with them. The planning is counted on the Usage page (WhatsApp question planning); credits and the project's status are checked before it runs, so nothing is spent for a call that cannot send
Questions"questions": [{ "field": "order_number", "question": "What is your order number?" }, { "field": "issue", "question": "What went wrong with it?" }] — or plain strings: "questions": ["What is your order number?", "What went wrong with it?"]Your questions, sent as given (1–50; field is normalised to snake_case and must be unique — a repeated key answers 422 — the answers come back under those keys). A plain string gets its key from its first words (what_is_your_order), so a voice assistant can hand its questions over as-is
Published planneither prompt nor questionsThe plan you planned and published in the project's Playground (WhatsApp reply loop mode) — never the draft you may still be editing (the Playground's own Send test runs the draft, so you can try changes before publishing them). A project with nothing published answers 400

Send exactly one of prompt, questions or question — two together answer 422. For example, the published plan:

curl -X POST http://localhost:3000/api/external/send-wa-loop \
  -H "X-API-Key: vlk_..." \
  -H "Content-Type: application/json" \
  -d '{ "recipient": "+14155550100", "meta": { "lead": "42" } }'

Each question is one invocation, each answer one capture, exactly as if you had asked them one by one. field, step and total come from the plan (sending them with no question answers 422); expiry_minutes and meta apply to every question of the run.

The response is the first question's reference plus the run:

{
  "request_id": "7c2a…",
  "question": "What is your full name?",
  "field": "full_name",
  "step": 1,
  "total": 3,
  "status": "pending",
  "run": {
    "run_id": "9d4e…",
    "status": "running",
    "plan": "published",
    "brief": null,
    "step": 1,
    "total": 3,
    "error": null,
    "questions": [
      { "step": 1, "field": "full_name", "question": "What is your full name?", "request_id": "7c2a…", "status": "pending", "reply": null, "replied_at": null },
      { "step": 2, "field": "email", "question": "And the best email address to reach you on?", "request_id": null, "status": null, "reply": null, "replied_at": null },
      { "step": 3, "field": "city", "question": "Which city are you in?", "request_id": null, "status": null, "reply": null, "replied_at": null }
    ],
    "answers": {},
    "started_at": "2026-09-17T09:12:00Z",
    "completed_at": null
  }
}

Read any question of the run (GET /api/external/loop/{request_id}) and its run block tells you where the whole run stands: plan says where the questions came from (published, prompt — with the prompt in brief — or questions); status is running while a question waits for its reply, completed once every question is answered (answers then holds field → answer for all of them, completed_at when the last one arrived — the person also gets one closing WhatsApp message, “Thanks, that's everything! {brand} has all your answers. You can continue on the call now.”, sent as a plain text reply inside their 24-hour window and free — it costs no credits and is not counted as a message sent; a lone question gets the same message once answered, and so does the last question of a sequence you number yourself with step/total — or run from the Playground), or stopped — a question was rejected, expired or cancelled, or the next one could not be sent (credits out, channel off, project inactive); error says why, and the answers collected until then stay in answers. The account webhook gets WhatsApp plan completed (wa_plan.completed, every answer in context.answers, plus your meta) and WhatsApp plan stopped (wa_plan.stopped, the reason in context.error) on top of the per-question events.

Asking from a voice agent

The same endpoint accepts a vendor's tool-call envelope — Vapi is recognised from the request shape and answered in its tool-result format, exactly like POST /api/external/loop/send-secure-link. When the question was asked from a live Vapi call, the reply is pushed straight into the call as a system message the moment it arrives (through the call's control URL), so the assistant can continue without polling. A published-plan run started from a call is handed over once, when it completes — one system message listing every field and its answer — so the assistant is not interrupted after each question. Every vendor call is recorded with its call and assistant ids and shows up in the question's status as source.

Registering the calls as agent tools

The project's Getting Started page does this for Vapi: with the loop type set to WhatsApp reply loop (step 4) it creates the tool in your Vapi account — arguments recipient and prompt, both required (the assistant describes everything to collect in one call; the API plans one question per detail, asks them one at a time and the webhook sends each next question as the previous reply arrives), callback POST /api/external/send-wa-loop with the project's key in the x-api-key header — and a sample assistant whose script walks the caller through it. Once the last answer is in, all the answers are pushed into the live call through its control URL. For any other agent, register the calls by hand:

Give your LLM or voice agent both calls as tools and let it decide when to ask. Keep the API key in the tool's server/auth settings as the X-API-Key header, never in the schema or the system prompt.

ToolMaps toDescription to give the model
ask_on_whatsapp (recipient, question, optional expiry_minutes, meta)POST /api/external/send-wa-loopAsk the person one question on WhatsApp and let them type the answer instead of saying it — for details that are hard to say aloud or easy to mishear (email addresses, spellings of names, addresses, ID numbers) — never ask them to dictate details. Use their phone number if it is already known; otherwise ask for their WhatsApp number once and use it as given, without repeating it back. Ask one thing per call. Returns a request_id; the answer arrives in this conversation when they reply, or read it with get_whatsapp_answer. Do not ask the same question again while one is pending.
run_whatsapp_plan (recipient, optional prompt or questions, optional meta)POST /api/external/send-wa-loop with no questionCollect several details on WhatsApp in one go: the person gets the questions one at a time and every answer comes back here when the last one arrives. Say what to collect in prompt, give the exact questions with a key for each, or leave both out for the project's standard set (the plan published in the Playground). Use their phone number if it is already known; otherwise ask for their WhatsApp number once and use it as given, without repeating it back. Call it once per person; the answers can also be read with get_whatsapp_answer (run.answers).
get_whatsapp_answer (request_id)GET /api/external/loop/{request_id}Read the answer to a question sent with ask_on_whatsapp. Returns status (pending, replied with the answer in reply, failed, expired). While pending, wait or ask the person to reply on WhatsApp — do not send a new question. For a plan run, run.status and run.answers cover the whole run.

A system-prompt line such as “When you need the caller's email address, confirm their WhatsApp number, call ask_on_whatsapp with the question, and carry on once the answer arrives” ties the two together. To collect several details, ask them one after another — each question is its own request.

Read a question's status

curl http://localhost:3000/api/external/loop/7c2a… -H "X-API-Key: vlk_..."
{
  "request_id": "7c2a…",
  "status": "replied",
  "project_id": "1f00…",
  "project_name": "Lead Capture",
  "recipient": "+14155550100",
  "question": "What is the best email address to reach you on?",
  "field": "email",
  "meta": { "field": "email" },
  "origin": "api",
  "created_at": "2026-09-15T09:12:00Z",
  "sent_at": "2026-09-15T09:12:01Z",
  "delivery_status": "read",
  "error": null,
  "expires_at": "2026-09-15T10:12:01Z",
  "reply": "ravi@example.com",
  "replied_at": "2026-09-15T09:13:40Z",
  "reply_raw": { "from": "14155550100", "id": "wamid.HBg…", "timestamp": "1789000000", "type": "text", "text": { "body": "ravi@example.com" } },
  "source": null
}
FieldMeaning
statuspending (question sent, no answer yet), replied, failed (WhatsApp rejected the message, or later reported it undeliverable), or expired (the window passed with no answer)
submission_idThe conversation this question belongs to — its row on the project's Responses page, which collects every question and answer of the conversation (a plan run, a numbered sequence, or a question on its own). Also returned by the send call
originapi for questions asked through this API, playground for those sent from the project's Playground (WhatsApp reply loop mode)
delivery_statusMeta's receipt for the question: accepted (the API took it), then sent, delivered, read, or failed
replyPresent once status is replied: the answer as typed — every message sent before the next question, joined with a space. A non-text message is described — an image with a caption reads caption [image], a tapped button its label, a shared location its coordinates — and the full messages are in reply_raw
reply_rawThe inbound messages that make up the answer, oldest first, exactly as WhatsApp delivered them (type, media ids, …)
message_idWhatsApp's id of the question as delivered
message_textThe message as it appeared on WhatsApp: the template's text with your brand, the task number and the question filled in
step, totalThe question's place in a numbered conversation, as sent
expires_atWhen the question stops waiting for its answer
sourceWhen the question was asked by a vendor tool: { "vendor": "vapi", "call_id", "assistant_id", "tool_call_id", "tool_name" }; null for plain API calls
runWhen the question is part of a plan run: where the questions came from (plan, brief), the run's status, progress (step of total), every question with its reply so far and answers (field → answer) — see Run a plan; null for a question asked on its own

Poll this endpoint, or subscribe the account webhook to WhatsApp reply received (wa_loop.replied) — its payload's context carries the request_id, the question, the reply, the recipient and your meta, so nothing needs a second call. WhatsApp question sent, WhatsApp question failed and WhatsApp reply continued (wa_loop.reply_updated — a further message was appended; context.reply is the full answer, context.added the new part) are available too.

Message layout

The first message to a person is the Meta-approved question template (never free-form text), so it reaches people who have not messaged the number recently:

  • The first question — one on its own, or step 1 of a conversation — “Hello! This is {brand}. We need one quick detail from you. Please reply to this message with your answer. — {question} — Sent via VocaLoop.”

  • Every later question of a conversation (step 2 onwards, e.g. the Playground or a plan run) is plain text: just the question, no header or footer. That works because it follows the person's reply to the previous one, inside WhatsApp's 24-hour window — so if you number the questions yourself, send each one only after its predecessor was answered (a plan run does this for you). The greeting lives in the template only, so the questions themselves (yours, or the ones the AI plans from a prompt) should not open with one. The template is used by default once Meta has approved it (whatsapp-message-templates/).

How a reply is matched to its question

Every message the person sends belongs to their latest question: the one most recently sent to that phone number, whichever loop or project sent it. If they use WhatsApp's Reply on a specific question instead — say several questions are open on the same number — that quoted question gets the answer, as long as it is still within its reply window. The first message answers the question; any further message before the next question goes out is appended to the answer, joined with a space (so "Rahul" followed by "Gharat" reads Rahul Gharat), and reply_raw lists every message. As soon as the next question is sent, new messages belong to it and the previous answer is final.

A message from a number with no question, or whose latest question has failed, expired or been cancelled, is ignored, as is a redelivery of a message already stored (Meta occasionally retries). Nothing is ever sent back automatically.

Each stored answer is one capture on the account's credits (always accepted — overage when the balance is short).

Receiving replies: the Meta webhook

Replies and delivery receipts reach VocaLoop through the webhook of the Meta app that owns your WhatsApp number (the same app whose credentials are in WHATSAPP_*). One-time setup, by the server administrator:

  1. In the root .env, set WHATSAPP_WEBHOOK_VERIFY_TOKEN to any long random string and WHATSAPP_APP_SECRET to the app's secret (Meta app → App settings → Basic → App secret). Restart the app.

  2. In the Meta app, open WhatsApp → Configuration → Webhook, click Edit, and enter the callback URL https://<your-app-host>/api/external/whatsapp/webhook with the verify token from step 1. Meta calls the URL once to check the token.

  3. Under Webhook fields, subscribe to messages.

The endpoint verifies every delivery against WHATSAPP_APP_SECRET (X-Hub-Signature-256) and rejects anything unsigned or mis-signed. While the secret is empty — a local stack — deliveries are accepted with a warning in the backend log; never run production that way. Meta needs a public HTTPS URL, so for local testing put a tunnel (ngrok or similar) in front of port 3000 and register the tunnel URL.

The webhook is shared by the whole number: a submitted WhatsApp form is stored as its form loop's answers; replies to fill-link messages sent by the Secure Link Loop API, or messages from people with no open question, are simply ignored.

scripts/whatsapp-register-webhook.sh https://<host> registers the callback on the WhatsApp Business Account from the command line (it checks the handshake first); scripts/whatsapp-register-webhook.sh status shows what is registered.

Meta delivers a number's webhooks to one URL. When a second stack needs the same replies — a developer's tunnel next to production, say — set WHATSAPP_WEBHOOK_FORWARD_URLS on the stack Meta calls to the other stack's full webhook URL (comma-separated for several). Every accepted delivery is then relayed there byte for byte, with Meta's signature header, so the other stack verifies it with the same WHATSAPP_APP_SECRET and handles it exactly like a direct delivery — replies, receipts and submitted WhatsApp forms alike. The relay is fire-and-forget: a stack that is down simply misses that delivery.

Testing on a local stack

Meta cannot reach localhost. Either put a tunnel in front of port 3000 (ngrok, cloudflared) and register the tunnel URL as the callback, or simulate the reply — the script posts exactly what Meta would, to your local webhook:

scripts/whatsapp-fake-reply.sh +14155550100 "Ravi"                        # answers the latest question sent to that phone (a second call appends)
scripts/whatsapp-fake-reply.sh +14155550100 "Ravi" --quote wamid.HBg…     # answers the question with that message id (its `message_id` on the status call)

It only works while WHATSAPP_APP_SECRET is empty (there is no signature to forge), which is the local default.

Errors

CodeMeaning
400The whatsapp channel is disabled on the Integrations page — or a plan call with neither prompt nor questions and no published plan
401Missing, invalid, or deactivated API key
402The account is out of credits (or its free trial has ended)
403Project is inactive, or the key lacks the endpoint's action
404Unknown request id — or one that isn't this key's project
422Invalid input (a blank question or prompt, more than one of prompt/questions/question, an empty questions list, field/step/total on a plan call, a recipient that isn't a phone number, bad expiry_minutes, too many meta keys, malformed request id)
502The AI could not turn a prompt into questions — try again
503The system WhatsApp credentials, the question template (WHATSAPP_QUESTION_TEMPLATE) or, for a prompt, the AI planner are not configured on the server