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 send real requests to 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 — the password field carries SHA-256 of the password, not the password
PW=$(printf %s 'your-password' | shasum -a 256 | cut -d' ' -f1)

TOKEN=$(curl -s -X POST https://www.ledgrsa.com/auth/login \
  -H 'Content-Type: application/json' \
  -d "{\"email\":\"you@example.co.za\",\"password\":\"$PW\"}" \
  | jq -r .accessToken)

# 2 — pick the company the call is about
COMPANY=$(curl -s https://www.ledgrsa.com/api/companies \
  -H "Authorization: Bearer $TOKEN" | jq -r '.[0].id')

# 3 — call the API
curl -s 'https://www.ledgrsa.com/api/invoices?limit=50' \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Company-Id: $COMPANY"

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://www.ledgrsa.com. 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
# SHA-256 the password first — see the note below
PW=$(printf %s 'your-password' | shasum -a 256 | cut -d' ' -f1)

curl -X POST https://www.ledgrsa.com/auth/login \
  -H 'Content-Type: application/json' \
  -d "{\"email\":\"you@example.co.za\",\"password\":\"$PW\"}"

{
  "accessToken": "<jwt>",
  "refreshToken": "<refresh-token>",
  "userId": "0f2c…",
  "tenantId": "9b41…",
  "email": "you@example.co.za",
  "tenantName": "Your Business",
  "role": "owner"
}
The password field is SHA-256 of the password, hex-encoded and lowercase — never the password itself.

Every Ledgr client hashes it before the request leaves the device, and the server verifies against that value, so posting the plaintext is refused as Invalid email or password even when the password is right. It is not a substitute for TLS — it means the plaintext never reaches a log, a proxy or a crash dump. Every sample in the code samples below does it, in its own language.

If the account has two-factor authentication on, add totpCode to the same body. One field takes either a six-digit authenticator code or a recovery code — the server tells them apart by their shape. Without it the call answers 401 TOTP_REQUIRED.

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.

Choosing the company

A tenant can hold more than one legal entity, so a call about documents, banking, payroll or the ledger has to say which. Send the company's id in an X-Company-Id header. There is deliberately no default: a request that silently picked an entity could post an invoice or a journal into the wrong set of books. Omitting it on a company-scoped route answers 400 — X-Company-Id header is required for this request.

GET /api/companies lists the ones you may reach, and it needs no header itself. Tenant-wide routes — contacts, inventory, the consolidated group views — take no company either; passing one there is harmless and ignored.

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.

API keys are included from Business. The number a business may hold at once depends on its plan, and the pricing page lists it per plan; a plan without keys cannot generate one.

The customer generates a key under Roles & Users → API Keys, picking read or write per module, and who owns it. A key owned by a person acts as them and stops working if they are deactivated. A key owned by the business acts with a role chosen when it is created, keeps working when whoever created it leaves, and can be revoked by anyone who manages access; it is the one to ask for when an integration should outlive a member of staff. The plaintext (ledgr_…) is shown once, at generation. Exchange it for an access token, find the company the calls are about, then send both on every call:

Exchange a key for a token
# 1 — trade the key for a one-hour access token
TOKEN=$(curl -s -X POST https://www.ledgrsa.com/auth/token \
  -H 'Authorization: Bearer ledgr_…' \
  | jq -r .accessToken)

# 2 — find the company (any key may, whatever its scopes)
COMPANY=$(curl -s https://www.ledgrsa.com/api/companies \
  -H "Authorization: Bearer $TOKEN" | jq -r '.[0].id')

# 3 — call anything the key's scopes allow
curl -s 'https://www.ledgrsa.com/api/invoices?limit=50' \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Company-Id: $COMPANY"

How a key finds its company. GET /api/companies lists the companies the key may reach. It is the one read every key may make whatever its scopes, on every plan, because every company-scoped call needs the id it returns. A company's details, its access list and every change to it still need a multi-company scope. A company's id never changes, so an integration can look it up once at set-up and keep it beside the key.

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.

Connecting an app to many businesses: OAuth 2

