Secure Link Loop API
A loop is how VocaLoop collects critical information from a professional: you describe what you need, they get a message — on WhatsApp, by email or by text, your choice — with a secure form link, and their answers come back to you. Everything hangs off one resource, /api/external/loop:
| Endpoint | What it does | Auth |
|---|---|---|
POST /api/external/loop/send-secure-link | Send a loop — generate the form from a prompt and send its link on the chosen medium. Returns the request_id | API key with Send loops |
GET /api/external/loop/{id} | Where that loop stands and, once submitted, the response with its analytics. The same endpoint reads a WhatsApp reply loop question or a WhatsApp form loop by its request_id | 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.
Need the answers one question at a time, typed in the chat instead of a form? That is the separate WhatsApp Reply Loop API (
/api/external/send-wa-loop) — same keys, same credits, no form. Want the same form filled in inside WhatsApp, with no link to open? That is the WhatsApp Form Loop API (/api/external/send-wa-form).
The Secure Link Loop API replaced the earlier
/api/v1/external/*endpoints and the/api/integrations/vapi/generate-sendwebhook on 2026-09-09. Those paths no longer exist; point integrations at the calls below.
Send a loop
curl -X POST http://localhost:3000/api/external/loop/send-secure-link \
-H "X-API-Key: vlk_..." \
-H "Content-Type: application/json" \
-d '{
"medium": "whatsapp",
"recipient": "+14155550100",
"prompt": "collect the lead name, email and company size",
"expiry_minutes": 30
}'| Field | Type | Notes |
|---|---|---|
medium | whatsapp, email or text, required | The channel the message goes out on. It must be enabled on the Integrations page — a disabled or unconfigured medium answers 400 before anything is generated, any other value 422 |
recipient | string, required | Where the message goes: the professional's phone with country code (E.164, e.g. +14155550100) for whatsapp and text, their email address for email. A recipient that doesn't fit the medium answers 422 |
prompt | optional string | What the form should collect, in natural language — the fields you describe become the form (the key's project form is regenerated from it). Omitted = the project's current form, as built in the Playground or by an earlier prompt, is sent as-is; a project with no form yet answers 400 |
expiry_minutes | optional integer | The fill link stops working that many minutes after the message goes out and becomes the project's saved window. Omitted keeps the project's window (links never expire until one is set) |
The message goes out through the account's channel for the chosen medium, as set up on the Integrations page:
medium | Runs on | Enabled |
|---|---|---|
whatsapp | The server's system Meta credentials (WHATSAPP_* in the root .env) | By default |
email | The server's system email (SES, SMTP fallback) | Turn it on under Integrations |
text | The account's own Twilio credentials | Saving the credentials under Integrations turns it on |
Generation uses the server's OpenAI key.
Message text is chosen in this order: the project's template for that medium (Templates tab — a custom message, or a pinned system default which skips straight to the stock message) → the account default template for that medium (Settings → Templates) → the stock message “Hello! {company} has requested a few details from you. Please complete the "{project}" project using this secure link: {link}”. The stock WhatsApp message goes out as a Meta-approved template so it delivers cold; email and text send the rendered text as-is.
| Placeholder | Value |
|---|---|
{link} | The fill link |
{project} | The project name |
{company} | The account's brand name |
{name} | The account owner's name |
Response — keep the request_id, it is the reference for everything that follows:
{
"request_id": "9be4…",
"project_id": "1f00…",
"project_name": "Lead Capture",
"fill_link": "http://localhost:3000/fill/9be4…",
"medium": "whatsapp",
"recipient": "+14155550100",
"status": "pending",
"error": null
}status is pending once the message went out and failed when the delivery failed (provider detail in error). The fill_link comes back either way, so you can still hand it over another way.
Sending from a voice agent
The same endpoint accepts a vendor's tool-call envelope — the vendor is recognised from the request shape and answered in its tool-result format. Vapi is supported today; the Getting Started wizard creates the tool for you. 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
If your own LLM or voice agent decides when to collect details, register both calls as tools it can invoke (OpenAI / Anthropic tool use, LangChain, Vapi or Retell custom tools). Give the tools full descriptions — the model chooses the right one from them — 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. The Getting Started wizard's custom path shows ready-to-paste definitions; in short:
| Tool | Maps to | Description to give the model |
|---|---|---|
send_loop (medium, recipient, optional prompt, expiry_minutes) | POST /api/external/loop/send-secure-link | Send a secure form to a person on WhatsApp so they can share their details instead of dictating them. Use it whenever structured information is needed — when they agree to fill out a form, when more than one or two fields are needed, or when the details are sensitive or hard to say aloud. Confirm the WhatsApp number first and put in prompt exactly what to collect. Returns a request_id (keep it for get_loop_status), the fill link and the delivery status. Do not send another form for the same request unless the delivery failed. |
get_loop_status (request_id) | GET /api/external/loop/{request_id} | Check whether a form sent with send_loop has been filled and read the answers. Call it only with a request_id returned by send_loop — when the person says they have submitted, when the answers are needed to continue, or every few seconds while waiting. Returns status (pending, submitted with answers in data, failed, expired). While pending, wait or ask the person to complete the form — do not send a new one. |
A system-prompt line such as “When you need the caller's details, confirm their WhatsApp number and call send_loop; wait for them to submit, then call get_loop_status and read the answers back” ties the two together.
Read a loop's status
curl http://localhost:3000/api/external/loop/9be4… -H "X-API-Key: vlk_..."{
"request_id": "9be4…",
"status": "submitted",
"project_id": "1f00…",
"project_name": "Lead Capture",
"medium": "whatsapp",
"recipient": "+14155550100",
"fill_link": "http://localhost:3000/fill/9be4…",
"created_at": "2026-09-09T09:12:00Z",
"submitted_at": "2026-09-09T09:14:31Z",
"expires_at": null,
"sends": [
{ "subject": null,
"body": "Hi Ravi, Acme needs a few details for Lead Capture. Fill it here: http://…",
"sent_at": "2026-09-09T09:12:01Z" }
],
"data": { "full_name": "Ravi Kumar", "email": "ravi@example.com", "company_size": "50-200" },
"analytics": {
"ip": "203.0.113.7",
"user_agent": "Mozilla/5.0 (iPhone; …)",
"accept_language": "en-IN,en;q=0.9",
"referer": null,
"client": { "timezone": "Asia/Kolkata", "locale": "en-IN", "screen": "390x844", "time_to_submit_ms": 84210 }
},
"ai_usage": [
{ "stage": "generate_project", "model": "gpt-4.1", "calls": 1,
"input_tokens": 1200, "output_tokens": 300, "total_tokens": 1500 }
],
"source": null
}| Field | Meaning |
|---|---|
status | pending (link sent, no response yet), submitted, failed (the message never went out), or expired (the link's window passed) |
medium, recipient | Where the message went — the values the send call echoed |
sends | Every delivery of the link (the first send plus any resends, oldest first) with the message as it went out — always on medium to recipient |
expires_at | When the link dies — null when the project has no expiry window |
data, analytics | Present only once status is submitted: the professional's answers keyed by field name, and the capture context (IP, user agent, language, plus the browser's timezone, locale, screen and time-to-submit) |
ai_usage | Tokens spent generating this loop's form, per stage |
source | When the loop 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 configure the account webhook to be pushed every submission as it happens, with the data inside the payload.
Errors
| Code | Meaning |
|---|---|
400 | The chosen medium is disabled on the Integrations page, or (text) its own credentials aren't saved; or no prompt was sent and the project has no form yet |
401 | Missing, invalid, or deactivated API key |
402 | The account is out of credits (or its free trial has ended) |
403 | Project is inactive, or the key lacks the endpoint's action |
404 | Unknown request id — or one that isn't this key's project |
422 | Invalid input (bad payload shape, unknown medium, a recipient that doesn't fit the medium, missing recipient, empty prompt, malformed request id) |
502 | The form could not be generated from the prompt |
503 | Generation (OPENAI_API_KEY) or the system WhatsApp / email credentials are not configured on the server |