VocaLoop

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:

EndpointWhat it doesAuth
POST /api/external/loop/send-secure-linkSend a loop — generate the form from a prompt and send its link on the chosen medium. Returns the request_idAPI 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_idAPI 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-send webhook 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
  }'
FieldTypeNotes
mediumwhatsapp, email or text, requiredThe 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
recipientstring, requiredWhere 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
promptoptional stringWhat 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_minutesoptional integerThe 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:

mediumRuns onEnabled
whatsappThe server's system Meta credentials (WHATSAPP_* in the root .env)By default
emailThe server's system email (SES, SMTP fallback)Turn it on under Integrations
textThe account's own Twilio credentialsSaving 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.

PlaceholderValue
{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:

ToolMaps toDescription to give the model
send_loop (medium, recipient, optional prompt, expiry_minutes)POST /api/external/loop/send-secure-linkSend 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
}
FieldMeaning
statuspending (link sent, no response yet), submitted, failed (the message never went out), or expired (the link's window passed)
medium, recipientWhere the message went — the values the send call echoed
sendsEvery 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_atWhen the link dies — null when the project has no expiry window
data, analyticsPresent 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_usageTokens spent generating this loop's form, per stage
sourceWhen 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

CodeMeaning
400The 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
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 (bad payload shape, unknown medium, a recipient that doesn't fit the medium, missing recipient, empty prompt, malformed request id)
502The form could not be generated from the prompt
503Generation (OPENAI_API_KEY) or the system WhatsApp / email credentials are not configured on the server