Developers

The Couchwork API and webhooks

Create and read contacts, companies and leads from other systems, and have Couchwork tell your other tools when something changes. It works with Zapier, Make, n8n or your own code.

Overview

Couchwork runs on your own server, so the API lives on your address. In this page we write https://YOUR-SERVER; use the address you sign in to the Couchwork app with (for a box behind a tunnel, that is something like https://acme.example.com). The server needs to be reachable from wherever the calls come from.

  • REST and JSON. Everything is under /api/v1. Send and receive JSON.
  • Contacts and leads. Create, find, update and move them through your pipelines, and add notes.
  • Webhooks. Instead of polling, get a signed message the moment a lead or contact changes.
  • Machine-readable. Your server publishes an OpenAPI description at /api/v1/openapi.json, with no key needed. Import it into Postman or generate a client from it.

Quick start

  1. Create a key. In the Couchwork app, an admin goes to Settings → Integrations → API keys → Create API key, names it and picks its permissions. Copy the key now: it starts cw_ and is shown only once.
  2. Check it works.
    curl https://YOUR-SERVER/api/v1/me \
      -H "Authorization: Bearer cw_…"
    { "name": "Zapier", "scopes": ["contacts:read", "contacts:write", "leads:read", "leads:write"] }
  3. Create a lead with its contact in one call:
    curl -X POST https://YOUR-SERVER/api/v1/leads \
      -H "Authorization: Bearer cw_…" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: 3f1d2c4e-0b7a-4a53-9a31-5c2d8e9f1a10" \
      -d '{
        "title": "Website redesign",
        "value": 4000,
        "notes": "Asked for a quote on the contact form",
        "contact": { "firstName": "Sam", "lastName": "Jones", "email": "sam@example.com" }
      }'
    {
      "id": "5d3f6a52-…",
      "title": "Website redesign",
      "value": 4000,
      "notes": "Asked for a quote on the contact form",
      "dueDate": null,
      "pipeline": { "id": "…", "name": "Sales" },
      "stage": { "id": "…", "name": "Lead", "kind": "open" },
      "assignee": null,
      "contact": { "id": "…", "name": "Sam Jones", "email": "sam@example.com", "phone": "", "company": "" },
      "createdAt": "2026-10-17T11:20:00.000Z",
      "updatedAt": "2026-10-17T11:20:00.000Z"
    }

The lead appears on the board straight away, in the first stage of your first pipeline (or in the stage you name with stageId, or the first stage of the pipeline you name with pipelineId), and anything set up to run when a lead enters that stage, such as an automatic email, runs as normal.

Keys and permissions

Send the key on every request as Authorization: Bearer cw_…. Keep it secret, like a password: anyone holding it can do what it is allowed to do. Only a fingerprint is stored on your server, so a lost key can't be shown again. Revoke it in the app and make another.

PermissionAllows
contacts:readList and read shared contacts and companies
contacts:writeCreate and change shared contacts and companies
leads:readList and read leads, pipeline stages and the people who can own leads
leads:writeCreate and change leads, move them between stages and add notes

Give each key only what it needs, and make a separate key for each system so you can revoke one without breaking the others. A key's permissions can't be changed later: revoke it and create another.

How it works

Who owns what a key creates

A key acts as the person who created it. Contacts and leads it creates are owned by them, and the lead's activity says "Lead created via API: Zapier", so your team can see where things came from. Moves, changes and notes are labelled the same way.

Shared contacts only

The API only ever sees shared contacts. Personal contacts are private to their owner: they are not listed, can't be fetched by ID, and don't count when checking for a duplicate email.

Retrying safely (idempotency)

Networks fail, and tools retry. Add an Idempotency-Key header (any unique value, such as a UUID) to creates. If the same key is sent again within 24 hours you get the original answer back, with the header Idempotent-Replayed: true, and nothing is created twice.

Finding and polling

Lists return { "data": [...], "total": 123, "limit": 50, "offset": 0 }. Use limit (1 to 100) and offset to page. To pick up changes, ask for what changed since your last check:

curl "https://YOUR-SERVER/api/v1/leads?updatedSince=2026-10-17T09:00:00Z&order=asc" \
  -H "Authorization: Bearer cw_…"

Where you can, use webhooks instead, so changes arrive the moment they happen.

Mistakes are reported, not ignored

Requests with fields the API doesn't know are refused with a message such as Unrecognized key(s) in object: 'emial', so a typo doesn't silently lose data.

Errors

Errors are JSON, { "error": "What went wrong" }, with a status code:

400The request is invalid; the message says why
401No key, a wrong key, or a revoked key
403The key doesn't have the permission this needs
404Not found (or a personal contact)
409Conflict, for example a contact with that email already exists
429Too many requests. Wait for the number of seconds in the Retry-After header

Limits

