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
Developers
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.
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.
/api/v1. Send and receive JSON./api/v1/openapi.json, with no key needed. Import it into Postman or generate a client from it.cw_ and is shown only once.curl https://YOUR-SERVER/api/v1/me \ -H "Authorization: Bearer cw_…"
{ "name": "Zapier", "scopes": ["contacts:read", "contacts:write", "leads:read", "leads:write"] }
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.
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.
| Permission | Allows |
|---|---|
contacts:read | List and read shared contacts and companies |
contacts:write | Create and change shared contacts and companies |
leads:read | List and read leads, pipeline stages and the people who can own leads |
leads:write | Create 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.
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.
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.
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.
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.
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 are JSON, { "error": "What went wrong" }, with a status code:
| 400 | The request is invalid; the message says why |
| 401 | No key, a wrong key, or a revoked key |
| 403 | The key doesn't have the permission this needs |
| 404 | Not found (or a personal contact) |
| 409 | Conflict, for example a contact with that email already exists |
| 429 | Too many requests. Wait for the number of seconds in the Retry-After header |
At most 120 requests a minute per key. Repeated wrong keys from one address are slowed down too.
This reference is generated from the API's own OpenAPI description. Every path starts with your server address.
/api/v1/meCheck a key
Works with any valid key. Returns its name and permissions.
/api/v1/pipelinesList pipelines with their stages
Needs leads:read A workspace can have several pipelines (for example Sales and Support), each with its own stages.
/api/v1/stagesList every stage
Needs leads:read A flat list of the stages of all pipelines, each saying which pipeline it belongs to.
/api/v1/usersList people who can own leads
Needs leads:read
/api/v1/contactsList shared contacts
Needs contacts:read
| Name | In | Description |
|---|---|---|
email | query | Exact email address. |
q | query | Words in the name, email or company. |
updatedSince | query | Only records changed at or after this time (ISO 8601, e.g. 2026-10-17T09:00:00Z). Use it to poll for changes. |
limit | query | 1 to 100. Default 50. |
offset | query | How many to skip. Default 0. |
/api/v1/contactsCreate a shared contact
Needs contacts:write Without upsert, an email that already exists is refused with 409.
| Name | In | Description |
|---|---|---|
upsert | query | Set to true to update the existing contact with the same email instead. |
Idempotency-Key | header | Send 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. |
| Field | Type | Notes |
|---|---|---|
firstName | string | |
lastName | string | |
email | string or null | |
phone | string | |
company | string | Matched to an existing company by name, or a new company is created. |
companyId | string or null uuid | Put the person in this company. Send null on a PATCH to remove them from their company. |
jobTitle | string | |
notes | string | |
tags | array of string |
/api/v1/contacts/{id}Get a contact
Needs contacts:read
| Name | In | Description |
|---|---|---|
id | path required |
/api/v1/contacts/{id}Change a contact
Needs contacts:write Only the fields you send change.
| Name | In | Description |
|---|---|---|
id | path required |
| Field | Type | Notes |
|---|---|---|
firstName | string | |
lastName | string | |
email | string or null | |
phone | string | |
company | string | Matched to an existing company by name, or a new company is created. |
companyId | string or null uuid | Put the person in this company. Send null on a PATCH to remove them from their company. |
jobTitle | string | |
notes | string | |
tags | array of string |
/api/v1/companiesList companies
Needs contacts:read Companies that have at least one shared contact, or were created on purpose.
| Name | In | Description |
|---|---|---|
q | query | Words in the name or website. |
updatedSince | query | Only records changed at or after this time (ISO 8601, e.g. 2026-10-17T09:00:00Z). Use it to poll for changes. |
limit | query | 1 to 100. Default 50. |
offset | query | How many to skip. Default 0. |
/api/v1/companiesCreate 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.
| Name | In | Description |
|---|---|---|
upsert | query | Set to true to update the existing company with the same name instead. |
Idempotency-Key | header | Send 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. |
| Field | Type | Notes |
|---|---|---|
name required | string | |
website | string | |
phone | string | |
address | string | |
notes | string |
/api/v1/companies/{id}Get a company with its people
Needs contacts:read
| Name | In | Description |
|---|---|---|
id | path required |
/api/v1/companies/{id}Change a company
Needs contacts:write Only the fields you send change. Renaming updates the company shown on each person.
| Name | In | Description |
|---|---|---|
id | path required |
| Field | Type | Notes |
|---|---|---|
name | string | |
website | string | |
phone | string | |
address | string | |
notes | string |
/api/v1/leadsList leads
Needs leads:read
| Name | In | Description |
|---|---|---|
pipelineId | query | Only leads in this pipeline. |
stageId | query | Only this stage. |
status | query | open, won or lost. |
assigneeId | query | Only this owner. |
contactId | query | Only this contact. |
q | query | Words in the title. |
updatedSince | query | Only records changed at or after this time (ISO 8601, e.g. 2026-10-17T09:00:00Z). Use it to poll for changes. |
order | query | Newest change first (desc, default) or oldest first (asc). |
limit | query | 1 to 100. Default 50. |
offset | query | How many to skip. Default 0. |
/api/v1/leadsCreate a lead
Needs leads:write Anything set up to run when a lead enters its stage (emails, alerts, webhooks) runs as normal.
| Name | In | Description |
|---|---|---|
Idempotency-Key | header | Send 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. |
| Field | Type | Notes |
|---|---|---|
title required | string | |
pipelineId | string uuid | Create the lead in the first stage of this pipeline. Not with stageId. |
stageId | string uuid | Defaults to the first stage of the first pipeline. Not with pipelineId. |
contactId | string uuid | |
contact | object | Alternatively, 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. |
assigneeId | string uuid | |
value | number | |
dueDate | string | YYYY-MM-DD |
notes | string |
/api/v1/leads/{id}Get a lead
Needs leads:read
| Name | In | Description |
|---|---|---|
id | path required |
/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.
| Name | In | Description |
|---|---|---|
id | path required |
| Field | Type | Notes |
|---|---|---|
title | string | |
contactId | string or null | |
assigneeId | string or null | |
value | number | |
dueDate | string or null | |
notes | string |
/api/v1/leads/{id}/moveMove a lead to another stage
Needs leads:write Goes to the end of that stage.
| Name | In | Description |
|---|---|---|
id | path required |
| Field | Type | Notes |
|---|---|---|
stageId required | string uuid |
/api/v1/leads/{id}/notesAdd a note to a lead
Needs leads:write Appears in the lead’s activity.
| Name | In | Description |
|---|---|---|
id | path required |
| Field | Type | Notes |
|---|---|---|
text required | string |
Contact| Field | Type | Notes |
|---|---|---|
id | string uuid | |
firstName | string | |
lastName | string | |
email | string or null | |
phone | string | |
company | string | |
companyId | string or null uuid | The company this person belongs to. |
jobTitle | string | |
notes | string | |
tags | array of string | |
owner | object or null | |
createdAt | string date-time | |
updatedAt | string date-time |
Lead| Field | Type | Notes |
|---|---|---|
id | string uuid | |
title | string | |
value | number | |
notes | string | |
dueDate | string or null | |
pipeline | object | |
stage | object | |
assignee | object or null | |
contact | object or null | |
createdAt | string date-time | |
updatedAt | string date-time |
Stage| Field | Type | Notes |
|---|---|---|
id | string | |
name | string | |
kind | open | won | lost | |
position | integer | |
pipeline | object | The pipeline this stage belongs to. |
Person| Field | Type | Notes |
|---|---|---|
id | string | |
name | string |
Company| Field | Type | Notes |
|---|---|---|
id | string uuid | |
name | string | |
website | string | |
phone | string | |
address | string | |
notes | string | |
people | integer | How many shared contacts belong to it. |
createdAt | string date-time | |
updatedAt | string date-time |
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.
webhook.test event straight away and tells you whether your receiver answered.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-Event | The event name, e.g. lead.created |
Couchwork-Delivery | The delivery id (same as id). Keep the ones you've handled and ignore repeats |
Couchwork-Signature | t=<unix seconds>,v1=<hex>, explained below |
Answer with any 2xx status within 10 seconds. Do slow work after replying.
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.
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.
| Event | Sent when | data contains |
|---|---|---|
lead.created | A lead is created, in the app, by the AI, through the API, or from the website form | lead, createdBy, source (app, web_form or api) |
lead.updated | A lead's details change | lead, updatedBy |
lead.stage_changed | A lead moves to another stage | lead, from, to, movedBy |
lead.won | A lead moves into a stage marked as won | as lead.stage_changed |
lead.lost | A lead moves into a stage marked as lost | as lead.stage_changed |
lead.deleted | A lead is deleted | lead (as it was), deletedBy |
contact.created | A shared contact is created (including by import) | contact, by |
contact.updated | A shared contact changes | contact, by |
contact.deleted | A shared contact is deleted | contact (as it was), by |
form.submitted | Someone sends the website enquiry form | form (id, name: which website form), lead, contact, submission (name, email, phone, subject, message) |
webhook.test | You press Send test | message |
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>.
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.
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);
}
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)
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.
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.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.)
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.
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.