API reference
The conventions every endpoint follows. For the per-endpoint request and
response shapes, the live OpenAPI browser at /api/docs is generated from
the code and is always current — this page is what it can’t tell you: how
authentication, scoping, paging, and errors behave across the whole surface.
For the click-path version of the same thing, see API & webhooks.
Base URL and versioning
https://api.webiller.com/api/v1The v1 prefix changes only for a breaking change. Additive changes — a new
field on a response, a new optional parameter, a new endpoint, a new webhook
event — ship without a version bump, so parse defensively: treat unknown
fields as data you don’t need yet rather than an error.
Authentication
Every endpoint except the health check and the public portal requires either a user session (JWT, what the apps use) or an API key. Keys are what your own code should use.
curl https://api.webiller.com/api/v1/invoices \
-H "Authorization: Bearer wb_live_7f3a9c21_..."A key is shaped wb_<env>_<id>_<secret>. The first three segments are a
non-secret handle: they identify the key in the UI and in logs, and they are
what a secret scanner recognises if one is ever pasted somewhere public. Only a
hash of the whole key is stored, so it is shown exactly once, when it is
created.
A key belongs to one business and stays there. Switching business in the app has no effect on a key that already exists — which is the point: an integration must survive the person who set it up leaving the team.
Scopes
A key carries resource:action scopes. action is read for GET, HEAD and
OPTIONS, and write for everything else. Two rules:
- write implies read on the same resource. A key that can create an invoice can read it back.
*:readand*:writeare wildcards covering every resource. These are what the “Read only” and “Read and write” choices in the UI mint, and what keys created before scopes existed carry.
// POST /api/v1/api-keys
{
"name": "Warehouse sync",
"scopes": ["invoices:write", "clients:read"]
}GET /api/v1/api-keys/scopes returns the scopable resources grouped the way the
UI shows them, so a partner integration can offer the same picker.
A request whose scope is missing gets a 403 naming the scope it needed:
{ "statusCode": 403, "message": "This API key is missing the invoices:write scope" }Administration is closed to keys entirely, whatever their scopes: team and
roles, business profile and branding, and API keys and webhooks themselves.
Those need a session from an owner, so a leaked key cannot mint a successor or
quietly revoke a sibling. Endpoints outside the scopable set answer 403 —
the list fails closed, so a newly added route family is unreachable by keys
until it is explicitly opened.
Pagination
List endpoints take page (1-based) and limit, and answer with the rows plus
a meta block. Ask for more than the maximum and you get the maximum, not an
error.
{
"data": [{ "id": "…" }],
"meta": { "page": 1, "limit": 20, "total": 137, "totalPages": 7 }
}Money, dates, and decimals
- Money is always an integer in minor units —
1050is €10.50. There are no floating-point amounts anywhere in the API, in either direction. - Dates are
YYYY-MM-DD. Timestamps are ISO 8601 in UTC. - Decimals (quantities, tax rates) are sent as numbers and returned as strings, because that is what preserves the stored precision.
- Every amount belongs to the currency on its own document. Figures in different currencies are never summed, and neither should yours be.
Errors
Errors are JSON with the HTTP status repeated in the body.
| Status | Means |
|---|---|
400 | The request was malformed — usually a body the parser could not read |
401 | Missing, revoked, or expired credentials |
403 | Authenticated, but not allowed: a missing scope, or an owner-only route |
404 | No such record in this business |
409 | A conflict with the record’s current state (e.g. paying a draft) |
422 | Validation failed — the body reached the endpoint and was rejected |
429 | Rate limited; see Retry-After |
422 carries the field messages:
{
"statusCode": 422,
"message": ["dueDate must be a valid ISO 8601 date string"],
"error": "Unprocessable Entity"
}Rate limits
120 requests per minute per key. Every response carries
X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (a Unix
timestamp); a 429 also carries Retry-After in seconds.
The limit is enforced per server instance, so the effective ceiling scales with however many are running and resets on deploy. It exists to stop a runaway script, not to meter a quota — don’t build a scheduler that depends on the exact number.
Webhooks
Ten events, listed with their triggers in
API & webhooks. Every delivery is a POST
of the same envelope:
{
"id": "3f6c…",
"event": "invoice.paid",
"businessId": "b1a2…",
"createdAt": "2026-07-31T09:14:22.104Z",
"data": { "id": "inv_…", "number": "INV-0042", "total": 145200 }
}Headers on every attempt:
| Header | Value |
|---|---|
X-Webiller-Event | The event name |
X-Webiller-Delivery | Delivery id — stable across retries, so use it to dedupe |
X-Webiller-Attempt | 1–5 |
X-Webiller-Signature | t=<unix>,v1=<hex> |
Verifying a delivery
The signature is HMAC-SHA256(secret, "<timestamp>.<raw body>"). The timestamp
is inside the signed string, which is what makes a captured delivery
unreplayable once your freshness window passes.
Sign the raw body bytes, before any JSON parsing — re-serialising changes whitespace and key order and will not match.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = new Map(header.split(',').map((p) => p.split('=')));
const timestamp = Number(parts.get('t'));
const signature = parts.get('v1') ?? '';
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}Retries and delivery guarantees
A delivery is at-least-once: answer 2xx quickly and do the work
afterwards, and make your handler idempotent on X-Webiller-Delivery.
Anything other than 2xx — including a redirect, which is deliberately not
followed — is a failure and is retried after 1m, 5m, 30m, 2h, 6h, five
attempts in all. A retry re-sends the body that was originally signed, byte for
byte, rather than re-reading records that may have changed since.
Your endpoint must be reachable over https on a public address. weBiller resolves the hostname at the moment of each delivery and refuses to connect if it points at a private, loopback, link-local, or cloud-metadata address — so an endpoint on a VPN or behind a private DNS name will never receive anything, however it looked when you subscribed.