At most 120 requests a minute per key. Repeated wrong keys from one address are slowed down too.

Endpoints

This reference is generated from the API's own OpenAPI description. Every path starts with your server address.

GET /api/v1/me

Check a key

Works with any valid key. Returns its name and permissions.

Responses

  • 200 The key
  • 401 Missing, wrong or revoked key

GET /api/v1/pipelines

List pipelines with their stages

Needs leads:read A workspace can have several pipelines (for example Sales and Support), each with its own stages.

Responses

  • 200 Pipelines in order
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

GET /api/v1/stages

List every stage

Needs leads:read A flat list of the stages of all pipelines, each saying which pipeline it belongs to.

Responses

  • 200 Stages, pipeline by pipeline
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

GET /api/v1/users

List people who can own leads

Needs leads:read

Responses

  • 200 People
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

GET /api/v1/contacts

List shared contacts

Needs contacts:read

Parameters

NameInDescription
emailqueryExact email address.
qqueryWords in the name, email or company.
updatedSincequeryOnly records changed at or after this time (ISO 8601, e.g. 2026-10-17T09:00:00Z). Use it to poll for changes.
limitquery1 to 100. Default 50.
offsetqueryHow many to skip. Default 0.

Responses

  • 200 Contacts, most recently changed first
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

POST /api/v1/contacts

Create a shared contact

Needs contacts:write Without upsert, an email that already exists is refused with 409.

Parameters

NameInDescription
upsertquerySet to true to update the existing contact with the same email instead.
Idempotency-KeyheaderSend a unique value (for example a UUID) with each create. If you retry the same request with the same value, you get the original answer back and nothing is created twice. Kept for 24 hours.

Request body

FieldTypeNotes
firstNamestring
lastNamestring
emailstring or null
phonestring
companystringMatched to an existing company by name, or a new company is created.
companyIdstring or null uuidPut the person in this company. Send null on a PATCH to remove them from their company.
jobTitlestring
notesstring
tagsarray of string

Responses

  • 200 Updated an existing contact (upsert)
  • 201 Created
  • 400 Invalid input
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 409 Email already exists
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

GET /api/v1/contacts/{id}

Get a contact

Needs contacts:read

Parameters

NameInDescription
idpath required

Responses

  • 200 The contact
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 404 Not found
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

PATCH /api/v1/contacts/{id}

Change a contact

Needs contacts:write Only the fields you send change.

Parameters

NameInDescription
idpath required

Request body

FieldTypeNotes
firstNamestring
lastNamestring
emailstring or null
phonestring
companystringMatched to an existing company by name, or a new company is created.
companyIdstring or null uuidPut the person in this company. Send null on a PATCH to remove them from their company.
jobTitlestring
notesstring
tagsarray of string

Responses

  • 200 The contact
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 404 Not found
  • 409 Another contact has that email
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

GET /api/v1/companies

List companies

Needs contacts:read Companies that have at least one shared contact, or were created on purpose.

Parameters

NameInDescription
qqueryWords in the name or website.
updatedSincequeryOnly records changed at or after this time (ISO 8601, e.g. 2026-10-17T09:00:00Z). Use it to poll for changes.
limitquery1 to 100. Default 50.
offsetqueryHow many to skip. Default 0.

Responses

  • 200 Companies, most recently changed first
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

POST /api/v1/companies

Create a company

Needs contacts:write A name that already exists (ignoring case, punctuation and endings like Ltd) is refused with 409, unless upsert is true.

Parameters

NameInDescription
upsertquerySet to true to update the existing company with the same name instead.
Idempotency-KeyheaderSend a unique value (for example a UUID) with each create. If you retry the same request with the same value, you get the original answer back and nothing is created twice. Kept for 24 hours.

Request body

FieldTypeNotes
name requiredstring
websitestring
phonestring
addressstring
notesstring

Responses

  • 200 Updated an existing company (upsert)
  • 201 Created
  • 400 Invalid input
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 409 Name already exists
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

GET /api/v1/companies/{id}

Get a company with its people

Needs contacts:read

Parameters

NameInDescription
idpath required

Responses

  • 200 The company and its shared contacts
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 404 Not found
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

PATCH /api/v1/companies/{id}

Change a company

Needs contacts:write Only the fields you send change. Renaming updates the company shown on each person.

Parameters

NameInDescription
idpath required

Request body

FieldTypeNotes
namestring
websitestring
phonestring
addressstring
notesstring

Responses

  • 200 The company
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 404 Not found
  • 409 Another company has that name
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

GET /api/v1/leads

List leads

Needs leads:read

Parameters

