Submit a lead
POST /api/public/leads — full reference with examples.
POST /api/public/leads
Authorization: Bearer ck_live_...
Content-Type: application/json
Submits a lead into the workspace owning the API key. For voice workspaces, the Vapi call is dispatched synchronously — the response carries the Vapi call ID or the exact error string. For WhatsApp workspaces, the agent turn is enqueued via Inngest and the response returns immediately.
Request body
{
// Required
name: string, // 1-120 chars
phone: string, // E.164-style, 7-32 chars
consent: true, // Must be literal true
// Optional
email?: string, // RFC 5322 email
goal?: string, // Up to 2000 chars
metadata?: Record<string, unknown>, // Free-form bag (see below)
formResponses?: Record<string, string> // Voice-only, see below
}
metadata — the flexible field bag
This is the escape hatch for any field your form has that Coachbot doesn't model first-class. Examples:
"metadata": {
"companyName": "Acme Corp",
"industry": "B2B SaaS",
"source": "homepage form",
"utm_source": "google",
"utm_campaign": "spring-launch"
}
Every key/value pair is rendered into the agent's prompt as {{leadMetadata}} (voice) or a "What else we know about this lead" section (WhatsApp). The assistant can reference them in conversation — e.g. "I see you're from Acme Corp — what's the use case there?"
Values that aren't strings get JSON-stringified. Nullish or empty-string values are dropped silently.
formResponses — voice Form Builder answers
Voice workspaces have a Form Builder at /admin/<slug>/form for multi-step qualification questions. If your external form already collected answers to those questions, pass them as a { questionId: answerString } map:
"formResponses": {
"q_abc123": "yes",
"q_def456": "intermediate"
}
Get the question IDs from the workspace's /admin/<slug>/form page or from a previous browser-based form submission's network tab.
This is always optional from the API — external forms can't be expected to mirror Coachbot's Form Builder exactly. The agent can still collect the answers live on the call.
Responses
201 Created — voice workspace, Vapi accepted
{
"ok": true,
"leadId": "f54466f4-afc3-4e27-84f9-a3be6fcac978",
"status": "in_progress",
"vapiCallId": "019ea22e-80ca-7001-95fb-4932d5c048f2",
"callQueued": true
}
The lead's phone should ring within ~10 seconds.
201 Created — WhatsApp workspace, agent turn enqueued
{
"ok": true,
"leadId": "f54466f4-afc3-4e27-84f9-a3be6fcac978",
"status": "new",
"messageQueued": true
}
The Inngest function will fire the OpenAI call + the Twilio send within a few seconds.
400 Bad Request — validation failure
{
"ok": false,
"error": "Validation failed.",
"issues": [
{ "path": ["phone"], "message": "Phone must be a valid E.164-style number." }
]
}
409 Conflict — duplicate phone
{
"ok": false,
"error": "duplicate_phone",
"message": "A lead with this phone number already exists in the workspace."
}
The same phone can be a lead in different workspaces — the unique constraint is (workspace_id, phone). Within one workspace, retries on the same phone return 409 until you delete the existing lead.
502 Bad Gateway — Vapi rejected the call (voice only)
{
"ok": false,
"error": "voice_dispatch_failed",
"message": "Vapi /call failed (400): {\"statusCode\":400,\"message\":\"Couldn't get tool for hook. ...\"}"
}
The just-inserted lead + call_session rows are rolled back so you can retry with the same phone (no duplicate_phone collision). The error message is the verbatim Vapi response — fix the underlying issue (typo in Vapi assistant config, etc.) and retry.
End-to-end example
curl -X POST https://<your-host>/api/public/leads \
-H "Authorization: Bearer ck_live_a1b2c3d4..." \
-H "Content-Type: application/json" \
-d '{
"name": "Jane Doe",
"phone": "+447123456789",
"email": "jane@acme.com",
"goal": "Looking to migrate from a competitor",
"consent": true,
"metadata": {
"companyName": "Acme Corp",
"industry": "B2B SaaS",
"currentVendor": "Competitor X",
"source": "homepage form"
}
}'
Response:
{
"ok": true,
"leadId": "f54466f4-afc3-4e27-84f9-a3be6fcac978",
"status": "in_progress",
"vapiCallId": "019ea22e-80ca-7001-95fb-4932d5c048f2",
"callQueued": true
}
The agent now has {{leadMetadata}} populated with:
- companyName: Acme Corp
- industry: B2B SaaS
- currentVendor: Competitor X
- source: homepage form
And can naturally reference Acme Corp by name on the call.