Products groupsHuman Resources Live folder_sharedDocument Management (EDMS) Live insightsSales & Finance Live diversity_3CRM Live dynamic_formForge Live HR modules badgeEmployee Records event_availableLeave Management person_searchRecruitment rocket_launchOnboarding scheduleTimesheet & Clocking savingsBudget Management logoutExit Management trending_upPerformance Management schoolLearning & Development view_kanbanProject Management On the roadmap local_shippingProcurement Soon Resources menu_bookGuides & documentation downloadProduct catalogue (PDF) Company codeDevelopers & API sellPricing apartmentAbout us mailContact
Developers

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.

OAuth 2.0 client credentials 53 endpoints v1 current version 1 hour token lifetime JSON everywhere

Quickstart

Three steps. The whole thing takes about five minutes.

  1. 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.
  2. 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.
  3. Call the API. Send the token as Authorization: Bearer <token>. When it expires, exchange again.
Complete examplebash
# 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.

key
The client secret is shown exactly once.

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.

integration_instructions
Using an OAuth client library?

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.

POST 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.

Request — form (standard)
grant_type=client_credentials
&client_id=htc_…
&client_secret=hts_…
&scope=fin:read
Request — JSON (equivalent)
{
  "grant_type": "client_credentials",
  "client_id": "htc_…",
  "client_secret": "hts_…",
  "scope": "fin:read"
}
Response
{
  "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.

ScopeGrants
sales:readSales & receivables
Customers, items, quotes, sales orders, invoices, payments received, credit notes, sales receipts.
purchasing:readPurchasing & payables
Vendors, purchase requisitions, purchase orders, bills, payments made, vendor credits.
inventory:readInventory
Goods receipts and stock adjustments.
accounting:readThe books
Chart of accounts, journal entries, fiscal years, trial balance, profit & loss, balance sheet.
hr:readPeople
Staff directory, office locations and public holidays.
leave:readLeave
Leave requests and the leave categories your company has configured.
crm:readCRM
Companies, contacts, deals and leads.
sales:writeSales — write
Reserved. No write endpoint exists yet, so this grants nothing today.
fin:readLegacy — finance read
The API's original scope. Still works exactly as documented (customers, items, invoices); superseded by sales:read for anything new.
fin:writeLegacy — 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.

download
/developers/openapi.json

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.

Generate a clientbash
npx @openapitools/openapi-generator-cli generate \
  -i https://hubtoll.org/developers/openapi.json \
  -g typescript-fetch -o ./hubtoll-client

Or call it directly

Node.jsjavascript
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();
}
Pythonpython
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).

Fetch every invoicejavascript
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.

1
Get a token
2
Make a request
Get a token first.
lightbulb
Nothing to test against yet?

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.

Inventory inventory:read Stock movements — what came in against a purchase order, and every correction since.
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.
People hr:read Your staff directory plus company reference data — where you operate and which days you are closed.
Leave leave:read Leave requests across the company and the categories they are booked against.
CRM crm:read Your pipeline: the companies and people you sell to, the deals in flight, and inbound leads.
Identity Prove a credential works and see what it can reach.

Sales & receivables sales:read

The money-in side: who you sell to, what you sell, and every document from quote to settled invoice.

GET /v1/customers sales:read

List customers. Your customer records. Paginated.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
searchstringFree-text match on name or email.
Example responsejson
{
  "content": [
    {
      "id": "4194c3c7-…",
      "name": "Frames and heights",
      "email": "accounts@framesandheights.com",
      "currencyCode": "NGN",
      "billingCurrencies": ["NGN"]
    }
  ],
  "totalElements": 5
}
GET /v1/customers/{id} sales:read

Get one customer. A single customer. 404 if it does not belong to your company.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "4194c3c7-…",
  "name": "Frames and heights",
  "email": "accounts@framesandheights.com",
  "currencyCode": "NGN"
}
GET /v1/items sales:read

List items. The products and services you sell or buy, with their prices.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
searchstringFree-text match on the item name or SKU.
Example responsejson
{
  "content": [
    {
      "id": "9d2f…",
      "name": "Consulting day",
      "salesPrice": "150000.00",
      "currencyCode": "NGN"
    }
  ],
  "totalElements": 5
}
GET /v1/items/{id} sales:read

Get one item. A single item, including its per-currency stated prices where it has any.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "9d2f…",
  "name": "Consulting day",
  "sku": "CONS-DAY",
  "type": "SERVICE",
  "salesPrice": "150000.00",
  "currencyCode": "NGN",
  "prices": [ { "currencyCode": "USD", "salesPrice": "900.00" } ],
  "active": true
}
GET /v1/quotes sales:read

List quotes. Quotes you have raised, with their status and whether they have been converted.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/quotes/{id} sales:read

Get one quote. A single quote including its line items.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "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": [ … ]
}
GET /v1/sales-orders sales:read

