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.