API reference
Lead API
Bring leads from Meta Lead Ads, your website form, Make.com, Zapier or n8n straight onto your board — and let your other tools know when a lead moves. REST, JSON, one API key per integration.
Base URL: https://livelisted.app/api/v1
Keys and webhook endpoints are managed under Settings → API & Webhooks (owners and admins).
curl -X POST https://livelisted.app/api/v1/leads \
-H "Authorization: Bearer llk_…" \
-H "Content-Type: application/json" \
-d '{"name":"Max Muster","email":"max@example.com","phone":"+43 660 1234567",
"project":"Seeblick Residences","unit":"A-01","source":"website","message":"Bitte um Exposé"}'Authentication
Every request carries the key in the Authorization header as a Bearer token (or in X-API-Key). Keys start with llk_ and are shown exactly once when created. Treat them like a password: server-side only, never in a browser or an app that ships to customers.
Authorization: Bearer llk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # or X-API-Key: llk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Permissions
leads:read lets a key list leads, stages, projects and units. leads:write additionally creates and updates leads and includes read. A key without the needed permission gets 403 with code scope.
Endpoints
Create a lead
POST/api/v1/leads
Creates a lead in the first open stage of your board and notifies your team by email like a public enquiry does. One of name, email or phone is required, plus the project (id, public slug or exact name). The unit is optional and matched by ref inside that project; an unknown ref is kept as text so the lead is never lost.
POST /api/v1/leads
Authorization: Bearer llk_…
Content-Type: application/json
{
"name": "Max Muster",
"email": "max@example.com",
"phone": "+43 660 1234567",
"project": "Seeblick Residences",
"unit": "A-01",
"message": "Bitte um Exposé und Besichtigungstermin.",
"source": "meta",
"external_id": "leadgen_1234567890",
"utm": { "source": "facebook", "medium": "paid", "campaign": "seeblick-herbst" },
"budget": 420000,
"consent": { "text": "Ich stimme der Kontaktaufnahme zu.", "at": "2026-09-09T10:12:00Z" }
}201 Created
Location: /api/v1/leads/7d0e…
{
"id": "7d0e3a2c-…",
"stage": { "id": "…", "name": "Unbearbeitet", "position": 0, "kind": "open" },
"project_id": "…",
"unit_id": "…",
"url": "https://livelisted.app/enquiries?lead=7d0e3a2c-…",
"deduplicated": false,
"lead": { "id": "7d0e3a2c-…", "name": "Max Muster", "email": "max@example.com", "…": "…" }
}List leads
GET/api/v1/leads?project=&stage=&since=&limit=
Newest first, at most 200 per call. Filter by project (id or name), stage (id or name), since (ISO 8601) and include_archived=true.
GET /api/v1/leads?project=Seeblick%20Residences&stage=Besichtigung&since=2026-09-01T00:00:00Z&limit=100 Authorization: Bearer llk_…
200 OK
{
"count": 2,
"data": [
{
"id": "7d0e3a2c-…",
"name": "Max Muster",
"first_name": null,
"last_name": null,
"email": "max@example.com",
"phone": "+43 660 1234567",
"message": "Bitte um Exposé…",
"source": "meta",
"budget": 420000,
"utm": { "source": "facebook", "medium": "paid", "campaign": "seeblick-herbst" },
"external_id": "leadgen_1234567890",
"consent": { "text": "Ich stimme der Kontaktaufnahme zu.", "at": "2026-09-09T10:12:00Z" },
"stage": { "id": "…", "name": "Besichtigung", "position": 2, "kind": "open" },
"project": { "id": "…", "name": "Seeblick Residences" },
"unit": { "id": "…", "ref": "A-01" },
"assigned_to": null,
"status": "contacted",
"created_at": "2026-09-09T10:12:03Z",
"last_activity_at": "2026-09-09T14:30:00Z",
"next_follow_up_at": null,
"archived_at": null,
"url": "https://livelisted.app/enquiries?lead=7d0e3a2c-…"
}
]
}Get one lead
GET/api/v1/leads/{id}
Update a lead
PATCH/api/v1/leads/{id}
Move the lead (stage = id or name), assign a team member (assigned_to_email), add a note (appears on the timeline as "API") or change name, phone, message, budget, next_follow_up_at. Moving a lead never changes a unit's status — the person confirms that on the board, exactly as with drag and drop.
PATCH /api/v1/leads/7d0e3a2c-…
Authorization: Bearer llk_…
Content-Type: application/json
{
"stage": "Besichtigung",
"assigned_to_email": "vertrieb@example.com",
"note": "Termin am Freitag 15:00 vereinbart."
}Stages
GET/api/v1/stages
The pipeline stages of your organisation in board order, with their kind (open, reserved, won, lost). Use the ids in PATCH requests when your stage names change.
200 OK
{
"data": [
{ "id": "…", "name": "Unbearbeitet", "position": 0, "kind": "open" },
{ "id": "…", "name": "In Kontakt", "position": 1, "kind": "open" },
{ "id": "…", "name": "Besichtigung", "position": 2, "kind": "open" },
{ "id": "…", "name": "Verhandlung", "position": 3, "kind": "open" },
{ "id": "…", "name": "Reserviert", "position": 4, "kind": "reserved" },
{ "id": "…", "name": "Verkauft", "position": 5, "kind": "won" },
{ "id": "…", "name": "Verloren", "position": 6, "kind": "lost" }
]
}Projects
GET/api/v1/projects
Active projects with unit count and public slug — everything you need to map a Meta form or a website to a project.
200 OK
{
"data": [
{ "id": "…", "name": "Seeblick Residences", "location": "Pörtschach", "locale": "de-AT", "currency": "EUR", "slug": "seeblick-residences", "unit_count": 24, "created_at": "…" }
]
}Units
GET/api/v1/units?project=
Ref, status, floor, area and both prices of the project's units, for pre-filling dropdowns in a form or matching an ad to a unit.
200 OK
{
"project": { "id": "…", "name": "Seeblick Residences", "currency": "EUR", "locale": "de-AT" },
"data": [
{ "id": "…", "ref": "A-01", "floor": "EG", "rooms": 3, "area": 78.5, "price": 498000, "price_investor": 415000, "status": "available" }
]
}Brochures and links
GET/api/v1/documents?project=
GET/api/v1/documents/{id}/links
POST/api/v1/documents/{id}/links
The organisation's brochures (uploaded in livelisted), optionally filtered by project — with page count and render status, so an automation knows which document to send.
200 OK
{
"data": [
{ "id": "…", "title": "Exposé Seeblick", "project_id": "…", "unit_id": null, "page_count": 31, "version": 2, "render_status": "ready", "links": 4, "created_at": "2026-09-09T…" }
]
}Creates a link to a brochure. With lead_id the link is personal: the viewer counts as that lead from the first page, no email gate, and every page and second lands on the lead's timeline (webhook document.viewed). A second call for the same lead returns the existing link with deduplicated: true. Optional slug sets the address (/d/<slug>), expires_at a deadline, allow_download the download button.
POST /api/v1/documents/{id}/links
Authorization: Bearer llk_…
Content-Type: application/json
{
"lead_id": "…",
"name": "Max Muster",
"allow_download": false,
"expires_at": "2026-12-31"
}201 Created
Location: https://livelisted.app/d/k3Zp9QwA
{
"id": "…",
"slug": "k3Zp9QwA",
"url": "https://livelisted.app/d/k3Zp9QwA",
"name": "Max Muster",
"lead_id": "…",
"require_email": false,
"allow_download": false,
"expires_at": "2026-12-31T23:59:59.000Z",
"active": true,
"view_count": 0,
"deduplicated": false
}Lead fields
| Field | Type | Description |
|---|---|---|
| name | string | Full name. Alternatively first_name and last_name; we join them. |
| string | Email address of the prospect. Reply-to of the notification mail. | |
| phone | string | Phone number, free format. |
| message | string | Free text, up to 4000 characters. |
| project | string | Required. Project id, public slug (from the price-list link) or exact name, case-insensitive. |
| unit | string | Unit ref inside the project, e.g. A-01. Unknown refs are stored as text. |
| source | string | Where the lead came from: website, meta, make, zapier … Default api. Shown on the card. |
| external_id | string | Your id for the lead (Meta leadgen_id, form submission id). Repeats with the same id return the existing lead instead of creating a duplicate. |
| utm | object | Object with source, medium, campaign, content, term. Stored on the lead. |
| budget | number | string | Number in the project's currency. "350.000" and "350000" both work. |
| consent | object | true | Object { text, at } — the consent text the prospect saw and when. Or true. |
| assigned_to_email | string | Email of a team member the lead is assigned to. |
| tags | string[] | Array of strings, stored under utm.tags. |
| custom | object | Object with your own key/value pairs, stored under utm.custom. |
| lang | "de" | "en" | de or en — language of the prospect. Defaults to the project's language. |
Errors
Errors are JSON with one shape: { error: { code, message, fields? } }. fields names the offending fields on validation errors.
| 401 | unauthorized | Missing, malformed, unknown or revoked key. |
| 403 | scope | The key lacks the needed permission. |
| 400 | validation | The body or query has invalid fields — see fields. |
| 404 | not_found | Lead, project, stage or unit not found in your organisation. |
| 429 | rate_limited | More than 120 requests per minute with one key. Retry-After says when to try again. |
| 500 | server | Something went wrong on our side. Retry with the same external_id — it is safe. |
400 Bad Request
{
"error": {
"code": "validation",
"message": "The request has invalid fields.",
"fields": { "email": "Not a valid e-mail address.", "project": "Required: the project's id, public slug or exact name." }
}
}Idempotency
Send external_id whenever you have a stable id for the lead (Meta's leadgen_id, your form's submission id). A repeat request with the same external_id answers 200 with the existing lead and deduplicated: true instead of creating a second card. Retries after a timeout are therefore safe.
Rate limits
120 requests per minute per key. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; a 429 carries Retry-After. If you sync in bulk, batch your reads with since and stay under the limit.
X-RateLimit-Limit: 120 X-RateLimit-Remaining: 117 Retry-After: 42 # on 429 only
Webhooks
Add an endpoint under Settings → API & Webhooks, choose the events and store the secret shown once. We POST a JSON envelope to the URL; your endpoint answers with any 2xx within 5 seconds.
Events
lead.created— a lead was created — through the API, a form, the price-list link or by handlead.stage_changed— a lead moved to another stage (from_stage, to_stage in data)lead.updated— a lead was changed through the APIdocument.viewed— a brochure viewer identified with an email address (data.document, data.view with duration and pages next to the lead)ping— the "Send test" button — same envelope, data.message only
Payload
POST https://your-endpoint.example.com/livelisted
Content-Type: application/json
X-Livelisted-Event: lead.stage_changed
X-Livelisted-Delivery: evt_5f1c…
X-Livelisted-Signature: t=1757412345,v1=9a3e…
{
"id": "evt_5f1c…",
"type": "lead.stage_changed",
"created_at": "2026-09-09T12:05:45Z",
"data": {
"lead": { "id": "7d0e3a2c-…", "name": "Max Muster", "email": "max@example.com", "phone": "+43 …", "source": "meta", "budget": 420000, "utm": {}, "external_id": "leadgen_1234567890", "stage": { "id": "…", "name": "Besichtigung", "kind": "open" }, "project_id": "…", "unit_id": "…", "unit_ref": "A-01", "created_at": "…", "last_activity_at": "…", "url": "https://livelisted.app/enquiries?lead=7d0e3a2c-…" },
"from_stage": { "id": "…", "name": "In Kontakt", "kind": "open" },
"to_stage": { "id": "…", "name": "Besichtigung", "kind": "open" },
"project": { "id": "…", "name": "Seeblick Residences" },
"unit": { "id": "…", "ref": "A-01" }
}
}Verifying the signature
Every delivery carries X-Livelisted-Signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of "<t>.<raw body>" with your endpoint's secret. Compute it over the raw request body (not a re-serialised object), compare in constant time and reject timestamps older than five minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyLivelisted(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(String(parts.v1 ?? ""), "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
// Express: app.post("/livelisted", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyLivelisted(req.body.toString("utf8"), req.get("X-Livelisted-Signature"), process.env.LL_SECRET)) return res.sendStatus(401);
// const event = JSON.parse(req.body); … res.sendStatus(204);
// });Retries
Delivery is attempted immediately. On a timeout or a non-2xx answer we retry after 1 minute, 10 minutes, 1 hour, 6 hours and 24 hours (retries currently run in a daily batch, so the early steps arrive together). After 20 failures in a row the endpoint is switched off and the owners get an email; activate it again in the settings once fixed. Deliveries can arrive more than once — use the envelope id to deduplicate.
Integration recipes
Make.com: Meta Lead Ads → livelisted
- Create an API key with leads:write in livelisted (name it "Make – Meta Lead Ads").
- In Make, start a scenario with the module Facebook Lead Ads → Watch Leads and connect your page and form.
- Add an HTTP → Make a request module: method POST, URL https://livelisted.app/api/v1/leads, header Authorization: Bearer llk_…, body type Raw, content type JSON.
- Map the body: full_name → name, email → email, phone_number → phone, Lead ID → external_id, your form's question about the unit → unit, the project name as a fixed value → project, source → "meta". Add utm.campaign from the ad name if you like.
- Run once, check the card on the board, switch the scenario on. Repeats are harmless thanks to external_id.
Website form
Never put an API key into browser code — anyone can read it there. Post your form to your own backend (a serverless function, your CMS, a Make webhook) and let it call /api/v1/leads with the key. Add utm from the page URL and external_id from the submission id. If all you need is an enquiry button, the public price-list link and the widget already have one — no key required.
Zapier, n8n and everything else
Any tool with a generic HTTP or Webhooks module works: POST JSON to /api/v1/leads with the Authorization header. In Zapier use Webhooks by Zapier → Custom Request; in n8n the HTTP Request node with Header Auth. For the other direction, point a livelisted webhook at the tool's Catch Hook / Webhook trigger URL — remember that the payload is signed, not authenticated with the API key.