If your app serves many Ledgr businesses, do not ask each one to paste a key. Register the app once (POST /api/oauth/apps with its name and exact redirect addresses; the client secret is shown once), then send each customer to /oauth/authorize?response_type=code&client_id=…&redirect_uri=…&scope=…&state=…. They sign in on Ledgr's own page, which never shows you their password, see the scopes you asked for, and can untick any of them. You get back ?code=…&state=…, and you exchange the code once, within ten minutes, at POST /oauth/token (form-encoded, grant_type=authorization_code, with your client id and secret). PKCE (S256) is accepted.

The access token acts as the person who connected you, with only the scopes they left ticked. It can never do more than they can. Keep the refresh token with your client secret: it is exchanged at /oauth/token with grant_type=refresh_token, and is refused on its own. The connection appears in that person's API Keys list, where they can disconnect it, and it stops if they leave the business.

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 /api call made with a token exchanged from a key carries it, once the key's scopes have admitted the call:

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 problem-details body naming the limit (limit and retryAfterSeconds beside the usual fields). Back off on that header rather than retrying immediately — a tight retry loop spends its whole quota on retries.

The per-key limit applies to that exchanged token only; an /api call made with a signed-in user's token is not metered per key and carries no rate-limit headers. The routes outside /api — /auth/*, including /auth/token, and /external/leads, which takes the key itself — sit under a separate limit per calling address. They send the same three headers about that limit, and the same 429 body when it is spent.

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 and searching is the quickest way through it.

The detail pane gives the path, the summary, the authentication it takes, the module and plan that license it, the scope an API key needs, and the request body where there is one. Every endpoint says what it answers. A worked example is published wherever the endpoint's response type can be established from the source, which is most of them. Its field names and nesting are generated from that type rather than written by hand, so they are what the server sends; the values in it are illustrative. The rest answer no JSON body at all — a 204, a PDF, a CSV, an HTML page, a redirect — and say so instead; a handful answer JSON whose shape the source does not state, and say that rather than showing a plausible wrong one. Whenever you want the figures rather than the shape, you run the call and read what your own tenant returns, which is the answer that cannot go stale.

  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. Only the exchanged token is kept, in sessionStorage for that browser tab; a pasted key is held in the page's memory and gone on reload. Neither is ever sent anywhere but this server.

  4. Check what it needs

    The middle pane shows the method, path, auth kind and request body, plus the plan that licenses it and the API-key scope it wants — and a worked response where one is published. 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. X-Company-Id is filled with your first company when you sign in; change it to reach another entity of a group.

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, press Use a sandbox company in the playground once you are signed in.

A sandbox company of sample data

Use a sandbox company creates, once, a separate company inside your own account called Sandbox — sample data, with three sample customers, two products and three invoices — one paid, one open, one overdue — posted to its own ledger, and switches X-Company-Id to it. Your real company's books are not touched.

  • It comes with API access, so it is offered on Business and above — the plans that include API keys. If you move to a plan without them, a sandbox you already have becomes read-only: it still reads and can still be deleted, nothing in it is removed, and it takes writes again when API access is back.
  • It does not count against your plan's company allowance, and you may have one at a time. It is marked Sandbox wherever the app lists companies, including the company switcher, and its name cannot be changed. When you open the app it goes to your own company; switch to the sandbox when you want it.
  • Contacts and products are one book per account in Ledgr, shared by every company, so the sample ones appear in your contact list too, each named Sandbox · ….
  • Delete the sandbox removes it in one step with every record in it and nothing of your real company's, however deep the records it made go. If a record outside the sandbox has come to point at a sandbox record, the delete is refused naming the records that block it, and nothing is deleted.
  • The same three calls are in the catalogue: GET, POST and DELETE /api/sandbox.

To try API keys you need a plan that includes them — Business or above.

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/tenant-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 supply
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.

Paging a list

The lists that grow with trading volume are paged. Send ?limit= and ?page=; the reply is an envelope rather than a bare array:

GET /api/invoices?limit=50&page=2
{
  "items": [ … 50 invoices … ],
  "total": 1340,
  "page": 2,
  "limit": 50,
  "totalPages": 27
}
  • total is the number of rows matching your filter, not the number returned. It is what tells you there is more.
  • limit is clamped, never refused — 0 or below becomes the default, above the maximum becomes the maximum, and the reply says which limit was applied. So ?limit=100000 is a smaller page, not a 400.
  • page is 1-based. A page past the end is an empty items with the same total, not a 404.
  • Filters apply before paging, so ?status= and ?limit= compose the way you would expect.