List sales orders. Confirmed orders awaiting invoicing or delivery.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/sales-orders/{id} sales:read

Get one sales order. A single order including its line items.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "…",
  "number": "SO-000002",
  "customerName": "Frames and heights",
  "date": "2026-08-02",
  "expectedDate": "2026-08-20",
  "status": "CONFIRMED",
  "currencyCode": "NGN",
  "total": "450000.00",
  "lines": [ … ]
}
GET /v1/invoices sales:read

List invoices. Sales invoices with their status and balance. The endpoint most integrations start with.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
statusstringDRAFT, PENDING_APPROVAL, ISSUED, PARTIALLY_PAID, PAID or VOID. An unknown value is a 400 naming the valid ones.
Example responsejson
{
  "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
}
GET /v1/invoices/{id} sales:read

Get one invoice. A single invoice including its line items and its payment history.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "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": [ … ]
}
GET /v1/payments-received sales:read

List payments received. Money in from customers, and which invoices each payment settled.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /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.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "…",
  "number": "PAY-000002",
  "amount": "150000.00",
  "whtAmount": "0.00",
  "unappliedAmount": "0.00",
  "receiptUrl": "https://…",
  "applications": [
    { "invoiceNumber": "INV-000012", "appliedAmount": "150000.00" }
  ]
}
GET /v1/credit-notes sales:read

List credit notes. Credits raised against customers.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/credit-notes/{id} sales:read

Get one credit note. A single credit note, its lines and what it has been applied to.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "…",
  "number": "CN-000001",
  "customerName": "Frames and heights",
  "date": "2026-08-05",
  "status": "ISSUED",
  "currencyCode": "NGN",
  "total": "50000.00",
  "appliedAmount": "50000.00",
  "lines": [ … ]
}
GET /v1/sales-receipts sales:read

List sales receipts. Cash sales — paid at the point of sale, with no invoice stage.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/sales-receipts/{id} sales:read

Get one sales receipt. A single cash sale including its lines.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "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.

GET /v1/vendors purchasing:read

List vendors. Your suppliers.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
searchstringFree-text match on name or email.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/vendors/{id} purchasing:read

Get one vendor. A single vendor, including the currencies they transact in.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "…",
  "name": "Office Supplies Ltd",
  "email": "accounts@supplies.example",
  "currencyCode": "NGN",
  "currencies": ["NGN", "USD"],
  "paymentTerms": 30,
  "whtApplicable": true,
  "taxTreatment": "STANDARD",
  "active": true
}
GET /v1/purchase-requisitions purchasing:read

List purchase requisitions. Internal requests to buy, with their approval status.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/purchase-requisitions/{id} purchasing:read

Get one requisition. A single requisition, its lines and its approval trail.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "…",
  "number": "REQ-000003",
  "requestedByName": "Kofi Mensah",
  "department": "Operations",
  "date": "2026-08-01",
  "status": "APPROVED",
  "estimatedTotal": "120000.00",
  "lines": [ … ]
}
GET /v1/purchase-orders purchasing:read

List purchase orders. Orders placed with suppliers.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/purchase-orders/{id} purchasing:read

Get one purchase order. A single PO including its lines.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "…",
  "number": "PO-000002",
  "vendorName": "Office Supplies Ltd",
  "date": "2026-08-03",
  "expectedDate": "2026-08-17",
  "status": "ISSUED",
  "currencyCode": "NGN",
  "committedTotal": "120000.00",
  "lines": [ … ]
}
GET /v1/bills purchasing:read

List bills. Supplier bills with their status and outstanding balance.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/bills/{id} purchasing:read

Get one bill. A single bill, its lines and its approval trail.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "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" }
  ]
}
GET /v1/payments-made purchasing:read

List payments made. Money out to suppliers.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/payments-made/{id} purchasing:read

Get one payment made. A single payment and which bills it settled.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "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"
}
GET /v1/vendor-credits purchasing:read

List vendor credits. Credits a supplier has given you.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/vendor-credits/{id} purchasing:read

Get one vendor credit. A single vendor credit and its applications.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "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.

GET /v1/goods-receipts inventory:read

List goods receipts. Stock received against purchase orders.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/goods-receipts/{id} inventory:read

Get one goods receipt. A single receipt including quantities and costs.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "…",
  "number": "GRN-000002",
  "vendorName": "Office Supplies Ltd",
  "date": "2026-08-04",
  "status": "POSTED",
  "grniTotal": "120000.00",
  "grniCleared": "120000.00",
  "lines": [ … ]
}
GET /v1/stock-adjustments inventory:read

List stock adjustments. Corrections to on-hand quantities.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/stock-adjustments/{id} inventory:read

Get one stock adjustment. A single adjustment and its lines.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "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.

GET /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.

Example responsejson
[
  {
    "id": "…",
    "code": "1050",
    "name": "Bank",
    "rootType": "ASSET",
    "currencyCode": "NGN",
    "system": true,
    "systemRole": "BANK_DEFAULT",
    "active": true
  }
]
GET /v1/journals accounting:read

