LIO CRM — Public API Reference
This document describes only the public endpoints — the routes that are reachable without a logged-in LIO session cookie / standard app JWT, i.e. everything an external integration, partner service, provider webhook, or customer-facing link can call directly.
All of these are registered on the public router (mounted at /api/v1) plus a handful of root-level infrastructure endpoints. Internal, authenticated app APIs (the /api/... routes behind authenticate) are intentionally not covered here.
This is reference documentation only. It does not change any behavior.
1. Conventions#
Base URL#
https://api-lio.worksbuddy.ai/api/v1Root-level endpoints (health, OIDC, a few webhooks) sit at the host root, not under /api/v1; each is called out below.
Content type#
Request and response bodies are JSON (Content-Type: application/json), except:
- Provider webhooks that sign the raw bytes (Slack, Teams, Zoom, Meta, WhatsApp, Evox, Calendly, Didit, WorksBuddy Voice, Ciao, SSO) — sent as raw/form bodies.
- Meta data-deletion callback —
application/x-www-form-urlencoded. - Email-tracking pixels — respond with an image / HTTP redirect, not JSON.
Standard response envelope#
Most endpoints return:
{ "success": true, "message": "...", "data": { }, "total": 0, "page": 1, "limit": 10, "totalPages": 0 }Errors:
{ "success": false, "message": "..." }or, on some endpoints:
{ "success": false, "error": { "message": "...", "type": "...", "code": null } }A few of the oldest lead endpoints return { message, data } without a success field — noted where that applies.
Common HTTP status codes#
| Code | Meaning |
|---|---|
200 | OK |
201 | Created |
400 | Bad request (missing required query/body) |
401 | Missing / invalid credential |
403 | Valid credential but not allowed (bad key, IP block, wrong org) |
404 | Resource not found (or org/user could not be resolved) |
409 | Duplicate (lead already exists; survey already answered) |
410 | Gone (expired survey link) |
422 | Validation error |
429 | Rate limited |
Rate limits#
- A global Redis-backed limiter guards
/apiand/api/v1(webhooks, SSE, static uploads and OAuth/OIDC flows are skipped inside it). - A dedicated public limiter exists (
300 req/min) and is applied per-route on the booking, quote and VoC survey links (see each section for the specific cap). - Every public request/response pair is written to
integration_logs(directionIN) by the inbound logger — except the quote-link routes, which are skipped so the token and customer details are never logged.
2. Authentication schemes#
The public API uses several different credential types. Each endpoint below names which one it expects. Secrets are configured via environment variables — values are never shown here.
| # | Scheme | How the caller sends it | Resolves | Used by |
|---|---|---|---|---|
| A | API Key | Header x-api-key: <raw key> | Looks up the hashed key in api_keys (must be active, unexpired; optional IP allow-list) → organization_id | POST /lead |
| B | API-Key JWT | Header Authorization: Bearer <jwt> signed with jwtSecretKey, payload carries sso_id | Finds the user by sso_id → org + role | GET /lead/lead-list |
| C | Shared-Secret JWT | Header Authorization: Bearer <jwt> signed with sharedSecretKey, payload { sso_id, organization_id, source? } | Verifies claims, resolves the user/org | /lead/add, /user/list, /deal/* (shared), /feed/public/add, /ai-email/reply-webhook, /intent-signal |
| D | Public JWT | Header Authorization: Bearer <jwt> signed with jwtSecretKey, payload carries org_id | Verifies the org only, then acts as the org's oldest user (the creator) | /public/lead/create, /public/lead/activity, /ciao-calls/* |
| E | Revo JWT | Header Authorization: Bearer <jwt> signed with automationJwtSecret, claim sub = <user email> | Finds the LIO user by email → org | Auto-detected on every withRevoFallback(...) route (below) |
| F | Evox static key | Header x-evox-key: <static key> plus organization_id in query or body | Compares against evoxStaticKey | GET /evox/leads |
| G | Web-form key | Header x-api-key: <raw key> matching the form's stored key, :form_link in the path | Matches the web form row → org | GET /web-form/:form_link |
| H | URL token | A 32-byte token / HMAC token in the path (no header) | The token (or its stored hash) is the only credential | Booking, Quote, VoC survey, Email tracking |
| I | Provider signature | Provider-specific signature header over the raw body (e.g. X-Twilio-Signature, X-Hub-Signature-256), verified inside the controller | n/a — the provider is the caller | All webhooks in §9 |
The Revo fallback (withRevoFallback)#
Many routes are wrapped in withRevoFallback(primaryAuth). The behaviour is:
- If the
AuthorizationBearer token verifies againstautomationJwtSecret, it is a Revo JWT (scheme E) and Revo auth runs. - Otherwise the wrapped primary scheme runs (A, C, or the standard app
authenticate). - A JWT whose header
algis notHS256/RS256(e.g.alg: none) is rejected outright with401.
So a route documented as "Shared-Secret JWT or Revo JWT" accepts either.
Note: routes wrapped as
withRevoFallback(authenticate)fall back to the standard LIO app JWT when the token is not a Revo token. They are listed here because they live on the public/api/v1router and are reachable by Revo without an app session, but a non-Revo caller still needs a valid LIO user token.
3. Leads#
3.1 Create lead (strict)#
POST /api/v1/lead- Auth: API Key (A) or Revo JWT (E).
- Body (
unknownkeys allowed → auto-created as custom fields):
| Field | Type | Notes |
|---|---|---|
email | string | Required. Valid email. |
first_name, last_name | string | 2–50 chars, optional. |
phone_number | string | optional; also creates a Contact when present |
company, job_title, website | string | website accepts a bare domain (https:// is prepended); company is upserted |
address, city, state, country, pin_code | string | optional |
status, priority, lead_source | number or name string | names are resolved to the org's lookup ids; Revo callers always get source REVO; otherwise defaults to Website source / Medium priority |
score | int 0–100 | optional |
estimated_value | number | optional |
assigned_to | int (user id) | optional; web-form round-robin may override |
linkedin_url | string | optional (bare domain accepted) |
notes | string | optional |
tags | string[] | tag names; created if missing, stored as ids |
custom_fields | object | optional nested custom data |
merge | 1 | on duplicate → 200 "Lead merged successfully" |
duplicate | 1 | ignore duplicate and create anyway |
- Duplicate rule: exact match on
first_name + last_name + email + phone_numberwithin the org; plus a single-field email check when the org's dedup policy isemail/email_phone. - Success
201:{ "message": "Lead created successfully", "data": { ...lead } } - Errors:
422(validation),409("This lead already exists in the system"/"A lead with this email already exists").
3.2 Create lead (shared-secret variant)#
POST /api/v1/lead/addSame controller and body as §3.1, but Auth: Shared-Secret JWT (C) or Revo JWT (E).
3.3 Create lead (permissive)#
POST /api/v1/lead/create- Auth: standard LIO app JWT or Revo JWT (E).
- Required: at least one of
emailorlinkedin_url. Everything else optional. - Dedup runs on whichever identifier(s) are present (email exact; LinkedIn by slug, matched with an
ILIKEon/in/<slug>or/pub/<slug>). - Accepts a domain alias (
company_website/domain/domain_url) mapped ontowebsite. Name is derived from the email/LinkedIn slug when not given. - Body knobs
merge: 1/duplicate: 1behave as in §3.1. - Success
201:{ "success": true, "message": "Lead created successfully", "data": { ...lead } } - Duplicate:
200(merge) or409withdata: { id, email, linkedin_url }.
3.4 Create lead (public JWT)#
POST /api/v1/public/lead/createSame permissive controller as §3.3, but Auth: Public JWT (D) — the token's org_id selects the organization; the org's oldest user is recorded as creator.
3.5 Add activity to a lead (public JWT)#
POST /api/v1/public/lead/activity- Auth: Public JWT (D).
- Body:
| Field | Type | Notes |
|---|---|---|
lead_id | int | Required. Must belong to the token's org. |
activity_type | string | Required. |
activity_description | string | Required. |
type_id | int | optional |
- Success
201:{ "success": true, "message": "Activity added successfully" } - Errors:
422(validation),404(lead not found in org).
3.6 List leads#
GET /api/v1/lead/lead-list- Auth: API-Key JWT (B) or Revo JWT (E).
- Query:
limit(default 10),page(default 1),search_value,order_by(one ofid,created_at,updated_at,first_name,last_name,email,score),order_in(ASC/DESC). - Scope: org-scoped; non-admin callers see only leads they created or are assigned.
- Success
200:
{ "success": true, "message": "Leads fetched successfully",
"data": [ { "id": 1, "first_name": "...", "email": "...", "status": 1, "...": "..." } ],
"total": 42, "page": 1, "limit": 10, "totalPages": 5 }3.7 Change lead status by name#
POST /api/v1/lead/:id/change-status- Auth: standard LIO app JWT or Revo JWT (E).
- Path:
:id— lead id. - Body:
{ "status": "<status name>" }(resolved case-insensitively against the org's status lookups, then the global default). Runs the normal status-change side-effects. - Errors:
422(invalid id / unknown status),404(lead not found).
4. Deals#
4.1 List deals#
GET /api/v1/deal/list- Auth: Shared-Secret JWT (C) or Revo JWT (E).
- Query:
limit,page,order_by,order_in,lead_id,pipeline_id,stage_id,search_value(matches title/description),start_date+end_date. - Scope: only deals whose lead belongs to the caller's org.
- Success
200: standard list envelope; each row is flattened withdeal_id,deal_title, and the lead'sfirst_name,last_name,email,phone,company,city,state,country.
4.2 Move deal to a stage (by id)#
POST /api/v1/deal/:id/move-stage- Auth: Shared-Secret JWT (C) or Revo JWT (E).
- Body:
{ "stage_id": <int> }(required; must belong to the deal's pipeline). - Success
200:{ "success": true, "message": "Deal moved to stage successfully", "data": { "deal_id", "stage_id", "stage_name" } } - Errors:
422(invalid stage),404(deal not found),403(deal not in caller's org).
4.3 Change deal stage (by name)#
POST /api/v1/deal/:id/change-stage- Auth: Shared-Secret JWT (C) or Revo JWT (E).
- Body:
{ "stage": "Won" | "Lost" | "Negotiation" }(case-insensitive; resolved to a stage of the deal's pipeline). Stampswon_atthe first time the deal enters a won stage. - Success
200:{ "success": true, "message": "Deal stage updated successfully", "data": { "deal_id", "stage_id", "stage_name" } }
4.4 Create deal (by names)#
POST /api/v1/deal/create- Auth: standard LIO app JWT or Revo JWT (E).
- Body:
| Field | Type | Notes |
|---|---|---|
lead_id | int | Required. Must exist in the org. |
title | string | Required. 2–50 chars. |
value | number ≥ 1 | Required. |
expected_close_date | date | Required. |
pipeline | string name | optional; defaults to the org's first active pipeline |
stage | string name | optional; defaults to the pipeline's first stage |
description | string | optional, ≤ 1000 chars |
- Success
201:
{ "success": true, "message": "Deal created successfully",
"data": { "id", "lead_id", "title", "value", "expected_close_date", "description",
"pipeline": { "id", "name" }, "stage": { "id", "name" } } }- Errors:
422(validation / unknown pipeline/stage),404(lead not found),400(pipeline deal limit reached).
5. Feed, Users, Web form#
5.1 Add activity to the feed#
POST /api/v1/feed/add- Auth: standard LIO app JWT or Revo JWT (E).
- Body:
model_id,model_type(lead→ Lead,deal→ LeadDeal),activity_type,activity_description(all required); optionalfailure_reason,type_id,image. - Success
201:{ "success": true, "message": "Activity logged successfully", "data": { "id", "source" } }
5.2 Add activity to the feed (shared-secret)#
POST /api/v1/feed/public/addSame as §5.1, but Auth: Shared-Secret JWT (C) or Revo JWT (E); the caller is resolved from the token's sso_id + organization_id.
5.3 List users in the organization#
GET /api/v1/user/list- Auth: Shared-Secret JWT (C) or Revo JWT (E).
- Success
200:{ "success": true, "data": [ { "id": 12, "name": "..." } ] }(ids numeric).
5.4 View a public web form#
GET /api/v1/web-form/:form_link- Auth: Web-form key (G) —
x-api-keyheader must match the form's stored key. - Success
200:{ "success": true, "data": { "id", "form_name", "form_link", "form_html", "api_key_raw" } } - Errors:
401(key missing),403(wrong key for this form),404(form not found).
6. Integrations (Evox, intent, automation)#
6.1 Evox — fetch leads#
GET /api/v1/evox/leads- Auth: Evox static key (F) —
x-evox-keyheader andorganization_id(query or body). - Query:
page,limit(max 100),search,order_by,order_in, and filtersstatus,priority,lead_source,assigned_to(comma-separated ids),city,state,country,company,score_min,score_max,created_from,created_to. "Deleted"-status leads are excluded. - Errors:
401(key missing),403(invalid key),400(organization_idmissing).
6.2 Ingest an intent signal#
POST /api/v1/intent-signal- Auth: Shared-Secret JWT (C) or Revo JWT (E).
- Body:
| Field | Type | Notes |
|---|---|---|
lead_id | int/string | Required. Must exist in the org. |
signal_type | string | Required. One of: pricing_page_visit, demo_requested, form_submitted, reply_received, email_opened, email_opened_3x, link_clicked, case_study_download, manual |
signal_detail | any | optional |
source | string | optional (default webhook) |
occurred_at | date | optional |
- Errors:
401(no org in token),400(missing/unsupported fields),404(lead not found).
6.3 Automation trigger — recent events (Revo preview)#
GET /api/v1/automation/triggers/:triggerKey/recent- Auth: standard LIO app JWT or Revo JWT (E).
- Returns the caller's recent real events for a workflow trigger in the Revo
sample_outputenvelope; empty →{ "data": [] }.
6.4 Evox Ciao calls#
POST /api/v1/ciao-calls/prepare
POST /api/v1/ciao-calls/result- Auth: Public JWT (D) — the token's org is used; its
idis an SSO uuid. preparereturns abooking_refbefore Evox dials a lead;resultforwards theCallResult(adds a lead note and books a meeting for Evox calls).
6.5 AI email reply webhook#
POST /api/v1/ai-email/reply-webhook- Auth: Shared-Secret JWT (C).
- Marks AI email drafts as replied when an inbound email references a previously-sent draft's
message_id.
7. Email tracking (token in URL)#
Public by design — these URLs are embedded in emails sent to recipients. Auth is the HMAC signature inside the URL token (validated in the controller); tampering is rejected.
GET /api/v1/track/open/:token # 1×1 tracking pixel; logs email_opened / email_opened_3x
GET /api/v1/track/click/:token # logs link_clicked, then 302-redirects to the real URL8. Customer-facing links (token in URL)#
These power the pages a lead/customer opens from an email — no login. The emailed token (only its hash is stored) is the sole credential. Each group has its own rate limiter.
8.1 Public quote (rate limit: 60 / 10 min per link)#
GET /api/v1/public/quote/:token # view the quote (first open marks it "viewed")
POST /api/v1/public/quote/:token/accept # { name, email, agree: true }
POST /api/v1/public/quote/:token/decline # { reason?, name? }
POST /api/v1/public/quote/:token/request-changes # { message, name?, requested_changes?[] }requested_changes[]items:{ position, quantity?, note? }.- Success
200:{ "success": true, "message": "...", "data": { ...public quote } }. - Errors:
404(invalid link),422(validation). A repeat submission returns200 "Your response was already recorded."(idempotent). These routes are excluded fromintegration_logs.
8.2 Voice-of-Customer survey (rate limit: 20 / 10 min per IP)#
GET /api/v1/voc/survey/:token # fetch the survey (no CRM data exposed)
POST /api/v1/voc/survey/:token # { score (int, required), comment? }
POST /api/v1/voc/survey/:token/opt-out # unsubscribe from survey emails- Errors:
404invalid link,410expired,409already answered,422missing score.
8.3 Public booking page (rate limit: 300 / min)#
GET /api/v1/booking/:slug # rep profile: { rep.name, durations, timezone }
GET /api/v1/booking/:slug/availability # ?date=YYYY-MM-DD&duration=30&tz=... (or from/to range)
POST /api/v1/booking/:slug/confirm # book a slot- The
:slugidentifies the rep (booking_enabledmust be true); the rep's email is never exposed. confirmbody:{ start, end, name, email }(required) pluscompany,phone,notes. It match-or-creates the prospect as a lead in the rep's org and creates an online meeting (Meet/Teams link).- Errors:
404(page not found),422(missing fields / invalid slot).
9. Webhooks (provider → LIO)#
These are inbound endpoints that external providers call. You don't call them as a client; you configure the provider with the URL. Each is authenticated by a provider-specific signature over the raw request body (scheme I), verified inside the controller — do not add a Bearer/API-key to these.
Telephony & voice (under /api/v1, verified by provider signature)#
POST /api/v1/twilio/voice/outbound # X-Twilio-Signature
POST /api/v1/twilio/status-callback
POST /api/v1/twilio/recording-callback
POST /api/v1/twilio/transcription-callback
POST /api/v1/plivo/webhooks/answer # X-Plivo-Signature-V3
POST /api/v1/plivo/webhooks/status
POST /api/v1/plivo/webhooks/hangup
POST /api/v1/plivo/webhooks/recording
POST /api/v1/plivo/webhooks/transcription
POST /api/v1/plivo/webhooks/complianceInbox sync#
POST /api/v1/inbox/webhook/microsoft # ?validationToken on creation; later POSTs signed via clientState
POST /api/v1/inbox/webhook/gmail # ?token=<shared secret> (Google Pub/Sub push)External workflow callbacks#
POST /api/v1/webhooks/workflow/... # HMAC-signed (workflowWebhookAuth), raw bodyProvider webhooks registered at the host root (raw body + HMAC)#
POST /api/v1/webhooks/slack # Slack signature
POST /api/v1/webhooks/teams # Teams / Bot Framework JWT
POST /api/v1/webhooks/zoom # x-zm-signature
POST /api/v1/webhooks/meta # X-Hub-Signature-256 (Facebook Lead Ads)
POST /api/v1/webhooks/lead-source # generic lead-source (99acres, MagicBricks, …); per-org token in path
POST /api/v1/webhooks/evox # X-Evox-Signature
POST /api/v1/webhooks/calendly # Calendly-Webhook-Signature
POST /api/v1/webhooks/didit # X-Signature (KYC)
POST /api/v1/webhooks/whatsapp # per-org x-hub-signature-256 (AiSensy)
POST /api/v1/webhooks/worksbuddy-voice # HMAC
POST /api/v1/ciao # Ciao demo-booking, HMAC over raw bytes
POST /api/v1/webhooks/calling-payment # Stripe signature (prepaid calling wallet)
POST /api/v1/webhooks/meta-data-deletion # Meta data-deletion (urlencoded signed_request)SSO integration (host root)#
POST /api/webhooks/users # SSO user provisioning (X-SSO signature, raw body)
POST /api/webhooks # same SSO user webhook at the fleet-default path
* /api/v1/sso/... # SSO ↔ product plan-sync (X-SSO-Signature on writes)10. Infrastructure & identity (host root, no app auth)#
Health probes#
GET /health/live # 200 { "status": "ok" } — process is up
GET /health/ready # 200 ready / 503 not-ready, with { checks: { db, redis } }OIDC / OAuth (MCP connector identity provider)#
GET /.well-known/openid-configuration
GET /.well-known/oauth-authorization-server
GET /.well-known/jwks.json
GET /oauth/jwks
POST /oauth/register # dynamic client registration
POST /oauth/token # OIDC token (also /oauth/access-token)
POST /oauth/access-token
GET /oauth/userinfo
GET /oauth/authorize # 302 → frontend consent screen (needs MCP_CONSENT_URL)
POST /api/v1/oauth/token # MCP OAuth token (auth code + PKCE; no Bearer, per RFC 6749 §3.2)Static uploads#
GET /uploads/:storedName # public file download; original filename + inline/attachment dispositionSSE streams (token in query / Authorization — not app-session)#
GET /api/events # ?token=<jwt> — server-sent events for the org
POST /api/leads/email/bulk/stream # Authorization: Bearer <jwt> — bulk enrichment SSE11. Quick auth cheat-sheet#
| Endpoint(s) | Credential |
|---|---|
POST /lead | x-api-key (or Revo JWT) |
POST /lead/add, /user/list, /deal/list, /deal/:id/move-stage, /deal/:id/change-stage, /feed/public/add, /ai-email/reply-webhook, /intent-signal | Shared-secret Bearer JWT (or Revo JWT) |
GET /lead/lead-list | API-key Bearer JWT with sso_id (or Revo JWT) |
POST /public/lead/create, /public/lead/activity, /ciao-calls/* | Public Bearer JWT with org_id |
POST /lead/create, /deal/create, /feed/add, /lead/:id/change-status, GET /automation/triggers/:k/recent | LIO app JWT (or Revo JWT) |
GET /evox/leads | x-evox-key + organization_id |
GET /web-form/:form_link | x-api-key matching the form |
| Booking / Quote / VoC survey / tracking | token in the URL |
| All §9 webhooks | provider signature over raw body |
| Health / OIDC / uploads | none |