BillingHub API

Create invoices, estimates and quotes from your own system, read them back, send them to your client, and be told when they are paid. Five endpoints and three webhook events — the whole of it is on this page.

Quick start

Mint a key on your API & webhooks settings page, then ask for your credit balance. It is the smallest request there is, and it proves the key works before you create anything with it.

curl https://billinghub.online/api/v1/balance \
  -H "Authorization: Bearer bh_live_a1b2c3d4_YOUR_SECRET"
{"credits": 42}

Every request goes to https://billinghub.online over HTTPS, sends and receives JSON, and carries the key in an Authorization header.

Authentication

A key looks like bh_live_<prefix>_<secret> and goes in one place:

Authorization: Bearer bh_live_a1b2c3d4_YOUR_SECRET

Never in a query string. A key in a URL lands in access logs, proxy logs and browser history, and you cannot get it back out of any of them. Requests that put it anywhere but this header are refused.

The secret is shown once, by the page that mints it. We store a hash, so nothing can show it to you again — if you lose it, mint another key and revoke the old one. Revoking takes effect immediately.

Keys are per account, not per user, and each one is rate limited on its own. Give a separate key to each integration: if one runs away, the others keep working.

Rate limit

60 requests a minute, per key. Not per address — a key names exactly one account, so one office behind one connection is not sharing a budget with its neighbours, and one integration spread over twenty hosts does not get twenty budgets.

Over the limit answers 429 with a Retry-After header giving the seconds to wait. Documents are created when work is done rather than in a loop, so this is here to stop a runaway retry, not to meter you: what you are sold is credits.

Errors

A refusal carries a JSON body — {"error": "…"}, or {"errors": {…}} when individual fields are at fault. The status code is the part to branch on, and the four that get confused mean genuinely different things:

Every failure under /api/ answers this way, whatever Accept you sent and however deep it happened — an unknown path, a rejected key, a malformed body, an outage of ours. One shape, one field to read: error. You will never get an HTML page here.

CodeMeansWhat to do
401 The key did not resolve — missing, malformed, unknown, revoked, or the wrong secret. Check the key. All five cases answer alike on purpose, so that nobody can enumerate keys by reading the differences.
403 The key is real. Your current plan does not include API access. Nothing is wrong with your credentials — the answer is on your plan.
402 Your plan is right and your credit balance is empty. Buy credits. Only invoices ever answer this — estimates and quotes cost nothing, whatever your balance is.
404 No such document type, or a document that is not yours. Someone else's document answers 404 rather than 403: confirming that a guessed reference names something real would itself be the leak.
409 Your account has no business details yet — or an Idempotency-Key is in conflict. Every document prints a seller, so fill in your business profile once, in the portal. The other case is described under Idempotency; the message says which it is.
422 The request was understood and refused: a bad field, a duplicate number, a draft that cannot be marked paid, a document with no recipient. Read errors, which is keyed by field path — buyer.name, items.0.rate.
429 Over 60 requests a minute on this key. Wait the number of seconds in Retry-After.
{
  "errors": {
    "buyer.name": "This field is required.",
    "items.0.rate": "This amount could not be read."
  }
}

Idempotency

Two requests here spend money — creating a document and issuing one — and a timeout tells you nothing about whether either went through. Send an Idempotency-Key header on POST /api/v1/documents/<type> or on POST /api/v1/documents/<reference>/issue and you can retry the same request as often as you like: the first one does the work, and every later one is answered with that same first response.

curl -X POST https://billinghub.online/api/v1/documents/invoice \
  -H "Authorization: Bearer bh_live_a1b2c3d4_YOUR_SECRET" \
  -H "Idempotency-Key: 8f14e45f-ea2b-4c53-9d6f-1b0a7c2e5a91" \
  -H "Content-Type: application/json" \
  -d '{ … }'

Use a fresh key — a UUID is the usual choice — for each document you mean to create, and reuse it for every retry of that one. A replayed answer carries Idempotency-Replayed: true, so you can tell it from a document that was created just now.

A key is a promise about one request. Reusing one with a different body answers 409 rather than handing back the first document, because that would be us quietly returning the wrong answer. A key belonging to a request still in flight answers 409 too; retry it in a moment.

Keys are remembered for 24 hours and are scoped to your account, so they need only be unique to you. Only a success is remembered: a request refused with 402 or 422 created nothing and charged nothing, so the key is free again immediately and the retry those statuses ask for is the retry you can make. Use a fresh key per document — one key cannot cover creating a document and then issuing it, and reusing it across the two answers 409.

The header is optional and no other endpoint takes it — reads have nothing to duplicate, and a send is already safe to repeat.

Endpoints