Not every list is paged, and the ones that are not are bounded by their nature.

A chart of accounts, a location list, a set of VAT periods or a company hierarchy is tens of rows for the life of the business, and those return everything. Anything that grows every day you trade — invoices, expenses, bills, bank transactions, stock movements, time entries, journal lines, payslips, leads, activities — is paged. If a list you are calling returns an array rather than the envelope above, it is one of the bounded ones; the endpoint catalogue's response example shows which shape you get.

Retrying safely — clientId

A create can be retried without creating a second record, and the mechanism is one field. You generate the record's UUID before the first attempt and send it as clientId. It becomes the record's primary key, so a second POST of the same body — after a timeout the server had in fact committed, a dropped connection, a crash between commit and your acknowledgement — is answered with the record that already exists rather than a duplicate.

The same call twice, one record
POST /api/contacts
{ "clientId": "9b1f...c204", "name": "Karoo Fresh Holdings Ltd", "contactType": "customer" }
→ 201 { "id": "9b1f...c204", … }

POST /api/contacts          # identical retry
{ "clientId": "9b1f...c204", "name": "Karoo Fresh Holdings Ltd", "contactType": "customer" }
→ 200 { "id": "9b1f...c204", … }   # the same record, not a second one
  • It has to be a UUID. Anything else is refused with a 400 naming the field — refused rather than ignored, because a silently dropped id is a duplicate waiting to happen on your next retry.
  • Generate it once per intended record, not once per attempt. A fresh UUID on the retry is a different record and Ledgr will happily create it. This is the one mistake that defeats the whole mechanism.
  • The reply carries the id either way, so you do not need to know which of the two happened. If you do care, the status distinguishes them: 201 created, 200 already there.
  • Where a create has side effects — a document number from a sequence, an opening stock movement, a ledger posting — the second call skips all of them. It is a read-before-insert inside the create's own transaction, not an upsert.

Supported on the nineteen creates the Ledgr apps queue offline — in invoicing, contacts, expenses, inventory, the CRM, job cards, time tracking, payroll leave and workflows — and on several more, among them bank accounts, bills, journals, fixed assets and recipes. The endpoint catalogue's sample body shows clientId wherever it is accepted. On an endpoint that does not accept it the field is simply ignored, so sending it always is safe.

There is no Idempotency-Key header, and no other retry mechanism.

Everything else — an approval, a stock movement, a document conversion, a payment run — is a decision about state as it is now, and replaying one is not safe in a way a header could fix. Read the resource back before retrying anything that is not a create.

Query parameters

A dozen names mean the same thing wherever they appear, and three of them behave in a way worth knowing before you meet it in a response:

ParameterBehaviour
limit, page, offset Paging, above. Clamped rather than refused.
status, type Matched case-insensitively, and an unrecognised value is refused rather than ignored. A filter that quietly answers with everything is the worst outcome, because every figure in the reply is real and none of them answers the question you asked.
from, to, asOf YYYY-MM-DD. An unparseable date is refused, never defaulted to today — these decide which period a report or a return describes. asOf re-derives the position at that date rather than filtering today's, so it ties to a balance sheet at the same date.
year, month, period, taxYear Same rule: refused rather than defaulted. taxYear is the South African year of assessment named by the year it ends in — 2027 is 1 March 2026 to 28 February 2027 — and a year Ledgr holds no tables for is refused outright rather than served from the nearest one.
format json always answers, carrying the figures and any issues. A file format (pdf, csv, easyfile) answers 422 with the reasons when the document does not validate, so a wrong file is never emitted.
costCentreId A UUID, or the literal unallocated. An unparseable value is refused rather than widened to the whole company.