List journal entries. Every posting to the ledger, newest first — including those raised automatically by invoices, bills and payments.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/journals/{id} accounting:read

Get one journal entry. A single entry with all its debit and credit lines. Posted journals are immutable.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "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" }
  ]
}
GET /v1/fiscal-years accounting:read

List fiscal years. Your financial years and their periods, with which are open, soft-closed or locked.

Example responsejson
[
  { "id": "…", "name": "2026", "startDate": "2026-01-01", "endDate": "2026-12-31", "closed": false }
]
GET /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.

ParameterTypeMeaning
fromdateStart of the window, YYYY-MM-DD. Required.
todateEnd of the window, YYYY-MM-DD, inclusive. Required.
Example responsejson
{
  "fromDate": "2026-01-01",
  "toDate": "2026-12-31",
  "rows": [ … ],
  "totalDebit": "3100000.00",
  "totalCredit": "3100000.00"
}
GET /v1/reports/profit-and-loss accounting:read

Profit & loss. Income, cost of sales, gross profit, operating expenses and net profit for a period.

ParameterTypeMeaning
fromdateStart of the window, YYYY-MM-DD. Required.
todateEnd of the window, YYYY-MM-DD, inclusive. Required.
Example responsejson
{
  "fromDate": "2026-01-01",
  "toDate": "2026-12-31",
  "currency": "NGN",
  "income": [ … ],
  "netProfit": "820000.00"
}
GET /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.

ParameterTypeMeaning
asOfdateThe date to report as at, YYYY-MM-DD. Required.
Example responsejson
{
  "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.

GET /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.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [
    {
      "id": "…",
      "firstName": "Amaka",
      "lastName": "Obi",
      "email": "amaka.obi@…",
      "approvalStatus": "APPROVED",
      "mine": false
    }
  ],
  "totalElements": 115
}
GET /v1/offices hr:read

List office locations. Your office locations — useful for tagging records in another system with the same places.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /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.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "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.

GET /v1/leave-requests leave:read

List leave requests. Every leave request in the company, with its dates, category and approval status.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [
    {
      "id": "…",
      "startDate": "2026-09-01",
      "endDate": "2026-09-05",
      "approvalStatus": "APPROVED",
      "mine": false
    }
  ],
  "totalElements": 139
}
GET /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.

Example responsejson
[
  {
    "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.

GET /v1/crm/companies crm:read

List CRM companies. The organisations in your pipeline.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/crm/companies/{id} crm:read

Get one CRM company. A single company with its owner, domain and custom-field answers.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "…",
  "name": "Frames and heights",
  "domain": "framesandheights.com",
  "industry": "Construction",
  "lifecycleStage": "CUSTOMER",
  "ownerName": "Ian Stone",
  "contactCount": 3,
  "mine": false
}
GET /v1/crm/contacts crm:read

List CRM contacts. The people you deal with, and the company each belongs to.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/crm/contacts/{id} crm:read

Get one CRM contact. A single contact.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "id": "…",
  "firstName": "Grace",
  "lastName": "Wong",
  "fullName": "Grace Wong",
  "email": "grace@framesandheights.com",
  "companyName": "Frames and heights",
  "jobTitle": "Finance Lead",
  "doNotEmail": false,
  "mine": false
}
GET /v1/crm/deals crm:read

List deals. Deals in flight with their stage, value and owner.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/crm/deals/{id} crm:read

Get one deal. A single deal including its stage history.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "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
}
GET /v1/crm/leads crm:read

List leads. Inbound leads, their source and score.

ParameterTypeMeaning
pageintegerPage number, starting at 0. Defaults to 0.
sizeintegerRows per page. Defaults to 10 on finance lists and 20 elsewhere — pass it explicitly. Maximum 500; larger values are clamped, not rejected.
Example responsejson
{
  "content": [ … ],
  "size": 20,
  "totalElements": 42,
  "totalPages": 3,
  "first": true,
  "last": false
}
GET /v1/crm/leads/{id} crm:read

Get one lead. A single lead.

ParameterTypeMeaning
iduuidThe record id. Required — part of the path.
Example responsejson
{
  "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.

GET /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.

Example responsejson
{
  "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:write scope 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.

StatusMeaningWhat to do
400Bad requestSomething 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).
401Not authenticatedMissing, expired or revoked token. Get a new one from the token endpoint. Client libraries should treat this as “refresh and retry once”.
403Scope missingThe token is valid but does not carry the scope this endpoint needs. Create or rotate a credential with the right scope.
404Not foundNo record with that id in your company. Ids are never shared across companies.
429Too many requestsYou are being rate limited. Back off and retry — and cache your access token rather than exchanging on every call.
500Our faultNothing 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.

WhatLimitWhy 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.

Correlating a call with your own trace idbash
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; v1 will keep behaving as documented here.
support_agent
Need something that isn't 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.