Skip to content

Lodge API

1Quickstart

Ask an administrator to add your app in Admin → Integrations. Copy the key; it is shown once. Then check it:

GET /me returns your app's name and scopes.

Base URL: https://<your Lodge address>/api/v1. Each workspace has its own address.

Base URL
https://<your Lodge address>/api/v1

1.1GET/me

Needs scopeevents:write

Returns the app's slug, name and scopes. Use it to verify a key.

Answers
StatusMeaning
200The key works
401Unknown or malformed keyMissing or malformed Authorization header. Expected: Bearer hub_live_…
Request
curl "https://<your Lodge address>/api/v1/me" \
  -H "Authorization: Bearer hub_live_…"
Response200 · OK
{
  "ok": true,
  "app": {
    "slug": "billing",
    "name": "Billing",
    "scopes": [
      "events:write",
      "tasks:write",
      "crm:write"
    ],
    "webhook_url": "https://billing.example.com/hooks/lodge",
    "webhook_events": [
      "task.completed"
    ]
  }
}

2Authentication

Send the key as a bearer token: Authorization: Bearer hub_live_…. Lodge keeps only a SHA-256 hash of it, so a lost key is replaced, not recovered. Scopes are set per app:

Used by
events:writeGET /me · POST /events
tasks:writePOST /tasks
tasks:readGET /tasks
projects:readGET /projects
people:readGET /people
kpis:readGET /kpis
kpis:writePOST /kpi-entries
crm:writePOST /contacts · POST /companies
Request
GET /api/v1/me HTTP/1.1
Host: <your Lodge address>
Authorization: Bearer hub_live_…

3Events

Record something that happened in another tool. It appears in What's happening, can notify people, and can be forwarded to other apps by webhook. Send an idempotency_key and a retry returns deduplicated: true instead of a second item.

3.1POST/events

Needs scopeevents:write

Record something that happened.

