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 legacyworkspace_id/workspace_createdfields alongside the newproject_id/project_createdones. 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.
| Endpoint | Required action |
|---|---|
GET /external/projects[/{id}] | Read project |
POST /external/projects[/{id}]/send | Send |
POST /external/projects/generate-send | Generate & send |
GET /external/requests | Check status |
POST /external/projects[/{id}]/submissions | Submit 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:
| Field | Type | Notes |
|---|---|---|
channels | array, 1–10 | One entry per delivery channel |
channels[].channel | "email" | "whatsapp" | "text" | The integration for that channel must be configured |
channels[].recipients | string or array of strings, 1–100 | Email addresses or phone numbers (E.164 for phones). Max 100 deliveries total per request across all channels |
channels[].template | optional | A 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 |
variables | optional map, max 20 | Values 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.
Generate a project and WhatsApp its link (one call)
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:
| Placeholder | Value |
|---|---|
{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 |
| custom | Anything 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.
Submit data directly (no fill link)
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
| Code | Meaning |
|---|---|
400 | A listed channel is disabled or its credentials aren't configured, or the project has no saved preview |
401 | Missing, invalid, or deactivated API key |
403 | Project is inactive, the key lacks the endpoint's action, or namespace doesn't match the key's project |
404 | Project/request not found — or it isn't this key's project |
422 | Invalid input (bad payload shape, bad phone number, empty prompt/namespace) |
502 | The project could not be generated from the prompt |
503 | Generation (OPENAI_API_KEY) or a system-mode channel's credentials are not configured on the server |