The Billr API
A small REST API for the two things worth automating: invoices and clients. JSON goes in, JSON comes out, and a webhook tells you the moment an invoice is created, sent or paid.
The API comes with a subscription. Everything on this page is readable without an account.
Two minutes to your first request
Every endpoint lives under one base URL, speaks JSON, and needs one header. There is no SDK to install and nothing to configure. If you can send an HTTP request, you are ready.
https://billr-app.com/api/v1Create a key on the developers page inside your account, then list your five most recent documents:
curl https://billr-app.com/api/v1/invoices?limit=5 \ -H "Authorization: Bearer billr_live_YOUR_KEY"
That is the whole learning curve. The rest of this page is reference material for when you need a specific field.
One header, one key
Send your key as a bearer token on every request. Keys start with billr_live_ and are tied to one account, so the API never has to be told which organisation you mean.
Authorization: Bearer billr_live_YOUR_KEY
A key is shown to you exactly once, at the moment you create it. Only a hash of it is stored, which means a lost key can be replaced but never recovered. Revoking one takes effect on the next request.
Keep it server side
A key can read and create everything in your account. It belongs in your backend, never in a browser or a mobile app.
Use one key per system
Separate keys for your site, your scripts and your staging environment mean you can revoke one without breaking the others.
Comes with a subscription
Requests from an account without one return 402 with the code plan_required. Nothing else about the request changes.
How values are written
Money is never a decimal number. Every amount is an integer, so nothing is ever off by a cent after a currency conversion or a JSON round trip. Three unit conventions follow from that, and they hold everywhere in the API and in every webhook.
…Centsinteger- A whole number of cents. 1250 is 12.50, and a negative value is a credit. Divide by 100 to display it.
…Bpinteger- Basis points. 2100 is a tax rate of 21 percent, 600 is 6 percent, 0 is exempt. Divide by 100 to display it.
…Milliinteger- Thousandths of a unit, so quantities can carry three decimals. 1000 is one item, 2500 is two and a half hours.
Dates and timesstring- Returned as ISO 8601 in UTC. When you send one, YYYY-MM-DD is enough and is the format to prefer.
Currencystring- A three letter ISO code such as EUR or USD. Sent in upper case and stored that way.
Idsstring- Opaque strings. Treat them as text, never parse them, and store them as given.
Missing valuesnull- A field with no value is null rather than absent, so the shape of an object never changes between records.
Responses are sent with Cache-Control: no-store. List endpoints wrap their results in an envelope, single records are returned on their own:
{
"data": [ { … }, { … } ],
"limit": 50,
"count": 2
}One shape, whatever went wrong
Every failure returns the same envelope, with a stable machine readable code and a message written for whoever has to read the log at two in the morning.
{
"error": {
"code": "invalid_body",
"message": "Every line needs a description (lines.0.description)"
}
}| Status | Code | What it means |
|---|---|---|
| 400 | invalid_body | The JSON was missing a required field or a value was out of range. The message names the field. |
| 400 | invalid_status | The status filter was not one of the known statuses. The message lists them all. |
| 401 | unauthorized | The key was missing, malformed, unknown or revoked. All four look the same from outside. |
| 402 | plan_required | The account has no subscription, or its monthly allowance for that action is used up. |
| 404 | not_found | No record with that id. A record belonging to another account answers the same way, on purpose. |
A 401 also carries WWW-Authenticate: Bearer realm="Billr API", so a generic HTTP client knows what to ask for.
Invoices
GET/api/v1/invoices
Lists your invoices and credit notes, newest first.
limitinteger- How many to return. Defaults to 50, never goes above 100. Anything unparseable falls back to the default.
statusstring- Optional filter. One of DRAFT, SENT, VIEWED, PARTIALLY_PAID, PAID, OVERDUE, CANCELLED.
curl "https://billr-app.com/api/v1/invoices?status=PAID&limit=20" \ -H "Authorization: Bearer billr_live_YOUR_KEY"
POST/api/v1/invoices
Creates a draft and returns it with status 201. The number is assigned for you, in sequence, using the same counter the app uses, so an invoice made through the API sits in the same unbroken series as one made by hand.
Creating is deliberately separate from sending. Nothing is e-mailed to your client until you send it from the app, which means a script with a bug cannot mail a stranger.
linesarray, required- At least one line. Each line is described below.
typestring- INVOICE or CREDIT_NOTE. Defaults to INVOICE.
clientIdstring- The id of a stored client. Fills in the bill-to block and the currency from that client.
billToNamestring- Required unless you pass a clientId. Set it alongside a clientId to override just this once.
billToCompanystring- Company name on the invoice.
billToEmailstring- Where the invoice would be sent from the app.
billToVatstring- The client VAT or tax number.
billToAddressstring- The address block as one string. Line breaks are kept.
issueDatestring- YYYY-MM-DD. Defaults to today.
dueDatestring- YYYY-MM-DD. Defaults to the issue date plus your account payment term.
currencystring- Three letters. Defaults to the client currency, then to your account currency.
discountCentsinteger- A discount on the whole invoice, spread across the lines before tax. Zero or more.
notesstring- The note block under the totals.
termsstring- Your payment terms, printed below the notes.
descriptionstring, required- What you are charging for.
unitPriceCentsinteger, required- Price of one unit, in cents. Negative is allowed for a correction line.
quantityMilliinteger- Quantity in thousandths. Defaults to 1000, which is one.
unitstring- What one unit is called: hour, day, item. Defaults to item.
taxRateBpinteger- Tax on this line in basis points. Defaults to your account tax rate.
curl -X POST https://billr-app.com/api/v1/invoices \
-H "Authorization: Bearer billr_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"clientId": "cl_example",
"dueDate": "2026-09-30",
"lines": [
{
"description": "Design work, September",
"quantityMilli": 12500,
"unit": "hour",
"unitPriceCents": 8500,
"taxRateBp": 2100
}
]
}'Totals are computed for you. A line amount is the quantity times the unit price, tax is summed once per rate rather than once per line, and any discount is spread across the lines in proportion so the printed tax base always reconciles with the total charged.
GET/api/v1/invoices/{id}
Returns one invoice with its lines. An id that belongs to another account answers 404, exactly as one that does not exist, so the API never confirms that a record is out there somewhere.
Clients
GET/api/v1/clients
Lists your clients, sorted by name.
limitinteger- How many to return. Defaults to 50, never goes above 100.
archivedstring- Pass 1 to list archived clients instead of active ones.
POST/api/v1/clients
Creates a client and returns it with status 201. Only a name is required, so a client captured from a form with one field is still a valid client.
namestring, required- The person or business you invoice.
companystring- Company name, if it differs from the name.
emailstring- Where invoices are sent.
phonestring- Contact number.
vatNumberstring- VAT or tax number.
addressL1string- Street and number.
addressL2string- A second address line.
postalCodestring- Postal or zip code.
citystring- City.
countrystring- Country.
currencystring- Three letters. Used as the default on invoices for this client.
notesstring- Private notes. Never printed on an invoice.
curl -X POST https://billr-app.com/api/v1/clients \
-H "Authorization: Bearer billr_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Amelie Janssens",
"company": "Studio Noord",
"email": "[email protected]",
"city": "Antwerp",
"country": "Belgium",
"currency": "EUR"
}'What comes back
There is one definition of an invoice as JSON and one of a client, shared by the endpoints and the webhooks. Anything that can read a webhook can read the API, and the other way round.
The invoice object
{
"id": "inv_example",
"number": "2026-0042",
"type": "INVOICE",
"status": "SENT",
"clientId": "cl_example",
"billTo": {
"name": "Amelie Janssens",
"company": "Studio Noord",
"email": "[email protected]",
"vatNumber": "BE0123456789",
"address": "Kloosterstraat 12\n2000 Antwerp"
},
"issueDate": "2026-09-01T00:00:00.000Z",
"dueDate": "2026-09-30T00:00:00.000Z",
"currency": "EUR",
"subtotalCents": 106250,
"discountCents": 0,
"taxCents": 22313,
"totalCents": 128563,
"paidCents": 0,
"notes": null,
"terms": "Payment within 30 days.",
"publicUrl": "https://billr-app.com/i/…",
"sentAt": "2026-09-01T09:12:44.201Z",
"viewedAt": null,
"paidAt": null,
"createdAt": "2026-09-01T09:11:02.884Z",
"updatedAt": "2026-09-01T09:12:44.204Z",
"lines": [
{
"id": "ln_example",
"position": 0,
"description": "Design work, September",
"quantityMilli": 12500,
"unit": "hour",
"unitPriceCents": 8500,
"taxRateBp": 2100,
"amountCents": 106250
}
]
}statusstring- DRAFT, SENT, VIEWED, PARTIALLY_PAID, PAID, OVERDUE or CANCELLED.
paidCentsinteger- How much has been recorded against the invoice so far. Compare it with totalCents rather than reading status alone.
publicUrlstring- The link your client opens to view and download the invoice. Safe to send, and it needs no account.
viewedAtstring or null- The first time that link was opened. Null until it is.
linesarray- Always in the order they appear on the document, by position.
The client object
{
"id": "cl_example",
"name": "Amelie Janssens",
"company": "Studio Noord",
"email": "[email protected]",
"phone": null,
"vatNumber": "BE0123456789",
"address": {
"line1": "Kloosterstraat 12",
"line2": null,
"postalCode": "2000",
"city": "Antwerp",
"country": "Belgium"
},
"notes": null,
"currency": "EUR",
"archived": false,
"portalUrl": "https://billr-app.com/p/…",
"createdAt": "2026-08-14T15:02:19.771Z",
"updatedAt": "2026-08-14T15:02:19.771Z"
}portalUrl is the client portal: one page holding every invoice you ever sent that client, which you can share once instead of re-sending links.
Be told, instead of asking
Add an endpoint on the developers page, choose the events you care about, and Billr posts JSON to your URL as things happen. Nothing has to poll.
invoice.createdevent- An invoice or credit note was created, whether in the app or through the API.
invoice.sentevent- An invoice was e-mailed to a client.
invoice.paidevent- An invoice was paid in full.
The body is the same every time. data holds the invoice object described above, so you rarely need a second call to find out what happened.
{
"id": "4e1c2f8a-…",
"event": "invoice.paid",
"createdAt": "2026-09-14T11:04:52.317Z",
"data": { … the invoice object … }
}X-Billr-Eventstring- The event name, so you can route before parsing.
X-Billr-Signaturestring- Hex HMAC-SHA256 of the raw body, keyed with that endpoint signing secret.
X-Billr-Timestampstring- Unix seconds at the moment we sent it.
User-Agentstring- Billr-Webhooks/1.0
Verifying a delivery
Sign the raw bytes you received, before any JSON parsing, and compare in constant time. Reformatting the body first will change the signature.
import crypto from "node:crypto";
export function isFromBillr(rawBody, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Answer first, work afterwards
We wait five seconds for your reply and send each delivery once. Reply 200 straight away and queue the real work on your side. Every delivery carries its own id, so if you ever see one twice it is easy to recognise. The status of the last attempt, including any error, is shown next to the endpoint on your developers page.
What is not there yet
A short list, kept accurate on purpose. Knowing the edges up front is worth more than finding them in production.
- Invoices and clients can be listed and created through the API. Updating and deleting stay in the app for now.
- Sending an invoice by e-mail is an action you take in the app, so a script can never mail a client by accident.
- A webhook is delivered once, with a five second timeout and no retry queue. The last status is recorded on the endpoint so a failure is visible rather than silent.
- There is no rate limit today. Keep your request rate sensible, and if a limit is ever added it will be announced before it takes effect.
- The API is versioned in the path. A change that would break existing code lands on a new version rather than on v1.
Missing something you need? Say so and it goes on the list. The API grows in the direction people actually push it.
Wire it up this afternoon.
Start free, see whether Billr makes the invoice you want, and turn on the API when your own system needs to do the asking.