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://www.ledgrsa.com. 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.
# 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"
}
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/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.
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:
# 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:
{
"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:
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.
- 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. Only the exchanged token is kept, in
sessionStoragefor 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. - 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.
- 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-Idis 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.
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,POSTandDELETE /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:
| 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/tenant-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 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 |
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:
{
"items": [ … 50 invoices … ],
"total": 1340,
"page": 2,
"limit": 50,
"totalPages": 27
}
totalis the number of rows matching your filter, not the number returned. It is what tells you there is more.limitis 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=100000is a smaller page, not a 400.pageis 1-based. A page past the end is an emptyitemswith the sametotal, not a 404.- Filters apply before paging, so
?status=and?limit=compose the way you would expect.
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.
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.
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:
| Parameter | Behaviour |
|---|---|
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:
{
"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, a draft that has already been released | Re-read the resource before retrying. Retrying unchanged will conflict again. |
422 | The 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. |
429 | The API key's per-minute rate limit | Honour Retry-After. The limit is per key and the body names it; see rate limits. |
503 | A 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. |
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://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
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
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.
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.
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.
| Event | When | Scope its data needs |
|---|---|---|
logistics.stop.status_changed | A 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 stop | logistics:read |
logistics.proof.recorded | A proof of delivery is recorded — once per proof, however often a phone replays the capture. data.proof | logistics:read |
logistics.trip.dispatched | A trip is handed to the driver. data.trip, with its stops in order | logistics:read |
logistics.trip.completed | A trip is closed. data.trip, with every stop's outcome | logistics:read |
invoice.paid | A 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 status | invoices:read |
payroll.run.paid | A 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 pay | payroll:read |
bill.rejected | An approver rejects a supplier bill. data carries the bill's number, supplier, amounts and the reason given | expenses:read |
expense.rejected | An approver rejects an expense claim. data carries the claim's date, description, amount and the reason given | expenses: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:
{ "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 yourwhsec_…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.
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"
}'
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 generates; replaying it returns the stored proof rather than recording the delivery twice.
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.
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.