Body
FieldType and rules
typestringrequiredlower-case, dotted · max 80 chars
titlestringrequired1–200 chars
bodystringoptionaldefault ""max 2000 chars
urlstringoptionalhttp(s) URL · max 1000 chars
occurred_atstringoptionalISO 8601 date-time with offset
idempotency_keystringoptional1–200 chars
actorobjectoptional
actor.namestringoptionalmax 120 chars
actor.emailstringoptionalemail
entityobjectoptional
entity.typestringrequiredmax 60 chars
entity.idstringrequiredmax 200 chars
project_keystringoptional2–10 capitals or digits
notifyobjectoptional
notify.emailsstring[]optionalmax 50 items · each: email
notify.rolesenum[]optionaleach: admin | member
notify.titlestringoptionalmax 200 chars
payloadobjectoptionaldefault {}any keys · values: any JSON
  • type: lower-case, dotted. Shown in the feed and used for webhook subscriptions.
  • idempotency_key: repeat sends return 200 {deduplicated: true} instead of a second item.
  • actor.email: if it matches a member, the feed shows their avatar.
  • notify: creates in-app notifications (and emails, per each person's preference) for the people named or for every member with a role.
Answers
StatusMeaning
202Recorded
200Already recorded under this idempotency_key
422Invalid event
POST /eventsA sandbox: nothing is sent to any workspace. The check runs Lodge's own schema.

JSON · 414 bytes

Request
curl -X POST "https://<your Lodge address>/api/v1/events" \
  -H "Authorization: Bearer hub_live_…" \
  -H "Content-Type: application/json" \
  -d '{"type":"order.placed","title":"Acme placed an order for 40 units","body":"Standard plan · ships next week","url":"https://billing.example.com/orders/abc","idempotency_key":"order:abc","actor":{"name":"Jane Customer","email":"jane@customer.example"},"entity":{"type":"order","id":"abc"},"project_key":"LAUNCH","notify":{"roles":["admin"],"emails":["owner@example.com"],"title":"New order"},"payload":{"units":40}}'
Assumes a working key with the right scope; without one, the answer is 401 or 403 first.
Response

Accepted

{
  "ok": true,
  "id": "1c7e9a52-3b8d-4f60-a2e1-9d4c5b6a7f80",
  "feed_id": "8a2d4c6e-1f3b-4a5c-9e7d-0b1c2d3e4f5a",
  "deduplicated": false,
  "notified": 1,
  "feed_url": "https://<your Lodge address>/feed?app=billing"
}

4Tasks

Create a task, or update one you created before by sending the same external_ref. Updates are partial: send only what changed, so an app can close a task without touching its owner, date or description.

4.1POST/tasks

Needs scopetasks:write

Create or upsert a task.

Body
FieldType and rules
titlestringrequired1–200 chars
descriptionstringoptionalmax 8000 chars
project_keystringoptional2–10 capitals or digits
priorityenumoptionalA | B | C
statusenumoptionalnot_started | in_process | strong | completed | cancelled
percentintegeroptional0–100
due_datestringoptionalYYYY-MM-DD
owner_emailstringoptionalemail
collaborator_emailsstring[]optionalmax 6 items · each: email
external_refstringoptional1–200 chars
external_urlstringoptionalhttp(s) URL · max 1000 chars
  • With external_ref, a second call updates the same task (title, description, due, owner, status/percent) instead of creating another one. The owner is notified; the task shows the app as its source with a link back.
  • Updates are partial: { title, external_ref, status: "completed" } closes a task without touching its owner, date or description.
  • owner_email, collaborator_emails, actor.email and notify.emails on one of your staff domains also match the same mailbox name on the others.
Answers
StatusMeaning
201Created, or updated by external_ref
200The task was deleted in Lodge; the send is not applied (deleted: true)
400An unknown project_keyUnknown project key LAUNCH.
422Invalid task
POST /tasksA sandbox: nothing is sent to any workspace. The check runs Lodge's own schema.

JSON · 302 bytes

Request
curl -X POST "https://<your Lodge address>/api/v1/tasks" \
  -H "Authorization: Bearer hub_live_…" \
  -H "Content-Type: application/json" \
  -d '{"title":"Confirm delivery date with Acme","description":"Markdown allowed","project_key":"LAUNCH","priority":"A","due_date":"2026-09-25","owner_email":"owner@example.com","collaborator_emails":["teammate@example.com"],"external_ref":"order:abc","external_url":"https://billing.example.com/orders/abc"}'
Assumes a working key with the right scope; without one, the answer is 401 or 403 first.
Response

Created

{
  "ok": true,
  "task": {
    "id": "9d3e2a71-0c4b-4f86-b5d2-7a1c6e8f0b34",
    "title": "Confirm delivery date with Acme",
    "status": "not_started",
    "steps": {
      "done": 0,
      "total": 0
    },
    "percent": 0,
    "due_date": "2026-09-25",
    "url": "https://<your Lodge address>/tasks/9d3e2a71-0c4b-4f86-b5d2-7a1c6e8f0b34"
  }
}

4.2GET/tasks

Needs scopetasks:read

Read tasks. Each has its status and steps {done, total}. Deleted tasks are left out.

Query
projectKEYOnly this project's tasks.
statusopen · all · not_started · in_process · strong · completed · cancelledDefault open.
owneremailOnly tasks this person owns.
sourcemeOnly tasks this app created.
Answers
StatusMeaning
200Up to 200 tasks, earliest due first
404Unknown projectUnknown project LAUNCH.
422An unknown status
Request
curl "https://<your Lodge address>/api/v1/tasks?project=LAUNCH&status=open" \
  -H "Authorization: Bearer hub_live_…"
Response

OK

{
  "ok": true,
  "tasks": [
    {
      "id": "9d3e2a71-0c4b-4f86-b5d2-7a1c6e8f0b34",
      "title": "Confirm delivery date with Acme",
      "description": "Markdown allowed",
      "status": "in_process",
      "percent": 50,
      "priority": "A",
      "due_date": "2026-09-25",
      "project_id": "e2b7c9d4-5a13-4c08-9f6e-1d2b3c4a5e6f",
      "owner_id": "6b5a4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
      "source_app": "billing",
      "external_ref": "order:abc",
      "external_url": "https://billing.example.com/orders/abc",
      "updated_at": "2026-09-23T16:40:12.000Z",
      "steps": {
        "done": 1,
        "total": 3
      },
      "url": "https://<your Lodge address>/tasks/9d3e2a71-0c4b-4f86-b5d2-7a1c6e8f0b34"
    }
  ]
}

5Projects and people

One source of truth for the team and the work: names, emails, titles, departments, roles.

5.1GET/projects

Needs scopeprojects:read

Keys and names, for pickers in other apps. Archived projects are left out.

Answers
StatusMeaning
200Every project that isn't archived
Request
curl "https://<your Lodge address>/api/v1/projects" \
  -H "Authorization: Bearer hub_live_…"
Response200 · OK
{
  "ok": true,
  "projects": [
    {
      "id": "e2b7c9d4-5a13-4c08-9f6e-1d2b3c4a5e6f",
      "key": "LAUNCH",
      "name": "Spring launch",
      "status": "active",
      "target_date": "2026-11-30",
      "url": "https://<your Lodge address>/projects/LAUNCH"
    }
  ]
}

5.2GET/people

Needs scopepeople:read

One source of truth for "the team": name, email, title, department, role.

Answers
StatusMeaning
200The active roster, by name
Request
curl "https://<your Lodge address>/api/v1/people" \
  -H "Authorization: Bearer hub_live_…"
Response200 · OK
{
  "ok": true,
  "people": [
    {
      "id": "0d9c8b7a-6f5e-4d3c-2b1a-0f9e8d7c6b5a",
      "full_name": "Jordan Avery",
      "email": "jordan@example.com",
      "title": "Project lead",
      "department": "Operations",
      "role": "member"
    }
  ]
}

6Scorecard

Log one number per KPI per day; a second value for the same day replaces the first. KPIs Lodge computes itself refuse logged values with 409.

6.1POST/kpi-entries

Needs scopekpis:write

Log a number on the scorecard.

Body
FieldType and rules
kpistringrequired1–60 chars
valuenumberrequiredfinite
recorded_onstringoptionalYYYY-MM-DD
notestringoptionalmax 300 chars
  • kpi is the KPI's handle (shown on its page). One value per KPI per day; a second call for the same day replaces it.
  • Without recorded_on, the day is today in the workspace's time zone.
  • An app that owns a number (orders placed, open tickets, revenue booked) logs it once a day.
Answers
StatusMeaning
201Logged (or replaced for that day)
404An unknown or archived KPIUnknown KPI "orders-placed".
409Lodge computes this KPI itself
422Invalid KPI entry
POST /kpi-entriesA sandbox: nothing is sent to any workspace. The check runs Lodge's own schema.

JSON · 94 bytes

Request
curl -X POST "https://<your Lodge address>/api/v1/kpi-entries" \
  -H "Authorization: Bearer hub_live_…" \
  -H "Content-Type: application/json" \
  -d '{"kpi":"orders-placed","value":3,"recorded_on":"2026-09-22","note":"Acme, Northwind, Contoso"}'
Assumes a working key with the right scope; without one, the answer is 401 or 403 first.
Response

Logged

{
  "ok": true,
  "kpi": "orders-placed",
  "recorded_on": "2026-09-22",
  "value": 3,
  "url": "/kpis/4f6a8c0e-2b4d-4e6f-8a0c-2e4f6a8c0e2b"
}

6.2GET/kpis

Needs scopekpis:read

Every active KPI with its targets and latest logged value.

Answers
StatusMeaning
200The scorecard, in its order
Request
curl "https://<your Lodge address>/api/v1/kpis" \
  -H "Authorization: Bearer hub_live_…"
Response200 · OK
{
  "ok": true,
  "kpis": [
    {
      "id": "4f6a8c0e-2b4d-4e6f-8a0c-2e4f6a8c0e2b",
      "slug": "orders-placed",
      "title": "Orders placed",
      "description": "",
      "unit": "orders",
      "prefix": "",
      "direction": "up",
      "aggregation": "sum",
      "source": "app:billing",
      "target_day": null,
      "target_week": 15,
      "target_month": 60,
      "target_quarter": null,
      "target_year": null,
      "featured": true,
      "latest": {
        "recorded_on": "2026-09-22",
        "value": 3
      },
      "url": "/kpis/4f6a8c0e-2b4d-4e6f-8a0c-2e4f6a8c0e2b"
    }
  ]
}

7Contacts and companies

Send the people and organisations your other tools know, one at a time or up to 200 per call. Lodge merges them with what other apps sent. Your own staff are skipped. Nothing is ever sent to the people themselves, and no one in Lodge is notified.

7.1POST/contacts

Needs scopecrm:write

Send people to Contacts. One record, or { "items": [ … ] } with up to 200. Each item succeeds or fails on its own.

Body · each item
FieldType and rules
external_idstringrequired1–200 chars
namestringrequired1–200 chars
kindenumrequiredpartner | pilot | lead | customer | other
labelstringoptionalmax 80 chars
statusstringoptionalmax 80 chars
urlstringoptionalhttp(s) URL · max 1000 chars
factsobjectoptionalkeys max 60 chars · values: string | number | boolean | null · text values max 500 chars · at most 30 facts
owner_emailstringoptionalemail
seen_atstringoptionalISO 8601 date-time with offset
emailstringoptionalemail · max 320 chars
phonestringoptionalmax 60 chars
titlestringoptionalmax 120 chars
companyobject | nulloptional
company.external_idstringoptional1–200 chars
company.namestringrequired1–200 chars
company.domainstringoptionalmax 120 chars
  • People merge on your external_id, then the email address. The same person from two apps is one contact, with one card per app.
  • Your staff and anyone on the roster are skipped; they are the team, not contacts.
  • Fields you send overwrite; fields you leave out are kept.
Answers
StatusMeaning
200A result per item, in order (some may have failed)
422Every item failed, or the batch was empty or over 200Send between 1 and 200 items.
POST /contactsA sandbox: nothing is sent to any workspace. The check runs Lodge's own schema.

JSON · 408 bytes

Request
curl -X POST "https://<your Lodge address>/api/v1/contacts" \
  -H "Authorization: Bearer hub_live_…" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"external_id":"user:ck7a","name":"Jane Doe","email":"jane@customer.example","phone":"+1 555 0100","title":"Operations lead","kind":"customer","company":{"external_id":"account:ck9x","name":"Example Customer Co"},"label":"Account admin","status":"Active","seen_at":"2026-09-24T14:02:00Z","url":"https://billing.example.com/users/ck7a","facts":{"Role":"Account admin","Last sign-in":"2026-09-24"}}]}'
