API and webhooks
Last updated 26 October 2026.
Everything you can do in Kwikbill with clients, items, invoices, estimates, credit notes, purchase orders and payments you can also do over a plain REST API, and webhooks tell your other systems when something happens. Zapier, Make and n8n can use it today through their HTTP modules; a packaged Zapier app is planned. If you want an AI assistant rather than code, see Connect an AI assistant.
- Base URL
https://kwikbill.co.uk/api/v1. JSON in and out, UTF-8. - OpenAPI 3.1 document: https://kwikbill.co.uk/api/v1/openapi.json. Import it into Postman, Insomnia, a code generator or a custom GPT.
- Every business that is writable gets the full API. There are no plan tiers.
API keys
Go to Settings → API & integrations → API keys → Create key. Give it a name, tick the scopes it needs and, if you like, an expiry. The key looks like kb_live_ followed by 43 characters and is shown once; Kwikbill stores only a hash, so copy it straight away. Owners and admins can create keys. A key acts for the business, with the permissions of an admin, and actions it takes are attributed to the person who created it in the activity feed. Revoke a key from the same page at any time.
Send it as a bearer token on every request:
Authorization: Bearer kb_live_3f9a…Scopes
| Scope | Allows |
|---|---|
read | List and fetch anything, including PDFs. |
write | Create and update drafts, clients and items; record payments; void; convert; manage webhooks. Includes read. |
send | Email a document to the client. The only scope that reaches a third party, so it is kept separate. |
Conventions
- Dates are
YYYY-MM-DD; timestamps are RFC 3339 in UTC, for example2026-10-26T09:14:02Z. - Money is a decimal string in major units with the ISO currency, for example
"unit_rate": "240.00". Totals the server computes are also given in minor units as integers (total_minor: 136800) so you never have to round. Totals, tax and discounts are always recomputed server-side; any totals you send are ignored. - Lists are paged:
?page=1&page_size=50(maximum 100) and return{ data: [...], page, page_size, total }. - Tax on a line is either
tax_rate_idfromGET /meortax_rate_percent; an unknown percentage returns 400 with the rates available. - A document's
app_urlis the editor page for your team;public_urlis the client's page with the pay buttons on it. - Rate limits: 300 requests a minute and 10,000 a day per token. Over the limit you get 429 with
Retry-After.
Idempotency
Send an Idempotency-Key header (any string up to 128 characters, unique per operation) on every POST, PATCH and DELETE. If the same key is sent again within 24 hours, you get the stored response back and nothing is created twice. The same key with a different body is refused with 409. Retrying is therefore always safe, which matters when the caller is an automation that times out.
Errors
Every error has the same shape:
{ "error": { "code": "validation_error", "message": "lines[0].unit_rate must be a decimal string", "details": [ … ] } }| Status | code | Meaning |
|---|---|---|
| 400 | validation_error | The body or query failed validation; details lists each problem. |
| 400 | bad_request | Malformed JSON or a missing required header. |
| 401 | unauthorized | No token, or a revoked or expired one. The response carries a WWW-Authenticate header. |
| 403 | insufficient_scope | The token does not have the scope this endpoint needs. |
| 403 | read_only | The business's subscription has lapsed; reads still work, writes do not. |
| 403 | forbidden | The action is not allowed for this token. |
| 404 | not_found | No such record in this business. Records in other businesses are never visible, so they also return 404. |
| 409 | conflict | The document is in a state that forbids the change, or an Idempotency-Key was reused with a different body. |
| 429 | rate_limited | Over the limit. Wait for the number of seconds in Retry-After. |
Endpoints
| Route | Notes |
|---|---|
GET /me | Business name, currency, country, tax rates, numbering prefixes, the scopes of this token and the app URL. |
GET /clients?q=&page= · POST /clients · GET /clients/{id} · PATCH /clients/{id} | POST accepts match_on: "email" to return the existing client with that email instead of creating a duplicate. |
GET /items?q= · POST /items | Saved products and services. |
GET /invoices · POST /invoices · GET /invoices/{id} · PATCH /invoices/{id} | Filters: status, client_id, q, updated_since, page. PATCH works on editable statuses only. The same six routes exist for /estimates, /credit-notes and /purchase-orders. |
GET /{kind}/{id}/pdf | The PDF, rendered with the business's template. |
POST /{kind}/{id}/send | Scope send. Body { to?, cc?, subject?, body?, reminders_enabled? }; defaults come from the client and your email templates. |
POST /{kind}/{id}/void | Void a document. |
POST /invoices/{id}/payments · GET /invoices/{id}/payments | Record a manual payment; list payments on one invoice. |
POST /estimates/{id}/convert | Turn an estimate into an invoice; returns the new invoice. |
GET /payments?since=&page= | All payments across the business. |
GET /webhooks · POST /webhooks · DELETE /webhooks/{id} · POST /webhooks/{id}/test | Manage webhook endpoints. The signing secret is returned once, on creation. |
GET /openapi.json | The OpenAPI 3.1 document for everything above. |
Request and response fields for each are in the OpenAPI document.
Example: a client, an invoice, and sending it
1. Create (or find) the client
With match_on: "email", an existing client with that email is returned instead of a duplicate being made.
curl https://kwikbill.co.uk/api/v1/clients \
-H "Authorization: Bearer kb_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: client-priya-2026-10-26" \
-d '{
"name": "Priya Patel",
"email": "[email protected]",
"phone": "07700 900123",
"address": "14 Beech Avenue\nLeeds LS6 4AA",
"match_on": "email"
}'2. Create the invoice as a draft
You can pass client_id, or an inline client { name, email, … } which is matched on email, then name. Use terms_days or due_date.
curl https://kwikbill.co.uk/api/v1/invoices \
-H "Authorization: Bearer kb_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: job-1187-invoice" \
-d '{
"client_id": "cl_9f2a7c3e1b",
"terms_days": 14,
"reference": "Hallway, stairs and landing",
"lines": [
{ "description": "Decorating labour", "quantity": 4, "unit_rate": "240.00", "unit_type": "days", "tax_rate_percent": 20 },
{ "description": "Paint and sundries", "quantity": 1, "unit_rate": "180.00", "tax_rate_percent": 20 }
],
"notes": "Thanks for your business."
}'Response (201, with a Location header):
{
"id": "doc_4c1e8b2a9d",
"kind": "invoice",
"number": "INV-0042",
"status": "draft",
"client": { "id": "cl_9f2a7c3e1b", "name": "Priya Patel", "email": "[email protected]" },
"currency": "GBP",
"subtotal": "1140.00", "subtotal_minor": 114000,
"tax": "228.00", "tax_minor": 22800,
"total": "1368.00", "total_minor": 136800,
"balance": "1368.00", "balance_minor": 136800,
"issue_date": "2026-10-26",
"due_date": "2026-11-09",
"sent_at": null,
"viewed_at": null, "view_count": 0, "last_viewed_at": null, "email_opened_at": null,
"public_url": "https://kwikbill.co.uk/p/8Zr…",
"pdf_url": "https://kwikbill.co.uk/api/v1/invoices/doc_4c1e8b2a9d/pdf",
"app_url": "https://kwikbill.co.uk/invoices/doc_4c1e8b2a9d",
"created_at": "2026-10-26T09:14:02Z",
"updated_at": "2026-10-26T09:14:02Z"
}3. Send it
Needs the send scope. Leave the body empty to use the client's email address and your email templates, or set to, cc, subject and body.
curl https://kwikbill.co.uk/api/v1/invoices/doc_4c1e8b2a9d/send \
-H "Authorization: Bearer kb_live_..." \
-H "Content-Type: application/json" \
-d '{ "reminders_enabled": true }'After that, sent_at is set and, once the client opens the email and the page, email_opened_at, viewed_at, view_count and last_viewed_at fill in (see viewed tracking).
Webhooks
Add an endpoint in Settings → API & integrations → Webhooks or with POST /webhooks. Give it an https URL and the events you want. The signing secret (whsec_…) is shown once. The settings page lists recent deliveries and can send a test event or rotate the secret.
Events
client.createddocument.createddocument.sentdocument.vieweddocument.email_openeddocument.paiddocument.partially_paiddocument.overduedocument.voidedestimate.approvedestimate.declinedpayment.recordeddocument.shared_whatsapp
Each delivery is a POST with the full document (the same shape as GET returns), client or payment inside data:
{
"id": "evt_7d3f0a9c2e",
"type": "document.paid",
"created_at": "2026-11-02T16:40:11Z",
"workspace_id": "ws_2b8e…",
"data": { "document": { "id": "doc_4c1e8b2a9d", "number": "INV-0042", "status": "paid", "total": "1368.00", "…": "…" } }
}Headers and signature
Kwikbill-Event-IdandKwikbill-Event-Type: use the id to ignore a redelivery you have already handled.Kwikbill-Signature: t=<unix seconds>,v1=<hex>wherev1is HMAC-SHA256 of<t>.<raw body>with your secret. Verify it on the raw bytes before parsing, and reject timestamps more than five minutes old.User-Agent: Kwikbill-Webhooks/1.0.
Node.js verification:
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be the exact bytes received, not a re-serialised object.
export function verifyKwikbillSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "", "hex");
const want = Buffer.from(expected, "hex");
return given.length === want.length && timingSafeEqual(given, want);
}
// Express example: keep the raw body.
app.post("/kwikbill", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyKwikbillSignature(req.body.toString("utf8"), req.header("Kwikbill-Signature") ?? "", process.env.KWIKBILL_WEBHOOK_SECRET);
if (!ok) return res.status(400).end();
const event = JSON.parse(req.body.toString("utf8"));
// handle event.type; respond quickly and do the work afterwards
res.status(200).end();
});Delivery and retries
- Respond with any 2xx within 10 seconds. Redirects count as failures; response bodies are ignored.
- Failures are retried up to 6 times with exponential backoff starting at 30 seconds, about a day in total.
- After 20 consecutive failed events the endpoint is paused and the business owner emailed. Once the receiver is fixed, delete the endpoint and add it again.
- Only public https URLs are accepted; addresses that resolve to private or local networks are refused.
Read-only businesses
When a business's subscription lapses it becomes read-only. The API keeps answering every GET, and every write returns 403 read_only until the subscription is renewed in Settings → Subscription.
Questions
Email [email protected] with the endpoint, the time of the call and the error body you got back.