NameInDescription
pipelineIdqueryOnly leads in this pipeline.
stageIdqueryOnly this stage.
statusqueryopen, won or lost.
assigneeIdqueryOnly this owner.
contactIdqueryOnly this contact.
qqueryWords in the title.
updatedSincequeryOnly records changed at or after this time (ISO 8601, e.g. 2026-10-17T09:00:00Z). Use it to poll for changes.
orderqueryNewest change first (desc, default) or oldest first (asc).
limitquery1 to 100. Default 50.
offsetqueryHow many to skip. Default 0.

Responses

  • 200 Leads
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

POST /api/v1/leads

Create a lead

Needs leads:write Anything set up to run when a lead enters its stage (emails, alerts, webhooks) runs as normal.

Parameters

NameInDescription
Idempotency-KeyheaderSend a unique value (for example a UUID) with each create. If you retry the same request with the same value, you get the original answer back and nothing is created twice. Kept for 24 hours.

Request body

FieldTypeNotes
title requiredstring
pipelineIdstring uuidCreate the lead in the first stage of this pipeline. Not with stageId.
stageIdstring uuidDefaults to the first stage of the first pipeline. Not with pipelineId.
contactIdstring uuid
contactobjectAlternatively, describe the contact. An existing shared contact with the same email is reused (and left as it is); otherwise one is created. Not with contactId.
assigneeIdstring uuid
valuenumber
dueDatestringYYYY-MM-DD
notesstring

Responses

  • 201 Created
  • 400 Invalid input
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

GET /api/v1/leads/{id}

Get a lead

Needs leads:read

Parameters

NameInDescription
idpath required

Responses

  • 200 The lead
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 404 Not found
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

PATCH /api/v1/leads/{id}

Change a lead

Needs leads:write Only the fields you send change; send null to clear contactId, assigneeId or dueDate. Use /move to change stage.

Parameters

NameInDescription
idpath required

Request body

FieldTypeNotes
titlestring
contactIdstring or null
assigneeIdstring or null
valuenumber
dueDatestring or null
notesstring

Responses

  • 200 The lead
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 404 Not found
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

POST /api/v1/leads/{id}/move

Move a lead to another stage

Needs leads:write Goes to the end of that stage.

Parameters

NameInDescription
idpath required

Request body

FieldTypeNotes
stageId requiredstring uuid

Responses

  • 200 The lead
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 404 Not found
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

POST /api/v1/leads/{id}/notes

Add a note to a lead

Needs leads:write Appears in the lead’s activity.

Parameters

NameInDescription
idpath required

Request body

FieldTypeNotes
text requiredstring

Responses

  • 201 Added
  • 401 Missing, wrong or revoked key
  • 403 The key doesn’t have this permission
  • 404 Not found
  • 429 Too many requests (120 a minute per key). See the Retry-After header.

Objects

Contact

FieldTypeNotes
idstring uuid
firstNamestring
lastNamestring
emailstring or null
phonestring
companystring
companyIdstring or null uuidThe company this person belongs to.
jobTitlestring
notesstring
tagsarray of string
ownerobject or null
createdAtstring date-time
updatedAtstring date-time

Lead

FieldTypeNotes
idstring uuid
titlestring
valuenumber
notesstring
dueDatestring or null
pipelineobject
stageobject
assigneeobject or null
contactobject or null
createdAtstring date-time
updatedAtstring date-time

Stage

FieldTypeNotes
idstring
namestring
kindopen | won | lost
positioninteger
pipelineobjectThe pipeline this stage belongs to.

Person

FieldTypeNotes
idstring
namestring

Company

FieldTypeNotes
idstring uuid
namestring
websitestring
phonestring
addressstring
notesstring
peopleintegerHow many shared contacts belong to it.
createdAtstring date-time
updatedAtstring date-time

Webhooks

A webhook is Couchwork telling another system that something happened. You give Couchwork an address; when an event you chose occurs, it sends a POST there with the details.

  1. In the app: Settings → Integrations → Add webhook. Give it a name, the address to send to, and the events you want (or everything).
  2. Copy the signing secret. It is shown once. Your receiver uses it to prove a message really came from your Couchwork.
  3. Press Send test. Couchwork sends a webhook.test event straight away and tells you whether your receiver answered.

What arrives

Every message is a POST with a JSON body in this envelope:

{
  "id": "0b6e3a91-…",              // the delivery id: the same on every retry
  "event": "lead.stage_changed",
  "createdAt": "2026-10-17T11:20:00.000Z",
  "data": { … }                     // depends on the event, see below
}

and these headers:

Couchwork-EventThe event name, e.g. lead.created
Couchwork-DeliveryThe delivery id (same as id). Keep the ones you've handled and ignore repeats
Couchwork-Signaturet=<unix seconds>,v1=<hex>, explained below

Answer with any 2xx status within 10 seconds. Do slow work after replying.

Retries

