One REST API,
and a sandbox to prove it
Everything the mobile, desktop and web clients do, they do over the same public HTTP API. Sign in to the playground with your own credentials and fire real requests at your own tenant — no separate developer account, no mock data.
# 1 — exchange credentials for a JWT
TOKEN=$(curl -s -X POST https://app.ledgr.co.za/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.co.za","password":"…"}' \
| jq -r .token)
# 2 — call the API with it
curl -s https://app.ledgr.co.za/api/invoices?limit=50 \
-H "Authorization: Bearer $TOKEN"
API playground
The interactive console. Every route the server registers, searchable by path, module or scope — see the request and response shapes, then run the call against your own tenant.
/developers/playground/ →OpenAPI 3.1 spec
Generated from the same catalogue the playground renders, so the two can't disagree. Feed it to Postman, an HTTP client, or a client generator.
/api-docs/openapi.json →Health & diagnostics
/health is the liveness probe. /diagnostics adds the database
status and whether email and WhatsApp are configured.
Ledgr/Developers
Ledgr public API
A multi-tenant business platform behind a plain JSON API. Every
/api/* endpoint needs a bearer token, and every query is scoped to the tenant
that token belongs to.
Basics
| Base URL | The origin serving this page. In production, https://app.ledgr.co.za. Running locally, http://localhost:8080. |
|---|---|
| Content type | application/json on request and response. Send the header on anything with a body. |
| Dates | ISO 8601. Dates as 2026-04-10, timestamps as 2026-04-10T08:00:00Z. |
| Money | JSON numbers, not strings. Currency is a separate field, defaulting to ZAR. |
| Identifiers | UUIDs, everywhere, as strings. |
| Deletes | Soft. DELETE sets deleted_at or voids the document; it does not erase history. |
| Tenancy | Every row carries the id of the business that owns it, and the token you present decides which. It is not a parameter you can send — guessing another tenant's id returns 404, not their data. |
Authentication
Two ways in, both sent as Authorization: Bearer <token>.
1. JWT — for anything acting as a user
POST /auth/login with an email and password returns an access token plus the
identity behind it.
curl -X POST https://app.ledgr.co.za/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.co.za","password":"your-password"}'
{
"token": "<jwt>",
"userId": "0f2c…",
"tenantId": "9b41…",
"email": "you@example.co.za",
"tenantName": "Your Business"
}
Access tokens expire. Use POST /auth/refresh with your refresh token to get a
new one rather than storing credentials and logging in again on every request.
| POST | /auth/register | Create a tenant and its first user. Returns a JWT. |
| POST | /auth/login | Exchange credentials for an access token. |
| POST | /auth/refresh | Exchange a refresh token for a new access token. |
| POST | /auth/forgot-password | Trigger the password reset email. |
Changing a password, revoking a session or rotating a role can invalidate tokens issued
earlier. Treat a 401 as "refresh, and if that fails, sign in again" rather
than as a fatal error.
2. API keys — for integrations
If you are building an integration, this is the one to use. An API key belongs to the customer, not to you: they create it, choose what it can reach, and revoke it whenever they like. You never need their password, and you should never ask for it.
The customer generates a key under Roles & Users → API keys, picking read
or write per module. The plaintext (ledgr_…) is shown once, at generation.
Exchange it for an access token, then use that token everywhere:
# 1 — trade the key for a one-hour access token
TOKEN=$(curl -s -X POST https://app.ledgr.co.za/auth/token \
-H 'Authorization: Bearer ledgr_…' \
| jq -r .accessToken)
# 2 — call anything the key's scopes allow
curl -s 'https://app.ledgr.co.za/api/invoices?limit=50' \
-H "Authorization: Bearer $TOKEN"
The response also tells you what you were actually granted, so you can fail loudly at start-up instead of discovering a missing scope halfway through a sync:
{
"accessToken": "eyJhbGciOi…",
"tokenType": "Bearer",
"expiresIn": 3600,
"scope": "contacts:read invoices:read invoices:write",
"tenantId": "8f14e45f-…",
"rateLimitPerMinute": 120
}
Refresh the token when it expires by calling /auth/token again — the key is
the long-lived credential, the token is deliberately short-lived. Revoking a key stops it
on the next request, not when its last token would have expired.
What a key can reach
Three limits apply together, and the narrowest one wins:
- The tenant's plan. An endpoint in a module their plan doesn't include returns
403 MODULE_NOT_LICENSED, whatever the key's scopes say. - The key's scopes.
403 INSUFFICIENT_SCOPE, naming the scope needed — so the fix is a settings change, not a support ticket. - The role of the user who created the key. A key made by a Viewer cannot write, however it was scoped. A key is a ceiling on that person's access, never an expansion of it.
Scopes are <module>:read and <module>:write, and
write includes read. GET /api-scopes returns the full live list, so tooling
never has to hard-code it. full exists for a tool that genuinely replaces the
app — a migration, or the customer's own back office — and should be the exception.
If a key is lost, revoke it and generate a replacement; there is no way to retrieve the plaintext afterwards, by design. Setting keys up is covered in the roles guide.
Rate limits
120 requests per minute per key by default, and a customer can raise it for a specific key. You do not have to probe for the ceiling — every response carries it:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 41
X-RateLimit-Reset is seconds until the window rolls. Over the limit returns
429 with Retry-After and a body naming the limit. Back off on
that header rather than retrying immediately — a tight retry loop spends its whole quota
on retries.
Throttled requests are counted and shown to the customer alongside the key, so "it works intermittently" is a diagnosable statement rather than a mystery. If you are being throttled, they can see it too.
Using the sandbox
The API playground is a single page with three panes: the endpoint list on the left, the selected endpoint's detail in the middle, and the runner on the right. It carries every route this server registers, so the list is long enough that searching it is the point rather than an afterthought.
- Open
/developers/playground/No authentication is needed to read the docs.
- Find the endpoint — search rather than scroll
The search box matches across the path, the summary, the module and the scope at once, so
banking editandpost credit noteboth land somewhere useful. The method chips narrow it further, and the categories start collapsed. - Sign in, in the right-hand pane
Your email and password, or paste an API key. If two-factor is on, your authenticator code — or one of your recovery codes — goes in the same field. The token is held in
sessionStoragefor that browser tab only and is never sent anywhere but this server. - Read what it will cost you
The middle pane shows the method, path, auth kind, request body and an example response, plus the plan that licenses it and the API-key scope it wants. Those two are read from the server's own tables, not typed into the docs, so they are what the licence guard and the key check will actually do.
- Edit the path and body, then send
Substitute real ids for
{id}placeholders — the runner refuses to send a path that still has one. You get the status code, the round-trip time, what is left of your rate limit, and the formatted response. SetX-Company-Idif the tenant is a group and you want one entity rather than the active one.
Each endpoint has its own link (the address bar updates as you pick one), so pointing a colleague at the exact route you are discussing is a matter of copying the URL.
It calls the same server you are signed in to, against your own tenant, with your own
permissions. A POST creates a real record and a DELETE really
voids one. If you want somewhere to experiment freely, register a second business on the
Free plan and point the sandbox at that.
Endpoint catalogue
The catalogue is exhaustive, and held that way by a test. It is not a selection
somebody curates: ApiCatalogueCoverageTest walks the route files, resolves
what each one is mounted under, and fails the build in both directions — a route with no
catalogue entry, and a catalogue entry naming a route that is not served. The first would
ship an endpoint nobody could discover; the second is worse, because you would build
against it.
So the live list — with bodies, examples, the licensing plan and the required scope — is the playground and spec.json, and the count is on the page rather than written here, where it would go stale on the next release. The shape of it:
| Area | Paths | Notes |
|---|---|---|
| Authentication | /auth/* | No token required. Includes 2FA enrolment, recovery codes and session revocation. |
| Contacts | /api/contacts | Filter with ?type=CUSTOMER|SUPPLIER|EMPLOYEE|TAX_PRACTITIONER |
| Invoices & quotes | /api/invoices, /api/recurring, /api/invoice-settings, /api/invoice-templates | All nine document types; sub-resources for /send, /payments, /convert, /payment-links |
| Sales CRM | /api/crm/* | Leads, opportunities with lines, activities, targets, campaigns. Starter and up |
| Expenses | /api/expenses, /api/bills, /api/mileage, /api/purchase-orders | Starter and up |
| Banking | /api/bank-accounts, /api/bank-transactions, /api/bank-rules, /api/bank-statements, /api/reconciliation | Statement upload, plus /api/bank-feeds for Investec — the one SA bank a customer can connect with their own read-only API keys |
| Inventory | /api/inventory/*, /api/landed-costs | Products, batches, serials, locations, stock takes, transfers. Business and up |
| Job cards & time | /api/job-cards, /api/time-tracking | Both paginated and server-filtered. Time tracking is Starter and up |
| Manufacturing | /api/bom, /api/mrp, /api/procurement | Bills of materials, material planning, work orders, WIP. Manufacturing tier |
| Logistics & fleet | /api/logistics/*, /driver-api/v1/* | Trips, stops, proof of delivery, vehicle positions. Manufacturing tier |
| Payroll | /api/employees, /api/payroll-periods, /api/payslips, /api/leave-requests | Professional and up; /finalise locks a period |
| SARS | /api/sars/documents/*, /api/sars-submissions | Prepare and download only — Ledgr never transmits. Professional and up |
| Reports & ledger | /api/reports/*, /api/ledger, /api/fixed-assets | Take ?from=&to= or ?asOf= |
| Multi-company | /api/companies, /api/consolidation-groups, /api/intercompany-transactions | Consolidation is Enterprise |
| Administration | /api/roles, /api/users, /api/api-keys, /api/settings, /api/audit-log | Always available |
| Tenant & billing | /api/tenant/entitlements, /api/billing/*, /api/setup-progress | Plan, storage usage, renewal and the dashboard checklist |
| External ingest | /external/leads, /admin/billing/run-monthly | API key, scoped. Idempotent on a reference you mint |
| Inbound webhooks | /webhooks/payfast/itn/{tenantId} | Signature-verified and confirmed server-to-server before it is trusted |
| Portals | /portal/* | Customer, employee and supplier links. The token is in the path, not a header |
The catalogue includes routes that authenticate somebody else entirely — a driver's app,
a supplier, a customer following an emailed link, the platform back office. The
playground badges each one and says so on the endpoint, rather than leaving you to read
a 401 as a broken endpoint.
Errors
Failures come back as RFC 7807 problem details:
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "dueDate must not be before issueDate",
"instance": "/api/invoices"
}
The one exception is a permission denial from the role system, which returns the resource and action it refused so a client can say something useful:
{ "error": "Permission denied", "resource": "payroll", "action": "approve" }
| Status | Means | Do |
|---|---|---|
400 | Validation failed, or the body was malformed | Read detail — it names the field |
401 | Missing, expired or invalidated token | Refresh, then re-authenticate |
403 | Role lacks the permission, or the plan lacks the module | Check the role matrix, or the plan |
404 | No such record in your tenant | Do not assume it exists elsewhere — it may, and you cannot see it |
409 | Well-formed but conflicts with current state — an already-applied deposit, an already-invoiced billing aggregate | Re-read the resource before retrying |
500 | Unhandled server error | The detail is deliberately generic; the full trace is in the server log |
Billing ingestion for external apps
If you run your own product and want Ledgr to invoice for it, report one aggregate per customer per month rather than streaming individual transactions.
- Create the contact in Ledgr
The customer you are billing.
- Generate an API key bound to that contact
With the
transactions:writescope. Both the scope and the binding are required. - Post the monthly total
POST /admin/billing/run-monthlywithamount,month,sourceand a description. Posting again for the same contact and month updates the aggregate in place and returns200. - Let the aggregator invoice it
On the first of the month, pending events roll into draft invoices grouped by tenant and contact. After that, re-posting the same month returns
409— void the invoice first if it genuinely has to change.
Code samples
Create an invoice — cURL
curl -X POST https://app.ledgr.co.za/api/invoices \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"contactId": "9b41c0de-…",
"documentType": "INVOICE",
"issueDate": "2026-08-01",
"dueDate": "2026-08-31",
"currency": "ZAR",
"terms": "30",
"lines": [
{ "description": "Mobile app development",
"quantity": 1.0, "unitPrice": 45000.0, "discountRate": 0.0, "vatRate": 15.0 }
]
}'
JavaScript
const base = 'https://app.ledgr.co.za';
const { token } = await fetch(base + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
}).then(r => r.json());
const res = await fetch(base + '/api/reports/income-statement?from=2026-01-01&to=2026-12-31', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const problem = await res.json(); // RFC 7807
throw new Error(`${problem.status} ${problem.title}: ${problem.detail}`);
}
console.log(await res.json());
Kotlin
Kotlin Multiplatform clients can use the shared LedgrApiClient from the
shared module, which wraps every endpoint and handles the bearer header and
refresh for you. Point it at a base URL and call the method:
val client = LedgrApiClient(baseUrl = "https://app.ledgr.co.za")
val session = client.login(email = "you@example.co.za", password = "…")
val invoices = client.getInvoices(limit = 50)
Python
import requests
BASE = "https://app.ledgr.co.za"
token = requests.post(
f"{BASE}/auth/login",
json={"email": "you@example.co.za", "password": "…"},
timeout=30,
).json()["token"]
r = requests.get(
f"{BASE}/api/contacts",
params={"type": "CUSTOMER"},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
r.raise_for_status()
for contact in r.json():
print(contact["name"], contact.get("email"))
Incoming payment webhooks
PayFast posts payment notifications to Ledgr at
/webhooks/payfast/itn/{tenantId}. Ledgr verifies the signature, confirms the
payment with PayFast server-to-server before trusting it, and files the event in the
webhook inbox, where it is matched to an invoice within the tolerance you set under
Invoicing → Settings → Auto-reconciliation. Events that cannot be matched
confidently wait in the review queue with a manual match dropdown rather than being
silently dropped.
The merchant credentials are yours, entered per business (and optionally per company, for a group whose entities bank separately) in the same settings tab. The notification identifies which merchant it belongs to, so one Ledgr account can hold several.
Yoco is listed in GET /api/payment-gateways — which is the
single source both the app and this documentation read — and is reported as
unavailable for links. Asking for one is refused rather than accepted and left
unpaid. Yoco is used for buying Ledgr itself, which is a separate thing.
Web-to-lead: an enquiry straight from a website
POST /external/leads takes a lead from a site's contact form into the
customer's Sales CRM. It is an API-key endpoint needing the crm:write scope,
and it sits outside /api because the caller is a website, not a user.
curl -X POST https://app.ledgr.co.za/external/leads \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"name": "Thandi Mokoena",
"businessName": "Mokoena Interiors",
"email": "thandi@mokoena.co.za",
"phone": "082 555 0134",
"source": "website",
"estimatedValue": 48000,
"notes": "Wants a quote for a shopfitting job in Sandton",
"externalRef": "form-2026-08-22-00417"
}'
externalRef.
It is what makes the call idempotent. A form that retries because your server never saw the reply would otherwise create the lead twice — and two reps would then phone the same customer about the same job. Send your own submission id; a replay returns the lead that already exists.
For a bulk load rather than a live form, POST /api/crm/leads/import takes
CSV, and a preview flag returns the counts the real run will produce without writing
anything.
The driver API
Ledgr's own driver screens are built on /driver-api/v1, and it is a
published contract rather than an internal one — if you would rather write your own driver
app, it compiles against the same rules the server enforces.
A driver holds a token scoped to one driver record, sent as an ordinary
Authorization: Bearer header. It is not a user account: nobody buys a seat per
driver, and Ledgr does not meter seats anyway. Unlike the customer and employee portals the
token is in a header, never in the URL — an app has no reason to pay the access-log
cost that a clicked link does. Withdraw it by bumping the driver's token version, which
retires every token already on a phone.
| GET | /driver-api/v1/session | Who this token is, and whether it is still good. |
| GET | /driver-api/v1/trips | The driver's trips. |
| GET | /driver-api/v1/trips/{id} | One trip with its ordered stops — address, site contact, instructions and lines. |
| POST | /driver-api/v1/trips/{id}/start | Begin the run. |
| POST | /driver-api/v1/trips/{id}/complete | End it. |
| POST | /driver-api/v1/stops/{id}/status | Arrived, departed and so on. |
| POST | /driver-api/v1/stops/{id}/proof | The proof of delivery — signature, name, outcome, line quantities, coordinates. |
| POST | /driver-api/v1/stops/{id}/proof/photos | Photographs attached to that proof. |
| POST | /driver-api/v1/positions | Vehicle positions, in batches. |
| GET | /driver-api/v1/exception-reasons | The whole reason list, for offline use. |
Three rules shape the surface, and they are worth knowing before designing against it:
- One request per screen. A driver at a kerbside on one bar of signal cannot make a second call to finish what the first started.
- Nothing to resolve. Reason codes arrive with their labels and the whole list is downloadable, so the app never has to be online to render a choice.
- Every write is idempotent. A proof carries a reference your app mints; replaying it returns the stored proof rather than recording the delivery twice.
It is the number that makes a proof contestable, so it is derived from the coordinates
rather than accepted from the device. Send the position and its claimed accuracy
honestly — the geofence allows for the error the device reports, because failing a
truthful driver in a yard with a poor sky view is how a business learns to ignore the
flag. (0,0) is refused: it is what a failed location provider returns, not
a place anyone delivers to.
A proof carries the time the driver's device recorded it, and Ledgr stores that alongside the time it arrived. An offline capture legitimately turns up hours later, and keeping only one of the two would mean either back-dating a server record or restamping evidence with a time the signature demonstrably was not given.
A fix every five seconds is 17 280 rows a day per vehicle. Ledgr enforces a 60-second floor measured against the previous fix's device time — so an offline catch-up replays correctly — caps a batch at 500, refuses future-dated fixes, and tells you what it dropped.
What a proof means once you have sent it — how it is graded, and why a thin one is accepted rather than refused — is in the logistics and fleet guide.