API and webhooks
Connect your other services to Doodali: read and add clients, deals, invoices and more, and hear the moment things happen.
Getting started
The Doodali API lets other services read and add to a company in Doodali: your website, your booking system, Zapier, or code of your own. It speaks JSON over HTTPS, and every call goes to:
https://app.doodali.com/api/v1
- In Doodali, an admin of the company opens Settings, API and webhooks and makes a key, choosing what it can do.
- Copy the key straight away. It starts
dk_live_ and is only shown once. - Send it with every call, and check it works:
curl https://app.doodali.com/api/v1/me \
-H "Authorization: Bearer dk_live_your_key"
{
"company": { "id": 12, "name": "Riverside Studio" },
"key": { "name": "Zapier", "scopes": ["clients:read", "clients:write"] },
"acts_as": { "id": 4, "name": "Sam Lee" },
"rate_limit_per_minute": 120
}
Keys and what they can reach
Send the key in the Authorization header as Bearer dk_live_…. Never put it in a web page or an app people can download: anyone with the key can do what it allows.
- One company. A key belongs to the company it was made in, and only ever sees that company’s data. Ids from another company are answered with
404. - It acts as the admin who made it. Everything it changes is done as them, with the same checks, automations and notifications as if they’d done it in Doodali. If they stop being an admin of the company, or leave, their keys stop working (
key_owner_gone). - Revoke any time in Settings, API and webhooks. It stops working straight away.
- Nothing is deleted through the API, nothing is sent to your customers, and invoices are only ever made as drafts for someone to check and send in Doodali.
Scopes
Each key has only the scopes ticked when it was made. Write never includes read: a key that adds clients and reads them back needs both.
| Scope | Lets the key |
|---|
clients:read | list and get clients |
clients:write | add and change clients |
contacts:read | list and get contacts |
contacts:write | add and change contacts |
deals:read | list and get deals, and the pipelines and their stages |
deals:write | add deals, change them, move their stage, and mark them won, lost or open |
invoices:read | list and get invoices, with their lines |
invoices:write | make draft invoices |
projects:read | list and get projects and tasks |
projects:write | add and change projects and tasks |
subscribers:read | list and get people on the mailing list |
subscribers:write | add people who have agreed to your emails |
orders:read | list and get orders (what clients buy) |
webhooks:manage | add and remove webhooks, for events it can read |
A call without the scope it needs is answered 403 with the code insufficient_scope.
Requests and responses
- Send bodies as JSON with
Content-Type: application/json. Fields you don’t send are left as they are; fields Doodali doesn’t know are ignored. - Money is in pence (or cents) as whole numbers, in fields ending
_pence: £1,250.00 is 125000. - Dates are
YYYY-MM-DD. Times are ISO 8601 in UTC, like 2026-11-04T09:30:00.000Z. - Every record has
id, created_at and updated_at (when it last changed). - Adding something answers
201 with the new record. Changing something answers 200 with the record as it is now.
Lists, paging and polling
Every list answers in the same shape, oldest first:
{
"object": "list",
"data": [ { "id": 101, "name": "Northgate Dental" } ],
"has_more": true,
"next_cursor": "eyJpZCI6MTAxfQ"
}
limit: how many to a page, 1 to 100 (50 if you don’t say).cursor: to get the next page, send next_cursor from the last one. When has_more is false, you have them all.updated_since: only records added or changed at or after this time, ordered by when they changed. This is how to keep another system up to date: remember when you last asked, and ask for what’s changed since.
curl "https://app.doodali.com/api/v1/clients?updated_since=2026-11-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer dk_live_your_key"
Lists also take simple filters, given with each resource below, like ?status=active or ?client_id=101.
Errors
Anything that doesn’t work is answered with a status and the same shape, with a message written to be shown to a person:
{
"error": {
"code": "invalid_request",
"message": "Give the client a company name"
}
}
| Status | Code | Means |
|---|
| 400 | invalid_request, invalid_json, consent_required | Something in the request isn’t right: the message says what |
| 401 | unauthorized, key_owner_gone | No key, a wrong or revoked key, or its admin no longer has access |
| 402 | account_paused | The Doodali account is paused |
| 403 | insufficient_scope, forbidden | The key isn’t allowed to do that |
| 404 | not_found | No such record in this company, or no such call |
| 429 | rate_limited | Too many calls: wait for Retry-After seconds |
| 500 | server_error | Something went wrong at Doodali’s end: try again shortly |
Rate limits
Each key can make 120 calls a minute. Every answer says how many are left with X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the count starts again). Past the limit you get 429 with a Retry-After header. If you need more, use updated_since rather than fetching everything, or use webhooks.
Clients
| Call | Scope | What it does |
|---|
GET /clients | clients:read | List clients. Filters: status (active, prospect, contact, archived), email |
GET /clients/{id} | clients:read | Get one client |
POST /clients | clients:write | Add a client, with its main contact if you like |
PATCH /clients/{id} | clients:write | Change a client |
| Field | What it is |
|---|
name | Company name (needed to add one) |
status | active, prospect, contact or archived |
trading_name, sector, notes | Text |
email, billing_email, phone, website | How to reach them; billing_email is where invoices go |
company_number, vat_number | Their registration numbers |
address_line1, address_line2, town, county, postcode, country | Their address |
lead_source | Where they came from, from Doodali’s list (referral, google_organic, facebook_ads, direct and so on) |
owner_id | The account manager: a person in the company |
payment_terms_days | Days to pay, or null to use the company’s terms |
parent_id | The group client they belong to, if any |
contact | Only when adding: their main contact, { name, email, phone, role } |
curl https://app.doodali.com/api/v1/clients \
-H "Authorization: Bearer dk_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "name": "Northgate Dental", "status": "prospect", "email": "hello@northgate.example",
"contact": { "name": "Priya Shah", "email": "priya@northgate.example" } }'
{
"id": 101, "name": "Northgate Dental", "trading_name": null, "status": "prospect",
"email": "hello@northgate.example", "billing_email": null, "phone": null, "website": null,
"sector": null, "company_number": null, "vat_number": null,
"address_line1": null, "address_line2": null, "town": null, "county": null, "postcode": null, "country": null,
"lead_source": null, "parent_id": null, "owner_id": null, "owner_name": null, "payment_terms_days": null, "notes": null,
"created_at": "2026-11-04T09:30:00.000Z", "updated_at": "2026-11-04T09:30:00.000Z"
}
Deals
| Call | Scope | What it does |
|---|
GET /deals | deals:read | List deals. Filters: status (open, won, lost), client_id, stage_id |
GET /deals/{id} | deals:read | Get one deal |
GET /pipelines | deals:read | The company’s pipelines and their stages, for stage_id |
POST /deals | deals:write | Add a deal |
PATCH /deals/{id} | deals:write | Change a deal, move its stage, or mark it won, lost or open |
| Field | What it is |
|---|
client_id | The client it’s with. Or new_client_name to add them as a prospect at the same time |
title | What it’s for (needed to add one) |
stage_id | Its stage; the first stage if you don’t say |
expected_close | When you expect to win it |
notes, lead_source, campaign, owner_id | As in Doodali |
lines | The products: [{ product_id, title, amount_pence }] |
status | Only when changing: won, lost or open |
create_orders, orders_start_date, orders_end_date | With status won: turn its products into orders on the client |
lost_reason | With status lost: why |
monthly_pence, yearly_pence, one_off_pence, value_12m_pence | Read only: what it’s worth |
curl -X PATCH https://app.doodali.com/api/v1/deals/55 \
-H "Authorization: Bearer dk_live_your_key" -H "Content-Type: application/json" \
-d '{ "status": "won", "create_orders": true, "orders_start_date": "2026-12-01" }'
Invoices
| Call | Scope | What it does |
|---|
GET /invoices | invoices:read | List invoices. Filters: status (draft, sent, paid, void), client_id |
GET /invoices/{id} | invoices:read | Get one invoice, with its lines |
POST /invoices | invoices:write | Make a draft invoice. It is never issued or sent from the API |
| Field | What it is |
|---|
client_id | Who it’s for (needed) |
lines | [{ description, quantity, unit_pence, vat_treatment }]; vat_treatment is standard, reduced, zero, exempt, outside or reverse_charge, and follows the company’s VAT if you leave it out |
notes, issue_date, due_date | Optional |
number, status, net_pence, vat_pence, total_pence, currency, paid_at | Read only |
curl https://app.doodali.com/api/v1/invoices \
-H "Authorization: Bearer dk_live_your_key" -H "Content-Type: application/json" \
-d '{ "client_id": 101, "lines": [ { "description": "Website care, November", "quantity": 1, "unit_pence": 15000 } ] }'
If any line isn’t right, nothing is kept: the draft is taken away again and the error says which line.
Projects and tasks
| Call | Scope | What it does |
|---|
GET /projects | projects:read | List projects. Filters: status (open, done, archived), client_id |
GET /projects/{id} | projects:read | Get one project |
POST /projects | projects:write | Add a project for a client |
PATCH /projects/{id} | projects:write | Change a project; archived: true to archive it |
GET /tasks | projects:read | List tasks. Filters: project_id, assignee_id, done (true or false) |
GET /tasks/{id} | projects:read | Get one task |
POST /tasks | projects:write | Add a task to a project |
PATCH /tasks/{id} | projects:write | Change a task; done: true to tick it off |
| Field | What it is |
|---|
client_id, name | A project’s client and name (needed to add one) |
brief, start_date, due_date, budget_pence, owner_id, template_id | Optional; template_id starts it from one of your project templates |
project_id, title | A task’s project and title (needed to add one) |
phase, due_date, priority, notes, assignee_id, done, blocked, blocked_reason | A task’s details; priority is low, medium or high. The person given it is told, as in Doodali |
curl https://app.doodali.com/api/v1/tasks \
-H "Authorization: Bearer dk_live_your_key" -H "Content-Type: application/json" \
-d '{ "project_id": 77, "title": "Send the first draft", "due_date": "2026-11-20", "assignee_id": 4 }'
Mailing list
| Call | Scope | What it does |
|---|
GET /subscribers | subscribers:read | List the mailing list. Filters: status (in, out), email, tag |
GET /subscribers/{id} | subscribers:read | Get one person |
POST /subscribers | subscribers:write | Add someone who has agreed to your emails |
UK law (PECR) means you may only email people who have agreed to it, so adding someone needs both consent: true and consent_source, saying how they agreed. Both are recorded with them, just as when they’re added in Doodali. Someone who unsubscribed stays unsubscribed: adding them again doesn’t change that, and their status stays out. The company needs the Newsletters app.
| Field | What it is |
|---|
email | Their email address (needed) |
name | Optional |
tags | Optional list of tags, like ["Events"] |
consent | Must be true |
consent_source | How they agreed, like "Ticked the box on the website footer form" |
signed_up_at | Optional date they signed up, if not today |
status, consent_source | Read: in or out, and how they agreed (or unsubscribed) |
curl https://app.doodali.com/api/v1/subscribers \
-H "Authorization: Bearer dk_live_your_key" -H "Content-Type: application/json" \
-d '{ "email": "jo@example.com", "name": "Jo Hart", "tags": ["Website"],
"consent": true, "consent_source": "Ticked the box on the website footer form" }'
New people are answered 201; someone already on the list is answered 200, with their details as they are now.
Orders
| Call | Scope | What it does |
|---|
GET /orders | orders:read | List orders: what each client buys. Filters: client_id, kind (one_off, monthly, yearly) |
GET /orders/{id} | orders:read | Get one order |
| Field | What it is |
|---|
client_id, client_name | Who buys it |
title, description | What it is |
kind | one_off, monthly or yearly |
amount_pence | Its price, before VAT |
start_date, end_date | When it runs |
product_id, product_name | The product it’s for, if any |
Webhooks
Webhooks tell another system the moment something happens, rather than it asking every few minutes. Add an address in Settings, API and webhooks and choose its events. They fire however the thing happened: on screen, from an import, through the API or by a payment arriving.
| Event | When | data is |
|---|
client.created | A client is added | The client |
contact.created | A contact is added | The contact |
deal.won | A deal is won | The deal |
invoice.paid | An invoice is paid | The invoice, with its lines |
order.created | An order is made | The order |
subscriber.created | Someone joins the mailing list | The person |
Each one is a POST of JSON, with the record in the same shape as the API gives it:
POST https://your-app.example/doodali
Content-Type: application/json
Doodali-Event: deal.won
Doodali-Event-Id: evt_8812
Doodali-Signature: t=1793782200,v1=5f0c6c4e9b…
{
"id": "evt_8812",
"type": "deal.won",
"created_at": "2026-11-04T09:30:00.000Z",
"company": { "id": 12, "name": "Riverside Studio" },
"data": { "id": 55, "client_id": 101, "title": "New website", "status": "won", "value_12m_pence": 480000 }
}
Checking the signature
Every webhook is signed with the endpoint’s signing secret (it starts whsec_, and is shown once when you add the address). The Doodali-Signature header holds the time it was sent (t) and an HMAC SHA-256 of the time, a full stop and the exact body, made with the secret (v1). Check it before trusting anything, and turn away old ones so a copy can’t be sent again later. In Node:
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.DOODALI_WEBHOOK_SECRET; // whsec_…
// The raw body, exactly as sent: the signature is of these bytes.
app.post('/doodali', express.raw({ type: 'application/json' }), (req, res) => {
const body = req.body.toString('utf8');
const parts = Object.fromEntries(String(req.get('Doodali-Signature')).split(',').map((p) => p.trim().split('=')));
const sentAt = Number(parts.t);
const expected = crypto.createHmac('sha256', secret).update(`${sentAt}.${body}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - sentAt) < 300; // within 5 minutes
const ok = fresh && parts.v1 && parts.v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
if (!ok) return res.status(400).send('Bad signature');
const event = JSON.parse(body);
// Use event.id to skip one you've already had: a retry has the same id.
console.log(event.type, event.data);
res.status(200).send('OK');
});
app.listen(3000);
Answering, and retries
- Answer with any
2xx status within 10 seconds. Anything else, a redirect or no answer counts as a failure. - A failure is tried again after 1 minute, 10 minutes, 1 hour, 6 hours and 16 hours: six tries over about a day. Every try has the same
Doodali-Event-Id. - If every try fails and nothing gets through for about a day, the address is switched off and the company’s admins are told. Switch it back on in Settings once it’s fixed.
- Addresses must be
https:// on the public internet (port 443 or 8443). Private and internal addresses are refused. - While a company’s outgoing messages are held, no webhooks are sent for it.
- Settings shows each address’s recent deliveries, and has a button to send a test event (
type: "test").
Adding webhooks through the API
With the webhooks:manage scope, a service such as Zapier can add and remove its own webhooks. Each event also needs the read scope of what it sends: deal.won needs deals:read.
| Call | Scope | What it does |
|---|
GET /webhooks | webhooks:manage | The company’s webhooks |
POST /webhooks | webhooks:manage | Add one: { url, events, description }. The answer has its secret, once |
DELETE /webhooks/{id} | webhooks:manage | Remove one |
curl https://app.doodali.com/api/v1/webhooks \
-H "Authorization: Bearer dk_live_your_key" -H "Content-Type: application/json" \
-d '{ "url": "https://hooks.example.com/doodali", "events": ["client.created", "deal.won"] }'
Help
Stuck, or need something the API doesn’t do yet? Get in touch and tell us what you’re building.