Every documented query parameter is published in the OpenAPI document with its type and an example, so a generated client gets them too.

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 aggregate, a draft that has already been releasedRe-read the resource before retrying. Retrying unchanged will conflict again.
422The request is understood and the subject will not support it. Two places you will meet it: a SARS return asked for as a file when it does not validate, and a publish refused for a list of named reasons. The body carries every reason at once rather than the first.Read the reasons — each names what to fix. Ask for ?format=json to see the figures and the issues without a file.
429The API key's per-minute rate limitHonour Retry-After. The limit is per key and the body names it; see rate limits.
503A dependency this call needs is not available — file storage that cannot sign a URL, a payment credential that will not decrypt, a public base URL that is not configured. Not your request's fault and worth retrying, which is exactly why it is not a 500.Retry with backoff. If it persists, the deployment is missing configuration rather than your call being wrong.
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://www.ledgrsa.com/api/invoices \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Company-Id: $COMPANY" \
  -H 'Content-Type: application/json' \
  -d '{
    "contactId": "9b41c0de-…",
    "documentType": "invoice",
    "issueDate": "2026-08-01",
    "dueDate": "2026-08-31",
    "currency": "ZAR",
    "paymentTermsDays": 30,
    "lines": [
      { "description": "Mobile app development",
        "quantity": 1.0, "unitPrice": 45000.0 }
    ]
  }'

$TOKEN and $COMPANY are the two values from the key exchange above. paymentTermsDays is the credit term in days; terms is a separate free-text field and sets no credit term. A line left without vatRate takes the standard rate when the company is registered for VAT on the issue date; a company that is not charges none, whatever the line says. discountRate defaults to none.

JavaScript

fetch
const base = 'https://www.ledgrsa.com';
const email = 'you@example.co.za';
const password = 'your-password';

// The password field carries the SHA-256 hex of the password, never the password.
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password));
const hashed = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('');

const { accessToken } = await fetch(base + '/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email, password: hashed }),
}).then(r => r.json());
const auth = { Authorization: `Bearer ${accessToken}` };

// A report is about one company, so say which.
const [company] = await fetch(base + '/api/companies', { headers: auth }).then(r => r.json());

const path = '/api/reports/income-statement?from=2026-01-01&to=2026-12-31';
const res = await fetch(base + path, {
  headers: { ...auth, 'X-Company-Id': company.id },
});

if (!res.ok) {
  const problem = await res.json();      // RFC 7807
  throw new Error(`${problem.status} ${problem.title}: ${problem.detail}`);
}
console.log(await res.json());

Python

requests
import hashlib
import requests

BASE = "https://www.ledgrsa.com"

# The password field carries the SHA-256 hex of the password, never the password.
pw = hashlib.sha256("your-password".encode()).hexdigest()

token = requests.post(
    f"{BASE}/auth/login",
    json={"email": "you@example.co.za", "password": pw},
    timeout=30,
).json()["accessToken"]

r = requests.get(
    f"{BASE}/api/contacts",
    params={"type": "CUSTOMER"},
    headers={"Authorization": f"Bearer {token}"},
    timeout=30,
)
r.raise_for_status()
page = r.json()          # a page, not a bare list: {"items": [...], "total": ..., ...}
for contact in page["items"]:
    print(contact["name"], contact.get("email"))
print(f'{len(page["items"])} of {page["total"]} customers')

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) under Settings → Payments. The notification identifies which merchant it belongs to, so one Ledgr account can hold several.

Yoco posts to /webhooks/yoco/invoice/{tenantId} and is verified the on the same principle but differently in detail: a Yoco notification names no merchant of any kind, so the tenant travels in the callback path — routing only, never authorisation. The Standard Webhooks signature must verify against a secret that tenant stored, and the invoice reference Yoco echoes back must name the same tenant. Both are refused otherwise. Ledgr hands the tenant the exact URL to register on Settings → Payments, because one registered a character off fails exactly like no webhook at all — silently, and only once a customer has paid.

Ask GET /api/payment-gateways which gateways can take money.

It is the single source both the app and this documentation read, resolved against that tenant's own merchant accounts. Both PayFast and Yoco can issue a payment link; either is reported unavailable, with the reason in words, when its credentials are absent or half-finished — a Yoco key with no webhook signing secret being the case worth knowing about. POST /api/invoices/{id}/payment-links refuses on the same answer rather than creating a link that would be accepted and left unpaid.

Neither gateway is told a currency, so a payment link is ZAR only.

A link is refused on a foreign-currency invoice at both points — when it is created and when it is opened. Otherwise a USD 1 000 invoice would be charged as R1 000 and then matched and settled, and the customer's card statement would be the only place that discrepancy ever appeared.