<type> is invoice, estimate or quote. <reference> is the id a document is created with — the same one that appears in its portal URL.

GET /api/v1/balance

How many credits are left on the account this key belongs to.

{"credits": 42}

POST /api/v1/documents/<type>

Create a document. The seller is your own business profile — a seller block in the body is accepted and ignored, so no caller can issue paperwork under somebody else's name.

"issue": true is what takes the document out of draft, and for an invoice that is the line that spends a credit. Leave it out and you get a draft, which costs nothing, can still be edited in the portal, and can be issued later with POST /api/v1/documents/<reference>/issue. Estimates and quotes never spend a credit either way.

curl -X POST https://billinghub.online/api/v1/documents/invoice \
  -H "Authorization: Bearer bh_live_a1b2c3d4_YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "issue": true,
    "number": "INV-0042",
    "currency": "USD",
    "buyer": {
      "name": "Northwind Trading",
      "email": "ap@northwind.example",
      "addressLine1": "12 Harbour Road",
      "city": "Portland",
      "zipCode": "97205"
    },
    "items": [
      {
        "name": "Site survey",
        "description": "Half a day on site",
        "quantity": 1,
        "rate": "450.00",
        "taxes": [{"name": "Sales Tax", "rate": 8250}]
      },
      {
        "name": "Drone photography",
        "quantity": 3,
        "rate": "120.00"
      }
    ],
    "notes": "Thank you for your business.",
    "terms": "Payment due within 14 days."
  }'

Answers 201:

{
  "reference": "K3mQ8vT2xLpZ",
  "type": "invoice",
  "status": "issued",
  "number": "INV-0042",
  "pdfUrl": "https://billinghub.online/api/v1/documents/K3mQ8vT2xLpZ/pdf"
}

The buyer becomes a client on your account: matched on buyer.email if you have one already, created if not. Omit number and the next one in your own sequence is used — number in the answer is always the one the document actually carries.

Send an Idempotency-Key header. This request spends a credit when it issues, and a client that times out and retries — which is what clients do by default — would otherwise buy a second document. See Idempotency.

GET /api/v1/documents/<type>

Your documents of one type, newest first — estimate answers with estimates and nothing else. limit defaults to 25 and stops at 100; offset pages through the rest. total is the count of all of them, not of this page.

GET /api/v1/documents/invoice?limit=25&offset=50
{
  "documents": [
    {
      "reference": "K3mQ8vT2xLpZ",
      "type": "invoice",
      "status": "issued",
      "number": "INV-0042",
      "issuedAt": "2026-08-09",
      "currency": "USD",
      "totalCent": 84150
    }
  ],
  "total": 128
}

GET /api/v1/documents/<reference>

One document, whatever its type — in full. Every field the create accepts is answered here, under the name the create takes it by, so what you wrote can be read back and reconciled.

{
  "reference": "K3mQ8vT2xLpZ",
  "type": "invoice",
  "status": "issued",
  "number": "INV-0042",
  "currency": "USD",
  "issuedAt": "2026-08-09",
  "dueAt": "2026-08-23",
  "yourReference": "PO-88123",
  "buyer": {
    "name": "Northwind Trading",
    "email": "ap@northwind.example",
    "addressLine1": "12 Harbour Road",
    "addressLine2": "",
    "city": "Portland",
    "state": "",
    "zipCode": "97205",
    "country": "USA"
  },
  "items": [
    {
      "name": "Site survey",
      "description": "Half a day on site",
      "quantity": 1,
      "unitPriceCent": 45000,
      "amountCent": 45000,
      "taxes": [{"name": "Sales Tax", "rate": 8.25, "number": ""}]
    }
  ],
  "subtotalCent": 81000,
  "discountPercent": null,
  "discountCent": 0,
  "taxCent": 3150,
  "shippingCent": 0,
  "paidCent": 0,
  "totalCent": 84150,
  "amountDueCent": 84150,
  "notes": "Thank you for your business.",
  "notesName": "",
  "terms": "Payment due within 14 days.",
  "termsName": "",
  "payUrl": "https://billinghub.online/pay/9f3c…",
  "pdfUrl": "https://billinghub.online/api/v1/documents/K3mQ8vT2xLpZ/pdf"
}

payUrl is always present and is empty when there is nothing to pay — a settled invoice, any estimate or quote, or an account with no Stripe connected. Branch on the empty string rather than on a missing key.

yourReference is the reference you sent — your purchase order or job number. reference at the top is ours, the id every other endpoint and every webhook names the document by. A tax rate is a percent, the same unit the create takes.

GET /api/v1/documents/<reference>/pdf

The document itself, as application/pdf. The same bytes the portal renders and the same file that is attached to a send, with a Content-Disposition naming it after the document.

