VocaLoop

External API Reference

The External API lets any system send projects and collect responses with plain HTTP. All endpoints live under /api/v1/external and authenticate with a project API key created inside the target project:

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

Base URL: your app's public URL (locally http://localhost:3000). The interactive Swagger reference at /docs covers these endpoints too.

Renamed from “workspaces”: integrations built before the projects rename keep working — every old /api/v1/external/workspaces/... path is a permanent alias of its /api/v1/external/projects/... equivalent, and responses carry the legacy workspace_id / workspace_created fields alongside the new project_id / project_created ones. Message templates may keep using the {workspace} (or older {form}) placeholder; both resolve like {project}.

Scoping rule: a key belongs to one project and sees only that project — any other project id returns 404, indistinguishable from a project that doesn't exist, whether it belongs to the same account or another one. On top of that, every endpoint requires its action to be granted on the key (403 otherwise); new keys start with Generate & send and Send.

EndpointRequired action
GET /external/projects[/{id}]Read project
POST /external/projects[/{id}]/sendSend
POST /external/projects/generate-sendGenerate & send
GET /external/requestsCheck status
POST /external/projects[/{id}]/submissionsSubmit data

List accessible projects

curl http://localhost:3000/api/v1/external/projects -H "X-API-Key: vlk_..."

Returns the key's project (a one-element list, kept for compatibility with the pre-rename multi-project shape), including its id, name, description, and status. Only active projects can be sent.

Get one project

curl http://localhost:3000/api/v1/external/projects/<project_id> -H "X-API-Key: vlk_..."

Send a project (create a fill request)

The project id is optional everywhere — the key already pins the project, so POST /api/v1/external/projects/send (and /submissions, and generate-send without a namespace) targets the key's project. When an id is passed it must be that project's id.

curl -X POST http://localhost:3000/api/v1/external/projects/<project_id>/send \
  -H "X-API-Key: vlk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "channels": [
      {
        "channel": "email",
        "recipients": ["jane@example.com"],
        "template": {
          "subject": "Quick project from {company}",
          "body": "Hi {first_name}, please fill out {project}: {link}"
        }
      },
      { "channel": "text", "recipients": "+14155550100" }
    ],
    "variables": { "first_name": "Jane" }
  }'

Request body:

FieldTypeNotes
channelsarray, 1–10One entry per delivery channel
channels[].channel"email" | "whatsapp" | "text"The integration for that channel must be configured
channels[].recipientsstring or array of strings, 1–100Email addresses or phone numbers (E.164 for phones). Max 100 deliveries total per request across all channels
channels[].templateoptionalA plain string (used as the body) or {subject, body}. Falls back to the project's message template (custom, or pinned system default), then the account default from Settings → Templates, then a stock message
variablesoptional map, max 20Values for custom {placeholders} in templates. Built-ins {project}, {link}, {name}, {company} always win

One request row is created no matter how many channels/recipients you list — everyone shares the same single-use fill link; the first submission completes the request for all of them. Send one request per person when each should answer separately.

Response:

{
  "request_id": "9be4…",
  "status": "pending",
  "results": [
    { "channel": "email", "recipient": "jane@example.com",
      "fill_link": "http://localhost:3000/fill/9be4…/email",
      "status": "sent", "error": null }
  ]
}

status is pending as soon as one delivery went out, and failed only if every delivery failed. Per-recipient failures (bad number, provider error) appear in results[] with status: "failed" and the provider's error.

Preconditions — the request is rejected if the project is inactive, has no saved preview, or a listed channel's integration isn't configured.

One call regenerates the key's project UI from a prompt and sends the fill link to a phone number on WhatsApp; every response lands under that project. namespace is optional and kept for compatibility — when sent it must be the key's project name (403 otherwise, the same answer whether the name belongs to another project or to nobody). Keys cannot create projects; make the project in the app first and mint the key inside it.

