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.

Request
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" }
}
Response
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.

Request
GET /api/v1/leads?project=Seeblick%20Residences&stage=Besichtigung&since=2026-09-01T00:00:00Z&limit=100
Authorization: Bearer llk_…
Response
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

FieldTypeDescription
namestringFull name. Alternatively first_name and last_name; we join them.
emailstringEmail address of the prospect. Reply-to of the notification mail.
phonestringPhone number, free format.
messagestringFree text, up to 4000 characters.
projectstringRequired. Project id, public slug (from the price-list link) or exact name, case-insensitive.
unitstringUnit ref inside the project, e.g. A-01. Unknown refs are stored as text.
sourcestringWhere the lead came from: website, meta, make, zapier … Default api. Shown on the card.
external_idstringYour id for the lead (Meta leadgen_id, form submission id). Repeats with the same id return the existing lead instead of creating a duplicate.
utmobjectObject with source, medium, campaign, content, term. Stored on the lead.
budgetnumber | stringNumber in the project's currency. "350.000" and "350000" both work.
consentobject | trueObject { text, at } — the consent text the prospect saw and when. Or true.
assigned_to_emailstringEmail of a team member the lead is assigned to.
tagsstring[]Array of strings, stored under utm.tags.
customobjectObject 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.

401unauthorizedMissing, malformed, unknown or revoked key.
403scopeThe key lacks the needed permission.
400validationThe body or query has invalid fields — see fields.
404not_foundLead, project, stage or unit not found in your organisation.
429rate_limitedMore than 120 requests per minute with one key. Retry-After says when to try again.
500serverSomething 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

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

  1. Create an API key with leads:write in livelisted (name it "Make – Meta Lead Ads").
  2. In Make, start a scenario with the module Facebook Lead Ads → Watch Leads and connect your page and form.
  3. 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.
  4. 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.
  5. 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.