GET /api/v1/balance
How many credits are left on the account this key belongs to.
{"credits": 42}
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.
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.
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.
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.
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.
| Code | Means | What 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."
}
}
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.
<type> is invoice, estimate or
quote. <reference> is the id a document is
created with — the same one that appears in its portal URL.
/api/v1/balanceHow many credits are left on the account this key belongs to.
{"credits": 42}
/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.
/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
}
/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.
/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.
/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.
/api/v1/documents/<reference>/sendEmail 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.
/api/v1/documents/<reference>/mark-paidSettle 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.
/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.
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.
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%.totalCent, amountDueCent.| Field | Type | Default | Notes |
|---|---|---|---|
issue | boolean | false | Issue it instead of leaving a draft. Spends a credit for an invoice. |
buyer.name | string | — | Required. |
buyer.email | string | empty | Matches an existing client, and is where a send goes. |
buyer.addressLine1, buyer.city, buyer.zipCode | string | empty | Printed on the document, and stored on the client if this buyer is a new one. |
buyer.country | string | your own country | A 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. |
number | string | next in your sequence | Must be unused, or the request is refused. |
currency | string | USD | An unrecognised currency is refused, not defaulted. |
issuedAt, dueAt | string | today, +14 days | YYYY-MM-DD. 2026-02-30 is refused rather than shifted. |
items[].name | string | empty | A line with no name and no rate is dropped. |
items[].description | string | empty | Printed under the name. |
items[].quantity | integer | 1 | Whole numbers, 1 to 10000. |
items[].rate | string or number | 0 | A decimal amount in the document's currency. |
items[].taxes[] | array | none | {"name": "VAT", "rate": 20000} — thousandths of a percent, 0 to 100000. |
discountPercent | number | none | 0 to 100, fractions allowed. Taken off the subtotal. |
shipping | string or number | 0 | A decimal amount, added to the total. |
paid | string or number | 0 | A deposit already taken, subtracted from what is still owed. It is a figure you state, not a payment we observed. |
reference | string | empty | Your own reference — a purchase order or job number — printed on the document. Not the id we answer with. |
notes, terms | string | empty | Printed at the foot of the document. |
notesName, termsName | string | the usual headings | Retitle those two blocks — "Delivery", "Warranty". |
logo | string | — | Refused 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. |
seller | object | — | Ignored. 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.
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.
| What | Limit |
|---|---|
| Line items on a document | 100 |
| Taxes on one line | 20 |
| Taxes across the whole document | 200 |
| Quantity on a line | 1 to 10000 |
| Tax rate | 0 to 100000 (0% to 100%) |
| Any single amount, and the subtotal | 1000000000000 |
| Item name | 200 characters |
| Item description | 500 characters |
| Tax name | 60 characters |
| Party fields and the document number | 200 characters |
| Notes and terms | 2000 characters |
| Logo | 1 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.
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.
| Event | Fires when |
|---|---|
invoice.paid | Money arrived and the invoice is settled. |
invoice.sent | The email left the outbox — the transport accepted it. Not "the client read it": nothing knows that. |
invoice.failed | The 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"
}
}
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.
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
);
}
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);
}
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.
API access is included with the annual plan. Keys and webhook endpoints both live on one page in the portal.