Developers

REST API reference

Integrate Bilify programmatically. Manage clients, documents, items, expenses and statistics over a clean REST API, and receive signed webhooks on the events that matter.

Download OpenAPI spec
Base URL https://bilify.co.uk/api/v1

Overview

Every request operates inside the workspace pinned to the API key that authenticates it, acting as the user who created that key, with exactly the permissions that user has in the dashboard.

This page is generated from the OpenAPI document that is the source of truth. Download the raw spec to import it into Redoc, Swagger UI or Postman.

Authentication

Send a Stripe-style opaque key as a bearer token in the Authorization header. Keys are created and revoked in the dashboard under Settings > Integrations, and are shown exactly once at creation.

  • blf_live_binds the real production workspace.
  • blf_test_binds the separate sandbox workspace (test data only).
Example request
curl https://bilify.co.uk/api/v1/clients \
  -H "Authorization: Bearer blf_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

A missing, malformed, unknown, revoked or expired key returns a single generic 401 that never reveals which part was wrong.

Conventions

Stable references

Resources are addressed by their public ULID reference, never a numeric id. Field names are snake_case.

Money

Amounts appear twice: a decimal string in the major unit and an integer of minor units, always with a currency code. Compute against the cents; display the string.

Pagination

List endpoints accept page and per_page (capped at 100) and return a meta block with page, per_page, total and last_page.

Idempotency

Write endpoints accept an Idempotency-Key header. The first successful response is stored for 24 hours and replayed on an identical retry, so a create never runs twice.

Rate limiting

Requests are limited per key (120 per minute by default). Every response carries X-RateLimit headers; a 429 includes Retry-After.

Dates

Dates are ISO 8601: YYYY-MM-DD for calendar dates and RFC 3339 timestamps for instants.

Errors

Every failure uses one envelope with a stable machine code, a human message, and, for validation failures only, a map of field errors.

Error envelope
{
  "error": {
    "code": "validation_failed",
    "message": "The given data was invalid.",
    "errors": { "name": ["The name field is required."] }
  }
}
Code HTTP status

Webhooks

Bilify delivers outbound, HMAC-signed webhooks on domain events to endpoints you register per environment under Settings > Integrations. A delivery is retried on a backoff schedule, then marked dead; an endpoint whose recent deliveries all fail is auto-disabled.

Event catalog

Payload

Each delivery carries the event name, when it occurred, the environment, and the subject under data, in the same shape the matching GET endpoint returns.

Example payload
{
  "event": "document.issued",
  "occurred_at": "2026-07-14T10:32:00+00:00",
  "environment": "production",
  "data": { "reference": "01J...", "number": "0001", "status": "issued" }
}

Signature

Each delivery is signed with the X-Bilify-Signature header, in the Stripe-compatible scheme t=<unix>,v1=<hmac_sha256(secret, t + "." + body)>. Recompute the HMAC over the timestamp and the raw body, compare in constant time, and reject timestamps outside the 5 minute tolerance window.

X-Bilify-Signature
X-Bilify-Signature: t=1720952400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Verify a signature (PHP)

PHP
<?php

function bilify_webhook_is_valid(string $secret, string $header, string $rawBody, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $piece) {
        [$k, $v] = array_pad(explode('=', trim($piece), 2), 2, '');
        $parts[$k] = $v;
    }

    if (! isset($parts['t'], $parts['v1']) || ! ctype_digit($parts['t'])) {
        return false;
    }

    if (abs(time() - (int) $parts['t']) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);

    return hash_equals($expected, $parts['v1']);
}

$rawBody = file_get_contents('php://input');
$header  = $_SERVER['HTTP_X_BILIFY_SIGNATURE'] ?? '';

if (! bilify_webhook_is_valid($webhookSecret, $header, $rawBody)) {
    http_response_code(400);
    exit;
}

Verify a signature (Node.js)

Node.js
const crypto = require('crypto');

function bilifyWebhookIsValid(secret, header, rawBody, tolerance = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.trim().split('='))
  );

  if (!parts.t || !parts.v1) return false;

  const timestamp = parseInt(parts.t, 10);
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Schemas

The response and request shapes referenced above. Fields marked with an asterisk are required; a trailing question mark on a type marks it nullable.