curl -X POST http://localhost:3000/api/v1/external/projects/generate-send \
  -H "X-API-Key: vlk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "collect the lead name, email and company size",
    "phone": "+14155550100",
    "namespace": "Lead Capture",
    "template": "Hi {customer}, {company} needs a few details for {project}. Fill it here: {link}",
    "variables": { "customer": "Ravi" },
    "expiry_minutes": 30
  }'

phone must include the country code (E.164, e.g. +14155550100). The message goes out through the account's WhatsApp channel, which is enabled by default on the server's system Meta credentials (WHATSAPP_* in the root .env). The channel must stay enabled on the Integrations page for sends to go out.

expiry_minutes is optional: when set, the fill link stops working that many minutes after the message goes out, and that becomes the project's saved link window. When omitted, the project's saved window applies unchanged — and since projects default to no expiry, links never expire unless you set a window here or in the project's Settings.

template and variables are optional. The message text is chosen in this order: the request's template → the project's WhatsApp template (Templates tab — a custom message, or a pinned system default which skips straight to the stock message) → the account default WhatsApp template (Settings → Templates) → the stock message “Hello! {company} has requested a few details from you. Please complete the "{project}" project using this secure link: {link}”. Placeholders in {curly} braces are substituted at send time:

PlaceholderValue
{link}The fill link — appended to the end of the message automatically if your template omits it
{project}The project (namespace) name
{company}The account's brand name
{name}The account owner's name
customAnything else, e.g. {customer}, resolved from variables (max 20, values ≤ 500 chars)
{
  "project_id": "1f00…",
  "namespace": "Lead Capture",
  "project_created": false,
  "request_id": "9be4…",
  "fill_link": "http://localhost:3000/fill/9be4…/whatsapp",
  "status": "pending",
  "sms": { "channel": "whatsapp", "recipient": "+14155550100", "fill_link": "…", "status": "sent" }
}

The fill_link always comes back — if the delivery failed (status: "failed", provider detail in sms.error — the field keeps its pre-2026-09 name for compatibility), you can still hand the link over another way. Poll the request_id below to see when it's filled.

Check request status

curl "http://localhost:3000/api/v1/external/requests?ids=<id1>,<id2>" \
  -H "X-API-Key: vlk_..."

Returns one result per id:

{
  "results": [
    {
      "request_id": "9be4…",
      "status": "submitted",
      "project_id": "1f00…",
      "recipient": "jane@example.com",
      "channels": ["email"],
      "data": { "full_name": "Jane Doe", "position": "Engineer" },
      "created_at": "2026-07-16T09:12:00Z",
      "submitted_at": "2026-07-16T09:14:31Z"
    }
  ]
}

status is one of pending, submitted, failed, expired, or not_found (unknown id or an id belonging to a project other than the key's). data is only present once the status is submitted. For push instead of polling, configure a webhook.

When your system already has the answers — imports, migrations, agents filling projects on someone's behalf — write a submission straight in:

curl -X POST http://localhost:3000/api/v1/external/projects/<project_id>/submissions \
  -H "X-API-Key: vlk_..." \
  -H "Content-Type: application/json" \
  -d '{ "channel": "email", "data": { "full_name": "Jane Doe" } }'

data keys should match the project's field names (max 100 keys / 20 KB); an optional client_meta object is stored with the submission's analytics. The submission appears on the project's Responses page and triggers the project's webhook like any other.

Errors

CodeMeaning
400A listed channel is disabled or its credentials aren't configured, or the project has no saved preview
401Missing, invalid, or deactivated API key
403Project is inactive, the key lacks the endpoint's action, or namespace doesn't match the key's project
404Project/request not found — or it isn't this key's project
422Invalid input (bad payload shape, bad phone number, empty prompt/namespace)
502The project could not be generated from the prompt
503Generation (OPENAI_API_KEY) or a system-mode channel's credentials are not configured on the server