Connect your systems to Hubtoll
A small, honest REST API over your company's own data. Standard OAuth 2.0 client credentials, predictable JSON, and a playground on this page so you can see a real response before you write a line of code.
Quickstart
Three steps. The whole thing takes about five minutes.
- Create a credential. In Hubtoll, go to Corporate management → API integration, click New credential, name it and choose what it may access. You'll see a client ID and a client secret. The secret is shown once — copy it there and then.
- Exchange it for a token. Your system POSTs the ID and secret to the token endpoint and gets back an access token that lasts an hour.
-
Call the API. Send the token as
Authorization: Bearer <token>. When it expires, exchange again.
# 1. get a token
TOKEN=$(curl -s -X POST https://api.hubtoll.com/api/oauth/token \
-H 'Content-Type: application/json' \
-d '{"grant_type":"client_credentials","client_id":"htc_…","client_secret":"hts_…"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
# 2. use it
curl -s https://api.hubtoll.com/api/v1/invoices?size=5 \
-H "Authorization: Bearer $TOKEN"
Getting a credential
Credentials belong to a company, not to a person. That is deliberate: an integration should keep working when the person who set it up goes on leave or leaves the company.
Creating one needs the API integration – editor permission. It is a permission in its own right, separate from general settings access, because a credential is a standing key to your data that works outside any login session.
Hubtoll stores only a one-way hash of it, so nobody — including us — can show it to you again. If you lose it, use Issue a new secret on that credential. Treat it like a password: keep it on your server, never in browser code, a mobile app, or a repository.
Revoking is immediate. When you revoke a credential, tokens already issued from it stop working on the very next call — you do not have to wait for them to expire. The same is true of narrowing a credential's access.
Authentication
Hubtoll uses the OAuth 2.0 client credentials grant (RFC 6749 §4.4) — the standard way one system authenticates to another with no human present. The token endpoint accepts the standard form-encoded body and JSON, so both an OAuth client library and a hand-written integration work.
Set its token endpoint auth method to client_secret_post (credentials in the request
body). HTTP Basic (client_secret_basic) is not offered on this endpoint. Errors come
back in the standard shape too — an error field of invalid_client,
invalid_scope, invalid_request or unsupported_grant_type —
so a library's retry and failure handling behaves exactly as it would anywhere else.
https://api.hubtoll.com/api/oauth/token
Send application/x-www-form-urlencoded (what OAuth libraries emit) or JSON — both below
are equivalent. scope is optional; omit it to receive everything the credential holds.
grant_type=client_credentials
&client_id=htc_…
&client_secret=hts_…
&scope=fin:read
{
"grant_type": "client_credentials",
"client_id": "htc_…",
"client_secret": "hts_…",
"scope": "fin:read"
}
{
"access_token": "eyJhbGciOi…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "fin:read"
}
Scopes
A credential carries scopes; a token can ask for the same set or a smaller one, never a larger one.
| Scope | Grants |
|---|---|
sales:read | Sales & receivables Customers, items, quotes, sales orders, invoices, payments received, credit notes, sales receipts. |
purchasing:read | Purchasing & payables Vendors, purchase requisitions, purchase orders, bills, payments made, vendor credits. |
inventory:read | Inventory Goods receipts and stock adjustments. |
accounting:read | The books Chart of accounts, journal entries, fiscal years, trial balance, profit & loss, balance sheet. |
hr:read | People Staff directory, office locations and public holidays. |
leave:read | Leave Leave requests and the leave categories your company has configured. |
crm:read | CRM Companies, contacts, deals and leads. |
sales:write | Sales — write Reserved. No write endpoint exists yet, so this grants nothing today. |
fin:read | Legacy — finance read The API's original scope. Still works exactly as documented (customers, items, invoices); superseded by sales:read for anything new. |
fin:write | Legacy — finance write Reserved from the API's first release. Grants nothing today; kept only so a token request naming it does not fail. |
Client libraries & the OpenAPI spec
The API is described by an OpenAPI 3.1 document, generated from the same source as this page — so it cannot drift from what you are reading. Point a generator at it and you get a typed client in your language rather than hand-written HTTP calls.
Works with openapi-generator, Swagger Codegen, Kiota, and the import feature in Postman, Insomnia and
Bruno. The security scheme is declared as clientCredentials, so a generated client knows
how to fetch and attach the token itself.
npx @openapitools/openapi-generator-cli generate \
-i https://hubtoll.org/developers/openapi.json \
-g typescript-fetch -o ./hubtoll-client
Or call it directly
const BASE = "https://api.hubtoll.com/api";
// Cache the token — it lasts an hour. Exchanging per request
// wastes a round trip and will hit the token-endpoint limit.
let token, expiresAt = 0;
async function accessToken() {
if (token && Date.now() < expiresAt) return token;
const r = await fetch(`${BASE}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: process.env.HUBTOLL_CLIENT_ID,
client_secret: process.env.HUBTOLL_CLIENT_SECRET,
}),
});
if (!r.ok) throw new Error(`token: ${(await r.json()).error}`);
const t = await r.json();
token = t.access_token;
expiresAt = Date.now() + (t.expires_in - 60) * 1000; // refresh a minute early
return token;
}
async function get(path) {
let r = await fetch(BASE + path,
{ headers: { Authorization: `Bearer ${await accessToken()}` } });
if (r.status === 401) { // expired or revoked: refresh ONCE, then retry
token = null;
r = await fetch(BASE + path,
{ headers: { Authorization: `Bearer ${await accessToken()}` } });
}
if (!r.ok) throw new Error((await r.json()).message);
return r.json();
}
import os, time, requests
BASE = "https://api.hubtoll.com/api"
_token, _expires = None, 0
def access_token():
global _token, _expires
if _token and time.time() < _expires:
return _token
r = requests.post(f"{BASE}/oauth/token", json={
"grant_type": "client_credentials",
"client_id": os.environ["HUBTOLL_CLIENT_ID"],
"client_secret": os.environ["HUBTOLL_CLIENT_SECRET"],
})
r.raise_for_status()
t = r.json()
_token = t["access_token"]
_expires = time.time() + t["expires_in"] - 60
return _token
def get(path, **params):
for attempt in (1, 2):
r = requests.get(BASE + path, params=params,
headers={"Authorization": f"Bearer {access_token()}"})
if r.status_code == 401 and attempt == 1:
globals()["_token"] = None # refresh once, then retry
continue
r.raise_for_status()
return r.json()
Paging through everything
Lists cap at 500 rows. To walk a whole resource, follow totalPages — do not assume a single
page, and do not raise size beyond the cap (it is clamped, silently, so you would loop for ever
believing you had it all).
const all = [];
for (let page = 0; ; page++) {
const r = await get(`/v1/invoices?page=${page}&size=200`);
all.push(...r.content);
if (page + 1 >= r.totalPages) break;
}
Playground
Run a real request against your own company, from this page. Your credentials stay in this browser tab — they are sent straight to the Hubtoll API and are never stored, logged, or sent to this website.
Every response below is a real example from a live company. You can also point the playground at
http://localhost:3000/api if you're running Hubtoll locally.
Endpoints
Everything is scoped to the company that owns the credential. There is no company identifier anywhere in a URL, and no parameter that changes which company you're reading — that comes from your token alone, so one company's integration can never reach another's data.
Every list takes page (0-based) and size (default 10 on finance lists, 20
elsewhere; maximum 500 — larger values are clamped, not rejected) and returns the same envelope:
content, size, totalElements, totalPages,
first, last. Note the envelope does not echo the page number back — track it
in your loop.
A credential is a company-level actor: where the console narrows a list to what one employee may
see, the API returns the whole company for the areas the credential was granted — it has no personal
records, so every record's mine flag is false. What bounds it is its scopes
and its company; the company comes from the token and no endpoint accepts one.
sales:read
The money-in side: who you sell to, what you sell, and every document from quote to settled invoice.
purchasing:read
The money-out side: suppliers, requisitions, purchase orders, bills and what you have paid.
inventory:read
Stock movements — what came in against a purchase order, and every correction since.
accounting:read
Your double-entry ledger and the statements derived from it. Nothing here is a stored figure; it is all computed from the journals.
hr:read
Your staff directory plus company reference data — where you operate and which days you are closed.
leave:read
Leave requests across the company and the categories they are booked against.
crm:read
Your pipeline: the companies and people you sell to, the deals in flight, and inbound leads.
Sales & receivables sales:read
The money-in side: who you sell to, what you sell, and every document from quote to settled invoice.
/v1/customers
sales:read
List customers. Your customer records. Paginated.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
search | string | Free-text match on name or email. |
{
"content": [
{
"id": "4194c3c7-…",
"name": "Frames and heights",
"email": "accounts@framesandheights.com",
"currencyCode": "NGN",
"billingCurrencies": ["NGN"]
}
],
"totalElements": 5
}
/v1/customers/{id}
sales:read
Get one customer. A single customer. 404 if it does not belong to your company.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "4194c3c7-…",
"name": "Frames and heights",
"email": "accounts@framesandheights.com",
"currencyCode": "NGN"
}
/v1/items
sales:read
List items. The products and services you sell or buy, with their prices.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
search | string | Free-text match on the item name or SKU. |
{
"content": [
{
"id": "9d2f…",
"name": "Consulting day",
"salesPrice": "150000.00",
"currencyCode": "NGN"
}
],
"totalElements": 5
}
/v1/items/{id}
sales:read
Get one item. A single item, including its per-currency stated prices where it has any.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "9d2f…",
"name": "Consulting day",
"sku": "CONS-DAY",
"type": "SERVICE",
"salesPrice": "150000.00",
"currencyCode": "NGN",
"prices": [ { "currencyCode": "USD", "salesPrice": "900.00" } ],
"active": true
}
/v1/quotes
sales:read
List quotes. Quotes you have raised, with their status and whether they have been converted.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/quotes/{id}
sales:read
Get one quote. A single quote including its line items.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "QT-000004",
"customerName": "Frames and heights",
"date": "2026-08-01",
"expiryDate": "2026-08-31",
"status": "SENT",
"currencyCode": "NGN",
"subtotal": "450000.00",
"taxTotal": "0.00",
"total": "450000.00",
"lines": [ … ]
}
/v1/sales-orders
sales:read
List sales orders. Confirmed orders awaiting invoicing or delivery.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/sales-orders/{id}
sales:read
Get one sales order. A single order including its line items.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "SO-000002",
"customerName": "Frames and heights",
"date": "2026-08-02",
"expectedDate": "2026-08-20",
"status": "CONFIRMED",
"currencyCode": "NGN",
"total": "450000.00",
"lines": [ … ]
}
/v1/invoices
sales:read
List invoices. Sales invoices with their status and balance. The endpoint most integrations start with.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
status | string | DRAFT, PENDING_APPROVAL, ISSUED, PARTIALLY_PAID, PAID or VOID. An unknown value is a 400 naming the valid ones. |
{
"content": [
{
"id": "575c2856-…",
"number": "INV-000012",
"customerName": "Frames and heights",
"date": "2026-08-06",
"dueDate": "2026-10-10",
"status": "ISSUED",
"currencyCode": "NGN",
"total": "450000.00",
"balanceDue": "450000.00"
}
],
"totalElements": 5
}
/v1/invoices/{id}
sales:read
Get one invoice. A single invoice including its line items and its payment history.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "575c2856-…",
"number": "INV-000012",
"status": "ISSUED",
"currencyCode": "NGN",
"total": "450000.00",
"balanceDue": "450000.00",
"lines": [
{ "itemName": "Consulting day", "qty": "3.000000", "rate": "150000.00", "lineNet": "450000.00", "lineTax": "0.00" }
],
"payments": [ … ]
}
/v1/payments-received
sales:read
List payments received. Money in from customers, and which invoices each payment settled.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/payments-received/{id}
sales:read
Get one payment received. A single payment, its allocations against invoices (appliedAmount per invoice), any tax withheld at source (whtAmount), and a signed link to the branded receipt.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "PAY-000002",
"amount": "150000.00",
"whtAmount": "0.00",
"unappliedAmount": "0.00",
"receiptUrl": "https://…",
"applications": [
{ "invoiceNumber": "INV-000012", "appliedAmount": "150000.00" }
]
}
/v1/credit-notes
sales:read
List credit notes. Credits raised against customers.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/credit-notes/{id}
sales:read
Get one credit note. A single credit note, its lines and what it has been applied to.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "CN-000001",
"customerName": "Frames and heights",
"date": "2026-08-05",
"status": "ISSUED",
"currencyCode": "NGN",
"total": "50000.00",
"appliedAmount": "50000.00",
"lines": [ … ]
}
/v1/sales-receipts
sales:read
List sales receipts. Cash sales — paid at the point of sale, with no invoice stage.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/sales-receipts/{id}
sales:read
Get one sales receipt. A single cash sale including its lines.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "RCPT-000003",
"customerName": "Walk-in customer",
"date": "2026-08-06",
"method": "CASH",
"bankAccountName": "Undeposited Funds",
"currencyCode": "NGN",
"total": "25000.00",
"lines": [ … ]
}
Purchasing & payables purchasing:read
The money-out side: suppliers, requisitions, purchase orders, bills and what you have paid.
/v1/vendors
purchasing:read
List vendors. Your suppliers.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
search | string | Free-text match on name or email. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/vendors/{id}
purchasing:read
Get one vendor. A single vendor, including the currencies they transact in.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"name": "Office Supplies Ltd",
"email": "accounts@supplies.example",
"currencyCode": "NGN",
"currencies": ["NGN", "USD"],
"paymentTerms": 30,
"whtApplicable": true,
"taxTreatment": "STANDARD",
"active": true
}
/v1/purchase-requisitions
purchasing:read
List purchase requisitions. Internal requests to buy, with their approval status.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/purchase-requisitions/{id}
purchasing:read
Get one requisition. A single requisition, its lines and its approval trail.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "REQ-000003",
"requestedByName": "Kofi Mensah",
"department": "Operations",
"date": "2026-08-01",
"status": "APPROVED",
"estimatedTotal": "120000.00",
"lines": [ … ]
}
/v1/purchase-orders
purchasing:read
List purchase orders. Orders placed with suppliers.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/purchase-orders/{id}
purchasing:read
Get one purchase order. A single PO including its lines.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "PO-000002",
"vendorName": "Office Supplies Ltd",
"date": "2026-08-03",
"expectedDate": "2026-08-17",
"status": "ISSUED",
"currencyCode": "NGN",
"committedTotal": "120000.00",
"lines": [ … ]
}
/v1/bills
purchasing:read
List bills. Supplier bills with their status and outstanding balance.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/bills/{id}
purchasing:read
Get one bill. A single bill, its lines and its approval trail.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "BILL-000004",
"vendorName": "Office Supplies Ltd",
"date": "2026-08-01",
"dueDate": "2026-08-31",
"status": "POSTED",
"currencyCode": "NGN",
"subtotal": "80000.00",
"taxTotal": "6000.00",
"total": "86000.00",
"balanceDue": "86000.00",
"lines": [
{ "itemName": "A4 paper", "qty": "20.000000", "rate": "4000.00", "lineNet": "80000.00", "lineTax": "6000.00" }
]
}
/v1/payments-made
purchasing:read
List payments made. Money out to suppliers.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/payments-made/{id}
purchasing:read
Get one payment made. A single payment and which bills it settled.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "PMT-000001",
"vendorName": "Office Supplies Ltd",
"date": "2026-08-10",
"amount": "86000.00",
"whtAmount": "0.00",
"method": "BANK_TRANSFER",
"bankAccountName": "Bank",
"currencyCode": "NGN"
}
/v1/vendor-credits
purchasing:read
List vendor credits. Credits a supplier has given you.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/vendor-credits/{id}
purchasing:read
Get one vendor credit. A single vendor credit and its applications.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "VC-000001",
"vendorName": "Office Supplies Ltd",
"date": "2026-08-12",
"status": "ISSUED",
"currencyCode": "NGN",
"total": "10000.00",
"appliedAmount": "10000.00",
"refundedAmount": "0.00"
}
Inventory inventory:read
Stock movements — what came in against a purchase order, and every correction since.
/v1/goods-receipts
inventory:read
List goods receipts. Stock received against purchase orders.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/goods-receipts/{id}
inventory:read
Get one goods receipt. A single receipt including quantities and costs.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "GRN-000002",
"vendorName": "Office Supplies Ltd",
"date": "2026-08-04",
"status": "POSTED",
"grniTotal": "120000.00",
"grniCleared": "120000.00",
"lines": [ … ]
}
/v1/stock-adjustments
inventory:read
List stock adjustments. Corrections to on-hand quantities.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/stock-adjustments/{id}
inventory:read
Get one stock adjustment. A single adjustment and its lines.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "ADJ-000001",
"date": "2026-08-09",
"reason": "Stock count correction",
"status": "POSTED",
"lines": [ … ]
}
The books accounting:read
Your double-entry ledger and the statements derived from it. Nothing here is a stored figure; it is all computed from the journals.
/v1/accounts
accounting:read
Chart of accounts. Every ledger account with its code, type and role. Returns a plain array — a chart of accounts is small and complete. Balances are a question about a period, so they live on the reports, not here.
[
{
"id": "…",
"code": "1050",
"name": "Bank",
"rootType": "ASSET",
"currencyCode": "NGN",
"system": true,
"systemRole": "BANK_DEFAULT",
"active": true
}
]
/v1/journals
accounting:read
List journal entries. Every posting to the ledger, newest first — including those raised automatically by invoices, bills and payments.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/journals/{id}
accounting:read
Get one journal entry. A single entry with all its debit and credit lines. Posted journals are immutable.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"number": "JE-000009",
"postingDate": "2026-08-06",
"status": "POSTED",
"sourceType": "INVOICE_ISSUE",
"sourceNumber": "INV-000012",
"totalDebit": "450000.00",
"totalCredit": "450000.00",
"lines": [
{ "accountCode": "1100", "accountName": "Accounts Receivable", "debit": "450000.00", "credit": "0.00" },
{ "accountCode": "4000", "accountName": "Sales", "debit": "0.00", "credit": "450000.00" }
]
}
/v1/fiscal-years
accounting:read
List fiscal years. Your financial years and their periods, with which are open, soft-closed or locked.
[
{ "id": "…", "name": "2026", "startDate": "2026-01-01", "endDate": "2026-12-31", "closed": false }
]
/v1/reports/trial-balance
accounting:read
Trial balance. Debits and credits per account over a date range. Always balances — it is derived from the ledger, not stored.
| Parameter | Type | Meaning |
|---|---|---|
from | date | Start of the window, YYYY-MM-DD. Required. |
to | date | End of the window, YYYY-MM-DD, inclusive. Required. |
{
"fromDate": "2026-01-01",
"toDate": "2026-12-31",
"rows": [ … ],
"totalDebit": "3100000.00",
"totalCredit": "3100000.00"
}
/v1/reports/profit-and-loss
accounting:read
Profit & loss. Income, cost of sales, gross profit, operating expenses and net profit for a period.
| Parameter | Type | Meaning |
|---|---|---|
from | date | Start of the window, YYYY-MM-DD. Required. |
to | date | End of the window, YYYY-MM-DD, inclusive. Required. |
{
"fromDate": "2026-01-01",
"toDate": "2026-12-31",
"currency": "NGN",
"income": [ … ],
"netProfit": "820000.00"
}
/v1/reports/balance-sheet
accounting:read
Balance sheet. Assets, liabilities and equity as at a date, with a `balanced` flag that is true by construction: unclosed earnings are carried into equity.
| Parameter | Type | Meaning |
|---|---|---|
asOf | date | The date to report as at, YYYY-MM-DD. Required. |
{
"asOf": "2026-12-31",
"currency": "NGN",
"assets": [ { "code": "1050", "name": "Bank", "amount": "1250000.00" } ],
"liabilities": [ … ],
"equity": [ … ],
"totalAssets": "4200000.00",
"totalLiabilities": "1100000.00",
"totalEquity": "3100000.00",
"balanced": true
}
People hr:read
Your staff directory plus company reference data — where you operate and which days you are closed.
/v1/staff
hr:read
List staff. Your employee directory. A credential sees the whole company — it has no personal records, so every row's "mine" flag comes back false.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [
{
"id": "…",
"firstName": "Amaka",
"lastName": "Obi",
"email": "amaka.obi@…",
"approvalStatus": "APPROVED",
"mine": false
}
],
"totalElements": 115
}
/v1/offices
hr:read
List office locations. Your office locations — useful for tagging records in another system with the same places.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/holidays
hr:read
List public holidays. The public holidays your company observes. A scheduling integration uses these so it does not book work on a company holiday.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
Leave leave:read
Leave requests across the company and the categories they are booked against.
/v1/leave-requests
leave:read
List leave requests. Every leave request in the company, with its dates, category and approval status.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [
{
"id": "…",
"startDate": "2026-09-01",
"endDate": "2026-09-05",
"approvalStatus": "APPROVED",
"mine": false
}
],
"totalElements": 139
}
/v1/leave-categories
leave:read
List leave categories. The active leave types your company has configured. Each carries its settings history (entitlement and rules live there). Returns a plain array.
[
{
"id": "…",
"name": "Annual Leave",
"description": "Paid annual leave",
"active": true,
"settings": [ … ]
}
]
CRM crm:read
Your pipeline: the companies and people you sell to, the deals in flight, and inbound leads.
/v1/crm/companies
crm:read
List CRM companies. The organisations in your pipeline.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/crm/companies/{id}
crm:read
Get one CRM company. A single company with its owner, domain and custom-field answers.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"name": "Frames and heights",
"domain": "framesandheights.com",
"industry": "Construction",
"lifecycleStage": "CUSTOMER",
"ownerName": "Ian Stone",
"contactCount": 3,
"mine": false
}
/v1/crm/contacts
crm:read
List CRM contacts. The people you deal with, and the company each belongs to.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/crm/contacts/{id}
crm:read
Get one CRM contact. A single contact.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"firstName": "Grace",
"lastName": "Wong",
"fullName": "Grace Wong",
"email": "grace@framesandheights.com",
"companyName": "Frames and heights",
"jobTitle": "Finance Lead",
"doNotEmail": false,
"mine": false
}
/v1/crm/deals
crm:read
List deals. Deals in flight with their stage, value and owner.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/crm/deals/{id}
crm:read
Get one deal. A single deal including its stage history.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"name": "Q3 fit-out",
"amount": "2500000.00",
"currencyCode": "NGN",
"stageName": "Negotiation",
"status": "OPEN",
"probability": 60,
"companyName": "Frames and heights",
"ownerName": "Ian Stone",
"expectedCloseDate": "2026-09-30",
"mine": false
}
/v1/crm/leads
crm:read
List leads. Inbound leads, their source and score.
| Parameter | Type | Meaning |
|---|---|---|
page | integer | Page number, starting at 0. Defaults to 0. |
size | integer | Rows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected. |
{
"content": [ … ],
"size": 20,
"totalElements": 42,
"totalPages": 3,
"first": true,
"last": false
}
/v1/crm/leads/{id}
crm:read
Get one lead. A single lead.
| Parameter | Type | Meaning |
|---|---|---|
id | uuid | The record id. Required — part of the path. |
{
"id": "…",
"name": "Ada Eze",
"email": "ada@example.com",
"companyName": "Eze Logistics",
"source": "WEBSITE",
"score": 42,
"status": "NEW",
"mine": false
}
Identity
Prove a credential works and see what it can reach.
/v1/me
any scope
Who am I?. Confirms your token works and shows which company it belongs to and what it may access. Answers for any live credential, whatever its scopes — the fastest way to check a new one.
{
"corporateId": "be183892-…",
"clientId": "htc_TyXk0mRB…",
"scopes": ["sales:read", "accounting:read"]
}
What's not here yet
Stated plainly, because a missing endpoint you can plan around beats one you discover halfway through a build:
-
Writing. Every endpoint above is a read. Nothing in this API creates, changes or deletes
anything, so an integration cannot damage your data — and a leaked credential cannot either. The
sales:writescope exists as a placeholder and grants nothing today. - Webhooks. There is no push yet; poll the lists you care about. Cache your token and poll on a sensible schedule rather than per request.
Errors
Every error is JSON with a message you can show a human and a status you can branch on.
| Status | Meaning | What to do |
|---|---|---|
400 | Bad request | Something in the request is wrong — usually a malformed id, an unknown status value, or a missing required parameter. The body carries a message (or, for missing/mistyped parameters, a standard problem+json body whose detail field says which parameter). |
401 | Not authenticated | Missing, expired or revoked token. Get a new one from the token endpoint. Client libraries should treat this as “refresh and retry once”. |
403 | Scope missing | The token is valid but does not carry the scope this endpoint needs. Create or rotate a credential with the right scope. |
404 | Not found | No record with that id in your company. Ids are never shared across companies. |
429 | Too many requests | You are being rate limited. Back off and retry — and cache your access token rather than exchanging on every call. |
500 | Our fault | Nothing you sent caused this. Quote the X-Request-Id response header when you report it — it finds your exact request in our logs. |
Rate limits
Limits are per source IP, not per credential, and are a blunt anti-abuse throttle rather than a quota — they are set well above what a well-behaved integration needs.
| What | Limit | Why it differs |
|---|---|---|
POST /oauth/token |
30 / minute | Unauthenticated and accepts a secret, so it is a credential-guessing surface and is deliberately tighter. Cache your token and you will never approach it. |
Everything under /v1 |
200 / minute | Already-authenticated traffic. |
Exceeding a limit returns 429 with a Retry-After header in seconds. Honour it —
retrying immediately just burns the next window. If a legitimate integration is hitting these, tell us
rather than working around it.
Getting help
Every response carries an X-Request-Id header. Quote it when you report a problem — it
is how we find your exact request in our logs, and it turns "an endpoint returned 500 yesterday" into
something answerable in minutes.
You can also send your own: pass X-Request-Id on the request and we echo it back and log it
against your call, so your trace id and ours are the same string.
curl -i https://api.hubtoll.com/api/v1/invoices \
-H "Authorization: Bearer $TOKEN" \
-H "X-Request-Id: my-job-2026-08-18-0042"
HTTP/2 200
x-request-id: my-job-2026-08-18-0042
Rules of the road
- Cache your token. It lasts an hour. Exchanging on every call wastes a round trip and will hit the rate limit on the token endpoint, which is deliberately stricter than the API itself.
- Treat 401 as “refresh once, then retry”. That is what it means: no token, expired token, or revoked credential. A 403 is different — it means the token is fine but lacks a scope, and retrying will never help.
- Keep the secret server-side. Client credentials are for machine-to-machine use. A secret shipped in browser or mobile code is a public secret.
- Page through lists. Default page size is 25 and the maximum is 500. Ask for what you need.
- Money is a string. Amounts come back as decimal strings like
"450000.00", never floating-point numbers, so nothing rounds on the way to you. Parse them with a decimal type. - Dates without a time are calendar dates. An invoice date is
"2026-08-06"and means that day, in your company's timezone — not an instant. - Version is in the path. Today that is
v1. When a breaking change is needed it will appear under a new prefix;v1will keep behaving as documented here.
Tell us what you're building — the surface grows in the direction people actually integrate. Reach us on WhatsApp or email cloud@digitalvortextech.org.