Overview
The BillCPU API is a JSON REST API. Every request is made with an API key that belongs to one shop, and everything it touches stays inside that shop. Records keep the ids from your own system, so retries are safe and you can always look a record up by your id.
- Base URL:
https://app.billcpu.com/api/v1 - HTTPS only. JSON in, JSON out.
- Invoices you create are real accounting documents: issuing one posts it to the shop's books, exactly as if it was made in the app.
- The API is versioned. Fields are only ever added to v1, never renamed or removed.
Quick start
- Sign in to BillCPU as the shop owner or an admin and open Settings > Developers.
- Create an API key, tick only the scopes your system needs, and copy it. It is shown once.
- Store it as a secret on your server (never in browser code or a mobile app), then make your first call:
curl https://app.billcpu.com/api/v1/me \
-H "Authorization: Bearer bcpu_live_your_key_here" \
-H "Accept: application/json"{
"object": "shop",
"id": 1234,
"name": "Example Driving School",
"base_currency": "NAD",
"currencies": ["NAD", "USD"],
"vat_enabled": false,
"api_key": { "name": "Website", "prefix": "bcpu_live_AbCd12", "scopes": ["invoices:write", "payments:write"] }
}Keys and scopes
Send the key in the Authorization: Bearer header. Keys start with bcpu_live_. In Settings > Developers you can see when and from where each key was last used, limit a key to fixed IP addresses, and revoke it instantly. A key only works while the shop's subscription is active.
| Scope | Allows |
|---|---|
| customers:read | List and read customers |
| customers:write | Create and update customers |
| products:read | List and read products |
| products:write | Create and update products (catalogue sync) |
| invoices:read | List and read invoices, download PDFs |
| invoices:write | Create, send and void invoices |
| payments:write | Record payments on invoices |
| webhooks:manage | Manage webhook endpoints |
quotes:read and quotes:write can already be granted and are reserved for the quotes endpoints that are coming next.
Conventions
- Money is a string with two decimals beside its
currency, for example"3250.00". Documents in another currency also carry theexchange_ratethey were made at (base-currency units per one unit). - Dates are
YYYY-MM-DD; timestamps are ISO 8601. - Your ids: customers, invoices and payments accept an
external_id(up to 120 characters, unique in the shop). Creating an invoice with anexternal_idthat already exists returns the existing invoice instead of making a second one. - Idempotency: send
Idempotency-Key: any-unique-stringon writes. For 24 hours the same key and request return the first response again (withIdempotent-Replayed: true), so a timeout can be retried blindly. - Lists return newest first:
{ "object": "list", "data": [...], "has_more": true }. Use?limit=(up to 100) and?starting_after=<last id>to page, and?updated_since=to sync. - Rate limit: 120 requests a minute per key. Over that you get
429with aRetry-Afterheader.
Errors
Every error has the same shape. Validation errors add the failing fields.
{
"error": {
"type": "validation_error",
"message": "This payment is more than the balance due (1250.00).",
"fields": { "amount": ["This payment is more than the balance due (1250.00)."] }
}
}| HTTP | type | Meaning |
|---|---|---|
| 401 | authentication_error | Missing, wrong or revoked API key. |
| 403 | permission_error | The key lacks the scope this request needs, or is used from an IP it is not allowed from. |
| 404 | not_found | No such record in your shop. |
| 409 | idempotency_error | A request with the same Idempotency-Key is still running. |
| 422 | validation_error | Something in the request is wrong; see fields. |
| 422 | idempotency_error | The Idempotency-Key was already used for a different request. |
| 423 | subscription_error | The shop's BillCPU subscription is locked. |
| 429 | rate_limit_error | Too many requests; wait for the Retry-After header. |
| 500 | api_error | Our side failed. Retry with the same Idempotency-Key. |
Customers
/customerscustomers:readexternal_id, email, search (name)./customers/{id}customers:read/customerscustomers:write/customers/upsertcustomers:writeexternal_id (or else the same email), or creates one. 201 when created, 200 when updated./customers/{id}customers:write{
"external_id": "user-481",
"name": "Jane Example",
"email": "jane@example.com",
"phone": "+264 81 000 0000",
"billing_address": "12 Example Street, Windhoek"
}Products
/productsproducts:readsku, barcode, search./products/{id}products:read/products/upsertproducts:writesku: sku, name, unit_price, optional type (service, physical, non_inventory), barcode, tax_rate_id, price_includes_tax.A SKU or barcode belongs to one product in a shop (compared without regard to capitals or spaces); reusing one returns a validation error naming the product that has it. A product saved without a SKU gets a scannable EAN-13 code.
Invoices
/invoicesinvoices:write"issue": true posts it to the books and gives it a public link at once; "send_email": true also emails it to the customer. Leave both out for a draft./invoicesinvoices:readexternal_id, status (comma separated), customer_id, customer_external_id, updated_since./invoices/{id}invoices:read/invoices/{id}/pdfinvoices:readpaper_size and orientation./invoices/{id}/sendinvoices:writeemail overrides the customer's address./invoices/{id}/voidinvoices:writeName the customer with customer_id, or pass a customer object and it is created or updated by external_id or email. Each line names a product by product_id or sku (its name and price fill in), or is a free line with item_name and unit_price. currency defaults to the shop's base currency; other currencies must be set up in the shop first.
curl https://app.billcpu.com/api/v1/invoices \
-H "Authorization: Bearer bcpu_live_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"external_id": "order-1042",
"customer": { "external_id": "user-481", "name": "Jane Example", "email": "jane@example.com" },
"lines": [
{ "item_name": "Driving lessons, Code B, 10 hours", "description": "Includes pickup and drop off.", "quantity": 1, "unit_price": "3250.00" }
],
"issue": true,
"send_email": false
}'{
"object": "invoice",
"id": 5012,
"external_id": "order-1042",
"number": "INV-000077",
"status": "sent",
"currency": "NAD",
"issue_date": "2026-10-07",
"due_date": "2026-10-21",
"customer": { "object": "customer", "id": 881, "external_id": "user-481", "name": "Jane Example" },
"lines": [{ "item_name": "Driving lessons, Code B, 10 hours", "quantity": "1", "unit_price": "3250.00", "line_total": "3250.00" }],
"subtotal": "3250.00",
"tax_total": "0.00",
"total": "3250.00",
"amount_paid": "0.00",
"balance_due": "3250.00",
"public_url": "https://app.billcpu.com/i/00000000-0000-0000-0000-000000000000",
"pdf_url": "https://app.billcpu.com/i/00000000-0000-0000-0000-000000000000/pdf",
"payments": []
}public_url is the customer-facing invoice page (it can be shown in an iframe on your site) and pdf_url downloads the PDF. Statuses: draft, sent, viewed, partially_paid, paid, overdue, void.
Payments
/invoices/{id}/paymentspayments:writeamount, method (cash, bank_transfer, card, other), optional reference, paid_at and external_id. Returns the updated invoice.{
"amount": "3250.00",
"method": "bank_transfer",
"reference": "EFT 7781",
"paid_at": "2026-10-07 09:30:00",
"external_id": "order-1042-payment"
}A payment can't be larger than the balance due, can't go on a void invoice, and the same external_id on the same invoice is recorded only once.
Webhooks
Add an endpoint in Settings > Developers (or with the API below) and BillCPU POSTs a signed JSON event to it whenever something happens in the shop, from any channel: your API calls, the BillCPU app, the POS, or a customer paying online.
| Event | When |
|---|---|
| invoice.created | An invoice was created (also from POS or recurring billing). |
| invoice.updated | An invoice was edited. |
| invoice.sent | An invoice was issued (posted to the books, public link live). |
| invoice.viewed | Your customer opened the invoice link. |
| invoice.partially_paid | A payment covered part of the invoice. |
| invoice.paid | The invoice is fully paid. |
| invoice.overdue | The due date passed with money still owed. |
| invoice.voided | The invoice was voided. |
| payment.recorded | Money was recorded against an invoice (any channel). |
| credit_note.issued | A credit note reduced what the customer owes. |
| quote.created / quote.updated / quote.sent / quote.viewed | Quote lifecycle. |
| quote.accepted / quote.declined / quote.converted / quote.voided | Quote outcome. |
{
"id": "6c1f0a52-3b8e-4d61-9a43-5d2b7c0e9f10",
"type": "invoice.paid",
"created_at": "2026-10-07T09:30:05+00:00",
"shop_id": 1234,
"data": { "object": { "object": "invoice", "id": 5012, "external_id": "order-1042", "status": "paid", "balance_due": "0.00" } }
}- Answer with any
2xxwithin 10 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours. - An endpoint that keeps failing for 3 days is switched off and the shop owner is emailed. Missed events can be resent from the delivery log.
- Events can repeat; use the event
idto ignore duplicates (X-BillCPU-Eventcarries the type andX-BillCPU-Deliverythe delivery number), and treat your handler as "make the order match", not "add one more". - Endpoint URLs must be HTTPS on a public address.
/webhook-endpointswebhooks:manage/webhook-endpointswebhooks:manageurl, optional events (leave out for all) and description. The signing secret is in this response only./webhook-endpoints/{id}webhooks:manage/webhook-endpoints/{id}webhooks:manage/webhook-endpoints/{id}/testwebhooks:manageping event straight away and returns the delivery result.Verifying webhooks
Every delivery has an X-BillCPU-Signature header: t=<unix time>,v1=<hex signature>. The signature is an HMAC-SHA256 of <t>.<raw body> with your endpoint's secret (whsec_...). Check it against the raw body before parsing, and reject timestamps more than 5 minutes old. After you rotate a secret, deliveries carry a second v1 for the old secret for 24 hours, so accept a match on any of them.
function billcpuSignatureIsValid(string $rawBody, ?string $header, string $secret): bool
{
if (! $header || ! preg_match('/t=(\d+)/', $header, $t) || ! preg_match_all('/v1=([a-f0-9]{64})/', $header, $v)) {
return false;
}
if (abs(time() - (int) $t[1]) > 300) {
return false;
}
$expected = hash_hmac('sha256', $t[1].'.'.$rawBody, $secret);
foreach ($v[1] as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}import crypto from "node:crypto";
export function billcpuSignatureIsValid(rawBody, header, secret) {
const t = /t=(\d+)/.exec(header ?? "")?.[1];
const sigs = [...(header ?? "").matchAll(/v1=([a-f0-9]{64})/g)].map((m) => m[1]);
if (!t || sigs.length === 0 || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return sigs.some((s) => crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}Example: an online shop or booking site
- Order placed:
POST /invoiceswithexternal_idset to your order id, the customer object, one line per item and"issue": true. Save the returnedid,number,public_urlandpdf_urlon your order, and put the link (or the PDF from/invoices/{id}/pdf) in your order email. - Paid on your side (card checkout, or staff confirming a transfer):
POST /invoices/{id}/paymentswithexternal_idlikeorder-1042-payment. - Paid on BillCPU's side (the bookkeeper records it, or the customer pays from the invoice link): listen for
invoice.paid, find your order bydata.object.external_id, and mark it paid if it isn't already. - Cancelled:
POST /invoices/{id}/voidwhile it is unpaid. - Safety net: run a small job every few minutes that creates any missing invoice or payment. Because of
external_idit can never make a duplicate.
Keep BillCPU calls off your checkout's critical path: if BillCPU can't be reached, take the order anyway and let the safety net catch up.
Ready to connect?
Create a shop, open Settings > Developers, and make your first key. Questions about an integration? Reach us through the support link in the app.
