VocaLoop

WhatsApp Form Loop API

A WhatsApp form loop sends your project's form to a person as a native WhatsApp form: they get one WhatsApp message with a button, the button opens the form right inside WhatsApp (no browser, no link), and when they tap Submit every answer comes back to your agent at once.

It sits between the other two loops. The Secure Link Loop API sends the same form behind a web link; the WhatsApp Reply Loop API asks one question at a time in the chat. Sending hangs off /api/external/send-wa-form; the status is read from the one status endpoint every loop shares:

EndpointWhat it doesAuth
POST /api/external/send-wa-formSend the project's form as a WhatsApp form — regenerating it from a prompt first when you send one. Returns the request_idAPI key with Send loops
GET /api/external/loop/{id}Where that form stands and, once submitted, the answers (the same endpoint that reads the other two kinds of 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.

Submitted forms reach VocaLoop through Meta's webhook — the same one the reply loop uses, set up once per deployment (see Receiving replies). Until it is configured, forms go out but no answer ever arrives.

Send a form

curl -X POST http://localhost:3000/api/external/send-wa-form \
  -H "X-API-Key: vlk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "recipient": "+14155550100",
    "prompt": "the patient'\''s full name, email, preferred appointment date and insurance plan (Basic, Plus or Enterprise)",
    "expiry_minutes": 30,
    "meta": { "lead": "42" }
  }'
FieldTypeNotes
recipientstring, requiredThe person's phone with country code (E.164, e.g. +14155550100). Anything else answers 422
promptoptional stringWhat the form should collect — the project's form is regenerated from it and its fields become the WhatsApp form (the same generation as the secure-link loop; its tokens show on the Usage page under Project generation). Leave it out to send the project's current form — the one built in the Playground or by an earlier prompt. A project with no form yet answers 400
expiry_minutesoptional integerHow long to wait for the submission. Omitted = the server default (WA_LOOP_DEFAULT_EXPIRY_MINUTES, 5 minutes — the person is on a call). When the window passes the loop ends: the form becomes expired, a live call is told the caller did not fill it in (wa_form.expired webhook event), and a submission arriving later is ignored
metaoptional objectUp to 20 string tags echoed back on the status call

The message goes out through the account's WhatsApp channel (the server's system Meta credentials), which must be enabled on the Integrations page — a disabled channel answers 400 before anything is generated. Each form sent is one invocation on the account's credits (402 when the balance is out); each form submitted is one capture.

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

{
  "request_id": "7c2a…",
  "submission_id": "7c2a…",
  "project_id": "1f00…",
  "project_name": "Clinic Intake",
  "recipient": "+14155550100",
  "title": "Clinic intake",
  "fields": [
    { "field": "full_name", "label": "Full name", "type": "text", "required": true, "options": null, "control": "text_input" },
    { "field": "email", "label": "Email address", "type": "email", "required": true, "options": null, "control": "email_input" },
    { "field": "visit_date", "label": "Preferred date", "type": "date", "required": false, "options": null, "control": "date_picker" },
    { "field": "plan", "label": "Insurance plan", "type": "select", "required": true, "options": ["Basic", "Plus", "Enterprise"], "control": "radio" }
  ],
  "skipped": [],
  "status": "pending",
  "error": null,
  "expires_at": "2026-09-26T10:42:00Z",
  "meta": { "lead": "42" }
}

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 loop failed, so read the status rather than assuming a pending form was delivered.

fields lists the form's fields as laid out on WhatsApp, in order, with the key each answer comes back under (field) and the WhatsApp control it rides on (control, see What the form can carry). skipped names any field the WhatsApp form could not carry; an empty list means the whole form went out.

The request_id is also the id of the loop's row on the project's Responses page (submission_id is the same value), where the submitted answers appear like any other response.

What the form can carry

WhatsApp forms are Meta Flows: a fixed set of controls whose labels and choices VocaLoop fills in per send. The project form's field types map to them like this:

Project fieldWhatsApp control (control)Notes
texttext_inputUp to 8 per form
emailemail_inputUp to 2, then plain text inputs
phonephone_inputUp to 2, then plain text inputs
numbernumber_inputUp to 2, then plain text inputs; the answer comes back as a number
datedate_pickerUp to 2; the answer comes back as an ISO date (2026-09-10)
textareatext_areaUp to 2, then plain text inputs
select, radioradioUp to 3 choice groups of at most 20 options each; a fourth becomes a text input with the options listed under it. The answer is the option's text
checkboxopt_inUp to 3; the answer is true / false
file upload—Not carried — listed in skipped

Labels are cut to WhatsApp's limits (20 characters on inputs, 30 on choice groups, 40 on dates, 120 on checkboxes) and, when cut, the full label is shown as helper text under the control. A form with more fields than there are controls sends the ones that fit, in order, and reports the rest in skipped; a form with nothing the WhatsApp form can carry answers 400.

Every form message is a template message with a Flow button — never free-form text. Free-form WhatsApp messages only deliver inside the person's 24-hour reply window, so VocaLoop sends the Meta-approved template vocaloop_form_v1 (body: “Hello! This is {{1}}. We need a few details from you. Please tap the button below to open the short form "{{2}}" and fill it in here on WhatsApp. — Sent via VocaLoop.” — {{1}} your brand name, {{2}} the form's title — with an Open form button). The button opens the VocaLoop form Flow (vocaloop_form), created once per WhatsApp Business Account with scripts/whatsapp-flows.sh create vocaloop_form and published with … publish vocaloop_form; then submit the template with scripts/whatsapp-templates.sh push vocaloop_form_v1 (… status for its review state). If Meta rejects the template send (not approved yet, paused, renamed) the loop comes back failed with Meta's reason; nothing else is tried. The template name is WHATSAPP_FORM_TEMPLATE (defaults to vocaloop_form_v1); a server with it empty answers 503.