curl https://billinghub.online/api/v1/documents/K3mQ8vT2xLpZ/pdf \
  -H "Authorization: Bearer bh_live_a1b2c3d4_YOUR_SECRET" \
  -o invoice-0042.pdf

A draft renders too, watermark and all — it is your own document, and reviewing it before issuing is the thing this is most useful for. pdfUrl on the create and the read answers this same address, so you never have to build it.

POST /api/v1/documents/<reference>/issue

Take a draft out of draft. This is the line that spends a credit for an invoice — the same one "issue": true spends at create time — and it is how a draft you built earlier becomes a document you can send.

Answers 200:

{
  "reference": "K3mQ8vT2xLpZ",
  "type": "invoice",
  "status": "issued",
  "number": "INV-0042",
  "changed": true,
  "pdfUrl": "https://billinghub.online/api/v1/documents/K3mQ8vT2xLpZ/pdf"
}

Issuing what is already issued is a success, not an error. You get 200 with "changed": false, and nothing was charged — so a retry after a timeout is safe, and you can still tell the two apart when you reconcile. status is the document's current one, which is not always issued: an invoice already paid is past this step, not short of it.

Issuing freezes your business details and your client's onto the document, and an issued document can no longer be edited — change the client's address afterwards and the paperwork you already sent stays as it went out.

An empty balance answers 402 and leaves the document a draft. An estimate or a quote issues free, whatever your balance is; for those, issuing is what makes the proposal live for your client to accept or decline. This request spends money, so it takes an Idempotency-Key on the terms described under Idempotency.

POST /api/v1/documents/<reference>/send

Email the document to your client, with its PDF attached, using the subject and body template on your account.

Answers 202:

{
  "status": "queued",
  "recipient": "ap@northwind.example"
}

Queued, not sent. Mail leaves through an outbox and a worker hands it to the transport afterwards, so at the moment you get this answer nothing yet knows whether it will be delivered. The invoice.sent and invoice.failed webhooks are how you find out without polling; the document's timeline in the portal shows the same thing.

A draft sends too, the same as it does in the portal — useful when a client wants to look at something before it is final. What arrives is a draft: the PDF carries its watermark, there is no payment button, and the document stays a draft, so no credit is spent and you can still edit it. Sending is not a way to issue. The recipient is the document's own email override, or the client's address; a document with neither is refused with 422.

POST /api/v1/documents/<reference>/mark-paid

Settle an invoice that was paid somewhere we cannot see — a bank transfer, a cheque, cash. Payments taken through Stripe settle themselves and need none of this.

Answers 200:

{
  "reference": "K3mQ8vT2xLpZ",
  "type": "invoice",
  "status": "paid",
  "number": "INV-0042",
  "changed": true,
  "pdfUrl": "https://billinghub.online/api/v1/documents/K3mQ8vT2xLpZ/pdf"
}

Settling what is already settled answers 200 with "changed": false and records no second payment. A draft is refused with 422 — issue it first, so that the credit is spent where every other issued invoice spends it. An estimate or a quote is refused too: no proposal can be paid.

POST /api/v1/documents/<reference>/unmark-paid

Undo a settlement — for the invoice marked paid by mistake. It goes back to issued; the credit it was issued with is not refunded, and both the payment and this stay on its timeline, because that is a record of what happened rather than a state.

Anything not currently paid answers 200 with "changed": false and its real status.

Request body

Only buyer.name is required. Everything else is optional and has a sensible default; a value that is present and malformed is what gets refused.

Three things worth reading twice.
  • items[].rate is a decimal amount — "450.00", not 45000.
  • items[].taxes[].rate is thousandths of a percent: 8250 is 8.25%, 20000 is 20%.
  • Money in responses is always integer cents: totalCent, amountDueCent.
FieldTypeDefaultNotes
issuebooleanfalseIssue it instead of leaving a draft. Spends a credit for an invoice.
buyer.namestringRequired.
buyer.emailstringemptyMatches an existing client, and is where a send goes.
buyer.addressLine1, buyer.city, buyer.zipCodestringemptyPrinted on the document, and stored on the client if this buyer is a new one.
buyer.countrystringyour own countryA three-letter code (USA, GBR). Used when a new client is created from this request; one this application does not recognise falls back rather than failing.
numberstringnext in your sequenceMust be unused, or the request is refused.
currencystringUSDAn unrecognised currency is refused, not defaulted.
issuedAt, dueAtstringtoday, +14 daysYYYY-MM-DD. 2026-02-30 is refused rather than shifted.
items[].namestringemptyA line with no name and no rate is dropped.
items[].descriptionstringemptyPrinted under the name.
items[].quantityinteger1Whole numbers, 1 to 10000.
items[].ratestring or number0A decimal amount in the document's currency.
items[].taxes[]arraynone{"name": "VAT", "rate": 20000} — thousandths of a percent, 0 to 100000.
discountPercentnumbernone0 to 100, fractions allowed. Taken off the subtotal.
shippingstring or number0A decimal amount, added to the total.
paidstring or number0A deposit already taken, subtracted from what is still owed. It is a figure you state, not a payment we observed.
referencestringemptyYour own reference — a purchase order or job number — printed on the document. Not the id we answer with.
notes, termsstringemptyPrinted at the foot of the document.
notesName, termsNamestringthe usual headingsRetitle those two blocks — "Delivery", "Warranty".
logostringRefused with 422. The logo printed is the one on your business profile; there is no per-document logo, and being told so beats being ignored.
sellerobjectIgnored. The seller is the account the key belongs to.