Assumes a working key with the right scope; without one, the answer is 401 or 403 first.
Response

Merged

{
  "ok": true,
  "saved": 1,
  "skipped": 0,
  "failed": 0,
  "results": [
    {
      "external_id": "user:ck7a",
      "id": "3e1a5c7b-9d2f-4b6a-8c0e-1f3a5b7c9d2e"
    }
  ]
}

7.2POST/companies

Needs scopecrm:write

Send organizations to Contacts. One record, or { "items": [ … ] } with up to 200.

Body · each item
FieldType and rules
external_idstringrequired1–200 chars
namestringrequired1–200 chars
kindenumrequiredpartner | pilot | customer | prospect
labelstringoptionalmax 80 chars
statusstringoptionalmax 80 chars
urlstringoptionalhttp(s) URL · max 1000 chars
factsobjectoptionalkeys max 60 chars · values: string | number | boolean | null · text values max 500 chars · at most 30 facts
owner_emailstringoptionalemail
seen_atstringoptionalISO 8601 date-time with offset
domainsstring[]optionalmax 20 items · each: max 120 chars
websitestringoptionalmax 300 chars
regionstringoptionalmax 80 chars
industrystringoptionalmax 120 chars
  • Companies merge on your external_id, then the name (ignoring case), then any shared email domain.
  • facts are shown on the profile as sent, in order.
  • Nothing is ever sent to the people themselves and no one in Lodge is notified.
