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
  1. In Doodali, an admin of the company opens Settings, API and webhooks and makes a key, choosing what it can do.
  2. Copy the key straight away. It starts dk_live_ and is only shown once.
  3. 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.

ScopeLets the key
clients:readlist and get clients
clients:writeadd and change clients
contacts:readlist and get contacts
contacts:writeadd and change contacts
deals:readlist and get deals, and the pipelines and their stages
deals:writeadd deals, change them, move their stage, and mark them won, lost or open
invoices:readlist and get invoices, with their lines
invoices:writemake draft invoices
projects:readlist and get projects and tasks
projects:writeadd and change projects and tasks
subscribers:readlist and get people on the mailing list
subscribers:writeadd people who have agreed to your emails
orders:readlist and get orders (what clients buy)
webhooks:manageadd 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"
  }
}
StatusCodeMeans
400invalid_request, invalid_json, consent_requiredSomething in the request isn’t right: the message says what
401unauthorized, key_owner_goneNo key, a wrong or revoked key, or its admin no longer has access
402account_pausedThe Doodali account is paused
403insufficient_scope, forbiddenThe key isn’t allowed to do that
404not_foundNo such record in this company, or no such call
429rate_limitedToo many calls: wait for Retry-After seconds
500server_errorSomething 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

CallScopeWhat it does
GET /clientsclients:readList clients. Filters: status (active, prospect, contact, archived), email
GET /clients/{id}clients:readGet one client
POST /clientsclients:writeAdd a client, with its main contact if you like
PATCH /clients/{id}clients:writeChange a client
FieldWhat it is
nameCompany name (needed to add one)
statusactive, prospect, contact or archived
trading_name, sector, notesText
email, billing_email, phone, websiteHow to reach them; billing_email is where invoices go
company_number, vat_numberTheir registration numbers
address_line1, address_line2, town, county, postcode, countryTheir address
lead_sourceWhere they came from, from Doodali’s list (referral, google_organic, facebook_ads, direct and so on)
owner_idThe account manager: a person in the company
payment_terms_daysDays to pay, or null to use the company’s terms
parent_idThe group client they belong to, if any
contactOnly 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"
}

Contacts

CallScopeWhat it does
GET /contactscontacts:readList contacts. Filters: client_id, email
GET /contacts/{id}contacts:readGet one contact
POST /contactscontacts:writeAdd a contact to a client
PATCH /contacts/{id}contacts:writeChange a contact
FieldWhat it is
client_idThe client they work at (needed to add one)
nameTheir name (needed to add one)
roleJob title
email, phoneHow to reach them
is_primarytrue to make them the main contact
marketingRead only: in, out, or null if they’ve never said. To add someone to your emails, use the mailing list.
curl https://app.doodali.com/api/v1/contacts \
  -H "Authorization: Bearer dk_live_your_key" -H "Content-Type: application/json" \
  -d '{ "client_id": 101, "name": "Tom Reed", "role": "Practice manager", "email": "tom@northgate.example" }'

Deals

CallScopeWhat it does
GET /dealsdeals:readList deals. Filters: status (open, won, lost), client_id, stage_id
GET /deals/{id}deals:readGet one deal
GET /pipelinesdeals:readThe company’s pipelines and their stages, for stage_id
POST /dealsdeals:writeAdd a deal
PATCH /deals/{id}deals:writeChange a deal, move its stage, or mark it won, lost or open
FieldWhat it is
client_idThe client it’s with. Or new_client_name to add them as a prospect at the same time
titleWhat it’s for (needed to add one)
stage_idIts stage; the first stage if you don’t say
expected_closeWhen you expect to win it
notes, lead_source, campaign, owner_idAs in Doodali
linesThe products: [{ product_id, title, amount_pence }]
statusOnly when changing: won, lost or open
create_orders, orders_start_date, orders_end_dateWith status won: turn its products into orders on the client
lost_reasonWith status lost: why
monthly_pence, yearly_pence, one_off_pence, value_12m_penceRead 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

CallScopeWhat it does
GET /invoicesinvoices:readList invoices. Filters: status (draft, sent, paid, void), client_id
GET /invoices/{id}invoices:readGet one invoice, with its lines
POST /invoicesinvoices:writeMake a draft invoice. It is never issued or sent from the API
FieldWhat it is
client_idWho 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_dateOptional
number, status, net_pence, vat_pence, total_pence, currency, paid_atRead 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

CallScopeWhat it does
GET /projectsprojects:readList projects. Filters: status (open, done, archived), client_id
GET /projects/{id}projects:readGet one project
POST /projectsprojects:writeAdd a project for a client
PATCH /projects/{id}projects:writeChange a project; archived: true to archive it
GET /tasksprojects:readList tasks. Filters: project_id, assignee_id, done (true or false)
GET /tasks/{id}projects:readGet one task
POST /tasksprojects:writeAdd a task to a project
PATCH /tasks/{id}projects:writeChange a task; done: true to tick it off
FieldWhat it is
client_id, nameA project’s client and name (needed to add one)
brief, start_date, due_date, budget_pence, owner_id, template_idOptional; template_id starts it from one of your project templates
project_id, titleA task’s project and title (needed to add one)
phase, due_date, priority, notes, assignee_id, done, blocked, blocked_reasonA 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

CallScopeWhat it does
GET /subscriberssubscribers:readList the mailing list. Filters: status (in, out), email, tag
GET /subscribers/{id}subscribers:readGet one person
POST /subscriberssubscribers:writeAdd 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.

FieldWhat it is
emailTheir email address (needed)
nameOptional
tagsOptional list of tags, like ["Events"]
consentMust be true
consent_sourceHow they agreed, like "Ticked the box on the website footer form"
signed_up_atOptional date they signed up, if not today
status, consent_sourceRead: 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

CallScopeWhat it does
GET /ordersorders:readList orders: what each client buys. Filters: client_id, kind (one_off, monthly, yearly)
GET /orders/{id}orders:readGet one order
FieldWhat it is
client_id, client_nameWho buys it
title, descriptionWhat it is
kindone_off, monthly or yearly
amount_penceIts price, before VAT
start_date, end_dateWhen it runs
product_id, product_nameThe 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.

EventWhendata is
client.createdA client is addedThe client
contact.createdA contact is addedThe contact
deal.wonA deal is wonThe deal
invoice.paidAn invoice is paidThe invoice, with its lines
order.createdAn order is madeThe order
subscriber.createdSomeone joins the mailing listThe 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.

CallScopeWhat it does
GET /webhookswebhooks:manageThe company’s webhooks
POST /webhookswebhooks:manageAdd one: { url, events, description }. The answer has its secret, once
DELETE /webhooks/{id}webhooks:manageRemove 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.