Webhooks to your systems

Instead of polling, give Ledgr an address and it posts an event there the moment something changes. Add one under Roles & Users → API Keys → Webhooks, or with POST /api/webhooks. Webhooks come with API access: a plan that carries API keys carries webhooks.

EventWhenScope its data needs
logistics.stop.status_changedA driver sets off for a stop or arrives, a proof records its outcome, or a cancelled trip releases it. data carries previousStatus, status and the stoplogistics:read
logistics.proof.recordedA proof of delivery is recorded — once per proof, however often a phone replays the capture. data.prooflogistics:read
logistics.trip.dispatchedA trip is handed to the driver. data.trip, with its stops in orderlogistics:read
logistics.trip.completedA trip is closed. data.trip, with every stop's outcomelogistics:read
invoice.paidA payment settles an invoice's whole balance — typed in, matched from the bank, or taken online. data carries the invoice's number, customer, totals and statusinvoices:read
payroll.run.paidA pay run is marked paid — from the Pay button or a matched bank line. data carries the run's dates and totals, never an employee's paypayroll:read
bill.rejectedAn approver rejects a supplier bill. data carries the bill's number, supplier, amounts and the reason givenexpenses:read
expense.rejectedAn approver rejects an expense claim. data carries the claim's date, description, amount and the reason givenexpenses:read

The objects inside data are the ones the catalogue documents — a stop is the same shape GET /api/logistics/trips/{id} answers — so there is no second vocabulary to learn. Every event arrives in one envelope:

The event envelope
{ "id": "<event id>", "type": "logistics.proof.recorded",
  "createdAt": "2026-10-01T09:14:03Z", "companyId": "<uuid>", "data": { … } }

Scoped like an API key. A webhook is a read Ledgr makes on your behalf, so each event needs the read scope its data would need: a person adding one must be allowed to view that module, and an API key adding one must hold the scope. A webhook an API key created stops sending when that key is revoked.

Sending one again, and when Ledgr stops. Every delivery is in the webhook's log (GET /api/webhooks/{id}/deliveries). A finished one can be sent again with POST /api/webhooks/{id}/deliveries/{deliveryId}/redeliver — the same event under the same webhook-id, so a receiver that stores ids takes it as the one it missed. A webhook whose last five events each failed after every retry is switched off, and everyone who may manage webhooks is emailed; switch it back on once the address answers again, and send what it missed from its log.

Checking the signature

Every delivery follows the Standard Webhooks scheme, so any library for it works with the signing secret as it stands. Three headers arrive with the JSON body:

  • webhook-id — the event's id. The same on every retry of one event, so store it and answer 2xx to one you have already handled.
  • webhook-timestamp — Unix seconds when this attempt was signed. Refuse one more than five minutes from your clock, or a captured delivery can be replayed.
  • webhook-signature — v1, followed by the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of your whsec_… secret after the prefix.

Compute it over the raw body before parsing it, and compare in constant time. The secret is shown once, when the webhook is added; Ledgr stores it encrypted and cannot show it again. Lost it? Delete the webhook and add it again.

Delivery, retries and the log

A 2xx answer within 10 seconds is a delivery. Anything else is retried: two attempts straight away, then after a minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours — eight attempts across most of a day, after which the delivery is marked failed. A 410 Gone stops the retries at once. Redirects are never followed; give the final address. GET /api/webhooks/{id}/deliveries lists every attempt with the status your server answered, and POST /api/webhooks/{id}/test sends a signed webhook.test event on demand.

Only public addresses. The address must be https:// and must resolve to the public internet. An address that is, or resolves to, a loopback, private, link-local or cloud-metadata address is refused when the webhook is saved and checked again before every delivery.

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. It takes the API key itself (ledgr_…) as the bearer — not the token /auth/token exchanges it for, which this route refuses with a 401. Keep the key on your server: a key in a web page's own script is readable by anybody who opens it.

POST /external/leads
curl -X POST https://www.ledgrsa.com/external/leads \
  -H 'Authorization: Bearer ledgr_…' \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Thandi Mokoena",
    "companyName": "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 generates; 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 figure a disputed delivery turns on, 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.