Answers
StatusMeaning
200A result per item, in order
422Every item failed, or the batch was empty or over 200
POST /companiesA sandbox: nothing is sent to any workspace. The check runs Lodge's own schema.

JSON · 359 bytes

Request
curl -X POST "https://<your Lodge address>/api/v1/companies" \
  -H "Authorization: Bearer hub_live_…" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"external_id":"account:ck9x","name":"Example Customer Co","kind":"customer","domains":["customer.example"],"region":"US East","industry":"Logistics","website":"customer.example","label":"Annual plan","status":"Active","url":"https://billing.example.com/accounts/ck9x","owner_email":"owner@example.com","facts":{"Plan":"Annual","Open invoices":1}}]}'
Assumes a working key with the right scope; without one, the answer is 401 or 403 first.
Response200 · Merged
{
  "ok": true,
  "saved": 1,
  "skipped": 0,
  "failed": 0,
  "results": [
    {
      "external_id": "account:ck9x",
      "id": "7a9c1e3b-5d7f-4a2c-8e0b-3d5f7a9c1e3b"
    }
  ]
}

8Webhooks

Set a webhook URL and the events you want on your app's page. Each delivery carries four headers and a signature over the timestamp and the raw body: sha256=HMAC(secret, "<timestamp>.<raw body>"). Failed deliveries retry six times with quadratic back-off and show on the app's admin page. An app never receives its own events back.

