Kwita API
Everything the app can do, over plain JSON. Base URL https://kwita.co.za/api/v1. The API is on the Pro plan.
Authentication
Make an API key in Settings, then API. Keys are shown once, stored hashed, scoped to one business and to the scopes you pick (for example invoices:write, which includes read). Send the key as a bearer token on every request.
Authorization: Bearer kw_live_3f9c...Each key may make 120 requests a minute. A 429 response carries a Retry-After header in seconds. A 402 means the business is not on the Pro plan.
Responses and paging
Every response has the same envelope.
{ "ok": true, "data": { ... } }
{ "ok": true, "data": [ ... ], "nextCursor": "eyJpZCI6..." }
{ "ok": false, "error": "Not found", "code": "not_found" }Lists take ?limit= (1 to 200, default 50) and ?cursor=. Pass the nextCursor from the previous page until it is absent. Lists are sorted newest first.
Resources
Each resource supports GET /resource, GET /resource/:id, POST /resource and PATCH /resource/:id (send only the fields to change) unless noted. Reports are read only.
/contacts | Customers and suppliers |
/products | Products and services, with stock on hand |
/quotes | Quotes. PATCH changes a draft |
/invoices | Invoices. PATCH changes a draft |
/invoices/:id/approve | POST: approve a draft and post it to the ledger |
/invoices/:id/payments | POST: record a payment on an invoice |
/payments | Customer payments. POST with an invoiceId. No PATCH |
/expenses | Expenses and supplier bills. No PATCH |
/bank-transactions | Imported bank lines. POST imports statement rows, duplicates skipped |
/employees | Payroll employees, read only. ID and bank numbers are masked |
/payslips | Payslips, read only |
/projects | Projects |
/tasks | Tasks within projects |
/reports/trial-balance | Trial balance as at ?to= |
/reports/vat201 | VAT201 fields for ?from= and ?to= |
/reports/aged-debtors | Aged debtors as at ?to= |
Money and dates
Money is always integer cents, never decimals: 431250 is R4 312.50. VAT rates are basis points per line: 1500 is 15%, 0 is zero rated. Business dates are YYYY-MM-DD. Timestamps are epoch milliseconds. Totals are computed on the server from lines; a total you send is ignored.
Webhooks
Register up to 10 URLs in Settings, then Webhooks. Each delivery is a POST with a JSON body and an X-Kwita-Signature header of the form t=<unix seconds>,v1=<hex>, where v1 is the SHA-256 HMAC of t.body (the timestamp, a dot, then the raw body) with the endpoint secret. Respond with any 2xx within 10 seconds. Failed deliveries retry with backoff for 24 hours. The id is the same on retries, so you can dedupe.
invoice.created | An invoice was created |
invoice.paid | An invoice was paid in full |
payment.received | A customer payment was recorded |
quote.accepted | A customer accepted a quote |
receipt.received | A receipt arrived on WhatsApp and was read |
// Verify a webhook (Node, any framework)
import { createHmac, timingSafeEqual } from "node:crypto";
// header: X-Kwita-Signature: t=1761900000,v1=5f2c...
export function verify(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // replay window
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(String(parts.v1));
return a.length === b.length && timingSafeEqual(a, b);
}
// Body: { "id": "invoice.paid__inv_0231__wh_1", "event": "invoice.paid",
// "tenantId": "...", "data": { "id": "inv_0231", "totalCents": 431250 },
// "sentAt": 1761900000000 }Samples
curl
# List invoices, newest first
curl "https://kwita.co.za/api/v1/invoices?limit=20" \
-H "Authorization: Bearer kw_live_3f9c..."
# Create an invoice and post it to the ledger
curl https://kwita.co.za/api/v1/invoices \
-H "Authorization: Bearer kw_live_3f9c..." \
-H "Content-Type: application/json" \
-d '{
"contactId": "c_8f2a",
"date": "2026-10-31",
"dueDate": "2026-11-14",
"lines": [
{ "description": "Geyser install", "qty": 1,
"unitPriceCents": 375000, "vatBps": 1500 }
],
"approve": true
}'Node (fetch)
const BASE = "https://kwita.co.za/api/v1";
const headers = {
Authorization: `Bearer ${process.env.KWITA_API_KEY}`,
"Content-Type": "application/json",
};
// Page through contacts
let cursor: string | undefined;
do {
const url = new URL(`${BASE}/contacts`);
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers });
const body = await res.json();
if (!body.ok) throw new Error(body.error);
for (const contact of body.data) console.log(contact.name);
cursor = body.nextCursor;
} while (cursor);
// Record a payment
await fetch(`${BASE}/payments`, {
method: "POST",
headers,
body: JSON.stringify({
invoiceId: "inv_0231",
amountCents: 431250,
date: "2026-11-03",
method: "eft",
}),
});OpenAPI
The full contract, with every field and enum, is published as OpenAPI 3.1 at /api/v1/openapi.json. Import it into Postman, Insomnia or a code generator.
Questions or a scope you need that is not here: write to hello@kwita.co.za.