Everything in this table is applied to the document. The one field that is refused rather than used is logo, and it answers 422 saying so: a document prints the logo on your business profile, and a request that seemed to set one and did not is worse than one that was turned away.

Limits

One request is bounded, so that no single document can hold a worker for a minute. Everything here is refused with 422 naming the field, before any of the work it bounds is done.

WhatLimit
Line items on a document100
Taxes on one line20
Taxes across the whole document200
Quantity on a line1 to 10000
Tax rate0 to 100000 (0% to 100%)
Any single amount, and the subtotal1000000000000
Item name200 characters
Item description500 characters
Tax name60 characters
Party fields and the document number200 characters
Notes and terms2000 characters
Logo1 MB, and 1200 × 1200 pixels

The two document-wide ones exist because the per-field limits multiply: 100 items each carrying 20 taxes is 2000 places a tax gets printed, which every individual limit accepted and which cost a minute of a worker to render.

Webhooks

Register an endpoint on your API & webhooks settings page and we POST JSON to it when something happens that you cannot learn any other way without polling. Your signing secret is shown there whenever you need it.

EventFires when
invoice.paidMoney arrived and the invoice is settled.
invoice.sentThe email left the outbox — the transport accepted it. Not "the client read it": nothing knows that.
invoice.failedThe email did not get out and has stopped being retried.
{
  "event": "invoice.paid",
  "data": {
    "reference": "K3mQ8vT2xLpZ",
    "type": "invoice",
    "status": "paid",
    "number": "INV-0042",
    "totalCent": 84150,
    "currency": "USD"
  }
}

Verifying a delivery

Every delivery carries a signature over the exact bytes we sent, in Stripe's shape — if you have wired up Stripe, you have written this already:

BillingHub-Signature: t=1786195200,v1=5257a869e7ecebeda32affa62cdca3fa…

v1 is HMAC-SHA256 of <t>.<raw body>, keyed with your endpoint's secret. The timestamp is inside the signed string, not merely beside it: that is what stops a captured delivery being replayed at you for ever. Reject anything more than 300 seconds out of date, and compare in constant time.

Sign the raw request body, before any JSON parsing — re-encoding it changes the bytes and the signature will not match.

PHP
function verify(string $body, string $header, string $secret): bool
{
    $parts = [];
    foreach (explode(',', $header) as $piece) {
        $pair = explode('=', trim($piece), 2);
        if (count($pair) === 2) {
            $parts[$pair[0]] = $pair[1];
        }
    }

    $timestamp = $parts['t'] ?? '';
    $signature = $parts['v1'] ?? '';

    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    return hash_equals(
        hash_hmac('sha256', $timestamp . '.' . $body, $secret),
        $signature
    );
}
Node
const crypto = require('crypto');

function verify(body, header, secret) {
  const parts = Object.fromEntries(
    header.split(',').map((piece) => piece.trim().split('=', 2))
  );

  const timestamp = Number(parts.t);
  if (!Number.isInteger(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.' + body)
    .digest();
  const given = Buffer.from(parts.v1 || '', 'hex');

  return expected.length === given.length
    && crypto.timingSafeEqual(expected, given);
}

Retries

Answer 2xx and the delivery is done. Anything else, or no answer at all, is retried five times with a widening gap — 30 seconds, then 1, 2 and 4 minutes — and then given up on. Two deliveries of one event can reach you if a retry crosses a slow answer, so key your side off data.reference and the event name and make handling one twice cost nothing.

An endpoint must be a public http or https address — use https, because the payload carries your client's name and your figures. Private, loopback and link-local addresses are refused when you register the endpoint and checked again before every delivery, since a name can start resolving somewhere else in between. Redirects are not followed.

Getting a key

API access is included with the annual plan. Keys and webhook endpoints both live on one page in the portal.

Go to API settings