ReferenceAPI reference

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/v1

The 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.
  • *:read and *:write are 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 units1050 is €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.

StatusMeans
400The request was malformed — usually a body the parser could not read
401Missing, revoked, or expired credentials
403Authenticated, but not allowed: a missing scope, or an owner-only route
404No such record in this business
409A conflict with the record’s current state (e.g. paying a draft)
422Validation failed — the body reached the endpoint and was rejected
429Rate 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:

HeaderValue
X-Webiller-EventThe event name
X-Webiller-DeliveryDelivery id — stable across retries, so use it to dedupe
X-Webiller-Attempt15
X-Webiller-Signaturet=<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.