Delivery headers
X-Roundhouse-Eventthe event kind
X-Roundhouse-Deliveryone id per delivery
X-Roundhouse-TimestampUnix seconds
X-Roundhouse-Signaturesha256=<hex>

Kinds Lodge sends

  • task.created
  • task.completed
  • project.created
  • project.status
  • event.created
  • announcement.published
  • document.uploaded
  • page.created
  • member.joined
  • channel.created
  • kudos.given
  • goal.created
  • goal.status
  • kpi.created
A live signature, signed in this browser.
DEMO SECRET
whsec_demo_…
X-Roundhouse-Event
task.completed
X-Roundhouse-Delivery
5b0f6c2a-81e4-4d9b-a3c7-2f1e9d8c7b6a
X-Roundhouse-Timestamp
…
X-Roundhouse-Signature
…
Signs
…

Receiver · verifyRoundhouseWebhook()

…

integrations/examples/webhook-receiver.ts
import { verifyRoundhouseWebhook } from "./roundhouse-client";

export async function POST(request: Request) {
  const rawBody = await request.text();
  const ok = await verifyRoundhouseWebhook({
    secret: process.env.ROUNDHOUSE_WEBHOOK_SECRET!,
    timestamp: request.headers.get("x-roundhouse-timestamp"),
    signature: request.headers.get("x-roundhouse-signature"),
    rawBody,
  });
  if (!ok) return Response.json({ error: "bad signature" }, { status: 401 });
  // …handle JSON.parse(rawBody)
  return Response.json({ received: true });
}

9Errors

Every error has the same shape. 401 unknown key · 403 app disabled or missing scope · 422 validation, with the field that failed · 404 unknown project.

Statuses
StatusMeaning
401No key, or a key Lodge doesn't knowUnknown API key.
403The app is disabled, or lacks the scopeApp "billing" lacks the tasks:write scope.
422The body failed validation; issues name the field
404An unknown project (GET /tasks)
429Too many calls for this app, or too many bad keys from one network; see Retry-AfterRate limit exceeded for this app.
500Lodge's own failure; the details stay in its logSomething went wrong on our side. It has been logged.
Error shape
{
  "error": {
    "status": 422,
    "message": "Invalid event.",
    "issues": [
      {
        "path": "type",
        "message": "type must look like `deal.registered`"
      }
    ]
  }
}

10The client

integrations/roundhouse-client.ts is one TypeScript file with no dependencies. Copy it into any app; it covers every endpoint and verifies webhooks on the receiving side.

Methods
meGET /me
postEventPOST /events
createTaskPOST /tasks
logKpiPOST /kpi-entries
listKpisGET /kpis
listTasksGET /tasks
listProjectsGET /projects
upsertContactsPOST /contacts
upsertCompaniesPOST /companies
listPeopleGET /people
verifyRoundhouseWebhookreceiving side
integrations/roundhouse-client.ts
const hub = createRoundhouseClient({ baseUrl: process.env.ROUNDHOUSE_URL!, apiKey: process.env.ROUNDHOUSE_API_KEY! });
await hub.postEvent({ type: "ticket.opened", title: "Login loop on Android", idempotency_key: "ticket:123" });
await hub.createTask({ title: "Fix Android login loop", priority: "A", owner_email: "dev@example.com", external_ref: "ticket:123" });

11Names that don't change

Some names in this contract say "Roundhouse": the X-Roundhouse-* webhook headers, the client file and its environment variables. They come from Lodge's earlier name and are fixed on purpose, so your integrations never break.

Fixed names
X-Roundhouse-Eventwebhook header · the event kind
X-Roundhouse-Deliverywebhook header · one id per delivery
X-Roundhouse-Timestampwebhook header · Unix seconds
X-Roundhouse-Signaturewebhook header · sha256=<hex>
ROUNDHOUSE_URLenv · your Lodge address
ROUNDHOUSE_API_KEYenv · the app's key
ROUNDHOUSE_WEBHOOK_SECRETenv · the webhook secret
integrations/roundhouse-client.tsthe client file
createRoundhouseClientclient · calls the API
verifyRoundhouseWebhookclient · checks a delivery
The app's environment
ROUNDHOUSE_URL=https://<your Lodge address>
ROUNDHOUSE_API_KEY=hub_live_…
ROUNDHOUSE_WEBHOOK_SECRET=whsec_…

No integrations are installed by default; an administrator adds each app.

Questions about connecting something specific:Talk to usStart a trial