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
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: 1790000040

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

curl
-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:

json
{
  "error": {
    "type": "invalid_request",
    "message": "At least one item is required",
    "param": "items"
  }
}
FieldTypeDescription
authentication_error401Missing, invalid or revoked key.
invalid_request400Malformed JSON or a field that failed validation. Check param.
not_found404No such document or customer on your account.
limit_reached402Free document allowance used up — upgrade to Pro.
conflict409A request with this Idempotency-Key is still processing.
rate_limited429Too many requests. Wait for Retry-After.
server_error500Something broke on our side. Safe to retry with the same idempotency key.

Invoices

post/api/v1/invoices

Creates an invoice. Pass send: true to email the PDF to the customer immediately; otherwise it lands in your account as a draft.

FieldTypeDescription
customer_idstringId of an existing customer. Use this or customer, not both.
customerobjectNew or matched customer: name and email required; phone, address, city, country optional. Matched on email.
itemsarrayOne to 100 line items, each with description, quantity and unit_price.
currencystringThree-letter code. Defaults to the customer's currency, then GBP.
issue_datestringYYYY-MM-DD. Defaults to today.
due_datestringYYYY-MM-DD. Must not be before issue_date.
due_in_daysintegerDays from the issue date. Defaults to 14. Ignored if due_date is set.
tax_ratenumberPercentage from 0 to 100. Applied after the discount.
discountnumberFlat amount off the subtotal. Cannot exceed it.
notesstringFree text printed on the document.
sendbooleanEmail the document to the customer as a PDF straight away.
json
{
  "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.

get/api/v1/invoices/{id}

Retrieves an invoice, including its current status and payment date.

Quotes

post/api/v1/quotes

Same shape as an invoice, with an expiry instead of a due date. Accepted quotes can be converted to an invoice from your dashboard.

FieldTypeDescription
expiry_datestringYYYY-MM-DD. When the quote stops being valid.
valid_for_daysintegerDays 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.

get/api/v1/quotes/{id}

Receipts

post/api/v1/receipts

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

FieldTypeDescription
paid_atstringYYYY-MM-DD. When payment was taken. Defaults to today.
payment_methodstringPrinted 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.

FieldTypeDescription
invoice.senteventAn invoice was emailed to a customer.
invoice.paideventAn invoice was marked paid.
quote.senteventA quote was emailed to a customer.
quote.convertedeventA quote was turned into an invoice.
receipt.createdeventA receipt was created through the API.

The body wraps the same object the API returns, so you can reuse whatever parses your API responses:

json
{
  "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.paid

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

node.js
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")
  );
}
python
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.