Developers

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.

  • Scoped API keys — never a customer's password
  • Row-level tenant isolation on every query
  • RFC 7807 error bodies
Sign in and list invoices
# 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"

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 URLThe origin serving this page. In production, https://app.ledgr.co.za. Running locally, http://localhost:8080.
Content typeapplication/json on request and response. Send the header on anything with a body.
DatesISO 8601. Dates as 2026-04-10, timestamps as 2026-04-10T08:00:00Z.
MoneyJSON numbers, not strings. Currency is a separate field, defaulting to ZAR.
IdentifiersUUIDs, everywhere, as strings.
DeletesSoft. DELETE sets deleted_at or voids the document; it does not erase history.
TenancyEvery 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.

POST /auth/login
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/registerCreate a tenant and its first user. Returns a JWT.
POST/auth/loginExchange credentials for an access token.
POST/auth/refreshExchange a refresh token for a new access token.
POST/auth/forgot-passwordTrigger the password reset email.
Tokens are invalidated on security events.

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:

Exchange a key for a token
# 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:

POST /auth/token
{
  "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:

Response headers
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.

  1. Open /developers/playground/

    No authentication is needed to read the docs.

  2. 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 edit and post credit note both land somewhere useful. The method chips narrow it further, and the categories start collapsed.

  3. 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 sessionStorage for that browser tab only and is never sent anywhere but this server.

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

  5. 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. Set X-Company-Id if 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.

The sandbox is live, not a simulation.

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:

AreaPathsNotes
Authentication/auth/*No token required. Includes 2FA enrolment, recovery codes and session revocation.
Contacts/api/contactsFilter with ?type=CUSTOMER|SUPPLIER|EMPLOYEE|TAX_PRACTITIONER
Invoices & quotes/api/invoices, /api/recurring, /api/invoice-settings, /api/invoice-templatesAll 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-ordersStarter and up
Banking/api/bank-accounts, /api/bank-transactions, /api/bank-rules, /api/bank-statements, /api/reconciliationStatement 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-costsProducts, batches, serials, locations, stock takes, transfers. Business and up
Job cards & time/api/job-cards, /api/time-trackingBoth paginated and server-filtered. Time tracking is Starter and up
Manufacturing/api/bom, /api/mrp, /api/procurementBills 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-requestsProfessional and up; /finalise locks a period
SARS/api/sars/documents/*, /api/sars-submissionsPrepare and download only — Ledgr never transmits. Professional and up
Reports & ledger/api/reports/*, /api/ledger, /api/fixed-assetsTake ?from=&to= or ?asOf=
Multi-company/api/companies, /api/consolidation-groups, /api/intercompany-transactionsConsolidation is Enterprise
Administration/api/roles, /api/users, /api/api-keys, /api/settings, /api/audit-logAlways available
Tenant & billing/api/tenant/entitlements, /api/billing/*, /api/setup-progressPlan, storage usage, renewal and the dashboard checklist
External ingest/external/leads, /admin/billing/run-monthlyAPI 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
Listed does not mean callable by you.

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:

400 Bad Request
{
  "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:

403 Forbidden (RBAC)
{ "error": "Permission denied", "resource": "payroll", "action": "approve" }
StatusMeansDo
400Validation failed, or the body was malformedRead detail — it names the field
401Missing, expired or invalidated tokenRefresh, then re-authenticate
403Role lacks the permission, or the plan lacks the moduleCheck the role matrix, or the plan
404No such record in your tenantDo not assume it exists elsewhere — it may, and you cannot see it
409Well-formed but conflicts with current state — an already-applied deposit, an already-invoiced billing aggregateRe-read the resource before retrying
500Unhandled server errorThe 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.

  1. Create the contact in Ledgr

    The customer you are billing.

  2. Generate an API key bound to that contact

    With the transactions:write scope. Both the scope and the binding are required.

  3. Post the monthly total

    POST /admin/billing/run-monthly with amount, month, source and a description. Posting again for the same contact and month updates the aggregate in place and returns 200.

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

POST /api/invoices
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

fetch
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:

shared/LedgrApiClient
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

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

PayFast is the only gateway that can issue a payment link.

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.

POST /external/leads
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"
  }'
Always send 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/sessionWho this token is, and whether it is still good.
GET/driver-api/v1/tripsThe 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}/startBegin the run.
POST/driver-api/v1/trips/{id}/completeEnd it.
POST/driver-api/v1/stops/{id}/statusArrived, departed and so on.
POST/driver-api/v1/stops/{id}/proofThe proof of delivery — signature, name, outcome, line quantities, coordinates.
POST/driver-api/v1/stops/{id}/proof/photosPhotographs attached to that proof.
POST/driver-api/v1/positionsVehicle positions, in batches.
GET/driver-api/v1/exception-reasonsThe 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.
Distance from the stop is computed by the server, and is not a field you send.

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.

Send both clocks.

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.

Positions are rate-limited, and that is not a quota to work around.

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.