Billery API
Create and send invoices, quotes and receipts from your own app, website or booking system. Billery renders the PDF, emails the customer and keeps the document in your account alongside everything you create by hand.
The API handles documents, not money. Billery never collects payment from your customers — how you get paid stays entirely up to you.
Getting started
Every endpoint lives under https://app.billery.io/api/v1. Requests and responses are JSON, and all field names are snake_case.
Create a key in Settings → Developers, then send your first invoice:
curl https://app.billery.io/api/v1/invoices \
-H "Authorization: Bearer blry_test_..." \
-H "Content-Type: application/json" \
-d '{
"customer": { "name": "Ada Okafor", "email": "ada@example.com" },
"items": [
{ "description": "Boiler service", "quantity": 1, "unit_price": 120 }
],
"due_in_days": 14,
"send": true
}'Totals are always calculated by Billery from the line items, tax rate and discount — you never send a total, so your numbers and ours can't drift apart.
Authentication
Pass your key as a bearer token on every request. Keys carry the full permissions of your account, so keep them on your server — never in a browser, mobile app or public repo.
Authorization: Bearer blry_live_7f3c...A key is shown once, when you create it. We store only a hash, so a lost key can't be recovered — create a new one and revoke the old. Revoking takes effect immediately.
Test mode
Keys come in two modes. A blry_test_ key validates and prices a request exactly like a live one and returns a realistic response, but writes nothing and emails nobody. Build the whole integration against test keys, then swap in a blry_live_key when you're ready.
Test responses carry "livemode": false and placeholder ids and numbers, so don't store them as real references.
Rate limits
Each key may make 120 requests per minute. Successful responses — and the 429 itself — tell you where you stand:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1790000040X-RateLimit-Reset is a Unix timestamp in seconds. Go over and you get a 429 with a Retry-Afterheader in seconds — wait that long and retry rather than looping immediately. Limits apply per key, so a busy background job won't starve your checkout flow if you give them separate keys.
Idempotency
Send an Idempotency-Key header on any create request. If the same key arrives again — a retry after a timeout, a double-submit, a queue redelivery — we replay the original response instead of creating a second document, and mark it with Idempotent-Replay: true.
-H "Idempotency-Key: booking-4821-invoice"Use something derived from your own records, like an order id. A request that fails releases its key, so you can safely retry with the same one. A request still in flight returns 409 conflict.
Errors
Failures use standard status codes and always have the same body shape:
{
"error": {
"type": "invalid_request",
"message": "At least one item is required",
"param": "items"
}
}| Field | Type | Description |
|---|---|---|
| authentication_error | 401 | Missing, invalid or revoked key. |
| invalid_request | 400 | Malformed JSON or a field that failed validation. Check param. |
| not_found | 404 | No such document or customer on your account. |
| limit_reached | 402 | Free document allowance used up — upgrade to Pro. |
| conflict | 409 | A request with this Idempotency-Key is still processing. |
| rate_limited | 429 | Too many requests. Wait for Retry-After. |
| server_error | 500 | Something broke on our side. Safe to retry with the same idempotency key. |
Invoices
/api/v1/invoicesCreates an invoice. Pass send: true to email the PDF to the customer immediately; otherwise it lands in your account as a draft.
| Field | Type | Description |
|---|---|---|
| customer_id | string | Id of an existing customer. Use this or customer, not both. |
| customer | object | New or matched customer: name and email required; phone, address, city, country optional. Matched on email. |
| items | array | One to 100 line items, each with description, quantity and unit_price. |
| currency | string | Three-letter code. Defaults to the customer's currency, then GBP. |
| issue_date | string | YYYY-MM-DD. Defaults to today. |
| due_date | string | YYYY-MM-DD. Must not be before issue_date. |
| due_in_days | integer | Days from the issue date. Defaults to 14. Ignored if due_date is set. |
| tax_rate | number | Percentage from 0 to 100. Applied after the discount. |
| discount | number | Flat amount off the subtotal. Cannot exceed it. |
| notes | string | Free text printed on the document. |
| send | boolean | Email the document to the customer as a PDF straight away. |
{
"object": "invoice",
"id": "clx8f2a0b0000...",
"livemode": true,
"number": "INV-0009",
"status": "sent",
"currency": "GBP",
"issue_date": "2026-09-23",
"due_date": "2026-10-07",
"customer": { "id": "clx...", "name": "Ada Okafor", "email": "ada@example.com" },
"items": [
{ "description": "Boiler service", "quantity": 1, "unit_price": 120, "amount": 120 }
],
"subtotal": 120,
"discount": 0,
"tax_rate": 0,
"tax_amount": 0,
"total": 120,
"notes": null,
"url": "https://app.billery.io/i/8f2c...",
"pdf_url": "https://app.billery.io/i/8f2c.../pdf",
"sent_at": "2026-09-23T09:14:22.104Z",
"paid_at": null,
"created_at": "2026-09-23T09:14:20.881Z"
}url is a shareable page your customer can open without signing in, and pdf_url downloads the same document. Both are safe to put in your own emails or WhatsApp messages.
/api/v1/invoices/{id}Retrieves an invoice, including its current status and payment date.
Quotes
/api/v1/quotesSame shape as an invoice, with an expiry instead of a due date. Accepted quotes can be converted to an invoice from your dashboard.
| Field | Type | Description |
|---|---|---|
| expiry_date | string | YYYY-MM-DD. When the quote stops being valid. |
| valid_for_days | integer | Days from the issue date. Defaults to 30. Ignored if expiry_date is set. |
Everything else matches the invoice fields above. Responses carry "object": "quote", expiry_date and converted_invoice_id.
/api/v1/quotes/{id}Receipts
/api/v1/receiptsA receipt records money already taken — useful if you collect payment at the point of sale and only need to send proof. It's created as paid, so there's no due date and no chasing.
| Field | Type | Description |
|---|---|---|
| paid_at | string | YYYY-MM-DD. When payment was taken. Defaults to today. |
| payment_method | string | Printed on the receipt, e.g. "Card" or "Bank transfer". |
Receipts don't count towards the free document allowance, and the response comes back with "object": "receipt". Receipts are API-only for now — they don't appear as a create option in the dashboard.
Webhooks
Add an endpoint in Settings → Developers and we'll POST a JSON event to it whenever something happens on your account — so your system finds out an invoice was paid without polling for it.
| Field | Type | Description |
|---|---|---|
| invoice.sent | event | An invoice was emailed to a customer. |
| invoice.paid | event | An invoice was marked paid. |
| quote.sent | event | A quote was emailed to a customer. |
| quote.converted | event | A quote was turned into an invoice. |
| receipt.created | event | A receipt was created through the API. |
The body wraps the same object the API returns, so you can reuse whatever parses your API responses:
{
"id": "evt_9c1f4a2b7e0d",
"type": "invoice.paid",
"created": 1790000000,
"data": { "object": "invoice", "id": "clx...", "total": 120, ... }
}Verifying the signature
Each request carries a Billery-Signature header — an HMAC-SHA256 of timestamp.bodyusing your endpoint's signing secret:
Billery-Signature: t=1790000000,v1=5f8d3c...
Billery-Event: invoice.paidCompute the same value over the raw request body and compare. Reject anything with a timestamp more than five minutes old, which stops a captured request being replayed later.
import crypto from "crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.trim().split("="))
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (age > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1, "hex")
);
}import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{parts['t']}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])Delivery behaviour
Reply with any 2xx status as soon as you've stored the event, and do the slow work afterwards — we give up on a request after 5 seconds. A non-2xx or a timeout is retried twice more with a short backoff; after that the event is dropped, and the failure is shown against the endpoint in your dashboard.
Treat handlers as idempotent: use the event idto ignore anything you've already processed, since a retry can arrive after your slow first attempt actually succeeded. Events aren't queued for later replay, so an endpoint that's down for a while will miss events — treat webhooks as a fast path, and the API as the source of truth.
Currently emitting: invoice.sent, invoice.paid, quote.sent, quote.converted, receipt.created.