Sending 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 form was sent from a live Vapi call, the submitted answers are pushed straight into the call as a system message the moment they arrive (through the call's control URL), so the assistant can continue without polling; if the window passes with no submission, the assistant is told that instead. Every vendor call is recorded with its call and assistant ids and shows up in the loop'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 form loop (step 4) it creates the tool in your Vapi account — arguments recipient (required) and prompt (optional; omitted sends the project's current form), callback POST /api/external/send-wa-form with the project's key in the x-api-key header — and a sample assistant whose script walks the caller through it. The submitted answers are pushed into the live call through its control URL. The Playground has a matching WhatsApp form loop mode: build and publish the form, then Send test delivers it to your own phone as a WhatsApp form and shows the answers when you submit.

To register the calls as tools by hand, give your agent both calls and 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
send_whatsapp_form (recipient, optional prompt, expiry_minutes, meta)POST /api/external/send-wa-formSend the person a short form to fill in on WhatsApp instead of asking each detail aloud — for several details at once (names, email addresses, dates, choices). Describe what to collect in prompt, or leave it out for the project's standard form. Use their phone number if it is already known; otherwise ask for their WhatsApp number once and use it as given. Returns a request_id; the answers arrive in this conversation when they submit, or read them with get_whatsapp_form_answers. Do not send a second form while one is pending.
get_whatsapp_form_answers (request_id)GET /api/external/loop/{request_id}Read a form sent with send_whatsapp_form. Returns status (pending, submitted with the answers in data, failed, expired). While pending, wait or ask the person to open the form on WhatsApp.

Read a form's status

curl http://localhost:3000/api/external/loop/7c2a… -H "X-API-Key: vlk_..."
{
  "request_id": "7c2a…",
  "kind": "wa_form",
  "status": "submitted",
  "submission_id": "7c2a…",
  "project_id": "1f00…",
  "project_name": "Clinic Intake",
  "recipient": "+14155550100",
  "title": "Clinic intake",
  "message_text": "Hello! This is Acme Clinic. We need a few details from you. Please tap the button below to open the short form \"Clinic intake\" and fill it in here on WhatsApp.\n\nSent via VocaLoop.",
  "fields": [ { "field": "full_name", "label": "Full name", "type": "text", "required": true, "options": null, "control": "text_input" }, "…" ],
  "skipped": [],
  "meta": { "lead": "42" },
  "origin": "api",
  "created_at": "2026-09-26T10:12:00Z",
  "sent_at": "2026-09-26T10:12:01Z",
  "message_id": "wamid.HBg…",
  "delivery_status": "read",
  "error": null,
  "expires_at": "2026-09-26T10:42:01Z",
  "data": { "full_name": "Ravi Kumar", "email": "ravi@example.com", "visit_date": "2026-10-03", "plan": "Plus" },
  "submitted_at": "2026-09-26T10:14:40Z",
  "response_raw": { "from": "14155550100", "id": "wamid.HBg…", "type": "interactive", "interactive": { "type": "nfm_reply", "nfm_reply": { "name": "flow", "body": "Sent", "response_json": "{…}" } } },
  "ai_usage": null,
  "source": null
}
FieldMeaning
kindAlways wa_form — tells this loop apart from a secure-link loop or a WhatsApp question on the shared endpoint
statuspending (form sent, not submitted yet), submitted, failed (WhatsApp rejected the message, or later reported it undeliverable), or expired (the window passed without a submission)
submission_idThe loop's row on the project's Responses page — the same value as request_id
delivery_statusMeta's receipt for the message: accepted (the API took it), then sent, delivered, read, or failed
dataPresent once status is submitted: field → answer for every field of the form, typed as described above; a field left empty is null
response_rawThe submitted Flow exactly as WhatsApp delivered it
message_textThe message as it appeared on WhatsApp: the template's text with your brand and the form's title filled in (the button is not text)
ai_usageThe generation the prompt cost, when one was sent; null otherwise
sourceWhen the form was sent by a vendor tool: { "vendor": "vapi", "call_id", "assistant_id", "tool_call_id", "tool_name" }; null for plain API calls

Poll this endpoint, or subscribe the account webhook to WhatsApp form submitted (wa_form.submitted) — its payload's context carries the request_id, the recipient, the title, every answer in data and your meta, so nothing needs a second call. WhatsApp form sent, WhatsApp form failed and WhatsApp form expired are available too.

How a submission is matched to its form

The button VocaLoop sends carries the loop's request_id as the Flow's token, and WhatsApp returns that token with the submitted answers. A submission is stored when its token names a loop that is still pending, inside its window, and comes from the loop's recipient; anything else — an unknown token, another phone, a form submitted after the window, a redelivery of a submission already stored (Meta occasionally retries) — is ignored. Nothing is ever sent back automatically.

Testing on a local stack

Meta cannot reach localhost. Either put a tunnel in front of port 3000 and register the tunnel URL as the webhook callback, or simulate the submission — the script posts exactly what Meta would, to your local webhook, taking the form's field names as the answers:

scripts/whatsapp-fake-form-submit.sh +14155550100 7c2a… '{"full_name": "Ravi Kumar", "email": "ravi@example.com", "plan": "Plus"}'

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, the project has no form yet (and no prompt was sent), or none of the form's fields can be carried on WhatsApp
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 recipient that isn't a phone number, a blank prompt, bad expiry_minutes, too many meta keys, malformed request id)
502The AI could not turn a prompt into a form — try again
503The system WhatsApp credentials, the form template (WHATSAPP_FORM_TEMPLATE) or, for a prompt, the AI generator are not configured on the server