Ledgr Sales Feed API — v1
Push a merchant's daily takings into their books.
If your system already knows what a merchant sold today — a point-of-sale, an online store, a booking system, a market stall app — this API puts that day into their Ledgr books as income, correctly split for VAT, without anybody retyping anything.
- Base URL — South Africa:
https://ledgr.mobi/api - Base URL — Botswana:
https://bala.ledgr.mobi/api - Version:
v1. Every path below is prefixed with/v1.
So a full endpoint reads https://ledgr.mobi/api/v1/sales/days. The same software serves both markets under two names; use the base URL that matches where the merchant's books are kept, which GET /v1/ping will confirm.
The merchant issues you a key from inside their own account. You never ask them for a password, and you never choose which books the money lands in — they do.
1. The idea in one paragraph
The unit of this API is one trading day, not one transaction. A till does hundreds of sales a day; posting each one would bury a sole trader in noise and buy them nothing, because SARS and BURS want turnover, not receipts. So you send a day: what it took in total, how much of that was VAT, and how the money arrived (cash, card, EFT, other). That is the same shape as a cash-up sheet, which is what an accountant has always read.
A day is never final. A void lands late, a card batch settles the next morning, a till is recounted. So you may send the same day as often as you like: an identical resend does nothing, and a changed resend corrects the books.
2. Authentication
The merchant creates a key in Ledgr under More → Connected apps. It looks like this and is shown to them exactly once:
lgk_live_7Fq2xR9tKm4LpZ8sVnB3cW6yH1jD5gA0eTSend it on every request, either way:
X-Api-Key: lgk_live_7Fq2xR9tKm4LpZ8sVnB3cW6yH1jD5gA0eTAuthorization: Bearer lgk_live_7Fq2xR9tKm4LpZ8sVnB3cW6yH1jD5gA0eTA key is bound to one profile (one set of books) and optionally one income stream, both chosen by the merchant when they issue it. Nothing you send can change that destination. Treat the key as a password: store it encrypted, never put it in a URL, never log it, and offer the merchant a way to replace it.
Store keys per merchant, never globally
One key belongs to one merchant. If you are a platform with many merchants, you hold many keys — one per merchant who has connected Ledgr.
Test keys — build against these, not against a real shop's books
A merchant can issue a test key from the same screen. It starts lgk_test_ and it never writes anything:
lgk_test_3nQ7vX2mR8pL5wK9tB4cH6yD1jF0sAA test key authenticates the same way, resolves the same merchant, and runs every validation, tie-out and VAT calculation a live key runs. It answers with exactly what would have been recorded — and then stops, before a single row reaches the books.
{
"status": "not_recorded",
"test_mode": true,
"totals": { "gross": "12500.00", "vat": "1630.43" },
"tenders": [ ... ],
"note": "Test key — this day was checked in full and deliberately not written."
}It refuses exactly what a live key refuses. A rehearsal that accepts what production rejects is worse than no rehearsal, because it teaches you the wrong contract.
GET /v1/ping tells you which kind you are holding:
{ "mode": "test", "writes_to_books": false }Develop and test against lgk_test_. Switch to lgk_live_ when you are ready for the figures to be kept. Your first real request should never be your first real test.
One key works on both base URLs
Ledgr and Bala are one deployment sharing one database. A key is not regional: the same credential answers on ledgr.mobi/api and bala.ledgr.mobi/api identically. You do not need to discover which one a key belongs to — pick either, or match whichever brand the merchant knows.
3. Start here: GET /v1/ping
Call this first, and call it again whenever a merchant re-enters their key. It is the cheapest possible way to prevent the worst mistake in this integration: a month of somebody's takings landing in the wrong profile or the wrong currency, discovered at year end.
curl https://ledgr.mobi/api/v1/ping \
-H "X-Api-Key: lgk_live_..."{
"ok": true,
"connection": "Thekiso — Main Road till",
"profile": "Corner Shop",
"profile_id": "6f072d0f5af541269323ea335f824b91",
"currency": "ZAR",
"vat_registered": true,
"stream": "Shop",
"accepts": ["cash", "card", "eft", "other", "gift_card", "store_credit", "on_account"],
"mode": "live",
"writes_to_books": true
}accepts is the authoritative list of method values this server will take, and it is enforced: anything outside it is refused with 422. Read it rather than hard-coding the list — when we add a tender, accepts is how you find out without a redeploy. mode tells you whether this key writes (§2).
Show profile, stream and currency back to the merchant in your own UI before you send a single day. If it says "Corner Shop" and they run "Corner Bakery", they will catch it in two seconds — and nobody else will.
Where the money lands, and why you cannot choose it
A key is nailed to one profile and one income stream, both picked by the merchant when they issue it. Nothing you send can move it, and there is deliberately no field in the payload to try.
That is not a limitation, it is the point. In South Africa and Botswana a sole trader is their business for tax: one natural person, one return, carrying a salary, a shop and every side hustle together. So Ledgr keeps them as one profile and separates the work into streams — the shop, the driving, the weekend photography. The stream on the key is the only thing keeping a till's takings out of the rest of that person's money.
If the merchant tells you their shop figures are showing up mixed with their personal income, they have pointed the key at the wrong stream. They fix it in Ledgr by issuing a new key against the right one — you don't fix it, and you can't.
One key per till, per shop, per gig. Two shops feeding one profile is two keys, and their days never collide: the natural key is (your key, date), not (profile, date).
4. Send a day: POST /v1/sales/days
curl -X POST https://ledgr.mobi/api/v1/sales/days \
-H "X-Api-Key: lgk_live_..." \
-H "Content-Type: application/json" \
-d '{
"business_date": "2026-08-04",
"currency": "ZAR",
"reference": "THEKISO-2026-08-04",
"totals": { "gross": "12500.00", "vat": "1630.43" },
"tenders": [
{ "method": "cash", "gross": "3000.00" },
{ "method": "card", "gross": "8000.00" },
{ "method": "eft", "gross": "1500.00" }
]
}'Fields
| Field | Type | Required | Notes |
|---|---|---|---|
business_date | YYYY-MM-DD | yes | The trading day in the merchant's own timezone — see below. Not the upload date. |
currency | ZAR | BWP | yes | Must match the profile. We never convert. |
totals.gross | decimal string | yes | VAT-inclusive takings for the whole day. |
totals.vat | decimal string | no | Output VAT contained in totals.gross. Default "0". |
tenders[].method | see Rule 3 | yes | How the money arrived — or that it didn't. |
tenders[].gross | decimal string | yes | VAT-inclusive takings for that method. |
tenders[].vat | decimal string | no | Only if you split VAT per tender yourself. |
reference | string | no | Your own id for this day, echoed back. |
Send amounts as strings, not floats. 12500.00, not 12500.0 — a float cannot represent every cent exactly, and these numbers end up on a tax return.
business_date is the merchant's day, and we never reinterpret it
Whatever date you send is the date the sale is booked to. Ledgr does not convert it to UTC, to our timezone, or to anything else. Build the day in the merchant's local timezone — a sale rung up at 23:30 in Johannesburg belongs to that date, and that is exactly what you should send.
The only clock check we apply is a sanity one: a date more than one day ahead of our own is refused as a typo. That day of slack exists precisely so a till closing late in Gaborone or Johannesburg is never rejected for being "in the future".
Closed days, and zero
Send nothing for a day the shop was closed. There is no need to report a zero day, and no benefit to it.
A day whose tenders are all zero is refused (422), not recorded — so a zero day cannot silently reverse a real one that was already sent. If you need to take a day back out, use DELETE (§8), which is explicit about it.
How far back you may go
There is no backfill limit: you may load a merchant's full history at onboarding — a year, or several. Days land on the dates you give them, and every report and tax figure recomputes from the journal, so a backfilled year shows up in the right year.
There is, however, a closed-period rule, and it is not negotiable. If the merchant's accountant has closed the books through a date, any day on or before that date is refused (409) with the closing date, who closed them, and when. Your key cannot reopen a period — that is a decision for the person who closed it, made inside the merchant's own account, on the record. Once they reopen it, the same request succeeds unchanged, so hold the day and retry rather than dropping it.
This is the same rule the merchant themselves faces in the app. An earlier version of this page said there was no closed-period rule; that was true and it was a defect, because it meant an integration key could change figures behind a return the merchant had already filed. If a backfill lands in an OPEN period the merchant has already filed a return for, the figures they submitted will still change — so agree any deep backfill with them first. And if they already imported bank statements covering the same period, expect the duplicate review described in Rule 3.
Success
{
"id": "a91f...",
"business_date": "2026-08-04",
"version": 1,
"status": "active",
"unchanged": false,
"replaced_version": null,
"reference": "THEKISO-2026-08-04",
"currency": "ZAR",
"totals": { "gross": "12500.00", "vat": "1630.43" },
"tenders": [
{ "method": "cash", "gross": "3000.00", "vat": "391.30" },
{ "method": "card", "gross": "8000.00", "vat": "1043.48" },
{ "method": "eft", "gross": "1500.00", "vat": "195.65" }
],
"recorded_at": "2026-08-05T04:11:22"
}5. The three rules that will decide whether your integration is correct
Rule 1 — The parts must equal the whole, to the cent
tenders[].gross must sum exactly to totals.gross. If it doesn't, the day is refused with 422 and nothing is recorded.
This is deliberate and it is not negotiable. An integration that silently accepted a day whose parts didn't equal its whole would book a wrong turnover figure, and nobody would find out until SARS or BURS asked. Refusing it costs you ten minutes today. Accepting it costs a merchant a corrected return.
{
"detail": {
"message": "The tender lines add up to 9500.00, but the day's total says 10000.00. They must agree to the cent before this can be recorded.",
"field": "totals.gross"
}
}Rule 2 — Sending the same day twice is safe. Sending it differently corrects it.
The natural key is (your key, business_date). There is no "create" versus "update" — there is only "here is that day".
| What you send | What happens | Response |
|---|---|---|
| A new day | Recorded | unchanged: false, version: 1 |
| The identical day again | Nothing. Not reposted. | unchanged: true, same version |
| The same day with different figures | The old one is reversed out and the new one posted | unchanged: false, version: 2, replaced_version: 1 |
So: retry freely. A timeout, a duplicate cron run, a queue that redelivers — none of them can double a merchant's revenue. You do not need to track whether your last send succeeded. Just send the day again.
reference is deliberately excluded from the sameness check. Re-sending identical figures under a new reference is still the same day, and reposting it for the sake of a label would double the merchant's turnover.
Rule 3 — Tag the tender honestly
method is not decoration. It decides the other side of the accounting entry, and it is how Ledgr avoids counting the same money twice when the merchant later imports their bank statement.
Report every sale. Revenue is earned when the goods leave the shop, whatever the customer handed over. Never drop a tender because no cash moved — that understates what the shop made.
method | What it means | Where it lands |
|---|---|---|
cash | Notes and coins | Cash on hand |
card | Card machine | Bank |
eft | Transfer, instant EFT, payment link | Bank |
other | Anything else that reaches the bank | Bank |
gift_card | A gift card being spent | Reduces what the shop owes its customers |
store_credit | Credit from an earlier return, being spent | Same |
on_account | Sold on credit, to be paid later | Money owed to the shop |
The last three are revenue with no money arriving today. A gift card was paid for months ago; an account sale will be paid for later. Ledgr books the sale and does not pretend a deposit happened, so nothing appears on a bank statement to match — and nothing needs to.
Loyalty points are not a tender. They are a discount. Reduce totals.gross by the discount and do not report a line for them. A shop that gives away R50 of goods for points made R50 less, not R50 of something else.
Refunds: report the day net of refunds raised that day. If refunds exceed takings, send the day without that tender rather than a negative — a negative tender is refused.
If you report card takings as cash, the merchant's card settlement gets counted twice and their turnover is overstated. If you report cash as card, a real deposit gets silently swallowed and their turnover is understated. If you report a gift card as other, Ledgr waits forever for a deposit that will never come. Send what actually happened.
If other never reaches the bank
other is a catch-all for mobile money, SnapScan, Zapper and payment links, and those settle differently — some nightly, some instantly, some into a wallet that is not the merchant's bank account at all.
Ledgr does not require a matching deposit for other; it only permits one. An other day with no corresponding bank line simply stays unmatched, which is correct and harmless. But if a tender genuinely never touches the merchant's bank — a wallet balance they draw down later — treat it like a gift card: it is money held elsewhere, and on_account is usually the closest honest fit until we add a wallet tender. Tell us if you hit this; it is the next thing we would add.
Known limit, stated plainly. Card settlements usually arrive at the bank batched and net of the acquirer's fee, a day or two later. Ledgr matches a card day to a deposit of the same amount within four days, so it catches the clean case and not the netted one. Where the settlement is net of fees, the merchant reviews it during the statement import like any other line. We would rather tell you this than let you assume it is handled.
6. VAT
Two ways to send it. Pick one.
Day-level (recommended). Send totals.vat and let Ledgr apportion it across the tenders in proportion to their takings. Rounding residuals are given to the largest tender so the parts always sum to the whole exactly.
Per tender. Send vat on every tender line. They must add up to totals.vat, or the day is refused.
If the profile is not registered for VAT in Ledgr and you send VAT above zero, the day is refused with 422. We do not quietly drop it: an unregistered person charging VAT would be inventing a liability that doesn't exist, and the two systems now disagree about something a person needs to fix.
7. Read a day back: GET /v1/sales/days/{business_date}
curl https://ledgr.mobi/api/v1/sales/days/2026-08-04 \
-H "X-Api-Key: lgk_live_..."Returns the currently active version, in the same shape as a POST response, or 404 if nothing is recorded. Use it to reconcile your figures against Ledgr's without writing anything.
8. Withdraw a day: DELETE /v1/sales/days/{business_date}
curl -X DELETE https://ledgr.mobi/api/v1/sales/days/2026-08-04 \
-H "X-Api-Key: lgk_live_..."{ "business_date": "2026-08-04", "status": "reversed", "entries_reversed": 3 }This is a reversal, not a deletion. The original entries stay in the journal with contra-entries against them, because these are books and a book that can be quietly edited is not evidence of anything. The net effect on the merchant's income is zero.
A withdrawn date is not closed. Send it again and it is recorded normally — the version counter continues rather than restarting, so the merchant's history still shows that something stood there and was taken away. Withdraw, correct, re-send is a supported recovery path, not an edge case.
A test key has nothing to withdraw and gets 404; it cannot reverse a day a live key recorded.
9. Errors
| Status | Meaning | What to do |
|---|---|---|
401 | Missing, wrong or revoked key | Ask the merchant to re-issue and re-enter it. Do not retry. |
402 | The merchant's Ledgr subscription has ended | Stop sending. Tell them to renew. Retry daily at most. |
409 | The profile the key pointed at no longer exists | Ask the merchant for a new key. Do not retry. |
422 + field: "currency" | The day's currency is not the profile's | Catch it at connect time from /v1/ping, not on the first push. |
422 | The day itself is wrong | Do not retry unchanged. Read detail.field, fix, resend. |
5xx | Our problem | Retry with backoff. Retries are safe (Rule 2). |
There is no 404 on POST — a day you have never sent is simply created.
Every 422 has the same shape. message is written to be shown to a merchant as-is; field tells you which of your own inputs to point them at.
{
"detail": {
"message": "The tender lines add up to 9500.00, but the day's total says 10000.00. They must agree to the cent before this can be recorded.",
"field": "totals.gross"
}
}Every field value you can receive:
field | Raised when |
|---|---|
business_date | More than one day ahead of our clock |
currency | Does not match the profile's currency |
totals.gross | Tenders do not sum to it, or it is not a positive amount |
totals.vat | Per-tender VAT disagrees with it, it exceeds the takings, or the profile is not VAT-registered |
tenders | Missing, empty, or every line is zero |
tenders.method | Not one of the values in accepts |
tenders.<method>.gross | Negative or unparseable |
tenders.<method>.vat | Negative, unparseable, or not less than that tender's gross |
name | Only on key creation, not on this endpoint |
field may be null when the refusal is not about one input.
10. Suggested integration flow
1. Merchant enables the Ledgr connection in your product and pastes their key. 2. You call GET /v1/ping and show them the profile, the stream and the currency. This is the whole confirmation step — it is the only moment anybody can catch a key pointed at the wrong set of books or the wrong gig, and it costs you one screen. 3. On day-close (or a nightly job), you POST /v1/sales/days for that date. 4. If you get 422, surface detail.message to the merchant — it is written for them, not for you. 5. When a past day changes for any reason, just send that day again. No diffing, no bookkeeping on your side. 6. Re-send the last 3–7 days on every run. It is free (Rule 2) and it repairs any day your job missed while a network was down.
Point 6 is the single most useful thing you can do. It turns a fragile nightly push into a self-healing one, and it costs nothing.
Rate limits, and how to be a good citizen at scale
There is no enforced rate limit today, and we would rather tell you that than let you discover it. It is not a promise — if we add one we will document it here and answer 429 rather than failing silently.
If you run a nightly job for many merchants, the re-send-the-last-week advice above multiplies: a hundred merchants is roughly seven hundred requests. That is fine, but please:
- Spread it. Stagger merchants across your window rather than firing everything at 02:00. A few requests per second is plenty.
- Keep it sequential per merchant. Days for one key are cheap and ordered; there is nothing to gain from parallelising within a merchant.
- Back off on
5xx, exponentially. Retries are safe (Rule 2), so backing off costs you nothing.
If you are planning something much larger than that, tell us first and we will tell you honestly what the server will take.
11. Worked example — a full nightly job
import httpx
from decimal import Decimal
LEDGR = "https://ledgr.mobi/api"
def push_day(api_key: str, day, tenders: dict[str, Decimal], vat: Decimal):
"""tenders: {"cash": Decimal("3000.00"), "card": Decimal("8000.00"), ...}"""
lines = [{"method": m, "gross": f"{v:.2f}"} for m, v in tenders.items() if v > 0]
gross = sum(tenders.values())
r = httpx.post(
f"{LEDGR}/v1/sales/days",
headers={"X-Api-Key": api_key},
json={
"business_date": day.isoformat(),
"currency": "ZAR",
"reference": f"POS-{day.isoformat()}",
"totals": {"gross": f"{gross:.2f}", "vat": f"{vat:.2f}"},
"tenders": lines,
},
timeout=30,
)
if r.status_code == 422:
# Never retry this unchanged — the figures are wrong, not the network.
raise ValueError(r.json()["detail"]["message"])
r.raise_for_status()
return r.json()
# Re-send the last week every night. Identical days cost nothing and any day
# missed during an outage repairs itself.
for offset in range(7):
day = date.today() - timedelta(days=offset + 1)
push_day(key, day, totals_for(day), vat_for(day))12. What this API deliberately does not do
Said plainly so you don't design around something that isn't there:
- No per-transaction feed. Days only. See §1.
- No wallet tender yet. If a tender settles into a wallet the merchant draws down later rather than their bank, say so — see Rule 3.
- No expenses or purchases. Income only, for now.
- No customer or product records. Ledgr keeps books, not a CRM.
- No reading the merchant's books. You can read back what you sent (§7) and nothing else. Your key cannot see their bank imports, invoices, or tax position.
- No webhooks out. You push; we don't call you back.
If you need one of these, say so — the list is a scope decision, not a limitation of the design.
13. Support
Integration questions: support@ledgr.mobi. Include the reference you sent and the business_date; that is enough for us to find the day without asking a merchant for anything.