Developers

Put BillCPU behind your website, shop or booking system.

Create customers, invoices and payments from your own system, hand your customers a branded invoice link and PDF, and get a signed webhook the moment an invoice is paid. Your books in BillCPU stay complete without anyone retyping orders.

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

  1. Sign in to BillCPU as the shop owner or an admin and open Settings > Developers.
  2. Create an API key, tick only the scopes your system needs, and copy it. It is shown once.
  3. Store it as a secret on your server (never in browser code or a mobile app), then make your first call:
curl
curl https://app.billcpu.com/api/v1/me \
  -H "Authorization: Bearer bcpu_live_your_key_here" \
  -H "Accept: application/json"
Response
{
  "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.

ScopeAllows
customers:readList and read customers
customers:writeCreate and update customers
products:readList and read products
products:writeCreate and update products (catalogue sync)
invoices:readList and read invoices, download PDFs
invoices:writeCreate, send and void invoices
payments:writeRecord payments on invoices
webhooks:manageManage 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 the exchange_rate they 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 an external_id that already exists returns the existing invoice instead of making a second one.
  • Idempotency: send Idempotency-Key: any-unique-string on writes. For 24 hours the same key and request return the first response again (with Idempotent-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 429 with a Retry-After header.

Errors

Every error has the same shape. Validation errors add the failing fields.

422 response
{
  "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)."] }
  }
}
HTTPtypeMeaning
401authentication_errorMissing, wrong or revoked API key.
403permission_errorThe key lacks the scope this request needs, or is used from an IP it is not allowed from.
404not_foundNo such record in your shop.
409idempotency_errorA request with the same Idempotency-Key is still running.
422validation_errorSomething in the request is wrong; see fields.
422idempotency_errorThe Idempotency-Key was already used for a different request.
423subscription_errorThe shop's BillCPU subscription is locked.
429rate_limit_errorToo many requests; wait for the Retry-After header.
500api_errorOur side failed. Retry with the same Idempotency-Key.

Customers

GET/customerscustomers:read
Filters: external_id, email, search (name).
GET/customers/{id}customers:read
POST/customerscustomers:write
PUT/customers/upsertcustomers:write
Updates the customer with the same external_id (or else the same email), or creates one. 201 when created, 200 when updated.
PATCH/customers/{id}customers:write
PUT /customers/upsert
{
  "external_id": "user-481",
  "name": "Jane Example",
  "email": "jane@example.com",
  "phone": "+264 81 000 0000",
  "billing_address": "12 Example Street, Windhoek"
}

Products

GET/productsproducts:read
Filters: sku, barcode, search.
GET/products/{id}products:read
PUT/products/upsertproducts:write
Catalogue sync keyed on sku: 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

POST/invoicesinvoices:write
Create an invoice. "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.
GET/invoicesinvoices:read
Filters: external_id, status (comma separated), customer_id, customer_external_id, updated_since.
GET/invoices/{id}invoices:read
GET/invoices/{id}/pdfinvoices:read
The PDF. Optional paper_size and orientation.
POST/invoices/{id}/sendinvoices:write
Issue (if still a draft) and email it; optional email overrides the customer's address.
POST/invoices/{id}/voidinvoices:write

Name 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.

POST /invoices
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
  }'
201 response (shortened)
{
  "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

POST/invoices/{id}/paymentspayments:write
amount, method (cash, bank_transfer, card, other), optional reference, paid_at and external_id. Returns the updated invoice.
POST /invoices/5012/payments
{
  "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.

EventWhen
invoice.createdAn invoice was created (also from POS or recurring billing).
invoice.updatedAn invoice was edited.
invoice.sentAn invoice was issued (posted to the books, public link live).
invoice.viewedYour customer opened the invoice link.
invoice.partially_paidA payment covered part of the invoice.
invoice.paidThe invoice is fully paid.
invoice.overdueThe due date passed with money still owed.
invoice.voidedThe invoice was voided.
payment.recordedMoney was recorded against an invoice (any channel).
credit_note.issuedA credit note reduced what the customer owes.
quote.created / quote.updated / quote.sent / quote.viewedQuote lifecycle.
quote.accepted / quote.declined / quote.converted / quote.voidedQuote outcome.
Event body
{
  "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 2xx within 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 id to ignore duplicates (X-BillCPU-Event carries the type and X-BillCPU-Delivery the delivery number), and treat your handler as "make the order match", not "add one more".
  • Endpoint URLs must be HTTPS on a public address.
GET/webhook-endpointswebhooks:manage
POST/webhook-endpointswebhooks:manage
url, optional events (leave out for all) and description. The signing secret is in this response only.
PATCH/webhook-endpoints/{id}webhooks:manage
DELETE/webhook-endpoints/{id}webhooks:manage
POST/webhook-endpoints/{id}/testwebhooks:manage
Sends a signed ping 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.

PHP
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;
}
Node.js
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

  1. Order placed: POST /invoices with external_id set to your order id, the customer object, one line per item and "issue": true. Save the returned id, number, public_url and pdf_url on your order, and put the link (or the PDF from /invoices/{id}/pdf) in your order email.
  2. Paid on your side (card checkout, or staff confirming a transfer): POST /invoices/{id}/payments with external_id like order-1042-payment.
  3. Paid on BillCPU's side (the bookkeeper records it, or the customer pays from the invoice link): listen for invoice.paid, find your order by data.object.external_id, and mark it paid if it isn't already.
  4. Cancelled: POST /invoices/{id}/void while it is unpaid.
  5. Safety net: run a small job every few minutes that creates any missing invoice or payment. Because of external_id it 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.