API Reference · v2026-06-15

LeadStack public API

REST + outbound webhooks for the LeadStack CRM. Sub-account-scoped Bearer auth, idempotent writes, signed webhooks, request log observability.

Quickstart

1. Mint a key in Settings → API keys. Copy the lsk_live_... value — you only see it once.

2. Send a request. Authentication is HTTP Bearer:

curl https://YOUR_DEPLOYMENT/api/v1/contacts \
  -H "Authorization: Bearer lsk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"Acme Corp","email":"hello@acme.com"}'

3. Listen for events. Add a webhook in Settings → Webhooks, copy the signing secret, verify each delivery with HMAC-SHA256.

Authentication

Bearer-token auth on every request. Keys are scoped to one sub-account and one mode (live or test).

Key format

lsk_<mode>_<8-char-prefix>_<32-char-secret>
e.g. lsk_live_AB12CD34_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Scopes

  • admin — full CRUD across every resource. Server-to-server only.
  • forms-ingest — write-only on POST /v1/forms/:id/submissions. Safe to embed in client-side JS. Only endpoint with open CORS.

Live vs test mode

Live and test data are walled off entirely. A test-mode key cannot read or modify live data, and vice versa. Test-mode form submissions skip the Speed-to-Lead automation (no real emails or SMS fire).

Versioning

Versions are date-coded. Pin a request with LeadStack-Version: 2026-06-15. If omitted, the request resolves to the version stamped on your key at mint time — so existing integrations don't break when we release a new version.

Current version: 2026-06-15.

Errors

All error responses share a stable shape. Discriminate on code, not message.

{
  "error": {
    "type": "invalid_request",
    "code": "invalid_body",
    "message": "`name` is required (string ≤ 200 chars).",
    "request_id": "req_..."
  }
}

The request_id matches the X-Request-Id response header. Quote it in support tickets — it's the index we look up in the request log.

Rate limits

Per-key sliding windows, mode-namespaced (live + test separate budgets):

  • admin — 60 req/min, 1,000 req/hour
  • forms-ingest — 300 req/min

Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. A 429 response also sends Retry-After.

Idempotency

Send Idempotency-Key: <your-key> on POST / PATCH / DELETE. We cache the response for 24 hours; a retry with the same key returns the original response without re-executing the handler. A retry with the same key but a DIFFERENT body returns 409 idempotency_collision.

Keys are 1–255 characters of [A-Za-z0-9_-:.]. UUIDs are a good default.

Contacts

The lead / customer / person record.

GET/v1/contacts
POST/v1/contacts
GET/v1/contacts/:id
PATCH/v1/contacts/:id
DELETE/v1/contacts/:id

Object

{
  "id": "contact_xxx",
  "object": "contact",
  "livemode": true,
  "name": "Acme Corp",
  "email": "hello@acme.com",
  "phone": "+15555550100",
  "company": "Acme",
  "address": "...",
  "source": "website-form",
  "tags": ["hot"],
  "pipeline_stage": null,
  "territory_id": "global",
  "email_opted_out": false,
  "sms_opted_out": false,
  "attribution": null,
  "location": null,
  "created_at": "2026-05-31T10:00:00.000Z",
  "updated_at": "2026-05-31T10:00:00.000Z"
}

List

Cursor pagination via starting_after. Default limit 20, max 100.

GET /v1/contacts?limit=50&starting_after=contact_xxx

Deals

GET/v1/deals
POST/v1/deals
GET/v1/deals/:id
PATCH/v1/deals/:id
DELETE/v1/deals/:id

Stages: new, contacted, qualified, proposal, won, lost. Priorities: high, medium, low. Filter list by ?stage=...&contact_id=....

Tasks

GET/v1/tasks
POST/v1/tasks
GET/v1/tasks/:id
PATCH/v1/tasks/:id
DELETE/v1/tasks/:id

Filter list by ?completed=true|false and ?contact_id=.... Setting completed: true stamps completed_at and emits task.completed.

Events

GET/v1/events
POST/v1/events
GET/v1/events/:id
PATCH/v1/events/:id
DELETE/v1/events/:id

Calendar events. start_at + end_at are ISO 8601. status: scheduled, awaiting_payment, completed, cancelled, no_show.

Forms ingest

POST/v1/forms/:form_id/submissions

The single endpoint with open CORS. Use a key with forms-ingest scope (write-only) for browser submissions; an admin key works too.

POST /v1/forms/form_xxx/submissions
{
  "values": {
    "field_id_name":  "Acme Corp",
    "field_id_email": "hello@acme.com",
    "field_id_phone": "+15555550100"
  }
}

The submission creates a Contact, writes a form_submitted activity, fires the form's configured automation, and emits the form.submitted webhook. Test-mode submissions skip the automation fire so no real outbound traffic happens.

Webhooks

Subscribe to events from Settings → Webhooks. Each delivery is an HTTP POST to your URL with a signed JSON body.

Envelope

{
  "id": "evt_xxx",
  "type": "contact.created",
  "api_version": "2026-06-15",
  "created": 1716123456,
  "livemode": true,
  "data": { "contact": { ... } },
  "delivery": { "id": "del_xxx", "attempt": 1 }
}

Signature

Each request carries LeadStack-Signature: t=<unix_ts>,v1=<hmac_hex>. Verify it before trusting the payload.

// Node.js verification
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret, rawBody, header) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=", 2)),
  );
  const ts = Number(parts.t);
  if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // 5-min window
  const expected = createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`, "utf8")
    .digest("hex");
  return timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(parts.v1),
  );
}

Retries + circuit breaker

Non-2xx responses (or network failures) retry 3 times at 1m, 5m, 30m. After 10 consecutive failed deliveries, the subscription auto-pauses. Resume it from Settings → Webhooks after fixing the upstream.

Event types

  • contact.created · contact.updated · contact.deleted
  • deal.created · deal.updated · deal.stage.changed · deal.won · deal.lost
  • task.created · task.completed
  • event.created
  • form.submitted
  • quote.sent · quote.viewed · quote.accepted · quote.declined · quote.paid
  • booking.created · booking.cancelled