LIO API
Create leads in LIO from your own systems over a simple REST API.
Overview#
| API name | LIO API |
| Version | v1 |
| Base URL | https://api-lio.worksbuddy.ai/api/v1 |
| Protocol | HTTPS only |
| Format | JSON (application/json) |
| Authentication | X-API-Key: CRM_... |
| Who can create keys | Organization admins, from the LIO app |
| Key lifetime | Your choice: a specific expiry date, or never |
What the API does#
The LIO API lets external systems push new leads into LIO. Send a lead from your website form, landing page, chatbot, or partner app, and it lands in your LIO account, ready for your team to work.
Everything a key touches stays inside your organization. A key cannot read, change, or delete your CRM data, and cannot reach another organization.
What you can build#
- Lead capture: push leads from web forms, landing pages, chatbots, or partner systems straight into LIO.
- Enrichment on ingest: send status, priority, source, score, estimated value, assignee, tags, and any custom fields with the lead.
- De-duplication: LIO detects duplicates on create and can merge instead.
Get a key#
Sign in to LIO as an organization admin and open the API Keys area in Settings. Create a key, optionally set an expiry and an IP allow-list, then copy it. The full key is shown once. See Managing your keys.
Table of contents#
- Quick start
- Authentication
- Response format
- Error codes
- Rate limits
- Access restrictions
- Create a lead
- Managing your keys
- Security
- Code samples
- Troubleshooting
- Limitations
1. Quick start#
Step 1. Create a key in the LIO app (Settings, API Keys). See Managing your keys.
Step 2. Store it in your secret manager. The key is shown once and cannot be recovered later.
Step 3. Create a lead.
curl -X POST "https://api-lio.worksbuddy.ai/api/v1/lead" \
--header "X-API-Key: CRM_YOUR_API_KEY_HERE" \
--header "Content-Type: application/json" \
--data '{
"first_name": "Jordan",
"last_name": "Rivera",
"email": "jordan@example.com",
"phone_number": "+15551234567",
"company": "Example Co",
"lead_source": "Website"
}'Step 4. Read the response. On success you get 201 with { "message": "Lead created successfully", "data": { ...the lead } }.
2. Authentication#
Send your key in the X-API-Key header on every request. It is a bare key, not an Authorization: Bearer token.
X-API-Key: CRM_YOUR_API_KEY_HEREThat is the whole contract. No timestamp, no signature, no extra header.
Key format#
CRM_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...| Property | Value |
|---|---|
| Prefix | CRM_ |
| Body | 96 hexadecimal characters |
| Shown | Once, at creation |
| Stored by LIO | Hashed. The raw key is never stored and cannot be retrieved. |
If you lose a key, regenerate it or create a new one and delete the old one.
When a key stops working#
A key is refused when it is deactivated, deleted, past its expiry date, or when the request comes from an IP address outside the key's allow-list (if one is set).
Testing your key#
Send the request from a server or a terminal. The endpoint takes only the X-API-Key header, so a browser session has no effect on it.
3. Response format#
Read each endpoint's sample for its exact shape.
Create success#
{
"message": "Lead created successfully",
"data": { }
}Errors#
Authentication errors are shaped { "error": "..." }. Validation errors are { "message": [ ... ] }. Duplicate errors carry a status_code. See Error codes.
4. Error codes#
| HTTP | Body shape | Meaning | What to do |
|---|---|---|---|
401 | { "error": "API key missing" } | No X-API-Key header. | Send the header. |
403 | { "error": "Invalid or expired API key" } | Key is unknown, deactivated, or past expiry. | Regenerate or create a new key. |
403 | { "error": "IP not allowed" } | Request IP is not in the key's allow-list. | Call from an allowed IP, or widen the list. |
409 | { "status_code": 409, "message": "This lead already exists in the system" } | A lead with the same name, email, and phone already exists. | Send duplicate: 1 to force a new lead, or merge: 1 to merge. |
409 | { "status_code": 409, "message": "A lead with this email already exists" } | Email already on a lead. | Same as above. |
422 | { "message": [ "email must be a valid email" ] } | Validation failed. message is an array of field messages. | Fix the fields. |
429 | { "success": false, "message": "Too many requests. Please try again later." } | Rate limited. | Wait for the Retry-After header, then retry. |
500 | { "success": false, "message": "..." } | Server error. | Retry with backoff. |
Retry: 429, 500, 502, 503, 504 with exponential backoff and jitter. Never retry 401, 403, 409, or 422. They will not succeed on a repeat.
5. Rate limits#
A rate limit protects the service. Build your integration to handle throttling gracefully rather than assuming a fixed ceiling.
When you are limited#
You get 429 with a Retry-After header in seconds:
{ "success": false, "message": "Too many requests. Please try again later." }Staying under the limit#
- Handle
429and honorRetry-Afterfrom day one. - Batch your ingestion instead of firing everything at once.
- If several systems push leads into LIO, coordinate them so they do not exhaust the shared limit together.
6. Access restrictions#
An API key can create leads in your organization, and nothing else. It is refused on every other route, including sign-in and sessions, key management, and any other CRM resource.
A key cannot read your CRM, cannot create or revoke another key, cannot sign in as anybody, and cannot change what your organization is billed. Those actions happen in the LIO app.
7. Create a lead#
POST /lead| Full URL | https://api-lio.worksbuddy.ai/api/v1/lead |
| Authentication | X-API-Key: CRM_... |
| Content type | application/json |
Creates a lead in your organization. Extra keys you send that are not listed below are stored as custom fields on the lead. You can also send a nested custom_fields object.
status, priority, and lead_source accept either a numeric id or a name. Names are matched against your organization's values; an unknown lead_source name is created for you. A bare-domain website or linkedin_url (no scheme) has https:// prepended.
Request parameters
| Parameter | Data type | Required | Description |
|---|---|---|---|
first_name | string | No | 2 to 50 characters. |
last_name | string | No | 2 to 50 characters. |
email | string | No | Valid email. |
phone_number | string | No | |
address | string | No | |
city | string | No | |
state | string | No | |
country | string | No | |
pin_code | string | No | |
company | string | No | |
job_title | string | No | |
website | string | No | URL. Bare domains get https://. |
linkedin_url | string | No | URL. |
status | int or string | No | Status id or name. Defaults to your first status. |
priority | int or string | No | low, medium, high, urgent, or the id. Default medium. |
lead_source | int or string | No | Source id or name. Default Website. Unknown names are created. |
score | int | No | 0 to 100. |
estimated_value | number | No | |
assigned_to | int | No | User id to assign the lead to. |
notes | string | No | |
tags | array | No | |
custom_fields | object | No | Custom field values. Unknown top-level keys are folded in here too. |
duplicate | int | No | 1 forces creation even if a duplicate is detected. |
merge | int | No | 1 merges into the existing lead instead of erroring. |
Request sample
curl -X POST "https://api-lio.worksbuddy.ai/api/v1/lead" \
--header "X-API-Key: CRM_YOUR_API_KEY_HERE" \
--header "Content-Type: application/json" \
--data '{
"first_name": "Jordan",
"last_name": "Rivera",
"email": "jordan@example.com",
"phone_number": "+15551234567",
"company": "Example Co",
"job_title": "Head of Ops",
"lead_source": "Website",
"priority": "high",
"estimated_value": 25000,
"tags": ["inbound", "demo-request"],
"utm_campaign": "spring-launch"
}'Here utm_campaign is not a known field, so it is stored as a custom field.
Response body
| Field | Data type | Description |
|---|---|---|
message | string | Lead created successfully. |
data | Lead | The created lead. Key fields below. |
The Lead object (key fields)
| Field | Data type | Description |
|---|---|---|
id | int | |
first_name / last_name | string or null | |
email / phone_number | string or null | |
company / job_title / website | string or null | |
address / city / state / country / pin_code | string or null | |
status | int | Lookup id. status_text gives the label. |
priority | int | 1 low, 2 medium, 3 high, 4 urgent. priority_text gives the label. |
lead_source | int | Lookup id. lead_source_text gives the label. |
score | int | 0 to 100. |
estimated_value | number | |
assigned_to | int or null | |
linkedin_url | string or null | |
notes | string or null | |
tags | array | |
custom_fields | object | Any extra fields you sent. |
created_at / updated_at | datetime |
Response sample 201
{
"message": "Lead created successfully",
"data": {
"id": 5573,
"first_name": "Jordan",
"last_name": "Rivera",
"email": "jordan@example.com",
"phone_number": "+15551234567",
"company": "Example Co",
"job_title": "Head of Ops",
"status": 1,
"priority": 3,
"lead_source": 1,
"score": 0,
"estimated_value": 25000,
"tags": ["inbound", "demo-request"],
"custom_fields": { "utm_campaign": "spring-launch" },
"created_at": "2026-09-02T09:31:00.000Z",
"updated_at": "2026-09-02T09:31:00.000Z"
}
}Merge sample 200
When you send merge: 1 and a duplicate exists:
{ "status_code": 200, "message": "Lead merged successfully" }Errors
| Code | Body |
|---|---|
401 | { "error": "API key missing" } |
403 | { "error": "Invalid or expired API key" } |
403 | { "error": "IP not allowed" } |
409 | { "status_code": 409, "message": "This lead already exists in the system" } |
409 | { "status_code": 409, "message": "A lead with this email already exists" } |
422 | { "message": [ "email must be a valid email" ] } |
8. Managing your keys#
Keys are created and managed in the LIO app. Open the API Keys area in Settings as an organization admin.
Create#
Give the key a name and, optionally, an expiry date and an IP allow-list. The full key (CRM_...) is shown once, with a copy button. Once you close the dialog it cannot be shown again.
View#
The list shows each key's name, its status, who created it, and its expiry.
| Status | Meaning |
|---|---|
| Active | Working normally. |
| Expired | Past its expiry date. Every request returns 403. |
| Disabled | Turned off. Every request returns 403. |
Regenerate#
Rotates the key in place. A new raw key is issued and shown once; the old value stops working immediately. Use this when a key may have leaked.
Delete#
Removes the key. It stops working immediately.
Rotation#
To rotate with an overlap window:
- Create a second key with the same settings and a dated name, for example
Website ingest (2026-09). - Deploy the new key to your integration.
- Once nothing is using the old key, delete it.
Rotate immediately after any suspected exposure, and whenever someone with access to the secret leaves the team.
9. Security#
A key can create leads in your organization. Treat it as a secret.
- Never put a key in frontend code. Anything shipped to a browser is readable by anyone who opens developer tools. Call the API from your server.
- Never commit a key. Not to git, a Dockerfile, or a CI config. Use your platform's secret store.
- Never send a key in a URL. Query strings end up in logs. Use the
X-API-Keyheader. - Set an expiry. A key that never expires is a key you will forget about.
- Use the IP allow-list when your caller has stable IP addresses.
- One key per integration. Shared keys make it impossible to tell what broke.
- If a key leaks, regenerate or delete it first, then investigate. Both take effect on the next request.
10. Code samples#
Node.js#
const BASE = 'https://api-lio.worksbuddy.ai/api/v1';
const KEY = process.env.LIO_API_KEY; // CRM_...
async function createLead(lead) {
const res = await fetch(`${BASE}/lead`, {
method: 'POST',
headers: {
'X-API-Key': KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify(lead),
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get('Retry-After') || 60);
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return createLead(lead);
}
const body = await res.json().catch(() => ({}));
if (!res.ok) {
const message = Array.isArray(body.message) ? body.message.join(', ') : (body.message || body.error);
const err = new Error(message || `LIO API ${res.status}`);
err.status = res.status;
throw err;
}
return body.data;
}
const lead = await createLead({
first_name: 'Jordan',
email: 'jordan@example.com',
lead_source: 'Website',
priority: 'high',
});Python#
import os
import time
import requests
BASE = "https://api-lio.worksbuddy.ai/api/v1"
KEY = os.environ["LIO_API_KEY"] # CRM_...
session = requests.Session()
session.headers.update({"X-API-Key": KEY})
def create_lead(lead):
for _ in range(5):
res = session.post(f"{BASE}/lead", json=lead, timeout=30)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", 60)))
continue
body = res.json()
if not res.ok:
message = body.get("message") or body.get("error")
raise RuntimeError(f"LIO API {res.status_code}: {message}")
return body["data"]
raise RuntimeError("LIO API: rate limited after 5 attempts")
lead = create_lead({"first_name": "Jordan", "email": "jordan@example.com", "lead_source": "Website"})PHP#
<?php
$base = 'https://api-lio.worksbuddy.ai/api/v1';
$key = getenv('LIO_API_KEY'); // CRM_...
function create_lead(array $lead): array {
global $base, $key;
$ch = curl_init($base . '/lead');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . $key,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($lead),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = json_decode($raw, true);
if ($status >= 400) {
$message = $decoded['message'] ?? ($decoded['error'] ?? 'unknown');
if (is_array($message)) { $message = implode(', ', $message); }
throw new RuntimeException("LIO API {$status}: {$message}");
}
return $decoded['data'];
}
$lead = create_lead(['first_name' => 'Jordan', 'email' => 'jordan@example.com', 'lead_source' => 'Website']);11. Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
401 API key missing | No X-API-Key header, or it was sent as Authorization: Bearer. | Send X-API-Key: CRM_.... |
403 Invalid or expired API key | Key is unknown, disabled, past expiry, or copied with trailing whitespace. | Copy the full CRM_... key and trim it, or regenerate. |
403 IP not allowed | The key has an IP allow-list and your caller's IP is not on it. | Add the IP, or remove the allow-list. |
409 This lead already exists | Same name, email, and phone as an existing lead. | Send duplicate: 1 to force, or merge: 1 to merge. |
422 with a message array | A field failed validation. | Read the messages and fix those fields. |
| Extra fields "disappeared" | Unknown fields are stored as custom fields, not top-level columns. | Read them back from data.custom_fields. |
| Rate limited despite low volume | Other systems may be sharing the same limit. | Coordinate your callers so they do not compete for the shared limit. |
12. Limitations#
- Lead creation only. The API creates leads. There is no read, update, or delete, and no access to other CRM resources.
- Key management is done in the app. A key cannot create, rotate, or revoke another key.
- One environment. Test against a throwaway lead source or key.
Support#
For integration questions, contact your WorksBuddy account team.
Last updated: 2026-09-02