If your receiver is down, or answers anything other than 2xx, Couchwork tries again after about 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours: six attempts in all, over roughly 14 hours. Every attempt is recorded under Deliveries for that webhook, where you can also send one again by hand. History is kept for 14 days. Deliveries can arrive out of order, so use the timestamps, not the arrival order.

Where it can send

For safety, addresses must be https:// and must not point at localhost or a private network. If your receiver is on your own network, an admin can switch on Allow addresses on my own network. Redirects aren't followed.

Events

EventSent whendata contains
lead.createdA lead is created, in the app, by the AI, through the API, or from the website formlead, createdBy, source (app, web_form or api)
lead.updatedA lead's details changelead, updatedBy
lead.stage_changedA lead moves to another stagelead, from, to, movedBy
lead.wonA lead moves into a stage marked as wonas lead.stage_changed
lead.lostA lead moves into a stage marked as lostas lead.stage_changed
lead.deletedA lead is deletedlead (as it was), deletedBy
contact.createdA shared contact is created (including by import)contact, by
contact.updatedA shared contact changescontact, by
contact.deletedA shared contact is deletedcontact (as it was), by
form.submittedSomeone sends the website enquiry formform (id, name: which website form), lead, contact, submission (name, email, phone, subject, message)
webhook.testYou press Send testmessage

The lead and contact objects are the same ones the API returns (see Objects). Personal contacts, and everyone's private tasks, are never sent. Here is a lead.stage_changed event:

{
  "id": "0b6e3a91-…",
  "event": "lead.stage_changed",
  "createdAt": "2026-10-17T11:20:00.000Z",
  "data": {
    "lead": {
      "id": "5d3f6a52-…", "title": "Website redesign", "value": 4000, "notes": "", "dueDate": null,
      "pipeline": { "id": "…", "name": "Sales" },
      "stage": { "id": "…", "name": "Proposal", "kind": "open" },
      "assignee": { "id": "…", "name": "Priya" },
      "contact": { "id": "…", "name": "Sam Jones", "email": "sam@example.com", "phone": "", "company": "" },
      "createdAt": "2026-10-17T09:02:11.000Z", "updatedAt": "2026-10-17T11:20:00.000Z"
    },
    "from": { "id": "…", "name": "Lead" },
    "to": { "id": "…", "name": "Proposal", "kind": "open" },
    "movedBy": { "id": "…", "name": "Rory" }
  }
}

When a change was made through the API, the actor's name is API: <key name>.

Checking signatures

Anyone who knows your receiver's address could post to it, so check every request really came from your Couchwork. Couchwork-Signature looks like t=1760700000,v1=9c1f…. Work out HMAC-SHA256 of the text <t>.<raw body> using your signing secret, as hex, and compare it to v1 with a constant-time comparison. Use the body exactly as received (before any JSON parsing), and reject messages whose t is more than a few minutes old, which stops replays.

Node.js

import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const a = Buffer.from(parts.v1 ?? ''), b = Buffer.from(expected);
  return fresh && a.length === b.length && timingSafeEqual(a, b);
}

Python

import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts["t"])) < 300
    return fresh and hmac.compare_digest(parts.get("v1", ""), expected)

PHP

function verify(string $rawBody, string $header, string $secret): bool {
    parse_str(str_replace(',', '&', $header), $parts);
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    $fresh = abs(time() - (int) $parts['t']) < 300;
    return $fresh && hash_equals($expected, $parts['v1'] ?? '');
}

Rotate the secret from the webhook's Edit window if it ever leaks; the old one stops working immediately.

Recipes

Zapier, Make or n8n

  • Send Couchwork events to them: create a "Catch Hook" (Zapier) or "Custom webhook" (Make, n8n), paste its address into a new Couchwork webhook, press Send test, then build the rest of the automation.
  • Send things into Couchwork: use their "Webhooks / HTTP request" action to POST to /api/v1/leads with your key, as in the quick start. Use the ?upsert=true form of /contacts to keep contacts from duplicating.

Turn a form submission from another site into a lead

Have your form's backend call POST /api/v1/leads with the visitor's details as contact. Use the visitor's submission id as the Idempotency-Key, so a double-click or a retry can't create two leads. (If you only need a simple contact form, Couchwork also has a built-in website form you can paste in with one line.)

Keep another system's customers in sync

Poll GET /api/v1/contacts?updatedSince=… (and /companies) on a schedule, or subscribe to contact.created and contact.updated, and write changes to the other system. Create or update the other direction with POST /api/v1/contacts?upsert=true, using the email as the match.

Changelog

  • v1.1: companies. GET/POST /companies, GET/PATCH /companies/:id under the existing contacts permissions, and a companyId on contacts (also in the contact.* webhook payloads). Existing integrations keep working: a contact’s company text is matched to a company automatically.
  • v1.0: first release. Contacts and leads, stages and people, API keys with permissions, idempotency, polling, and signed webhooks with retries.