{"info":{"title":"Ledgr API","version":"v1","baseUrl":"","description":"Every endpoint Ledgr serves — the same API the Ledgr apps use. There is no reduced \"integration\" surface. Sign in with POST /auth/login for a JWT, or exchange an API key at POST /auth/token. Send X-Company-Id to act within one company of a group. Three limits apply together and the narrowest wins: the tenant's plan, the key's scopes, and the role of the user the key belongs to."},"categories":[{"name":"Authentication","blurb":"Every call carries one Authorization: Bearer header. A person signs in; an integration exchanges an API key. Nothing needs a customer's password.","endpoints":[{"method":"POST","path":"/auth/register","summary":"Create a tenant and its first user. Returns a JWT.","auth":"none","body":{"tenantName":"Cape Fine Foods","email":"andy@example.co.za","password":"2e1167ef9d17bf3d4bb558dc6a88e9b822d298a8837447f6d9ac3de7e592d909","firstName":"Andy","lastName":"Cameron","licenseCode":""},"responseExample":{"token":"<jwt>","userId":"<uuid>","tenantId":"<uuid>","email":"you@example.co.za","tenantName":"Your Business"}},{"method":"POST","path":"/auth/login","summary":"Exchange email and password for a JWT. A TOTP code and a recovery code go in the same field — they are told apart by shape, six digits against Crockford Base32 ABCDE-FGHJK.","auth":"none","body":{"email":"you@example.co.za","password":"your-password","totpCode":"123456"},"responseExample":{"token":"<jwt>","refreshToken":"<refresh-token>","userId":"<uuid>","tenantId":"<uuid>","email":"you@example.co.za","tenantName":"Your Business"}},{"method":"POST","path":"/auth/refresh","summary":"Exchange a refresh token for a new access token.","auth":"none","body":{"refreshToken":"<refresh-token>"}},{"method":"POST","path":"/auth/logout","summary":"Revoke the current session. The token stops working on the next request rather than when it expires.","auth":"jwt"},{"method":"GET","path":"/auth/me","summary":"The signed-in user, their tenant, their roles and the company they are acting in.","auth":"jwt"},{"method":"POST","path":"/auth/forgot-password","summary":"Send a password-reset email. Answers identically whether or not the address exists, so it cannot be probed for.","auth":"none","body":{"email":"you@example.co.za"}},{"method":"POST","path":"/auth/reset-password","summary":"Set a new password from the token in the reset email.","auth":"none","body":{"token":"<from the emailed link>","newPassword":"2e1167ef9d17bf3d4bb558dc6a88e9b822d298a8837447f6d9ac3de7e592d909"}},{"method":"POST","path":"/auth/validate-license","summary":"Check a licence key without redeeming it — the plan it carries, its seats and its expiry.","auth":"none","body":{"licenseCode":"SKU-1001"}},{"method":"POST","path":"/auth/token","summary":"Exchange an API key for a one-hour access token carrying that key's scopes. This is how an integration authenticates; it never needs a user's password.","auth":"api-key","responseExample":{"accessToken":"<jwt>","tokenType":"Bearer","expiresIn":3600,"scope":"invoices:write contacts:read","tenantId":"<uuid>","rateLimitPerMinute":120}},{"method":"GET","path":"/api-scopes","summary":"Every scope an API key can hold, with its label. Unauthenticated, so tooling can render a scope picker without a key.","auth":"none"},{"method":"GET","path":"/reset-password","summary":"The password-reset page the emailed link opens. HTML, not JSON.","auth":"none"}]},{"name":"Two-factor authentication","blurb":"TOTP with ten single-use recovery codes. Recovery is checked before the encrypted seed is touched, so a rotated LEDGR_SECRET_KEY is no longer a lockout.","endpoints":[{"method":"GET","path":"/api/2fa/me","summary":"Whether this user is enrolled, when they enrolled, and how many recovery codes are unused.","auth":"jwt"},{"method":"POST","path":"/api/2fa/enroll","summary":"Start enrolment. Returns the TOTP secret, the otpauth:// URI and ten recovery codes — the codes are shown ONCE and stored hashed, so no endpoint can ever show them again.","auth":"jwt"},{"method":"POST","path":"/api/2fa/verify","summary":"Confirm enrolment with a code from the authenticator app.","auth":"jwt","body":{"code":"123456"}},{"method":"DELETE","path":"/api/2fa/me","summary":"Disenrol, using the user's own current code.","auth":"jwt"},{"method":"POST","path":"/api/2fa/recovery-codes","summary":"Mint a fresh set of ten recovery codes. Every previously issued code stops working.","auth":"jwt"},{"method":"POST","path":"/api/users/{id}/2fa/reset","summary":"Clear ANOTHER user's 2FA after a lost phone. Needs roles:edit plus an owner or admin role, refuses id == self, revokes the target's sessions, audits at warning and emails them.","auth":"jwt"}]},{"name":"Tenant, plan & entitlements","blurb":"What this tenant has bought, what it is using, and the guided-setup checklist the dashboard draws.","endpoints":[{"method":"GET","path":"/api/tenant/entitlements","summary":"The plan, its modules, seat and storage allowances, live usage against them, and the licence's expiry. The client calls this at app start and from its nav guard.","auth":"jwt"},{"method":"POST","path":"/api/tenant/apply-license","summary":"Redeem a licence key against this tenant. A dated key creates a subscription with auto_renew off, so it announces its expiry rather than lapsing in silence.","auth":"jwt","body":{"licenseCode":"SKU-1001"}},{"method":"POST","path":"/api/tenant/addons","summary":"Buy or resize an add-on. It takes effect immediately and settles on the next renewal — the same contract the storage meter and the payroll band already have. Needs roles:edit, because it changes what the tenant pays. Quantity is at least 1: removing an add-on is a DELETE, since a zero-quantity holding would grant nothing and still bill. Answers with the whole entitlements document, so the caller never re-reads it.","auth":"jwt","body":{"addonId":"logistics","quantity":1}},{"method":"DELETE","path":"/api/tenant/addons/{addonId}","summary":"Stop an add-on. Cancelled rather than deleted: a holding kept for four months of a twelve-month term is part of explaining that term's invoice, and a row that is gone cannot explain a charge the customer is looking at. Answers with the updated entitlements.","auth":"jwt"},{"method":"GET","path":"/api/plans","summary":"The price list as the server holds it — every plan, its price, its seats and its modules. Prices exclude VAT.","auth":"jwt"},{"method":"GET","path":"/api/tenant-settings","summary":"The Settings screen's stored blob — company details, fiscal year, VAT profile, regional and notification preferences.","auth":"jwt"},{"method":"PUT","path":"/api/tenant-settings","summary":"Replace the settings blob. This REPLACES every section — send the whole object, not just the part you are changing. GET first.","auth":"jwt","body":{"taxVat":{"isVatRegistered":true,"vatRegistrationDate":"2019-04-01","standardVatRate":15.0,"drcEnabled":false,"provisionalTaxPayer":true,"sarsTaxRefNumber":"9012345678"},"branding":{"brandColorHex":"#0D47A1"},"localisation":{"currency":"ZAR","locale":"en-ZA","dateFormat":"DD/MM/YYYY","timeZone":"Africa/Johannesburg","numberFormat":"1 234,56","firstDayOfWeek":"Monday"},"integrations":{"whatsapp":true},"notifications":{"email-overdue-invoice":true,"wa-overdue-followup":false},"dataPrefs":{"autoBackupEnabled":false,"autoBackupFrequency":"Daily","retentionMonths":84}}},{"method":"GET","path":"/api/setup-progress","summary":"The dashboard checklist. Four of the five steps are detected live from the books rather than trusted from a flag; completion is per tenant, hiding is per user.","auth":"jwt"},{"method":"POST","path":"/api/setup-progress/steps","summary":"Mark the one step nothing can detect — that a migration from the previous system has been done.","auth":"jwt","body":{"stepKey":"Example","complete":true}},{"method":"POST","path":"/api/setup-progress/hidden","summary":"Hide the checklist for the calling user only. One colleague cannot dismiss it for another.","auth":"jwt","body":{"hidden":true}},{"method":"POST","path":"/api/data/export","summary":"A ZIP of CSVs — every table this tenant owns. The export is what the retention notices lead with, because the Tax Administration Act asks a business to keep records for five years.","auth":"jwt"}]},{"name":"Billing & subscription","blurb":"Ledgr's own billing. Yoco cannot charge a stored card, so a renewal is a fresh hosted checkout and an emailed link — never write copy implying a card is on file.","endpoints":[{"method":"POST","path":"/api/billing/checkout","summary":"Start a purchase or an upgrade. Returns a Yoco hosted-checkout URL to redirect the buyer to.","auth":"jwt","body":{"planId":"business","termMonths":12}},{"method":"GET","path":"/api/billing/orders","summary":"This tenant's licence orders, paid and outstanding.","auth":"jwt"},{"method":"GET","path":"/api/billing/orders/{id}","summary":"One order — its amount, its state and its checkout link if it is still payable.","auth":"jwt"},{"method":"GET","path":"/api/billing/subscription","summary":"The subscription: plan, term, current period end, whether it auto-renews, and the retention date if it has ended.","auth":"jwt"},{"method":"POST","path":"/api/billing/subscription/auto-renew","summary":"Turn renewal on or off. One endpoint for both directions, because two would let them disagree. Cancelling means stop renewing — no refund and no revocation.","auth":"jwt","body":{"autoRenew":false}},{"method":"POST","path":"/api/billing/subscription/pay-now","summary":"Settle the outstanding renewal early. A renewal EXTENDS from the existing expiry, so paying early never costs days.","auth":"jwt"},{"method":"POST","path":"/billing/public-checkout","summary":"Buy a licence before signing up. Mints an order and a Yoco checkout without a tenant existing yet.","auth":"none","body":{"planId":"starter","termMonths":1,"email":"you@example.co.za"}},{"method":"GET","path":"/billing/orders/{ref}","summary":"The public state of a pre-signup order, by its reference. What the buyer's return page polls.","auth":"none"},{"method":"GET","path":"/billing/success","summary":"The buyer's return page after a successful Yoco checkout. HTML.","auth":"none"},{"method":"GET","path":"/billing/cancelled","summary":"The buyer's return page after an abandoned checkout. HTML.","auth":"none"}]},{"name":"Users, roles & security","blurb":"Eight system roles, six actions per module, and a POPIA-retained audit log. A role decides what a person may do; the plan decides what the tenant bought.","endpoints":[{"method":"GET","path":"/api/users","summary":"Tenant users with their roles, 2FA state, session count and company access.","auth":"jwt"},{"method":"POST","path":"/api/users/invite","summary":"Invite a colleague, choosing their role and which companies they may reach.","auth":"jwt","body":{"email":"andy@example.co.za","firstName":"Example","lastName":"Example","role":"Example","roleId":"<uuid>"}},{"method":"PUT","path":"/api/users/{id}","summary":"Change a user's name, role, active flag or company access.","auth":"jwt","body":{"roleId":"<uuid>","isActive":true}},{"method":"DELETE","path":"/api/users/{id}","summary":"Deactivate a user and revoke their sessions.","auth":"jwt"},{"method":"POST","path":"/api/users/{id}/revoke-sessions","summary":"Sign a user out everywhere without deactivating them.","auth":"jwt"},{"method":"POST","path":"/api/users/{id}/transfer-ownership","summary":"Hand the tenant's single owner seat to another user. Gated harder than ordinary user management on purpose: `roles:edit` alone is not enough, because that permission is grantable to a custom role and giving away the god-mode seat is a different order of act from renaming one — only the current owner/admin may call it, and never on themselves. One transaction demotes the outgoing owner and promotes the target together, so the account is never briefly left with none or with two.","auth":"jwt","body":{}},{"method":"GET","path":"/api/user-sessions","summary":"Live sessions across the tenant — device, address and last seen.","auth":"jwt"},{"method":"GET","path":"/api/roles","summary":"System and custom roles. The eight that ship are Owner, Admin, Accountant, Sales, Viewer, Payroll Manager, Tax Practitioner and External Auditor, plus Buyer, Production Planner and Fleet Controller where the plan carries them.","auth":"jwt"},{"method":"POST","path":"/api/roles","summary":"Create a custom role.","auth":"jwt","body":{"name":"Branch manager","description":"Sells and banks, cannot see payroll"}},{"method":"PUT","path":"/api/roles/{id}","summary":"Rename or re-describe a role.","auth":"jwt","body":{"name":"Branch manager"}},{"method":"DELETE","path":"/api/roles/{id}","summary":"Delete a custom role. A system role cannot be deleted.","auth":"jwt"},{"method":"GET","path":"/api/roles/{id}/permissions","summary":"The full permission matrix for one role — every module against view, create, edit, delete, approve and export.","auth":"jwt"},{"method":"PUT","path":"/api/roles/{id}/permissions","summary":"Replace a role's permission matrix.","auth":"jwt","body":{"permissions":[{"resource":"Example","action":"Example"}]}},{"method":"GET","path":"/api/roles/my-permissions","summary":"What the calling user may do, resolved. The client's nav guard reads this rather than inferring it from a role name.","auth":"jwt"},{"method":"GET","path":"/api/audit-log","summary":"Audit events with severity filtering. Retained seven years for POPIA.","auth":"jwt"},{"method":"GET","path":"/api/security-policy","summary":"Session timeout, password policy, lockout rules, whether admins must hold 2FA, and the retention period.","auth":"jwt"},{"method":"PUT","path":"/api/security-policy","summary":"Change the security policy. Requiring 2FA for admins is enforced server-side, not advised to the client.","auth":"jwt","body":{"sessionTimeoutMins":60,"require2faForAdmins":true,"maxFailedLogins":5}}]},{"name":"API keys","blurb":"A key is a ceiling, never a grant: it can do nothing its owning user cannot. Created here, exchanged for a token at POST /auth/token.","endpoints":[{"method":"GET","path":"/api/api-keys","summary":"Keys for this tenant — name, scopes, bound contact, last use. The secret is never returned again.","auth":"jwt"},{"method":"POST","path":"/api/api-keys","summary":"Generate a key. The plaintext is shown ONCE in this response. Bind it to a contact if it will post billable events for one.","auth":"jwt","body":{"name":"Warehouse sync","scopes":["inventory:read","inventory:write"],"contactId":null},"responseExample":{"id":"<uuid>","name":"Warehouse sync","key":"ledgr_live_… (shown once)","scopes":["inventory:read","inventory:write"],"rateLimitPerMinute":120}},{"method":"DELETE","path":"/api/api-keys/{id}","summary":"Revoke a key. It stops working on the next request, not when its last token expires.","auth":"jwt"},{"method":"GET","path":"/api/api-keys/usage","summary":"Per-key call counts and throttling. Metering is per replica, so a tenant's effective limit is N times the published one — see the KDoc on ApiKeyMetering.","auth":"jwt"}]},{"name":"Files & attachments","blurb":"Uploaded bytes live in Cloud Storage, never in Postgres. Generated documents — invoices, payslips, returns — are computed on demand and never stored.","endpoints":[{"method":"GET","path":"/api/files/{id}","summary":"Returns a short-lived signed URL as JSON, deliberately NOT a 302: a redirect would carry your Ledgr token into Google's request logs, because HTTP clients re-send headers across a redirect. A miss answers 404, not 403, so an id cannot be probed for.","auth":"jwt","responseExample":{"url":"https://storage.googleapis.com/…?X-Goog-Signature=…","expiresInSeconds":900,"contentType":"image/jpeg","sizeBytes":184320}}]},{"name":"Search, insights & deadlines","blurb":"The three things the dashboard chrome reads. Each gates itself on the tenant's plan and the caller's role rather than trusting the nav to hide it.","endpoints":[{"method":"GET","path":"/api/search?q=acme","summary":"Cross-module global search behind the top bar. Each section is gated separately on plan modules and RBAC, so results never name a record the caller may not open.","auth":"jwt"},{"method":"GET","path":"/api/insights","summary":"Dashboard insights across growth, collections, cash flow, tax and expenses.","auth":"jwt"},{"method":"POST","path":"/api/insights/dismiss","summary":"Dismiss an insight so the carousel stops offering it.","auth":"jwt","body":{"id":"<uuid>"}},{"method":"GET","path":"/api/tax-deadlines","summary":"The SARS calendar for this tenant, gated by its VAT category, employer registration and fiscal year end — so a non-employer is not chased for an EMP201.","auth":"jwt"}]},{"name":"Push notifications","blurb":"Where a server-raised notification is delivered. Every route here is scoped to the caller's own tenant and user — there is no id parameter — so registering, listing and revoking act only on the device in the caller's hand. The four topics and their switches are the same ones the app shows in Settings; a deployment with no Firebase project answers serverConfigured=false and every send is a no-op rather than a silent failure.","endpoints":[{"method":"GET","path":"/api/push/status","summary":"Whether this deployment can send at all, and which devices this user has registered. Both halves matter: a configured server with no device sends nothing, and a device on an unconfigured server receives nothing.","auth":"jwt"},{"method":"POST","path":"/api/push/tokens","summary":"Register this install's FCM token. Idempotent — call it at every sign-in and on every token rotation; the same token for the same user just touches last_seen_at.","auth":"jwt","body":{"token":"<fcm-registration-token>","platform":"android","deviceId":"dev-<uuid>","label":"Andy's phone"}},{"method":"DELETE","path":"/api/push/tokens","summary":"Stop sending to this device. Both fields are optional: deviceId alone is enough when the token is no longer to hand, and neither retires every token this user holds. A sign-out does NOT need this — the server retires the device's tokens from /auth/logout.","auth":"jwt","body":{"token":"<fcm-registration-token>","deviceId":"dev-<uuid>"}},{"method":"POST","path":"/api/push/test","summary":"Send a real push to the caller's own devices and report what happened — the only honest answer to \"is push working\". Goes through the same sender a payment notification does and deliberately ignores the topic switches, so a failure tells you it is broken rather than switched off.","auth":"jwt"}]},{"name":"Contacts","blurb":"Accounts receivable and the address book. Every figure here describes money already invoiced — the pre-invoice half is Sales CRM.","endpoints":[{"method":"GET","path":"/api/contacts?type=CUSTOMER","summary":"List contacts. Filter by ?type=CUSTOMER|SUPPLIER|EMPLOYEE|TAX_PRACTITIONER.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:read"},{"method":"POST","path":"/api/contacts","summary":"Create a contact.","auth":"jwt","body":{"contactType":"customer","name":"Example","email":"billing@acme.co.za","phone":"+27210000000","vatNumber":"4123456789","addressLine1":"123 Long Street","city":"Cape Town","province":"Western Cape","creditLimit":50000.0,"paymentTerms":30},"responseExample":{"id":"<uuid>","name":"Acme Pty Ltd","type":"CUSTOMER","balance":0.0,"creditLimit":50000.0,"createdAt":"2026-08-22T09:00:00Z"},"module":"contacts","plan":"Free","scope":"contacts:write"},{"method":"GET","path":"/api/contacts/{id}","summary":"One contact with its full detail grid and credit position.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:read"},{"method":"PUT","path":"/api/contacts/{id}","summary":"Update a contact in place.","auth":"jwt","body":{"name":"Acme Pty Ltd","email":"newbilling@acme.co.za","creditLimit":75000.0},"module":"contacts","plan":"Free","scope":"contacts:write"},{"method":"DELETE","path":"/api/contacts/{id}","summary":"Soft-delete a contact.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:write"},{"method":"PATCH","path":"/api/contacts/{id}/tags","summary":"Set the badges — VIP, At Risk, NPO, B-BBEE level.","auth":"jwt","body":{"tags":["VIP","BBBEE-2"]},"module":"contacts","plan":"Free","scope":"contacts:write"},{"method":"POST","path":"/api/contacts/{id}/merge","summary":"Merge a duplicate into this contact. Re-points invoices, quotes, activities and communications rather than copying them.","auth":"jwt","body":{"survivorId":"<uuid>"},"module":"contacts","plan":"Free","scope":"contacts:write"},{"method":"GET","path":"/api/contacts/{id}/invoices","summary":"Every document raised against this contact.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:read"},{"method":"GET","path":"/api/contacts/{id}/communications","summary":"The communication timeline — emails, WhatsApps, statements sent, plus CRM activities merged in server-side.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:read"},{"method":"POST","path":"/api/contacts/{id}/communications","summary":"Log a communication by hand.","auth":"jwt","body":{"channel":"phone","subject":"Chased the March invoice","body":"Promised payment Friday."},"module":"contacts","plan":"Free","scope":"contacts:write"},{"method":"GET","path":"/api/contacts/{id}/statement.pdf","summary":"The customer statement as a PDF.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:read"},{"method":"POST","path":"/api/contacts/{id}/send-statement","summary":"Send the statement by email or WhatsApp.","auth":"jwt","body":{"channel":"email","asOf":"2026-08-31"},"module":"contacts","plan":"Free","scope":"contacts:write"},{"method":"GET","path":"/api/contacts/aged-debtors","summary":"Aged debtors by bucket — current, 30, 60, 90 and over.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:read"},{"method":"GET","path":"/api/contacts/{id}/portal-link","summary":"The customer's self-service portal link. The token travels in the URL path, which is why this is minted rather than guessable.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:read"},{"method":"POST","path":"/api/contacts/{id}/portal-link/revoke","summary":"Retire whatever portal link this contact is holding, by bumping their token generation.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:write"},{"method":"GET","path":"/api/contacts/{id}/supplier-portal","summary":"Whether this supplier is opted in to the supplier portal, and when they last signed in.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:read"},{"method":"POST","path":"/api/contacts/{id}/supplier-portal","summary":"Opt a supplier in and mint their portal credential. Returned once; issuing a new one retires the old.","auth":"jwt","body":{"enabled":true},"module":"contacts","plan":"Free","scope":"contacts:write"},{"method":"GET","path":"/api/contacts/{contactId}/transactions","summary":"Billable events posted against this contact by an external system, before they are invoiced.","auth":"jwt","module":"contacts","plan":"Free","scope":"contacts:read"},{"method":"POST","path":"/api/contacts/{contactId}/transactions/invoice","summary":"Roll this contact's pending billable events into a draft invoice.","auth":"jwt","body":{"from":"2026-08-01"},"module":"contacts","plan":"Free","scope":"contacts:write"}]},{"name":"Invoicing & quotes","blurb":"Nine document types through one engine. There is ONE layout — preview, download, email and portal all render the same HTML — and a template is a name and an accent colour.","endpoints":[{"method":"GET","path":"/api/invoices?limit=50&page=1&status=sent","summary":"List documents. The reference paginated endpoint: SQL paging plus five child collections batch-loaded by inList.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"POST","path":"/api/invoices","summary":"Create any of the nine document types — invoice, quote, proforma, deposit, credit, debit, delivery, purchase_order, receipt. Send a client-minted id to make the write idempotent for offline replay.","auth":"jwt","body":{"contactId":"<uuid>","documentType":"invoice","issueDate":"2026-08-10","dueDate":"2026-09-10","currency":"ZAR","terms":"30","notes":"Thanks for your business.","lines":[{"description":"Mobile app development","kind":"service","quantity":1.0,"unitPrice":45000.0,"discountRate":0.0,"vatRate":15.0}]},"responseExample":{"id":"<uuid>","documentNumber":"INV-0042","documentType":"invoice","status":"draft","subtotal":45000.0,"vatTotal":6750.0,"total":51750.0,"balanceDue":51750.0},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/invoices/{id}","summary":"One document with its lines, payments, reminders and attachments.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"PUT","path":"/api/invoices/{id}","summary":"Update a document. Note it soft-deletes and re-inserts every line, so anything reading lines for a return must filter deletedAt.","auth":"jwt","body":{"status":"sent","notes":"Updated note."},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"DELETE","path":"/api/invoices/{id}","summary":"Void a document. Sets the status; nothing leaves history.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/invoices/summary","summary":"The KPI row — outstanding, overdue, paid this month, and drafts.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"POST","path":"/api/invoices/{id}/send","summary":"Mark sent and deliver by email or WhatsApp, with the PDF attached if the tenant's settings say so.","auth":"jwt","body":{"channel":"email"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"POST","path":"/api/invoices/{id}/payments","summary":"Record a payment. Moves the document to partial or paid and advances any CRM deal linked to it.","auth":"jwt","body":{"amount":51750.0,"method":"EFT","date":"2026-08-12","reference":"FNB 4472"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"POST","path":"/api/invoices/{id}/convert","summary":"Convert a quote or pro-forma into a tax invoice. Mints the number and posts to the ledger.","auth":"jwt","body":{"terms":"30"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"POST","path":"/api/invoices/{id}/apply-deposit","summary":"Apply a deposit invoice against the final invoice, releasing it from Deferred Revenue (2150).","auth":"jwt","body":{"invoiceId":"<uuid>"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"POST","path":"/api/invoices/{id}/reminders","summary":"Send a payment reminder now, outside the configured schedule.","auth":"jwt","body":{"channel":"email"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/invoices/{id}/payment-links","summary":"Payment links already minted for this document.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"POST","path":"/api/invoices/{id}/payment-links","summary":"Mint a Ledgr /pay/{linkId} URL that builds and signs a PayFast checkout at request time. PayFast only — Yoco is refused, because it has no server-side call to obtain a checkout.","auth":"jwt","body":{"gateway":"payfast"},"responseExample":{"linkId":"<uuid>","url":"https://ledgr.co.za/pay/<linkId>","gateway":"payfast","expiresAt":"2026-09-10T00:00:00Z"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"POST","path":"/api/invoices/pdf","summary":"Render a document to PDF server-side, from the same HTML the preview and the portal use.","auth":"jwt","body":{"docType":"invoice","docTitle":"Example","number":"Example","date":"2026-09-30","due":"Example","status":"sent","sender":{"name":"Example"},"client":{"name":"Example"},"lines":[{"description":"Captured during the August review","quantity":2.0,"unitPrice":1500.0,"lineTotal":1500.0}],"subtotal":1500.0,"vatTotal":15.0,"total":1500.0},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/invoices/number-leases","summary":"Document-number leases this device holds. V213 built these so a device can mint real series numbers offline; DocumentNumbering still uses its own local lease and never asks for one.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"POST","path":"/api/invoices/number-leases","summary":"Reserve a block of numbers in a series.","auth":"jwt","body":{"nodeId":"<uuid>"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"POST","path":"/api/invoices/number-leases/release","summary":"Give an unused block back so the numbers are not burned.","auth":"jwt","body":{"nodeId":"<uuid>"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/recurring","summary":"Recurring invoice schedules.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"POST","path":"/api/recurring","summary":"Create a recurring schedule from a template document.","auth":"jwt","body":{"contactId":"<uuid>","description":"Captured during the August review","amount":1500.0,"nextDate":"2026-09-30","frequency":"monthly"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/recurring/{id}","summary":"One schedule and its issue history.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"PUT","path":"/api/recurring/{id}","summary":"Change a schedule.","auth":"jwt","body":{"frequency":"quarterly"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"DELETE","path":"/api/recurring/{id}","summary":"Delete a schedule. Documents it has already raised are untouched.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"POST","path":"/api/recurring/{id}/toggle","summary":"Pause or resume a schedule.","auth":"jwt","body":{"active":false},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/invoice-templates","summary":"The tenant's templates plus the five presets. A template is a name and an accent colour — the layout is fixed and there is exactly one.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"POST","path":"/api/invoice-templates","summary":"Add a template. Only name and colour are accepted; the legacy layout_json, secondary_colour, show_logo, show_signature and footer_text columns are read by nothing and no longer accepted.","auth":"jwt","body":{"name":"Site works","primaryColour":"#00838F"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/invoice-templates/{id}","summary":"One template.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"PUT","path":"/api/invoice-templates/{id}","summary":"Rename or recolour a template.","auth":"jwt","body":{"name":"Site works","primaryColour":"#0D47A1"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"DELETE","path":"/api/invoice-templates/{id}","summary":"Remove a tenant template. The five presets cannot be removed.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/invoice-settings","summary":"The seven-tab settings centre — numbering, labels, field visibility, email templates, branding, defaults and auto-reconciliation.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"PUT","path":"/api/invoice-settings","summary":"Replace the invoice settings.","auth":"jwt","body":{"activeTemplateName":"Site works","show":{"dueDate":true,"contactName":true,"businessAddress":true,"vatNumber":true,"discountColumn":true,"paymentLinks":true,"associationLogo":true},"defaults":{"terms":"Payment within 30 days.","bankDetails":"","footer":"","notes":""},"autoRecon":{"enabled":true,"yoco":false,"payfast":true,"peach":false,"matchToleranceZar":2.0}},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/document-sequences","summary":"The prefix and next number for each of the nine document types.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"PUT","path":"/api/document-sequences","summary":"Change a prefix or a starting number. A number already issued is never reissued.","auth":"jwt","body":{"docType":"invoice","prefix":"REF-0042","nextNumber":1001},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/reminder-configs","summary":"The automated reminder schedule — how many days before and after due, and on which channel.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"PUT","path":"/api/reminder-configs","summary":"Change the reminder schedule.","auth":"jwt","body":{"enabled":true,"steps":[{"days":3,"email":true,"whatsapp":true},{"days":7,"email":true,"whatsapp":false},{"days":14,"email":true,"whatsapp":false}],"message":""},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/invoices/{id}/attachments","summary":"Files filed against a sales document (V290) — the customer's signed purchase order, a site drawing, a spec sheet. The bytes are not in the response: fetch each `attachmentId` through GET /api/files/{id}, which returns a signed URL as JSON.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"POST","path":"/api/invoices/{id}/attachments?filename=SignedPO-4417.pdf&contentType=application/pdf","summary":"Attach a file. RAW BYTES as the body, not multipart and not base64 — base64 inflates a 4 MB scan by a third on exactly the connection this product is designed for. Ten per document, 15 MB each, closed type list (see InvoiceAttachmentPolicy). Never sent to the customer.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"DELETE","path":"/api/invoices/{id}/attachments/{attachmentId}","summary":"Unlink a file from the document. The stored object is NOT destroyed — an invoicing screen is not where evidence behind a disputed invoice should be shreddable.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:write"}]},{"name":"Payments & gateways","blurb":"PayFast is the one gateway that can take money, and its merchant credentials are per tenant. Resolution runs company, then tenant default, then deployment env — and that last step is skipped unless PAYFAST_ALLOW_DEPLOYMENT_MERCHANT declares a single-business deployment.","endpoints":[{"method":"GET","path":"/api/payment-gateways","summary":"Which gateways can actually take a payment on this deployment. The single source of truth both sides read — the client greys the rest out rather than offering a dead link.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"GET","path":"/api/payment-gateways/payfast/credentials","summary":"The tenant's PayFast merchant details. The key is write-only and comes back only as a masked hint. Read is gated on invoices:view.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"PUT","path":"/api/payment-gateways/payfast/credentials","summary":"Store PayFast merchant credentials, encrypted with SecretBox. Gated on banking:edit, not invoices:edit — this decides which bank account receives the money.","auth":"jwt","body":{"merchantId":"10000100","merchantKey":"46f0cd694581a","passphrase":"jt7NOE43FZPn","companyScoped":true},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"DELETE","path":"/api/payment-gateways/payfast/credentials","summary":"Remove stored credentials. Payment links already minted stop resolving a merchant.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/payment-gateways/yoco/credentials","summary":"The tenant's Yoco account. The secret key is write-only and comes back only as a masked hint. Also returns the exact webhook URL to register in the Yoco portal, and what is still missing before a payment could be reported. Read is gated on invoices:view.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"PUT","path":"/api/payment-gateways/yoco/credentials","summary":"Store the tenant's Yoco secret key and webhook signing secret, encrypted with SecretBox. Gated on banking:edit, not invoices:edit — this decides which bank account receives the money. Omitting webhookSecret leaves any stored one alone, because the two halves arrive separately: Yoco only reveals the signing secret once the webhook is registered, and registering it needs the URL this endpoint returns.","auth":"jwt","body":{"secretKey":"sk_test_abcdefghij1234567890","webhookSecret":"whsec_abcdefghij1234567890","companyScoped":true},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"DELETE","path":"/api/payment-gateways/yoco/credentials","summary":"Remove the stored Yoco account. Payment links already minted stop resolving an account.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/api/webhook-events","summary":"The payments inbox — incoming gateway events with their match state, matched, review or unmatched.","auth":"jwt","module":"invoices","plan":"Free","scope":"invoices:read"},{"method":"POST","path":"/api/webhook-events/{id}/match","summary":"Match an unmatched payment to an invoice by hand.","auth":"jwt","body":{"invoiceNumber":"Example"},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"POST","path":"/api/webhook-events/simulate","summary":"Post a synthetic gateway event, for demos and for exercising the matching rules without a real payment.","auth":"jwt","body":{"gateway":"payfast","amount":51750.0},"module":"invoices","plan":"Free","scope":"invoices:write"},{"method":"GET","path":"/pay/{linkId}","summary":"The customer-facing pay page. Public by necessity — the person paying is not a Ledgr user — and guarded by the link id being an unguessable capability.","auth":"none"},{"method":"GET","path":"/pay/{linkId}/done","summary":"Where PayFast returns the payer after a successful checkout.","auth":"none"},{"method":"GET","path":"/pay/{linkId}/cancelled","summary":"Where PayFast returns the payer after an abandoned checkout.","auth":"none"}]},{"name":"Customer portal","blurb":"No JWT — a portal token scoped to one document, in the URL path. Renders with no JavaScript, because it has to survive any email client's browser handoff.","endpoints":[{"method":"GET","path":"/portal/invoice/{token}","summary":"The customer's copy of a document, with pay, accept and decline where they apply.","auth":"portal-token"},{"method":"GET","path":"/portal/invoice/{token}/pdf","summary":"The same document as a PDF.","auth":"portal-token"},{"method":"POST","path":"/portal/invoice/{token}/{decision}","summary":"Accept or decline a quote online. A form post with a 303 redirect; JSON is accepted too. Records decided_at, the typed name as the signature and a decline reason — deliberately NO IP and no user agent, both of which are personal information needing a purpose and a retention position of their own.","auth":"portal-token","body":{"name":"T. Nkosi","reason":"Went with another supplier"}},{"method":"GET","path":"/portal/contact/{token}","summary":"The customer's self-service view — their documents, their statement and their balance.","auth":"portal-token"}]},{"name":"Sales CRM","blurb":"The six weeks before an invoice exists. Deal value is NET OF VAT, because output VAT is SARS' money and a VAT-inclusive pipeline overstates expected revenue by 15% against an income statement stated excluding it.","endpoints":[{"method":"GET","path":"/api/crm/leads?limit=50&page=1","summary":"Leads, paginated. The client reports \"50 of 312\" because a list that silently stops at fifty while looking complete is the failure paging was meant to fix.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"POST","path":"/api/crm/leads","summary":"Capture a lead.","auth":"jwt","body":{"name":"Sipho Dlamini","companyName":"Dlamini Logistics","email":"sipho@dlamini.co.za","phone":"+27821234567","source":"referral","estimatedValue":85000.0,"ownerUserId":"<uuid>"},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"GET","path":"/api/crm/leads/{id}","summary":"One lead with its activities.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"PUT","path":"/api/crm/leads/{id}","summary":"Update a lead or move its status through new, contacted, qualified, unqualified.","auth":"jwt","body":{"status":"qualified","estimatedValue":92000.0},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"DELETE","path":"/api/crm/leads/{id}","summary":"Delete a lead.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"POST","path":"/api/crm/leads/{id}/convert","summary":"Convert to a real contact, carrying the lead's activities across. Never duplicates a customer on the same email — the match runs through the same contactMatchKey the import uses.","auth":"jwt","body":{"createOpportunity":true},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"POST","path":"/api/crm/leads/import","summary":"Import leads from CSV. Set preview to run the same code and return the counts without writing. Deliberately forgiving: the only rejection is a row with no name, and money goes through parseFlexibleAmount because a pasted \"R1 500,50\" is what a South African spreadsheet contains.","auth":"jwt","contentType":"text/plain","bodyRaw":"name,business,email,phone,estimated value,source\nSipho Dlamini,Dlamini Logistics,sipho@dlamini.co.za,0821234567,85000,referral","notes":"The body is the CSV itself, as text/plain — not a JSON envelope. Add ?preview=true to count the rows without writing them; the preview and the commit read the same body through the same parser.","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"GET","path":"/api/crm/pipeline","summary":"The kanban board — every open deal by stage, with count, total and weighted total per column.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"GET","path":"/api/crm/opportunities","summary":"Deals as a table, the same data the board renders.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"POST","path":"/api/crm/opportunities","summary":"Create a deal. A deal with no lines keeps its typed value, which is the normal early state rather than a fallback.","auth":"jwt","body":{"name":"Warehouse expansion","contactId":"<uuid>","stageId":"<uuid>","value":26700.0,"expectedCloseDate":"2026-10-31","ownerUserId":"<uuid>"},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"GET","path":"/api/crm/opportunities/{id}","summary":"One deal, with its line count so a single-line deal is told apart from a priced one at the call site.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"PUT","path":"/api/crm/opportunities/{id}","summary":"Update a deal. Once it has lines, value is DERIVED — recomputeOpportunityValue is its only writer.","auth":"jwt","body":{"name":"Warehouse expansion phase 2","expectedCloseDate":"2026-11-30"},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"DELETE","path":"/api/crm/opportunities/{id}","summary":"Delete a deal and its lines.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"POST","path":"/api/crm/opportunities/{id}/stage","summary":"Move a deal. Every move writes a history row, which is where cycle time comes from.","auth":"jwt","body":{"stageId":"<uuid>"},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"GET","path":"/api/crm/opportunities/{id}/history","summary":"Every stage this deal has been through, and when.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"GET","path":"/api/crm/opportunities/{id}/lines","summary":"The deal's lines. They mirror invoice_lines field for field, so a line becomes an InvoiceLineRequest with no conversion step.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"PUT","path":"/api/crm/opportunities/{id}/lines","summary":"Replace the deal's lines and recompute its value in the same transaction. unit_cost is the one column invoice_lines does not have — snapshotted at pricing, nullable rather than zero, and it is what lets the board show margin on a deal that has not been invoiced.","auth":"jwt","body":[{"description":"Steel frame","quantity":40.0,"unitPrice":1850.0,"unitCost":1240.0,"vatRate":15.0}],"notes":"The body is a bare array of lines, replacing every line on the deal.","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"POST","path":"/api/crm/opportunities/{id}/quote","summary":"Raise a real quote through the invoicing engine from the deal's own lines, and store the link both ways.","auth":"jwt","body":{"issueDate":"2026-08-22","validUntil":"2026-09-22"},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"GET","path":"/api/crm/stages","summary":"The tenant's pipeline stages and their default probabilities.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"POST","path":"/api/crm/stages","summary":"Add a stage.","auth":"jwt","body":{"name":"Site survey","defaultProbability":35,"sortOrder":2},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"PUT","path":"/api/crm/stages/{id}","summary":"Rename, reorder or reprobability a stage.","auth":"jwt","body":{"name":"Example"},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"DELETE","path":"/api/crm/stages/{id}","summary":"Remove a stage. Refused while deals sit in it.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"GET","path":"/api/crm/activities?limit=50&page=1","summary":"Calls, meetings, emails, notes and tasks, paginated. Linked polymorphically to a lead, a deal or a contact.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"POST","path":"/api/crm/activities","summary":"Log an activity or set a task. Logging one updates the contact's last_contacted_at, which is what makes \"nobody has called these fifteen customers in four months\" answerable.","auth":"jwt","body":{"activityType":"call","subject":"Follow up on the frame quote","dueDate":"2026-08-28","ownerUserId":"<uuid>","opportunityId":"<uuid>"},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"PUT","path":"/api/crm/activities/{id}","summary":"Change an activity.","auth":"jwt","body":{"dueDate":"2026-08-29"},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"DELETE","path":"/api/crm/activities/{id}","summary":"Delete an activity.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"POST","path":"/api/crm/activities/{id}/complete","summary":"Mark a task done.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"GET","path":"/api/crm/follow-ups","summary":"My open tasks, oldest due first — the read the crm-tasks digest chases. No \"upcoming this week\": a digest listing work that is not yet due trains people to ignore it.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"GET","path":"/api/crm/owners","summary":"Who can own a lead, a deal or a task.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"GET","path":"/api/crm/performance","summary":"Pipeline by stage, weighted forecast, win rate, average sales cycle, and per-owner and per-source breakdowns.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"GET","path":"/api/crm/targets","summary":"Sales targets by period. owner_user_id null means the team's number. Attainment is NOT capped at 100, and no target renders as \"no target set\" rather than 0%.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"POST","path":"/api/crm/targets","summary":"Set a target. Measured against won deal value net of VAT, attributed by closed_at — the period it was won in, not the one somebody hoped.","auth":"jwt","body":{"periodStart":"2026-09-01","periodEnd":"2026-09-30","targetValue":250000.0,"ownerUserId":null},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"PUT","path":"/api/crm/targets/{id}","summary":"Change a target.","auth":"jwt","body":{"periodStart":"Example","periodEnd":"Example","targetValue":300000.0},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"DELETE","path":"/api/crm/targets/{id}","summary":"Remove a target.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"GET","path":"/api/crm/campaigns","summary":"Campaigns with channel, window, budget against spend, and attributed leads, deals and won revenue.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"POST","path":"/api/crm/campaigns","summary":"Create a campaign. Attribution is by the lead's source, so it is only as honest as the capture.","auth":"jwt","body":{"name":"Spring trade show","channel":"event","startDate":"2026-09-01","endDate":"2026-09-30","budget":40000.0},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"PUT","path":"/api/crm/campaigns/{id}","summary":"Update a campaign or record spend against it.","auth":"jwt","body":{"spend":38200.0},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"DELETE","path":"/api/crm/campaigns/{id}","summary":"Delete a campaign.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"GET","path":"/api/crm/attachments?opportunityId=<uuid>","summary":"Files linked to a deal, a lead or a contact. A link table rather than a column, because a deal has several and a single column means the second upload silently replaces the first.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"POST","path":"/api/crm/attachments/upload","summary":"Store a file and return its id. Raw bytes with the content type in the header, never base64 — a picker hands the client a ByteArray on every target and base64 inflates a 4MB drawing by a third. The server decides the stored content type from a closed list, never the one the client claims. The id then goes to POST /api/crm/attachments, which is what decides where it may be attached.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"POST","path":"/api/crm/attachments","summary":"Link an attachment to a deal, a lead or a contact.","auth":"jwt","body":{"opportunityId":"<uuid>","attachmentId":"<uuid>","label":"Signed acceptance"},"module":"crm","plan":"Starter","scope":"crm:write"},{"method":"DELETE","path":"/api/crm/attachments/{id}","summary":"Unlink. Removes the LINK, not the file — the same attachment may be referenced elsewhere, and a CRM screen is not where somebody should be able to destroy a receipt.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:write"},{"method":"GET","path":"/api/crm/contacts/{id}/summary","summary":"The CRM panel inside the contact detail view — open deals, recent activities and lifecycle stage. One contact record, two views onto it.","auth":"jwt","module":"crm","plan":"Starter","scope":"crm:read"},{"method":"PUT","path":"/api/crm/contacts/{id}","summary":"Set the CRM enrichment — lifecycle stage, industry, website and next follow-up.","auth":"jwt","body":{"lifecycleStage":"customer","industry":"Logistics","website":"https://dlamini.co.za"},"module":"crm","plan":"Starter","scope":"crm:write"}]},{"name":"Expenses & claims","blurb":"Expenses, supplier bills, mileage, per diem and petty cash. Mileage is at the SARS s8(1)(b)(iii) rate resolved from the TRIP's date, so a prior-year trip logged late is paid at that year's rate.","endpoints":[{"method":"GET","path":"/api/expenses?status=approved","summary":"Expense entries. Served through OfflineCache on the client, which caches the whole list per status and merges pending local writes on top.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"POST","path":"/api/expenses","summary":"Capture an expense. Accepts a client-minted id, which is what makes offline replay idempotent — the id becomes the row's primary key.","auth":"jwt","body":{"date":"2026-08-10","contactId":"<uuid>","amount":1234.56,"category":"Travel","description":"Client meeting in JHB"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"GET","path":"/api/expenses/{id}","summary":"One expense with its receipt and approval trail.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"PUT","path":"/api/expenses/{id}","summary":"Update an expense.","auth":"jwt","body":{"category":"Entertainment","description":"Client lunch"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"DELETE","path":"/api/expenses/{id}","summary":"Delete an expense.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/expenses/{id}/submit","summary":"Submit for approval.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/expenses/{id}/approve","summary":"Approve. This is a write transaction that runs a full GL posting, so it goes through dbQuery rather than on the request thread.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/expenses/{id}/reject","summary":"Reject, with a reason.","auth":"jwt","body":{"reason":"No VAT invoice attached"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/expenses/{id}/pay","summary":"Record the reimbursement.","auth":"jwt","body":{"bankAccountId":"<uuid>","date":"2026-08-25"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/expenses/receipts","summary":"Upload a receipt image. The bytes go to object storage; the row holds the reference.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"GET","path":"/api/expenses/receipts/{id}","summary":"The legacy base64 receipt read path, kept until the attachment backfill finishes. New code should use GET /api/files/{id}.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"POST","path":"/api/expenses/ocr","summary":"Run Tesseract over a receipt and return the legacy field shape.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"GET","path":"/api/expenses/category-budgets","summary":"Budget against actual per expense category.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"PUT","path":"/api/expenses/category-budgets/{category}","summary":"Set a category's monthly budget.","auth":"jwt","body":{"monthlyBudget":15000.0},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"GET","path":"/api/documents/ocr","summary":"Documents already scanned, with per-field confidence, provenance and raw text.","auth":"jwt"},{"method":"POST","path":"/api/documents/ocr","summary":"Scan a receipt, supplier invoice or statement — PDF, JPEG, PNG or multi-page TIFF. Unlike /api/expenses/ocr this returns confidence and provenance per field, not just values.","auth":"jwt"},{"method":"GET","path":"/api/documents/inbox","summary":"The document inbox: everything uploaded in bulk or emailed in, newest first, with what the reader made of each.","auth":"jwt"},{"method":"POST","path":"/api/documents/inbox","summary":"Drop documents in. Multipart, several files at a time; answers immediately and reads them off the request.","auth":"jwt"},{"method":"GET","path":"/api/documents/inbox/{id}","summary":"One item's full reading, plus what this tenant's own history says to file it as.","auth":"jwt"},{"method":"POST","path":"/api/documents/inbox/{id}/retry","summary":"Put a failed item back in the reading queue.","auth":"jwt"},{"method":"POST","path":"/api/documents/inbox/{id}/unsplit","summary":"Put a page that was split into several documents back together: the crops are discarded and the whole page is read again as one.","auth":"jwt"},{"method":"POST","path":"/api/documents/inbox/{id}/discard","summary":"Throw an inbox item away without filing it.","auth":"jwt"},{"method":"POST","path":"/api/documents/inbox/{id}/import-statement","summary":"Import an emailed bank statement into the account you choose. Held statements are the ones that read perfectly but could not prove which account they belong to, or did not add up to the closing balance the bank printed. Gated on banking:create, not expenses — this writes bank transactions.","auth":"jwt","body":{"bankAccountId":"<uuid>"}},{"method":"POST","path":"/api/documents/inbox/{id}/link","summary":"Attach an inbox item to the expense or bill it became, and remember how this supplier gets filed.","auth":"jwt","body":{"expenseId":"<uuid>","category":"fuel"}},{"method":"GET","path":"/api/documents/inbox/address","summary":"This company's email-in address. Minted on first ask; absent when the deployment cannot receive email.","auth":"jwt"},{"method":"POST","path":"/api/documents/inbox/address/rotate","summary":"Issue a new email-in address. The previous one stops working immediately.","auth":"jwt"},{"method":"POST","path":"/inbound/email/{secret}","summary":"Mailjet Parse API webhook: a forwarded supplier invoice becomes an inbox item. Registered only when LEDGR_INBOUND_EMAIL_SECRET is set.","auth":"none"},{"method":"POST","path":"/api/documents/ocr/{id}/link","summary":"Attach a scan to the expense or bill it turned into.","auth":"jwt","body":{"expenseId":"<uuid>"}},{"method":"GET","path":"/api/bills","summary":"Supplier invoices.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"POST","path":"/api/bills","summary":"Capture a supplier invoice.","auth":"jwt","body":{"vendor":"Example","issueDate":"2026-09-30","dueDate":"2026-09-30","amount":1500.0,"billNumber":"ACME-8841","lines":[{"description":"Steel angle 50x50","quantity":120.0,"unitPrice":84.5,"vatRate":15.0}]},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"GET","path":"/api/bills/{id}","summary":"One bill with its lines and payments.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"PUT","path":"/api/bills/{id}","summary":"Update a bill.","auth":"jwt","body":{"dueDate":"2026-09-15"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"DELETE","path":"/api/bills/{id}","summary":"Delete a bill.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/bills/{id}/pay","summary":"Record a payment against a bill.","auth":"jwt","body":{},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"GET","path":"/api/mileage/trips","summary":"Logged trips with their calculated claim.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"POST","path":"/api/mileage/trips","summary":"Log a trip. The rate is year-keyed on SarsTravelRates and resolved from the trip's own date — R4.95/km for 2026/27, R4.64/km for 2025/26.","auth":"jwt","body":{"tripDate":"2026-09-30","origin":"Example","destination":"Example","distanceKm":145.0,"purpose":"Site inspection"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"PUT","path":"/api/mileage/trips/{id}","summary":"Correct a trip.","auth":"jwt","body":{"distanceKm":64.0},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"DELETE","path":"/api/mileage/trips/{id}","summary":"Delete a trip.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"GET","path":"/api/per-diem/claims","summary":"Travel allowance claims.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"POST","path":"/api/per-diem/claims","summary":"Raise a per diem claim.","auth":"jwt","body":{"employee":"Example","destination":"Example","periodLabel":"Example","days":30,"ratePerDay":15.0},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"PUT","path":"/api/per-diem/claims/{id}","summary":"Update a claim.","auth":"jwt","body":{"days":3},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"DELETE","path":"/api/per-diem/claims/{id}","summary":"Delete a claim.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/per-diem/claims/{id}/approve","summary":"Approve a claim.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/per-diem/claims/{id}/reject","summary":"Reject a claim, with a reason.","auth":"jwt","body":{"reason":"Dates overlap an existing claim"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/per-diem/claims/{id}/pay","summary":"Record payment of a claim.","auth":"jwt","body":{"bankAccountId":"<uuid>"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"GET","path":"/api/petty-cash","summary":"The float and its transactions.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"PUT","path":"/api/petty-cash/float","summary":"Set or top up the float.","auth":"jwt","body":{"floatAmount":1500.0},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/petty-cash/txns","summary":"Record a petty-cash payment or receipt.","auth":"jwt","body":{"date":"2026-08-19","description":"Courier","amount":145.0,"category":"Postage"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"PUT","path":"/api/petty-cash/txns/{id}","summary":"Correct a petty-cash transaction.","auth":"jwt","body":{"amount":150.0},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"DELETE","path":"/api/petty-cash/txns/{id}","summary":"Delete a petty-cash transaction.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/expenses/{id}/reopen","summary":"Un-pay a claim: PAID back to APPROVED, reversing the cash leg to the accrual. Needs expenses:approve, because it undoes a payment decision. Added with the settlement guard and required by it — refusing to edit a paid claim would otherwise be a dead end, as this module had no way back from PAID at all.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/mileage/trips/{id}/submit","summary":"Submit a logged trip for approval. The claimant's own act, so expenses:create rather than approve.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/mileage/trips/{id}/approve","summary":"Approve a trip. Posts DR 6280 Mileage Reimbursement / CR 2120 Accruals at the cent-rounded claim. Needs expenses:approve — before these endpoints existed the only way to write a status was PUT, which took any string on expenses:edit.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/mileage/trips/{id}/reject","summary":"Reject a trip, or take an approval back before the money goes out. Reverses any posting. Needs expenses:approve.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/mileage/trips/{id}/pay","summary":"Mark a trip reimbursed. Moves the accrual to the bank (DR 2120 / CR 1350). Only from approved, and terminal — a paid trip can no longer be edited. Needs expenses:approve.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"}]},{"name":"Purchasing & suppliers","blurb":"The creditors mirror of the debtor side — orders, goods receipts, landed cost and supplier statement reconciliation.","endpoints":[{"method":"GET","path":"/api/purchase-orders","summary":"Purchase orders and the open commitment they represent.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"GET","path":"/api/purchase-orders/{id}","summary":"One order with its lines and receipt history.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"POST","path":"/api/purchase-orders/{id}/approve","summary":"Approve an order for transmission.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/purchase-orders/{id}/cancel","summary":"Cancel an order and release its commitment.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"GET","path":"/api/purchase-orders/{id}/receipts","summary":"What has been received against this order.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"POST","path":"/api/purchase-orders/{id}/receive","summary":"Receive goods. The stock movement is dated the RECEIPT date, not the day it was keyed — the journal already was, so a backdated capture used to put stock and the GL in different periods for one event.","auth":"jwt","body":{"receiptDate":"2026-09-30","lines":[{"lineId":"<uuid>","qtyReceived":2.0}],"locationId":"<uuid>"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"GET","path":"/api/landed-costs","summary":"Landed-cost dockets — freight, duty and clearing waiting to be apportioned into stock.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/landed-costs","summary":"Open a docket against one or more goods receipts.","auth":"jwt","body":{"docketNumber":"SHIP-2026-14","goodsReceiptIds":["<uuid>"],"charges":[{"costType":"freight","amount":18400.0}]},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/landed-costs/{id}","summary":"One docket and its apportionment.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"PUT","path":"/api/landed-costs/{id}","summary":"Change a docket before it is posted.","auth":"jwt","body":{"charges":[{"costType":"duty","amount":9200.0}]},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"DELETE","path":"/api/landed-costs/{id}","summary":"Discard an unposted docket.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"POST","path":"/api/landed-costs/{id}/post","summary":"Apportion the cost into the batches it brought in and raise the journal.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/landed-costs/defaults","summary":"Standing cost types and their apportionment basis.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/landed-costs/defaults","summary":"Add a cost type.","auth":"jwt","body":{"chargeType":"freight"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"DELETE","path":"/api/landed-costs/defaults/{id}","summary":"Remove a cost type.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/landed-costs/receipt-options","summary":"Goods receipts a docket can still be attached to.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"GET","path":"/api/suppliers/aged","summary":"Aged creditors by bucket.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"GET","path":"/api/suppliers/{id}/statement","summary":"A supplier statement as data.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"GET","path":"/api/suppliers/{id}/statement.pdf","summary":"The same statement as a PDF.","auth":"jwt","module":"expenses","plan":"Starter","scope":"expenses:read"},{"method":"POST","path":"/api/suppliers/{id}/send-statement","summary":"Send a remittance or statement to the supplier.","auth":"jwt","body":{"channel":"email"},"module":"expenses","plan":"Starter","scope":"expenses:write"},{"method":"POST","path":"/api/suppliers/{id}/reconcile","summary":"Reconcile the supplier's own statement against what Ledgr holds, and report the differences.","auth":"jwt","body":{"supplierBalance":1500.0},"module":"expenses","plan":"Starter","scope":"expenses:write"}]},{"name":"Banking & reconciliation","blurb":"Bank data enters two ways. Investec is the one South African bank a customer can connect to on their own — they mint their own client id, secret and API key in Investec Online, so Ledgr needs no agreement with the bank. Every other bank's data arrives as a statement: uploaded here, or emailed to the company's inbox address and imported by itself once it reconciles to the closing balance the bank printed.","endpoints":[{"method":"GET","path":"/api/bank-feeds","summary":"This company's Investec feeds, plus whether a feed can be connected at all and how to get the keys.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"POST","path":"/api/bank-feeds","summary":"Connect a feed with your own Investec client id, secret and API key. They are proved against Investec before they are stored, and the reply carries the accounts they can see.","auth":"jwt","body":{"clientId":"<your Investec client id>","clientSecret":"<your Investec client secret>","apiKey":"<your Investec API key>","syncFrom":"2026-06-01"},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/bank-feeds/{id}/accounts","summary":"Re-read the Investec account list, with a suggested Ledgr account matched by account number.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"POST","path":"/api/bank-feeds/{id}/link","summary":"Map an Investec account onto a Ledgr bank account. An unmapped feed imports nothing rather than guessing.","auth":"jwt","body":{"investecAccountId":"<uuid>","bankAccountId":"<uuid>"},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/bank-feeds/{id}/sync","summary":"Pull now. Idempotent — Investec's own transaction id plus a content signature, so nothing imports twice.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/bank-feeds/{id}/runs","summary":"The sync history, newest first. A feed that silently stops is worse than no feed.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"DELETE","path":"/api/bank-feeds/{id}","summary":"Disconnect and erase the stored Investec keys. Transactions already imported are kept.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/bank-accounts","summary":"Bank accounts with balances and sparkline trends.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"POST","path":"/api/bank-accounts","summary":"Add an account. Nine SA banks are supported for import.","auth":"jwt","body":{"name":"Example","type":"cheque","accountNumber":"62811234567","currency":"ZAR"},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/bank-accounts/{id}","summary":"One account.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"PUT","path":"/api/bank-accounts/{id}","summary":"Update an account.","auth":"jwt","body":{"name":"FNB Business Cheque (main)"},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"DELETE","path":"/api/bank-accounts/{id}","summary":"Remove an account.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/bank-transactions?bankAccountId=<uuid>","summary":"Transactions for an account.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"POST","path":"/api/bank-transactions","summary":"Key a transaction by hand.","auth":"jwt","body":{"bankAccountId":"<uuid>","date":"2026-08-14","description":"PAYFAST ACME","amount":51750.0},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/bank-transactions/{id}","summary":"One transaction and its match state.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"DELETE","path":"/api/bank-transactions/{id}","summary":"Delete a transaction.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/bank-transactions/import","summary":"Import transactions already parsed into Ledgr's shape. TransactionDedupe matches a hand-keyed row against an uploaded one.","auth":"jwt","body":{"bankAccountId":"<uuid>","transactions":[{"date":"2026-08-14","description":"PAYFAST ACME","amount":51750.0}]},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/bank-transactions/{id}/match","summary":"Match a transaction to an invoice or a bill.","auth":"jwt","body":{"invoiceId":"<uuid>"},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/bank-transactions/{id}/unmatch","summary":"Break a match.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/bank-statements/profiles","summary":"Column-mapping profiles for the nine supported banks, plus any the tenant has saved.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"POST","path":"/api/bank-statements/preview","summary":"Parse an uploaded CSV, OFX or QIF and show what would be imported, without writing.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/bank-statements/convert","summary":"Turn the PDF or scan your bank sent into a statement. Multipart upload; answers with the transactions, whether they reconcile to the bank's own closing balance, and the text to post to /import.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/bank-statements/import","summary":"Commit a parsed statement.","auth":"jwt","body":{"bankAccountId":"<uuid>","profile":"fnb-csv"},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/bank-statements/export","summary":"Export transactions back out as CSV.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"GET","path":"/api/bank-rules","summary":"Auto-categorisation rules.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"POST","path":"/api/bank-rules","summary":"Create a rule.","auth":"jwt","body":{"name":"Example"},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/bank-rules/{id}","summary":"One rule.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"PUT","path":"/api/bank-rules/{id}","summary":"Update a rule.","auth":"jwt","body":{},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"DELETE","path":"/api/bank-rules/{id}","summary":"Delete a rule.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/bank-rules/preview","summary":"Show what the rules would do to the unreconciled transactions, before doing it.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/bank-rules/apply","summary":"Apply the rules, creating the transactions they imply.","auth":"jwt","body":{"bankAccountId":"<uuid>"},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/reconciliation","summary":"Reconciliation sessions and their status ring.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"POST","path":"/api/reconciliation/start","summary":"Open a reconciliation to a statement date and closing balance.","auth":"jwt","body":{"bankAccountId":"<uuid>","statementBalance":1500.0},"module":"banking","plan":"Starter","scope":"banking:write"},{"method":"GET","path":"/api/reconciliation/{id}","summary":"One session — cleared, uncleared and the difference still to explain.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:read"},{"method":"POST","path":"/api/reconciliation/{id}/clear/{transactionId}","summary":"Tick a transaction as appearing on the statement.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/reconciliation/{id}/unclear/{transactionId}","summary":"Untick it.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/reconciliation/{id}/finalise","summary":"Close the reconciliation. Refused while the difference is non-zero.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"},{"method":"POST","path":"/api/reconciliation/{id}/reopen","summary":"Reopen a finalised reconciliation.","auth":"jwt","module":"banking","plan":"Starter","scope":"banking:write"}]},{"name":"General ledger","blurb":"Manual journals, the period lock, cost centres, FX revaluation and the fixed asset register. The roles that may read a trial balance are the roles that may post to it.","endpoints":[{"method":"GET","path":"/api/accounts","summary":"The chart of accounts.","auth":"jwt"},{"method":"POST","path":"/api/accounts","summary":"Add an account.","auth":"jwt","body":{"code":"SKU-1001","name":"Example","accountType":"expense","normalBalance":"Example"}},{"method":"GET","path":"/api/accounts/tree","summary":"The chart as a tree, for the picker.","auth":"jwt"},{"method":"GET","path":"/api/accounts/{id}","summary":"One account with its balance.","auth":"jwt"},{"method":"PUT","path":"/api/accounts/{id}","summary":"Rename or recode an account.","auth":"jwt","body":{"name":"Stock write-offs and shrinkage"}},{"method":"DELETE","path":"/api/accounts/{id}","summary":"Delete an account. Refused while journal lines, invoice lines or quote lines reference it — which is why the Quotes tables survive the route being unmounted.","auth":"jwt"},{"method":"GET","path":"/api/ledger/journals","summary":"Manual journals.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"POST","path":"/api/ledger/journals","summary":"Post a manual journal. Refused unless it balances and the period is open.","auth":"jwt","body":{"journalDate":"2026-08-31","memo":"Depreciation for August","lines":[{"accountId":"<uuid>","debit":4166.67,"credit":0.0},{"accountId":"<uuid>","debit":0.0,"credit":4166.67}],"reference":"JNL-0042","post":true},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/ledger/journals/{id}","summary":"One journal and its lines.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"POST","path":"/api/ledger/journals/{id}/reverse","summary":"Post the contra. Never edits the original.","auth":"jwt","body":{"date":"2026-09-01"},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/ledger/periods","summary":"Accounting periods and their lock state.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"POST","path":"/api/ledger/periods","summary":"Open a period.","auth":"jwt","body":{"periodStart":"2026-09-01","periodEnd":"2026-09-30","label":"September 2026"},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"PUT","path":"/api/ledger/periods/{id}/status","summary":"Lock or unlock a period. A locked period refuses every posting, including automatic ones.","auth":"jwt","body":{"status":"locked"},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/ledger/cost-centres","summary":"Cost centres.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"POST","path":"/api/ledger/cost-centres","summary":"Add a cost centre.","auth":"jwt","body":{"code":"CPT","name":"Cape Town branch"},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"PUT","path":"/api/ledger/cost-centres/{id}","summary":"Rename a cost centre.","auth":"jwt","body":{"code":"CPT","name":"Cape Town Depot","kind":"branch","isActive":true},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"DELETE","path":"/api/ledger/cost-centres/{id}","summary":"Remove a cost centre.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/ledger/account-budgets","summary":"Budgets per ledger account and period.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"PUT","path":"/api/ledger/account-budgets","summary":"Set a budget line.","auth":"jwt","body":{"budgets":[{"accountId":"<uuid>","periodMonth":"2026-09","amount":45000.0,"costCentreId":"<uuid>","notes":"Approved at the August board meeting"}]},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"DELETE","path":"/api/ledger/account-budgets/{id}","summary":"Remove a budget line.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/ledger/budget-by-account","summary":"Budget against actual, by account.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/ledger/fx-revaluations","summary":"Period-end FX revaluations already posted.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"POST","path":"/api/ledger/fx-revaluation","summary":"Revalue foreign-currency balances at the period-end rate and post the difference.","auth":"jwt","body":{"asOf":"2026-08-31"},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/ledger/health","summary":"Control-account checks — does the debtors control agree with the debtors ledger, does 1310 agree with the sum of open batch values.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"POST","path":"/api/ledger/rebuild","summary":"Rebuild the ledger from source documents. The repair of last resort, not a routine.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:write"},{"method":"POST","path":"/api/ledger/seed-chart","summary":"Seed a standard SA chart of accounts into an empty tenant.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/fx/rates","summary":"Exchange rates held, and how old each one is.","auth":"jwt"},{"method":"PUT","path":"/api/fx/rates","summary":"Record a rate by hand.","auth":"jwt","body":{"fromCurrency":"ZAR","toCurrency":"ZAR","rate":15.0}},{"method":"POST","path":"/api/fx/refresh","summary":"Fetch the latest rates from the ECB.","auth":"jwt"},{"method":"GET","path":"/api/fixed-assets","summary":"The asset register.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"POST","path":"/api/fixed-assets","summary":"Capitalise an asset against a SARS BGR7 class.","auth":"jwt","body":{"name":"Example","assetClass":"Example","acquisitionDate":"2026-09-30","cost":1500.0},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/fixed-assets/classes","summary":"The SARS BGR7 wear-and-tear classes and their write-off periods.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/fixed-assets/{id}","summary":"One asset with its carrying value.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"PUT","path":"/api/fixed-assets/{id}","summary":"Update an asset.","auth":"jwt","body":{"name":"Toyota Hilux (CA 123-456)"},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"DELETE","path":"/api/fixed-assets/{id}","summary":"Remove an asset that was capitalised in error.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/fixed-assets/{id}/schedule","summary":"The depreciation schedule for one asset.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"POST","path":"/api/fixed-assets/depreciation/run","summary":"Post the month's depreciation across the register.","auth":"jwt","body":{"period":"2026-08"},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"POST","path":"/api/fixed-assets/{id}/dispose","summary":"Dispose of an asset and post the profit or loss.","auth":"jwt","body":{"disposalDate":"2026-09-30","proceeds":310000.0},"module":"reports","plan":"Business","scope":"reports:write"}]},{"name":"Reports & analytics","blurb":"IFRS-for-SMEs statements, management reports and the compliance set. /api/reports/summary and /api/reports/cash-forecast belong to the DASHBOARD module, not REPORTS, so a Free tenant's dashboard is not blank.","endpoints":[{"method":"GET","path":"/api/reports/summary","summary":"The dashboard KPI row — cash, receivables, revenue MTD.","auth":"jwt","module":"dashboard","plan":"Free","scope":"dashboard:read"},{"method":"GET","path":"/api/reports/cash-forecast","summary":"The cash runway ring's figures.","auth":"jwt","module":"dashboard","plan":"Free","scope":"dashboard:read"},{"method":"GET","path":"/api/reports/income-statement?from=2026-03-01&to=2027-02-28","summary":"Income statement (IFRS for SMEs).","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/balance-sheet?asOf=2027-02-28","summary":"Balance sheet at a date.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/cash-flow?from=2026-03-01&to=2027-02-28","summary":"Direct-method cash flow statement.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/trial-balance?asOf=2027-02-28","summary":"Trial balance. Accepts ?costCentreId=<uuid> or the literal ?costCentreId=unallocated to restrict it to one branch, department or project; anything else is refused rather than widened to the whole company.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/general-ledger?accountId=<uuid>","summary":"General ledger detail for an account. Takes the same ?costCentreId= filter as the trial balance, brought-forward balance included.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/income-by-cost-centre?from=2026-03-01&to=2027-02-28","summary":"The income statement with one column per cost centre — the departmental or branch P&L, for one legal entity with several branches. Every column and the total come from one read of journal_lines through the one income-statement classifier, and the response says whether the columns reconcile to the company total rather than leaving the reader to add them up.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/drill-down/{accountId}","summary":"The transactions behind a figure, for click-through from a chart.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/aged-debtors","summary":"Aged debtors with the per-bucket breakdown the chart draws.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/aged-creditors","summary":"Aged creditors.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/budget-vs-actual?period=2026-08","summary":"Budget against actual with variance.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/vat-analysis?from=2026-07-01&to=2026-08-31","summary":"VAT by rate and by account, which is what a VAT201 is reconciled against.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/bbbee","summary":"The B-BBEE scorecard — six elements and the resulting level.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"PUT","path":"/api/reports/bbbee","summary":"Record the inputs the scorecard cannot derive from the books.","auth":"jwt","body":{"elements":[{"element":"ownership","score":18},{"element":"skills_development","score":11}]},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/reports/scheduled","summary":"Scheduled report subscriptions.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"POST","path":"/api/reports/scheduled","summary":"Schedule a report.","auth":"jwt","body":{"name":"Monthly management pack","reportType":"income-statement","frequency":"monthly","nextRun":"2026-09-01","recipients":"andy@example.co.za","format":"pdf","active":true},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"PUT","path":"/api/reports/scheduled/{id}","summary":"Change or pause a schedule.","auth":"jwt","body":{"active":false},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"DELETE","path":"/api/reports/scheduled/{id}","summary":"Remove a schedule.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/reports/afs","summary":"The annual financial statement set — cover page, responsibility statement, four statements with comparatives and disclosure-driven notes.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/afs/years","summary":"Which years an AFS set can be produced for.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/reports/afs/settings","summary":"AFS presentation settings and the Companies Act public interest score inputs.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"PUT","path":"/api/reports/afs/settings","summary":"Change the AFS settings.","auth":"jwt","body":{"preparedBy":"Cameron & Co","rounding":1},"module":"reports","plan":"Business","scope":"reports:write"},{"method":"GET","path":"/api/reports/afs/lead-schedules","summary":"The lead schedules behind each statement line.","auth":"jwt","module":"reports","plan":"Business","scope":"reports:read"},{"method":"GET","path":"/api/report-tables","summary":"The flat, refreshable tables Excel can bind to. Accepts an API KEY directly as well as a JWT, because Power Query cannot run a token exchange and a monthly refresh cannot hold an hourly token.","auth":"api-key"},{"method":"GET","path":"/api/report-tables/{report}?format=csv","summary":"One table as JSON or CSV. Read-only, scope- and licence-checked explicitly.","auth":"api-key"}]},{"name":"SARS returns","blurb":"Prepare and download. Ledgr computes each return and produces a file for eFiling or e@syFile — it does NOT transmit. format=json always answers; a file format answers 422 with reasons when the return does not validate, so a wrong file is never emitted.","endpoints":[{"method":"GET","path":"/api/sars/overview","summary":"Per-tax-type filing state — last filed and next due.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/documents","summary":"Which returns can be produced, and for which periods.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/documents/vat201?from=2026-07-01&to=2026-08-31&format=json","summary":"The VAT201. Output-tax supplies come from one owner — OutputTaxSupplies in shared — because omitting credit notes, debit notes and deposits once declared a refund of R6 353.49 where R6 336.81 was payable.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/documents/emp201?period=2026-08&format=json","summary":"The EMP201 — PAYE, UIF, SDL and the ETI set off against them.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/documents/emp501?taxYear=2027&format=json","summary":"The EMP501 bi-annual reconciliation.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/documents/irp5?taxYear=2027&format=easyfile","summary":"IRP5/IT3(a) certificates, reviewed per employee before generation — e@syFile rejects a whole import on one bad record.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/documents/itr14?year=2027&format=json","summary":"The ITR14. The account-type alias is folded once, in Itr14TrialBalanceLine's constructor, because a Xero-shaped imported chart once declared nil turnover.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/documents/itr14/trial-balance?year=2027&format=csv","summary":"The trial-balance CSV that ships as the ITR14's supporting schedule.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/documents/irp6?year=2027&period=1&format=json","summary":"The IRP6 provisional tax return.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/documents/dividends","summary":"Dividend declarations behind the DTR01/DTR02.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"POST","path":"/api/sars/documents/dividends","summary":"Record a dividend declaration.","auth":"jwt","body":{"declarationDate":"2026-08-15","paymentDate":"2026-08-31","dividendDeclared":250000.0,"reference":"DIV-2026-01"},"module":"sars","plan":"Professional","scope":"sars:write"},{"method":"DELETE","path":"/api/sars/documents/dividends/{id}","summary":"Remove a declaration.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:write"},{"method":"GET","path":"/api/sars/documents/dtr01/{declarationId}?format=json","summary":"The DTR01 for one declaration.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/vat201?from=2026-07-01&to=2026-08-31","summary":"The computed VAT201 boxes, as the screen renders them.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"POST","path":"/api/sars/vat201/submit","summary":"Note the VAT201 for a period as filed. The reference recorded is LEDGR'S OWN — nothing is transmitted to SARS.","auth":"jwt","body":{"periodStart":"2026-07-01","periodEnd":"2026-08-31"},"module":"sars","plan":"Professional","scope":"sars:write"},{"method":"GET","path":"/api/sars/it14?year=2027","summary":"The company income tax estimate for a fiscal year.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"POST","path":"/api/sars/it14/submit","summary":"Note the ITR14 as filed.","auth":"jwt","body":{"year":2027},"module":"sars","plan":"Professional","scope":"sars:write"},{"method":"GET","path":"/api/sars/irp6?year=2027&period=1","summary":"The provisional tax estimate for a period.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"POST","path":"/api/sars/irp6/submit","summary":"Note the IRP6 as filed.","auth":"jwt","body":{"year":2027,"period":1},"module":"sars","plan":"Professional","scope":"sars:write"},{"method":"GET","path":"/api/sars-submissions?type=vat201&status=filed","summary":"The filing register across every tax type.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"POST","path":"/api/sars-submissions","summary":"Add a filing record by hand.","auth":"jwt","body":{"submissionType":"VAT201","periodStart":"2026-07-01","periodEnd":"2026-08-31"},"module":"sars","plan":"Professional","scope":"sars:write"},{"method":"GET","path":"/api/sars-submissions/{id}","summary":"One filing record.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"PUT","path":"/api/sars-submissions/{id}","summary":"Correct a filing record.","auth":"jwt","body":{"submissionType":"VAT201","periodStart":"2026-07-01","periodEnd":"2026-08-31"},"module":"sars","plan":"Professional","scope":"sars:write"},{"method":"POST","path":"/api/sars-submissions/{id}/submit","summary":"Mark a prepared return as filed.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:write"},{"method":"GET","path":"/api/sars-submissions/emp201/{periodId}","summary":"The EMP201 figures for a payroll period.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars-submissions/emp501?taxYear=2027","summary":"The EMP501 reconciliation figures.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/transmissions","summary":"ISV direct filing. DORMANT — the whole tree is unregistered unless LEDGR_SARS_DIRECT_FILING is on, so these are a plain 404 by default and must never be surfaced in a client.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/transmissions/isv","summary":"ISV registration status. Dormant, as above.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/transmissions/preview/{submissionId}","summary":"Preview the envelope that would be transmitted. Dormant.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"POST","path":"/api/sars/transmissions/{submissionId}","summary":"Transmit a submission. Dormant.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:write"},{"method":"GET","path":"/api/sars/transmissions/{id}/detail","summary":"A transmission's state. Dormant.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"GET","path":"/api/sars/transmissions/{id}/envelope","summary":"The transmitted envelope. Dormant.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:read"},{"method":"POST","path":"/api/sars/transmissions/{id}/poll","summary":"Poll SARS for an outcome. Dormant.","auth":"jwt","module":"sars","plan":"Professional","scope":"sars:write"}]},{"name":"Payroll","blurb":"SARS PAYE keyed by year of assessment — 2022/23 to 2026/27 registered, an unsupported year is REFUSED rather than served from the nearest one. Part-year employment follows Fourth Schedule paras 9(1)/9(2): annualise to find the rate, then pro-rate by periods WORKED.","endpoints":[{"method":"GET","path":"/api/employees","summary":"Employees with their statutory fields.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"POST","path":"/api/employees","summary":"Create an employee. default_charge_rate and default_cost_rate live on the user record and fill in job-card labour — null means \"not set\" rather than zero, and the reports say so rather than showing a flattering 100% margin.","auth":"jwt","body":{"firstName":"Andy","lastName":"Cameron","idNumber":"8001015800087","taxNumber":"0000012345","email":"andy@example.co.za","basicSalary":45000.0,"startDate":"2026-03-01","uifLiable":true},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/employees/{id}","summary":"One employee.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"PUT","path":"/api/employees/{id}","summary":"Update an employee.","auth":"jwt","body":{"basicSalary":48000.0},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"DELETE","path":"/api/employees/{id}","summary":"Terminate an employee.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/employees/{id}/payslips","summary":"One employee's payslip history.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/employees/{id}/leave-balances","summary":"BCEA balances — 15 days annual, 30 days sick over a three-year cycle, three days family responsibility.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/employees/{id}/irp5?taxYear=2027","summary":"One employee's IRP5 as data.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/employees/{id}/irp5.pdf?taxYear=2027","summary":"The same certificate as a PDF.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll-periods","summary":"Payroll periods and their state.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"POST","path":"/api/payroll-periods","summary":"Open a period.","auth":"jwt","body":{"periodType":"MONTHLY","startDate":"2026-09-01","endDate":"2026-09-30","payDate":"2026-09-25"},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll-periods/{id}","summary":"One period with its totals.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"PUT","path":"/api/payroll-periods/{id}","summary":"Change a period's dates before it is run.","auth":"jwt","body":{"periodType":"MONTHLY","startDate":"2026-09-01","endDate":"2026-09-30","payDate":"2026-09-26"},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"DELETE","path":"/api/payroll-periods/{id}","summary":"Delete an unrun period.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"POST","path":"/api/payroll-periods/{id}/run","summary":"Calculate the period — PAYE, UIF at 1% capped at R177.12 each side, SDL at 1%, ETI, deductions and fringe benefits.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"POST","path":"/api/payroll-periods/{id}/approve","summary":"Approve the run and lock the payslips.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"POST","path":"/api/payroll-periods/{id}/reopen","summary":"Reopen an approved period for correction.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"POST","path":"/api/payroll-periods/{id}/pay","summary":"Record the payment run and post the journal.","auth":"jwt","body":{"bankAccountId":"<uuid>","paymentDate":"2026-09-25"},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll-periods/{id}/payslips","summary":"Every payslip in a period.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll-periods/{id}/journal","summary":"The journal the period posts, before or after posting.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payslips/{id}","summary":"One payslip, line by line.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"PUT","path":"/api/payslips/{id}","summary":"Adjust a payslip before the period is approved.","auth":"jwt","body":{},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payslips/{id}/pdf","summary":"The payslip as a PDF.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"POST","path":"/api/payslips/{id}/send","summary":"Send a payslip by email or WhatsApp.","auth":"jwt","body":{"channel":"whatsapp"},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"POST","path":"/api/payslips/bulk-approve","summary":"Approve many payslips at once. Approving one at a time is why approvals get skipped.","auth":"jwt","body":{"payslipIds":["<uuid>","<uuid>"]},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll/irp5?taxYear=2027","summary":"The full IRP5 certificate set for a tax year, per employee, with the validation state e@syFile will apply.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll/easyfile","summary":"What the e@syFile export would contain, and any records that would fail it.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll/easyfile/import-file?taxYear=2027","summary":"The e@syFile Employer import file itself.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll/easyfile/emp501?taxYear=2027","summary":"The EMP501 schedule that goes with it.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll/periods/{periodId}/remittances","summary":"Third-party remittances due for a period — medical aid, provident fund, garnishees.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll/beneficiaries","summary":"Third-party beneficiaries deductions are paid over to.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"POST","path":"/api/payroll/beneficiaries","summary":"Add a beneficiary.","auth":"jwt","body":{"name":"Momentum Retirement Annuity","beneficiaryType":"retirement_fund","bankName":"FNB","bankAccountNumber":"62012345678","bankBranchCode":"250655","remittanceEmail":"contributions@momentum.co.za"},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll/beneficiaries/types","summary":"The beneficiary types and how each is treated for PAYE.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"PUT","path":"/api/payroll/beneficiaries/{id}","summary":"Update a beneficiary.","auth":"jwt","body":{"name":"Momentum Retirement Annuity","beneficiaryType":"retirement_fund","bankAccountNumber":"62099998888"},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"DELETE","path":"/api/payroll/beneficiaries/{id}","summary":"Remove a beneficiary.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll/deductions","summary":"Employee deductions and their remittance schedules.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"POST","path":"/api/payroll/deductions","summary":"Add a deduction against an employee.","auth":"jwt","body":{"employeeId":"<uuid>","beneficiaryId":"<uuid>","startDate":"2026-09-01","calculation":"FIXED","employeeAmount":1500.0,"employerAmount":1500.0,"memberReference":"MOM-4471"},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"PUT","path":"/api/payroll/deductions/{id}","summary":"Change a deduction.","auth":"jwt","body":{"employeeId":"<uuid>","beneficiaryId":"<uuid>","startDate":"2026-09-01","calculation":"FIXED","employeeAmount":1800.0},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"DELETE","path":"/api/payroll/deductions/{id}","summary":"End a deduction.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"POST","path":"/api/payroll/cost-to-company","summary":"Structure a package backwards from a cost-to-company figure.","auth":"jwt","body":{"costToCompany":45000.0,"employeeId":"<uuid>","employerContributions":3200.0,"uifLiable":true,"sdlExempt":false,"apply":false},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll/fringe-benefits","summary":"Fringe benefits in force.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"POST","path":"/api/payroll/fringe-benefits","summary":"Add a fringe benefit. The engine and the tables existed from V211; nothing could write a row without a SQL client until this route.","auth":"jwt","body":{"employeeId":"<uuid>","benefitType":"MOTOR_VEHICLE","startDate":"2026-09-01","determinedValue":420000.0,"maintenancePlanIncluded":true,"businessUseAtLeast80":false},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll/fringe-benefits/types","summary":"The benefit types and the Seventh Schedule rule each follows.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"PUT","path":"/api/payroll/fringe-benefits/{id}","summary":"Change a fringe benefit.","auth":"jwt","body":{"employeeId":"<uuid>","benefitType":"MOTOR_VEHICLE","startDate":"2026-09-01","determinedValue":395000.0},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"DELETE","path":"/api/payroll/fringe-benefits/{id}","summary":"End a fringe benefit.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll/eti","summary":"The Employment Tax Incentive as a view — who qualifies, which of the 24 months they are in, and what may be taken off PAYE.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"PUT","path":"/api/payroll/eti/employees/{id}","summary":"Correct an employee's ETI eligibility or start month.","auth":"jwt","body":{"etiMonthsClaimedBeforeLedgr":6},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll/take-on","summary":"Opening year-to-date figures for an employer that switched to Ledgr part-way through a tax year.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll/take-on/columns","summary":"The columns the take-on import expects.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll/take-on/template","summary":"A CSV template for the take-on.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"POST","path":"/api/payroll/take-on/import","summary":"Import opening YTD figures.","auth":"jwt","body":{"taxYearEndYear":2027,"csvContent":"employeeCode,grossYtd,payeYtd\\nE001,240000,52000"},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"PUT","path":"/api/payroll/take-on/{employeeId}","summary":"Set one employee's opening figures by hand.","auth":"jwt","body":{"remuneration":240000.0,"paye":52000.0,"uifEmployee":1416.96},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll/change-requests","summary":"Change requests employees have raised from the self-service portal.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"POST","path":"/api/payroll/change-requests/{id}/review","summary":"Approve or decline an employee's change request.","auth":"jwt","body":{"note":"Bank confirmation letter checked against the account number."},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll/employees/{id}/portal-link","summary":"Whether this employee has a self-service link, and when it was issued.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"POST","path":"/api/payroll/employees/{id}/portal-link","summary":"Issue a self-service link. Deliberately RELATIVE — /portal/employee/{token} — so the client composes the host it is already on, which is the better pattern where it applies.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"DELETE","path":"/api/payroll/employees/{id}/portal-link","summary":"Revoke the employee's link.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/payroll/periods/{periodId}/payment-file","summary":"The salary payment file for an approved run. format=json is the review — rows with the account number masked, the total, and every employee who cannot be paid with the reason. format=csv is the file itself and answers 422 with the reasons when anything blocks, because a file that silently omits the three employees whose banking details were never captured pays seven of ten people and still foots. Not an ACB/BankServ fixed-width file: that layout needs a bank-assigned user code and nominated account Ledgr holds no field for.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll/returns/ui19","summary":"The UI-19 monthly declaration of employees to the UIF (uFiling). Carries the one thing the EMP201 does not — why each person stopped working here. A leaver with no reason captured, or one whose typed reason does not match a UI-19 reason, BLOCKS the return rather than being guessed at: a benefit claim is assessed against the declared reason, and the wrong one changes what the person may claim.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/payroll/returns/roe","summary":"The COIDA Return of Earnings (W.As.8) for 1 March to end February. Earnings are capped PER EMPLOYEE, which is the whole arithmetic — capping the payroll total instead overstates the assessment for every employer with anybody above the ceiling. Ledgr does not invent the maximum: a year it has no compiled figure for is refused with instructions, or the employer supplies it as ?maxEarnings=.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"}]},{"name":"Leave","blurb":"BCEA entitlements: 15 days annual, 30 days sick over a three-year cycle, three days family responsibility.","endpoints":[{"method":"GET","path":"/api/payroll/leave-liability","summary":"What is owed for annual leave earned and not taken, and whether 2160 agrees.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"GET","path":"/api/leave-requests?status=pending","summary":"Leave requests.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"POST","path":"/api/leave-requests","summary":"Raise a leave request.","auth":"jwt","body":{"employeeId":"<uuid>","leaveType":"annual","startDate":"2026-09-14","endDate":"2026-09-18","daysRequested":5.0,"reason":"Family holiday"},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"GET","path":"/api/leave-requests/{id}","summary":"One request.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:read"},{"method":"PUT","path":"/api/leave-requests/{id}","summary":"Amend a pending request.","auth":"jwt","body":{"employeeId":"<uuid>","leaveType":"annual","startDate":"2026-09-14","endDate":"2026-09-17","daysRequested":4.0},"module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"DELETE","path":"/api/leave-requests/{id}","summary":"Withdraw a request.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"POST","path":"/api/leave-requests/{id}/approve","summary":"Approve leave and draw down the balance.","auth":"jwt","module":"payroll","plan":"Professional","scope":"payroll:write"},{"method":"POST","path":"/api/leave-requests/{id}/reject","summary":"Decline leave, with a reason.","auth":"jwt","body":{"reason":"Two people already off that week"},"module":"payroll","plan":"Professional","scope":"payroll:write"}]},{"name":"Employee self-service portal","blurb":"No JWT — a portal token scoped to one employee, in the URL path. Revocation is a generation counter on the employee record, because a token is valid until it expires by construction.","endpoints":[{"method":"GET","path":"/portal/employee/{token}","summary":"The employee's own view — payslips, certificates, leave and their details.","auth":"portal-token"},{"method":"GET","path":"/portal/employee/{token}/payslips/{payslipId}","summary":"One payslip.","auth":"portal-token"},{"method":"GET","path":"/portal/employee/{token}/payslips/{payslipId}/pdf","summary":"That payslip as a PDF.","auth":"portal-token"},{"method":"GET","path":"/portal/employee/{token}/certificates","summary":"The employee's IRP5/IT3(a) certificates.","auth":"portal-token"},{"method":"GET","path":"/portal/employee/{token}/certificates/{taxYear}/pdf","summary":"One certificate as a PDF.","auth":"portal-token"},{"method":"POST","path":"/portal/employee/{token}/leave","summary":"Request leave, as JSON.","auth":"portal-token","body":{"type":"annual","fromDate":"2026-12-16","toDate":"2026-12-24"}},{"method":"POST","path":"/portal/employee/{token}/forms/leave","summary":"The same request as a plain form post. The page sends Referrer-Policy: no-referrer to keep its own token out of a third party's logs, which is why these posts sit outside the CORS gate.","auth":"portal-token"},{"method":"POST","path":"/portal/employee/{token}/change-request","summary":"Ask for a detail change — address, bank account, contact number — as JSON. It is a request, not a write.","auth":"portal-token","body":{"field":"bankAccountNumber","newValue":"62811111111"}},{"method":"POST","path":"/portal/employee/{token}/forms/change","summary":"The same change request as a form post.","auth":"portal-token"}]},{"name":"Inventory","blurb":"Stock is valued at what the BATCHES cost — stockValue is the identity the ledger posts against, never costPrice × qty. On-hand cannot go below zero on any of the three write paths, and nothing that still holds stock can be deleted.","endpoints":[{"method":"GET","path":"/api/inventory/products?page=1&limit=50&search=laptop","summary":"The catalogue, paged. Search asks the SERVER, on name, SKU and barcode — the barcode arm matters because a typed barcode used to work only while the list was one page.","auth":"jwt","responseExample":{"items":[{"id":"<uuid>","name":"Developer Kit","sku":"HW-DEV-K01","stockQty":42,"stockValue":336000.0}],"total":1340,"page":1,"limit":50,"totalPages":27},"module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/products","summary":"Create a product. SKU is unique per tenant, case-insensitively, over live rows; a negative price and a VAT rate outside 0–100 are refused.","auth":"jwt","body":{"name":"Developer Kit","sku":"HW-DEV-K01","sellPrice":12500.0,"costPrice":8000.0,"unit":"unit","vatRate":15.0,"costingMethod":"FIFO"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/products/{id}","summary":"One product, with its stock value for the active company.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"PUT","path":"/api/inventory/products/{id}","summary":"Update a product.","auth":"jwt","body":{"sellPrice":12900.0},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"DELETE","path":"/api/inventory/products/{id}","summary":"Soft-delete a product. REFUSED while there is stock to lose — a write-off has to be an adjustment somebody posts, not a silent disappearance with the value still sitting in 1310.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/products/lookup?sku=HW-DEV-K01","summary":"Resolve one product by barcode or SKU, for the scanner and the line picker. Takes the oldest match rather than nothing, for tenants whose data predates the uniqueness guard.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"GET","path":"/api/inventory/products/low-stock","summary":"Everything below its reorder point, tenant-wide — counting the loaded page would report page one's shortages as the whole catalogue.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"GET","path":"/api/inventory/products/low-stock-by-location","summary":"The same, split by location.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"GET","path":"/api/inventory/products/{id}/images","summary":"Product photographs.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/products/{id}/images","summary":"Upload a photograph. Raw bytes; the server recompresses and stores the object, never base64 in Postgres.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"DELETE","path":"/api/inventory/products/{id}/images/{imageId}","summary":"Remove a photograph.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/categories","summary":"Product categories.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/categories","summary":"Add a category.","auth":"jwt","body":{"code":"SKU-1001","name":"Example"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"PUT","path":"/api/inventory/categories/{id}","summary":"Rename a category.","auth":"jwt","body":{"name":"Workshop consumables"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"DELETE","path":"/api/inventory/categories/{id}","summary":"Remove a category.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/variants","summary":"Product variants — size, colour, configuration.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/variants","summary":"Add a variant.","auth":"jwt","body":{"productId":"<uuid>","name":"16GB / 512GB","sku":"HW-DEV-K01-16-512","sellPrice":14500.0},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"PUT","path":"/api/inventory/variants/{id}","summary":"Update a variant.","auth":"jwt","body":{"sellPrice":14900.0},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"DELETE","path":"/api/inventory/variants/{id}","summary":"Remove a variant.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/locations","summary":"Stock locations.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/locations","summary":"Add a location.","auth":"jwt","body":{"name":"Durban Showroom"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"PUT","path":"/api/inventory/locations/{id}","summary":"Rename a location.","auth":"jwt","body":{"name":"Durban Showroom (Umhlanga)"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"DELETE","path":"/api/inventory/locations/{id}","summary":"Remove a location. REFUSED while it holds stock — its stock_levels rows would otherwise point at a warehouse no list shows, while still counting towards the product's total.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/stock-levels?productId=<uuid>","summary":"On-hand by product and location.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"GET","path":"/api/inventory/movements?productId=<uuid>","summary":"Stock movement history with a running balance.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/adjust","summary":"Adjust stock up or down and post the journal. A negative resulting level is refused.","auth":"jwt","body":{"productId":"<uuid>","deltaQty":2.0,"reason":"Captured during the August review","locationId":"<uuid>"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/reorder-points","summary":"Reorder points and reorder quantities.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"PUT","path":"/api/inventory/reorder-points","summary":"Set a reorder point.","auth":"jwt","body":{"productId":"<uuid>","locationId":"<uuid>","reorderPoint":20.0,"reorderQty":100.0},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/transfers","summary":"Inter-location transfers.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/transfer","summary":"Raise a transfer. A shortfall is refused rather than driving the source negative, and the batch layers travel with the goods.","auth":"jwt","body":{"productId":"<uuid>","fromLocationId":"<uuid>","toLocationId":"<uuid>","qty":2.0},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"POST","path":"/api/inventory/transfers/{id}/confirm","summary":"Receive a transfer. StockCostingEngine.relocate moves the batch layers HERE — on receipt, not on despatch, because the sum of batch values has to keep equalling 1310 at every instant.","auth":"jwt","body":{"notes":"Received in full, no damage"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"POST","path":"/api/inventory/transfers/{id}/cancel","summary":"Cancel a transfer in flight.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/batches?productId=<uuid>","summary":"Batch layers — quantity remaining, unit cost, supplier, received and expiry dates, and the location they are actually at.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/batches","summary":"Create a batch layer.","auth":"jwt","body":{"productId":"<uuid>","batchNumber":"Example","receivedDate":"2026-09-30","qtyReceived":2.0,"unitCost":1500.0,"locationId":"<uuid>","expiryDate":"2027-08-20"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/batches/expiring?days=30","summary":"Batches expiring inside a window, with the location that holds them — naming the wrong branch is worse than naming none.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"DELETE","path":"/api/inventory/batches/{id}","summary":"Remove an empty batch layer.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/serials?productId=<uuid>","summary":"Serialised units and their status.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/serials","summary":"Register serial numbers against a batch.","auth":"jwt","body":{"productId":"<uuid>","batchId":"<uuid>","serials":["SN-0001","SN-0002"]},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/serials/{id}","summary":"One serialised unit and its history.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"GET","path":"/api/inventory/serials/lookup?serial=SN-0001","summary":"Find a unit by its serial.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"GET","path":"/api/inventory/serials/summary","summary":"Counts by status — in stock, sold, returned, scrapped.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/serials/assign","summary":"Assign serials to a document line at despatch.","auth":"jwt","body":{"invoiceId":"<uuid>","serialIds":["<uuid>"],"productId":"<uuid>"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"POST","path":"/api/inventory/serials/{id}/scrap","summary":"Write a unit off. Drains its OWN batch by one at that batch's cost — specific identification, which is the point of a serial — and posts DR 5070 / CR 1310. Only from in_stock.","auth":"jwt","body":{"notes":"Water damage"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"POST","path":"/api/inventory/serials/{id}/return","summary":"Return a unit to stock. A returned unit is back on the shelf only if a credit note restocked it.","auth":"jwt","body":{"notes":"Customer return, resold as open-box"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"DELETE","path":"/api/inventory/serials/{id}","summary":"Remove a serial registered in error.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/stock-takes","summary":"Stock takes and their variance.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/stock-takes","summary":"Open a stock take.","auth":"jwt","body":{"name":"Example","locationId":"<uuid>"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/stock-takes/{id}","summary":"One stock take with its counted lines.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"PUT","path":"/api/inventory/stock-takes/{id}/lines","summary":"Save counts. The variance is written WHEN COUNTS ARE SAVED, not only at commit — the list used to tell a supervisor a count with eight units missing was clean.","auth":"jwt","body":{"counts":[{"lineId":"<uuid>","countedQty":2.0}]},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"POST","path":"/api/inventory/stock-takes/{id}/commit","summary":"Post the variance as adjustments.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"POST","path":"/api/inventory/stock-takes/{id}/cancel","summary":"Abandon a stock take.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/boms","summary":"Assembly recipes. These are V96's simple recipes at the Business tier; the deep BOM work is /api/bom on the Manufacturing plan.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/boms","summary":"Create a recipe.","auth":"jwt","body":{"productId":"<uuid>","name":"Example","lines":[{"componentProductId":"<uuid>","quantity":4.0}]},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/boms/{id}","summary":"One recipe.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"PUT","path":"/api/inventory/boms/{id}","summary":"Change a recipe.","auth":"jwt","body":{"productId":"<uuid>","name":"Example","lines":[{"componentProductId":"<uuid>","quantity":5.0}]},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"DELETE","path":"/api/inventory/boms/{id}","summary":"Delete a recipe.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/boms/{id}/cost","summary":"What the recipe costs at current batch costs.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"GET","path":"/api/inventory/boms/using/{productId}","summary":"Which recipes use this component.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"GET","path":"/api/inventory/builds","summary":"Assembly builds.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/builds","summary":"Build assemblies from components. A short build is REFUSED, naming what is short and what maxBuildable says can be built now — costing a shortfall at list price once put the sum of batch values R61 441 above 1310 with no entry that could close it.","auth":"jwt","body":{"bomId":"<uuid>","qtyToBuild":2.0,"locationId":"<uuid>"},"module":"inventory","plan":"Business","scope":"inventory:write"},{"method":"GET","path":"/api/inventory/builds/{id}","summary":"One build and what it consumed.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:read"},{"method":"POST","path":"/api/inventory/builds/{id}/cancel","summary":"Reverse a build, returning components to their layers.","auth":"jwt","module":"inventory","plan":"Business","scope":"inventory:write"}]},{"name":"Job cards","blurb":"The field-service and workshop job. A job's cost is what the business SPENT — allocating a purchase writes ONE job_card_costs row carrying source_expense_id or source_bill_id, so double counting is impossible.","endpoints":[{"method":"GET","path":"/api/job-cards?status=in_progress&page=1&limit=50","summary":"Job cards, server-paged and server-filtered. It used to fetch one page of 100 and filter client-side, so a workshop lost its history after a fortnight.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"POST","path":"/api/job-cards","summary":"Raise a job. Accepts a client-minted id, which is what makes offline capture replayable — the CARD queues, but issuing parts and converting stay online-only.","auth":"jwt","body":{"contactId":"<uuid>","title":"Example","workRequested":"Replace element and thermostat","sourceQuoteId":null},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"GET","path":"/api/job-cards/{id}","summary":"One job with its labour, parts, costs and sign-off.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"PUT","path":"/api/job-cards/{id}","summary":"Update a job.","auth":"jwt","body":{"workRequested":"Replace element, thermostat and drip tray"},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"DELETE","path":"/api/job-cards/{id}","summary":"Delete a job.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/{id}/status","summary":"Move a job through open, in progress, awaiting parts, ready to invoice, closed.","auth":"jwt","body":{"status":"awaiting_parts"},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"GET","path":"/api/job-cards/summary","summary":"The board's tiles. Windowed to twelve months plus every open job.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"POST","path":"/api/job-cards/{id}/labour","summary":"Book labour. The rates default from users.default_charge_rate and default_cost_rate — before those existed, cost_rate defaulted to zero and every job reported 100% margin.","auth":"jwt","body":{"workDate":"2026-09-30","hours":2.0,"userId":"<uuid>","chargeRate":650.0,"costRate":320.0},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"PUT","path":"/api/job-cards/{id}/labour/{lineId}","summary":"Correct a labour line.","auth":"jwt","body":{"hours":4.0},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"DELETE","path":"/api/job-cards/{id}/labour/{lineId}","summary":"Remove a labour line.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/{id}/labour/import-time","summary":"Pull hours in from time tracking. Imports only APPROVED entries — the project invoicer always required it and this path did not, so approvals were bypassed by whichever of the two billing paths somebody used.","auth":"jwt","body":{"timeEntryIds":["<uuid>"]},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/{id}/parts","summary":"Add a part to the job, before it is issued.","auth":"jwt","body":{"description":"Captured during the August review","qty":2.0,"productId":"<uuid>","billable":true},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"PUT","path":"/api/job-cards/{id}/parts/{lineId}","summary":"Change a part line.","auth":"jwt","body":{"qty":2.0},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"DELETE","path":"/api/job-cards/{id}/parts/{lineId}","summary":"Remove a part line.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/{id}/parts/{lineId}/issue","summary":"Issue the part from stock at batch cost. What is in the van is out of the storeroom in the figures as well as in fact.","auth":"jwt","body":{"locationId":"<uuid>"},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/{id}/parts/{lineId}/return","summary":"Return an unused part to stock.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/{id}/costs","summary":"Add an on-cost typed by hand — a subcontractor, a callout fee.","auth":"jwt","body":{"description":"Crane hire","amount":4200.0,"markupPercent":10.0,"billable":true},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/{id}/costs/allocate","summary":"Allocate a real expense or bill to the job. JobCostAllocation is the single writer of both halves — the column on the purchase and the mirroring cost line — because either alone is a defect with a plausible screen.","auth":"jwt","body":{"expenseIds":["<uuid>"],"markupPercent":15.0,"billable":true},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"PUT","path":"/api/job-cards/{id}/costs/{lineId}","summary":"Change a cost line. An allocated purchase's AMOUNT is refused here and the response names where to correct it — what a job cost is a fact about the purchase; what the customer is charged is a decision about the job, so markup and billable stay editable.","auth":"jwt","body":{"markupPercent":20.0},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"DELETE","path":"/api/job-cards/{id}/costs/{lineId}","summary":"Remove a cost line.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"GET","path":"/api/job-cards/allocatable-costs","summary":"Expenses and bills that could still be allocated to a job.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"GET","path":"/api/job-cards/technicians","summary":"Who can be assigned, with their default rates.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"PUT","path":"/api/job-cards/technicians/{userId}/rates","summary":"Set a person's default charge and cost rates. Deliberately NOT a per-project-per-person rate table — users owns the figure for both modules.","auth":"jwt","body":{"defaultChargeRate":650.0,"defaultCostRate":320.0},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/{id}/technicians","summary":"Assign a technician to the job.","auth":"jwt","body":{"userId":"<uuid>"},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"DELETE","path":"/api/job-cards/{id}/technicians/{userId}","summary":"Unassign a technician.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/{id}/sign-off","summary":"Capture the customer's signature. The strokes become a 1-bit PNG in object storage as an AttachmentKind.SIGNATURE — never base64 into the column, which tenantStorageBytes does not count.","auth":"jwt","body":{"signOffName":"Example"},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/{id}/convert","summary":"Raise the invoice. Charges the VAT the tenant may actually charge — a company with no vat_number is not registered, and part lines bill at their own product's rate rather than a flat 15%.","auth":"jwt","body":{"issueDate":"2026-08-26"},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"GET","path":"/api/job-cards/{id}/report","summary":"The customer's job report — what was done, what was used, and the signature.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"POST","path":"/api/job-cards/{id}/report/send","summary":"Email the customer their signed job report, with the PDF attached.","auth":"jwt","body":{"email":"accounts@coastalcold.co.za"},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"GET","path":"/api/job-cards/dispatch","summary":"The dispatcher's day or week - a lane per technician, work placed by the clock, clashes flagged. Layout is computed by shared/domain/jobcards/DispatchBoard.kt so the screen cannot paint an overlap the server would not see. window=day|week, date=ISO anchor; an unknown window is refused rather than widened.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"POST","path":"/api/job-cards/{id}/dispatch","summary":"Reschedule and reassign in ONE write - what a drag across the board is. Two calls would leave a job at a time nobody chose when the second failed. unassignUserId is what makes a drag out of a lane take the job off THAT technician only.","auth":"jwt","body":{"scheduledStart":"2026-08-27T08:00:00","scheduledEnd":"2026-08-27T10:00:00","assignUserId":"00000000-0000-0000-0000-000000000000","unassignUserId":"00000000-0000-0000-0000-000000000000"},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"GET","path":"/api/job-cards/stock-locations","summary":"The stock locations the van picker may offer. Served here on jobcards:view because a role holding only jobcards:* cannot call the inventory tree at all - the same reason /api/bom/products exists.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"GET","path":"/api/job-cards/reports/wip","summary":"Work in progress — cost incurred and not yet billed. On a bad month it is most of a business's cash.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"GET","path":"/api/job-cards/reports/profitability","summary":"Margin per job, measured against quotedAmount where the job came from an accepted quote.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"GET","path":"/api/job-cards/reports/by-status","summary":"Jobs by state, for the board's counts.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"GET","path":"/api/job-cards/reports/completion-time","summary":"How long jobs take, from raised to closed.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"GET","path":"/api/job-cards/{id}/photos","summary":"The job's photographs. Ids, not bytes — fetch each through /api/files/{id}, because a job with twenty pictures would otherwise make this response tens of megabytes on a phone at a kerbside.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"POST","path":"/api/job-cards/{id}/photos?phase=before&caption=Board+before+work","summary":"Attach a photograph. RAW BYTES, not base64 — a Compose camera hands the client a ByteArray on every target and base64 inflates a 4 MB photo by a third. An 'after' is refused while the job is draft or scheduled: it is almost always a mis-tagged before. Capped at 24 per job.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"DELETE","path":"/api/job-cards/{id}/photos/{photoId}","summary":"Unlink a photograph. Removes the LINK, not the stored file — a job-card screen is not where somebody should be able to destroy evidence.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:write"}]},{"name":"Service contracts","blurb":"A cadence and an anchor date that raise ORDINARY job cards when a visit falls due. The contract owns no price: what a visit costs is a fact about the job card raised for it, priced the one way every job card is. Month stepping tracks the anchor, not the last clamp, so a 31st anchor gives 31 Jan / 28 Feb / 31 Mar.","endpoints":[{"method":"GET","path":"/api/job-cards/maintenance","summary":"The tenant's contracts, with the next due date and the number of visits owed COMPUTED server-side by MaintenanceSchedule — the same object the generator uses, so the countdown cannot disagree with the date a job card appears on.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:read"},{"method":"POST","path":"/api/job-cards/maintenance","summary":"Set up a contract. An unknown cadence is refused rather than defaulted: guessing would put a customer's quarterly inspection on a weekly footing.","auth":"jwt","body":{"contactId":"<uuid>","title":"Monthly generator service","cadence":"monthly","anchorDate":"2026-01-31","endsOn":null,"active":true,"leadUserId":"<uuid>"},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"PUT","path":"/api/job-cards/maintenance/{id}","summary":"Change a contract. active=false pauses it — it raises nothing whatever its dates say, and keeps its history.","auth":"jwt","body":{"contactId":"<uuid>","title":"Monthly generator service","cadence":"monthly","anchorDate":"2026-01-31","active":false},"module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"DELETE","path":"/api/job-cards/maintenance/{id}","summary":"End a contract. Does not touch the job cards it has already raised — those are real work, some of it invoiced.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:write"},{"method":"POST","path":"/api/job-cards/maintenance/{id}/raise","summary":"Raise the visits this contract owes, now. EVERY owed visit, not just the newest: a monthly contract three visits behind owes three job cards. Idempotent by a partial unique index on (contract, due date), so two replicas cannot raise the same visit twice. The same code the maintenance-jobcards scheduled job runs.","auth":"jwt","module":"job-cards","plan":"Business","scope":"job-cards:write"}]},{"name":"Time tracking","blurb":"The hours behind a job. rate_snapshot and cost_rate live on the ENTRY, because reading a project's live rate silently restated the value of every unbilled hour ever logged whenever a project was repriced.","endpoints":[{"method":"GET","path":"/api/time-tracking/entries?from=2026-08-01&to=2026-08-31&page=1","summary":"Time entries, server-paged and server-filtered. It used to return every entry the tenant had ever logged, with no limit and no filter, on every screen load.","auth":"jwt","module":"time-tracking","plan":"Starter","scope":"time-tracking:read"},{"method":"POST","path":"/api/time-tracking/entries","summary":"Log time. Amounts accept what people paste — \"1 500,50\" and \"R1500.50\" both parse.","auth":"jwt","body":{"projectId":"<uuid>","description":"Captured during the August review","workDate":"2026-09-30","hours":2.0,"billable":true},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"PUT","path":"/api/time-tracking/entries/{id}","summary":"Correct an entry. There was no edit route at all until V261, so a mis-typed 8 hours could only be deleted and re-captured — and delete needs a permission capture does not.","auth":"jwt","body":{"hours":6.0},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"DELETE","path":"/api/time-tracking/entries/{id}","summary":"Delete an entry. Refused once it is billed or held by a job card.","auth":"jwt","module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"PUT","path":"/api/time-tracking/entries/{id}/approve","summary":"Approve one entry. Records who approved it and when — approval used to be a bare boolean with no actor, under an invoice to a customer.","auth":"jwt","body":{"approved":true},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"POST","path":"/api/time-tracking/entries/approve","summary":"Approve many at once.","auth":"jwt","body":{"entryIds":["<uuid>","<uuid>"],"approved":true},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"POST","path":"/api/time-tracking/entries/{id}/bill","summary":"Mark an entry billed. Refused for an entry a job card already holds.","auth":"jwt","body":{"invoiceId":"<uuid>"},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"GET","path":"/api/time-tracking/projects","summary":"Projects, with budget hours, budget amount and actuals against them.","auth":"jwt","module":"time-tracking","plan":"Starter","scope":"time-tracking:read"},{"method":"POST","path":"/api/time-tracking/projects","summary":"Create a project.","auth":"jwt","body":{"contactId":"<uuid>","name":"Mobile app phase 2","hourlyRate":850.0,"budgetHours":320.0,"budgetAmount":272000.0},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"PUT","path":"/api/time-tracking/projects/{id}","summary":"Update a project. Repricing changes future entries only — logged hours keep the rate they were logged at.","auth":"jwt","body":{"hourlyRate":900.0},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"POST","path":"/api/time-tracking/projects/{id}/invoice","summary":"Invoice a project's approved, unbilled hours as service lines.","auth":"jwt","body":{"issueDate":"2026-08-31","terms":"30"},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"GET","path":"/api/time-tracking/timer/active","summary":"The running timer, if there is one.","auth":"jwt","module":"time-tracking","plan":"Starter","scope":"time-tracking:read"},{"method":"POST","path":"/api/time-tracking/timer/start","summary":"Start a timer.","auth":"jwt","body":{"projectId":"<uuid>","description":"API work"},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"POST","path":"/api/time-tracking/timer/{id}/stop","summary":"Stop a timer and write the entry.","auth":"jwt","module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"GET","path":"/api/time-tracking/weekly-totals?week=2026-W34","summary":"The week's totals per person and per project.","auth":"jwt","module":"time-tracking","plan":"Starter","scope":"time-tracking:read"},{"method":"GET","path":"/api/time-tracking/projects/{id}/tasks","summary":"Tasks on a project, with their charge and cost rates. `timetracking:view`.","auth":"jwt","module":"time-tracking","plan":"Starter","scope":"time-tracking:read"},{"method":"POST","path":"/api/time-tracking/projects/{id}/tasks","summary":"Add a task to a project. `timetracking:create`. A rate left null is 'not set' rather than zero — a zero cost rate reports a 100% margin.","auth":"jwt","body":{"name":"Site survey","chargeRate":950.0,"costRate":480.0,"active":true,"sortOrder":0,"clearChargeRate":false,"clearCostRate":false},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"PUT","path":"/api/time-tracking/projects/{id}/tasks/{taskId}","summary":"Correct a task. `timetracking:edit`. Set clearChargeRate or clearCostRate to unset a rate: null cannot say 'unset'.","auth":"jwt","body":{"name":"Site survey","chargeRate":950.0,"costRate":480.0,"active":true,"sortOrder":0,"clearChargeRate":false,"clearCostRate":false},"module":"time-tracking","plan":"Starter","scope":"time-tracking:write"},{"method":"DELETE","path":"/api/time-tracking/projects/{id}/tasks/{taskId}","summary":"Remove a task. `timetracking:delete`.","auth":"jwt","module":"time-tracking","plan":"Starter","scope":"time-tracking:write"}]},{"name":"Workflows","blurb":"Boards of work items — a month-end close, a client onboarding, an engineering change, a non-conformance. The one module with NO financial consequence: it posts nothing, moves no stock, mints no document number and raises no invoice, which is why a finished card can be dragged back out of Done where an invoiced job card cannot move at all. Hours logged on a card are ordinary `time_entries` rows carrying `workflow_item_id`, so the board feeds the client's matter without anybody re-keying anything.","endpoints":[{"method":"GET","path":"/api/workflows/templates","summary":"The five board templates the new-board dialog offers, each with its columns and the stage every column maps to. Data in `shared`, not rows: a template is read once at creation and the board owns its columns from that moment.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"GET","path":"/api/workflows/members","summary":"People a card can be assigned to. Four fields, not the user row — this fills a dropdown, and shipping roles, sessions and 2FA state to do that is the unbounded-list problem with a new name.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"GET","path":"/api/workflows/link-options","summary":"Matters and customers a board or a card can be linked to, as {id, name} pairs. Served here rather than from /api/time-tracking/projects and /api/contacts for the reason /api/bom/products exists: a role holding only workflows:* cannot call either, so the picker would be empty for exactly the person whose screen it is. Hours logged on a card become time entries against the matter this names.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"GET","path":"/api/workflows/boards?archived=true","summary":"Every board this company can see, with its columns and live counts. A board with no company is visible to all of them — the cost_centres shape. Archived boards are excluded unless asked for.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"POST","path":"/api/workflows/boards","summary":"Create a board from a template. The prefix is normalised upper case and must be free across the tenant, because it is half of every item number people quote to each other.","auth":"jwt","body":{"name":"Month-end close","keyPrefix":"CLOSE","description":"Reconciliations and reviews for one period","templateId":"month-end","contactId":null,"timeProjectId":null},"module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"GET","path":"/api/workflows/boards/{id}","summary":"One board, with its columns and their live counts.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"PUT","path":"/api/workflows/boards/{id}","summary":"Rename, re-point or archive a board, and save its columns as a WHOLE list. Whole-list because reorder, rename, add and remove happen in one dialog — four endpoints would let a board be saved with a column deleted and its replacement not yet added. A column still holding cards is refused, never orphaned.","auth":"jwt","body":{"name":"Month-end close","description":"Reconciliations and reviews","archived":false,"columns":[{"id":"<uuid>","name":"Not started","stage":"todo","wipLimit":null},{"id":null,"name":"In progress","stage":"in_progress","wipLimit":5}]},"module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"DELETE","path":"/api/workflows/boards/{id}","summary":"Delete an EMPTY board. One still holding cards is refused with the count and told to archive instead — deleting would strand their comments, their history and any hours logged against them.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"GET","path":"/api/workflows/boards/{id}/items?q=is%3Aoverdue+p%3Aurgent","summary":"The board's cards. `q` is the board's filter language — `is:` `stage:` `column:` `type:` `priority:` `label:` `assignee:` `for:` `due:` plus bare words, same key OR, different keys AND. Parsed by one object in `shared` so the client and the server cannot disagree about what `a:me` means.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"GET","path":"/api/workflows/boards/{id}/insights","summary":"Flow. Work in progress against each column's limit, throughput per week, and cycle time as a MEDIAN plus the 85th percentile — a mean over one card that waited four months for a supplier reports a typical card as taking three weeks, which is true of nothing on the board.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"GET","path":"/api/workflows/boards/{id}/export","summary":"The board as CSV. Gated on workflows:export.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"POST","path":"/api/workflows/items","summary":"Raise a card. The item number comes off the board's own counter taken FOR UPDATE, never count(*), which reissues a number the moment anything is deleted. A card inherits the board's matter unless it names its own.","auth":"jwt","body":{"boardId":"<uuid>","columnId":null,"title":"Reconcile the FNB cheque account","description":"Statement to 31 August","itemType":"task","priority":"high","labels":["vat","fnb"],"assigneeUserId":null,"contactId":null,"timeProjectId":null,"estimateMinutes":120,"dueDate":"2026-09-07"},"module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"GET","path":"/api/workflows/items/{id}","summary":"One card with its comments, its history and the time logged against it. The history is read from `audit_log` rather than a second trail, so “who moved this card” has one answer. The individual time entries are included only for a caller holding timetracking:view.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"PUT","path":"/api/workflows/items/{id}","summary":"Edit a card. Every field optional; null leaves it alone and “” clears it — the three-state convention bills.purchase_order_id established, so a caller learns one rule rather than two.","auth":"jwt","body":{"title":"Reconcile the FNB cheque account","description":"Statement to 31 August","itemType":"task","priority":"urgent","labels":["vat"],"assigneeUserId":"<uuid>","contactId":"","timeProjectId":"","estimateMinutes":180,"dueDate":"2026-09-07"},"module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"DELETE","path":"/api/workflows/items/{id}","summary":"Soft-delete a card. Owner and Admin only by default.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"POST","path":"/api/workflows/items/{id}/move","summary":"A drag. `insertIndex` rather than a rank: the client knows where the card landed between its neighbours and the server owns what number that becomes, so the fractional-index arithmetic — including renumbering an exhausted gap — lives on one side of the wire. A column at its work-in-progress limit refuses with a 409 naming the count and the limit.","auth":"jwt","body":{"columnId":"<uuid>","insertIndex":0},"module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"POST","path":"/api/workflows/items/{id}/comments","summary":"Comment on a card.","auth":"jwt","body":{"body":"Bank has sent the missing statement."},"module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"DELETE","path":"/api/workflows/comments/{id}","summary":"Soft-delete a comment.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"POST","path":"/api/workflows/items/{id}/time","summary":"Log time against a card as a `time_entries` row. Gated on timetracking:create, NOT workflows:edit — the resource a permission names is the thing being written, never the screen it was written from. Refused when neither the card nor its board is linked to a project: time_entries.project_id is NOT NULL and picking one on the user's behalf would attribute somebody's afternoon to a client who never asked for it.","auth":"jwt","body":{"minutes":90,"description":"Cleared the September reconciling items","entryDate":"2026-09-02","billable":true},"module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"GET","path":"/api/workflows/items/{id}/attachments","summary":"The files filed against one card, each with a signed URL minted for this read and never stored (V237). A file that cannot be signed comes back with a null URL rather than being hidden — one unsignable file must not make the other four disappear.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"POST","path":"/api/workflows/items/{id}/attachments?filename=brief.pdf","summary":"Attach a file to a card. Raw BYTES with the name on the query string, not multipart and not base64 — base64 inflates a 4 MB scan by a third on exactly the connection this product is designed for. The declared content type is a hint used only to accept or refuse; what is stored comes from the closed list in `AttachableFile`. Twelve files a card, 15 MB each. Gated on `workflows:edit`, not `create`: the file nearly always goes onto a card that already exists.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"DELETE","path":"/api/workflows/items/{id}/attachments/{linkId}","summary":"Unlink a file. Removes the LINK, never the bytes — the same attachment may be referenced from an expense or a sales document, and a board is not a place from which somebody should be able to destroy a receipt the books depend on. Deleting the file itself is DELETE /api/files/{id}.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"GET","path":"/api/workflows/recurrences?boardId=","summary":"Recurring checklists — the work that comes round again. Each carries its cadence, its next due date in words (never blank: \"Paused\" and \"Finished\" are answers) and the cards it raises.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"POST","path":"/api/workflows/recurrences","summary":"Set up a checklist that raises itself. A month-end close is fourteen cards, not one, so the recurrence owns a LIST; each occurrence raises them as ordinary cards with nothing special about them afterwards. The cadence arithmetic is `MaintenanceSchedule`, shared with service contracts rather than copied, so a 31st anchor gives 31 Jan then 28 Feb then 31 Mar.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"GET","path":"/api/workflows/recurrences/{id}","summary":"One recurring checklist with its cards, in the shape the editor saves — a dialog that saves what it loaded has to load what it saves.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:read"},{"method":"PUT","path":"/api/workflows/recurrences/{id}","summary":"Edit a checklist. A line that keeps its id keeps its identity, and that is load-bearing: the idempotency key is (line, occurrence), so a line re-created with a fresh id would raise its card again for an occurrence already dealt with.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"DELETE","path":"/api/workflows/recurrences/{id}","summary":"Stop a checklist recurring. The cards it has already raised STAY — they are ordinary work somebody may be halfway through. Pausing is the reversible option and the list says so.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:write"},{"method":"POST","path":"/api/workflows/recurrences/{id}/raise","summary":"Raise everything this checklist owes, now — the same implementation the `workflow-recurrences` job runs, because a button with its own version would eventually raise a different set from the timer. Reports occurrences raised AND occurrences passed over as older than the backfill window: a run that reported only what it did would be indistinguishable from one that found nothing owed.","auth":"jwt","module":"workflows","plan":"Enterprise","scope":"workflows:write"}]},{"name":"Bills of materials","blurb":"The deep BOM work — multi-level explosion, low-level coding, where-used, make/buy and the cost roll-up — plus AUTHORING since 2026-08-24. /api/inventory/boms stays exactly as it was on the `inventory` module at Business tier: no existing tenant loses what it already has, and the two doors share one server implementation so neither can become the lenient one. Building is NOT here — it consumes components at batch cost and posts to the ledger, so it stays on `inventory:create`. V289 adds an engineering-change step: a recipe is authored as a draft, submitted, and put into force by somebody holding `bom:approve` — which is what a build and MRP read.","endpoints":[{"method":"GET","path":"/api/bom/overview","summary":"The structural health of the BOM graph. Leads on what BLOCKS planning — parts flagged make-in-house with no recipe, and cycles — rather than on counts.","auth":"jwt","module":"bom","plan":"Manufacturing","scope":"bom:read"},{"method":"GET","path":"/api/bom/{productId}/explode?qty=100","summary":"Multi-level explosion for a quantity, level by level.","auth":"jwt","module":"bom","plan":"Manufacturing","scope":"bom:read"},{"method":"GET","path":"/api/bom/{productId}/where-used","summary":"Every parent this part appears in, at any level.","auth":"jwt","module":"bom","plan":"Manufacturing","scope":"bom:read"},{"method":"GET","path":"/api/bom/{bomId}/detail","summary":"One BOM with its lines, effectivity and scrap factors.","auth":"jwt","module":"bom","plan":"Manufacturing","scope":"bom:read"},{"method":"GET","path":"/api/bom/planning","summary":"Per-item planning parameters — make or buy, lead time, lot sizing, safety stock.","auth":"jwt","module":"bom","plan":"Manufacturing","scope":"bom:read"},{"method":"PUT","path":"/api/bom/planning","summary":"Set planning parameters for a product.","auth":"jwt","body":{"productId":"<uuid>","sourceType":"make","leadTimeDays":5,"lotSizing":50.0,"safetyStock":20.0},"module":"bom","plan":"Manufacturing","scope":"bom:write"},{"method":"PUT","path":"/api/bom/{bomId}/planning","summary":"Set planning parameters that belong to one BOM rather than the item.","auth":"jwt","body":{},"module":"bom","plan":"Manufacturing","scope":"bom:write"},{"method":"PUT","path":"/api/bom/lines/{lineId}/planning","summary":"Set a line's scrap factor and effectivity dates.","auth":"jwt","body":{"effectiveFrom":"2026-09-01"},"module":"bom","plan":"Manufacturing","scope":"bom:write"},{"method":"POST","path":"/api/bom/planning/bulk-source","summary":"Flag many items make or buy at once.","auth":"jwt","body":{"productIds":"<uuid>,<uuid>","sourceType":"buy"},"module":"bom","plan":"Manufacturing","scope":"bom:write"},{"method":"GET","path":"/api/bom/products","summary":"The products this tenant can name in a recipe — id, SKU, name and whether stock is tracked. Exists because a role holding only `bom:*` cannot call /api/inventory/products, so without it the recipe editor has no way to name a finished good or a component.","auth":"jwt","module":"bom","plan":"Manufacturing","scope":"bom:read"},{"method":"GET","path":"/api/bom/{bomId}","summary":"One recipe in the shape the EDITOR saves — every field plus the component lines. Distinct from {bomId}/detail, which is the analysis shape (roll-up, tree, where-used): a dialog that saves what it loaded has to load what it saves.","auth":"jwt","module":"bom","plan":"Manufacturing","scope":"bom:read"},{"method":"POST","path":"/api/bom","summary":"Author a recipe. Gated on `bom:create`, over the same implementation /api/inventory/boms uses — so a BOM engineer authors recipes without `inventory:create`, which also carries product creation and stock adjustments. Refuses a finished good that is not stock-tracked, a component that is the finished good, scrap at 100%, and an effectivity window that closes before it opens.","auth":"jwt","body":{"productId":"<uuid>","name":"Standard build","version":"1","outputQty":1.0,"labourCost":250.0,"overheadCost":80.0,"status":"active","notes":"Two people, one shift","effectiveFrom":"2026-09-01","effectiveTo":null,"lines":[{"componentProductId":"<uuid>","qtyPer":4.0,"scrapPercent":2.5,"notes":"Cut to length","sortOrder":0}]},"module":"bom","plan":"Manufacturing","scope":"bom:write"},{"method":"PUT","path":"/api/bom/{bomId}","summary":"Edit a recipe. An empty `lines` list leaves the components alone; send a list to replace them. The finished good is NOT re-pointable — moving a recipe to another product would restate every build already costed against it.","auth":"jwt","body":{"productId":"<uuid>","name":"Standard build","version":"1","outputQty":1.0,"labourCost":250.0,"overheadCost":80.0,"status":"active","notes":"Two people, one shift","effectiveFrom":"2026-09-01","effectiveTo":null,"lines":[{"componentProductId":"<uuid>","qtyPer":4.0,"scrapPercent":2.5,"notes":"Cut to length","sortOrder":0}]},"module":"bom","plan":"Manufacturing","scope":"bom:write"},{"method":"DELETE","path":"/api/bom/{bomId}","summary":"Remove a recipe — or ARCHIVE it when it has been built from, because it is then the record of how those units were costed. Gated on `bom:delete`.","auth":"jwt","module":"bom","plan":"Manufacturing","scope":"bom:write"},{"method":"GET","path":"/api/bom/pending","summary":"The approver's queue — every recipe awaiting a decision, oldest submission first. Gated on `bom:view`, not `bom:approve`: an author needs to see that their own submission is still sitting there, and hiding the queue from them is how people start asking in person.","auth":"jwt","module":"bom","plan":"Manufacturing","scope":"bom:read"},{"method":"POST","path":"/api/bom/{bomId}/submit","summary":"Ask for a decision. `bom:edit` — submitting is the author's act. Refuses a recipe with no components: there is nothing to approve about one, and a button that always 400s is how people learn to ignore a workflow.","auth":"jwt","module":"bom","plan":"Manufacturing","scope":"bom:write"},{"method":"POST","path":"/api/bom/{bomId}/approve","summary":"Put a recipe into force. `bom:approve`. There is deliberately NO self-approval refusal — nothing in Ledgr has one, the control is which roles hold the permission, and a three-user tenant with one engineer would otherwise never activate a recipe. Both actors are recorded.","auth":"jwt","body":{"note":"Checked against drawing rev C"},"module":"bom","plan":"Manufacturing","scope":"bom:write"},{"method":"POST","path":"/api/bom/{bomId}/reject","summary":"Send a recipe back to draft with a reason. `bom:approve`, and the reason is REQUIRED — a rejection with no reason means the author resubmits the same recipe.","auth":"jwt","body":{"note":"The beam quantity is per pair, not per frame"},"module":"bom","plan":"Manufacturing","scope":"bom:write"}]},{"name":"Material planning (MRP)","blurb":"Runs, planned orders, the time-phased grid and pegging. Material Planning PROPOSES work orders; Production owns them — there is deliberately no second Work Orders tab here.","endpoints":[{"method":"GET","path":"/api/mrp/runs","summary":"MRP runs and their outcomes.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"POST","path":"/api/mrp/runs","summary":"Run MRP across the planning horizon.","auth":"jwt","body":{"includeForecast":true},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/mrp/runs/latest","summary":"The most recent run, which is what the screen opens on.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"GET","path":"/api/mrp/runs/{id}","summary":"One run — its planned orders, exceptions and the time-phased grid.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"POST","path":"/api/mrp/planned-orders/{id}/firm","summary":"Firm a recommendation so the next run stops moving it.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/mrp/planned-orders/{id}/cancel","summary":"Discard a recommendation.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/mrp/convert","summary":"Split recommendations into requisitions for the buy side and work orders for the make side.","auth":"jwt","body":{"plannedOrderIds":["<uuid>"]},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/mrp/forecasts","summary":"Demand forecasts feeding the run.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"POST","path":"/api/mrp/forecasts","summary":"Add a forecast.","auth":"jwt","body":{"productId":"<uuid>","periodStart":"2026-10","qty":400.0},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"DELETE","path":"/api/mrp/forecasts/{id}","summary":"Remove a forecast.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/mrp/capacity","summary":"Load against capacity per work centre across the horizon.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"GET","path":"/api/mrp/calendar","summary":"The manufacturing calendar — working days, shifts and shutdowns.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"POST","path":"/api/mrp/calendar","summary":"Add a calendar entry.","auth":"jwt","body":{"date":"2026-12-25","isWorking":false,"notes":"Public holiday"},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"DELETE","path":"/api/mrp/calendar","summary":"Clear a range of calendar entries.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"DELETE","path":"/api/mrp/calendar/{id}","summary":"Remove one calendar entry.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/mrp/work-centres","summary":"Work centres, their capacity and their rates.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"POST","path":"/api/mrp/work-centres","summary":"Add a work centre.","auth":"jwt","body":{"code":"WELD-01","name":"Welding bay 1","capacityHoursPerDay":8.0,"labourRatePerHour":420.0},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"PUT","path":"/api/mrp/work-centres/{id}","summary":"Update a work centre.","auth":"jwt","body":{"capacityHoursPerDay":16.0},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"DELETE","path":"/api/mrp/work-centres/{id}","summary":"Remove a work centre.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/mrp/routings","summary":"Routings — the ordered operations a part goes through.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"POST","path":"/api/mrp/routings","summary":"Create a routing.","auth":"jwt","body":{"productId":"<uuid>","name":"Standard frame route"},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/mrp/routings/{id}","summary":"One routing and its operations.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"PUT","path":"/api/mrp/routings/{id}","summary":"Update a routing.","auth":"jwt","body":{"name":"Standard frame route rev B"},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"DELETE","path":"/api/mrp/routings/{id}","summary":"Delete a routing.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/mrp/routings/{id}/operations","summary":"Add an operation to a routing.","auth":"jwt","body":{"sequenceNo":10,"workCentreId":"<uuid>","setupHours":0.5,"runHoursPerUnit":0.05},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"PUT","path":"/api/mrp/routings/operations/{opId}","summary":"Change an operation.","auth":"jwt","body":{"runHoursPerUnit":0.05},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"DELETE","path":"/api/mrp/routings/operations/{opId}","summary":"Remove an operation.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/mrp/scrap-reasons","summary":"The scrap reason list.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"POST","path":"/api/mrp/scrap-reasons","summary":"Add a scrap reason.","auth":"jwt","body":{"code":"weld-porosity","label":"Weld porosity"},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"DELETE","path":"/api/mrp/scrap-reasons/{code}","summary":"Remove a scrap reason.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/mrp/scrap-analysis?from=2026-06-01&to=2026-08-31","summary":"Scrap by reason, by part and by work centre.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"GET","path":"/api/mrp/batch-trace?batchId=<uuid>","summary":"Genealogy: forward from a supplier lot to every job and finished batch that consumed it, backward from a finished batch to every lot inside it.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"GET","path":"/api/mrp/work-orders/{id}/genealogy","summary":"The lots that went into one work order's output.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"}]},{"name":"Production & work orders","blurb":"The execution screen. A completion posts what the stock ledger RECEIVED, not what was requested — work order quantities are fractional and stock movements are integers, and posting qty × standard against round(qty) received was this product's whole inventory-to-ledger difference.","endpoints":[{"method":"GET","path":"/api/work-orders?status=released","summary":"Work orders. The status filter lives here and nowhere else — it is the only way to reach a cancelled or completed order.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"POST","path":"/api/work-orders","summary":"Raise a work order.","auth":"jwt","body":{"productId":"<uuid>","qty":250.0,"plannedStart":"2026-09-01","plannedFinish":"2026-09-05"},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/work-orders/{id}","summary":"One work order with its material, labour and cost position.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"POST","path":"/api/work-orders/{id}/release","summary":"Release the order to the floor.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/work-orders/{id}/cancel","summary":"Cancel an order and return what it holds.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/work-orders/{id}/complete","summary":"Complete the order. closeJob stays a wire name — the screen says \"work order\" everywhere, but renaming the field would break the contract.","auth":"jwt","body":{"qtyCompleted":248.0,"qtyScrapped":2.0,"closeJob":true},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/work-orders/completions/{id}/reverse","summary":"Reverse a completion. Withdraws the units actually moved, not the quantity requested — the reversal path carried the same rounding defect mirrored.","auth":"jwt","body":{"qty":2.0,"reason":"Booked against the wrong work order"},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/work-orders/{id}/issue","summary":"Issue material. With no lines, issues the recipe's requirement for the outstanding quantity, which is what a storeman picking a whole job wants. Negative quantities return material at the price the job was charged.","auth":"jwt","body":{"locationId":"<uuid>","lines":[{"productId":"<uuid>","quantity":500.0}]},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/work-orders/{id}/labour","summary":"Book hours against the order.","auth":"jwt","body":{"workCentreId":"<uuid>","hours":6.0,"employeeId":"<uuid>"},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/work-orders/{id}/costing","summary":"The order's costing view — material, labour, overhead and the variances against standard.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"GET","path":"/api/work-orders/{id}/operations","summary":"The order's operations and their state.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"POST","path":"/api/work-orders/{id}/operations","summary":"Attach operations to the order, usually copied from the routing.","auth":"jwt","body":{},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/work-orders/operations/{opId}/start","summary":"Start an operation.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/work-orders/operations/{opId}/book","summary":"Book time and quantity against an operation.","auth":"jwt","body":{"hours":2.5,"qtyCompleted":60.0,"qtyScrapped":1.0},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/work-orders/operations/{opId}/complete","summary":"Complete an operation.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/work-in-progress","summary":"The open WIP balance by job. This is what reconciles the WIP control account to the floor. jobs and openJobCount stay wire names.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"GET","path":"/api/standard-costs","summary":"Stored standard-cost results.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"},{"method":"PUT","path":"/api/standard-costs","summary":"Override a standard cost by hand.","auth":"jwt","body":{"productId":"<uuid>","materialCost":1284.5},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"POST","path":"/api/standard-costs/roll-up","summary":"Roll the BOM cost up through every level and store the result.","auth":"jwt","body":{"productIds":["<uuid>"]},"module":"mrp","plan":"Manufacturing","scope":"mrp:write"},{"method":"GET","path":"/api/standard-costs/{productId}","summary":"One product's standard cost and how it was arrived at.","auth":"jwt","module":"mrp","plan":"Manufacturing","scope":"mrp:read"}]},{"name":"Procurement","blurb":"Requisitions and their approval routing, approved vendors, contracts, RFQs, and the three-way match.","endpoints":[{"method":"GET","path":"/api/procurement/overview","summary":"The buyer's dashboard — open requisitions, orders awaiting approval and unmatched receipts.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"GET","path":"/api/procurement/requisitions?status=submitted","summary":"Requisitions.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/requisitions","summary":"Raise a requisition.","auth":"jwt","body":{"requiredDate":"2026-09-10","lines":[{"productId":"<uuid>","quantity":500.0,"estimatedUnitPrice":84.5}]},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/requisitions/{id}","summary":"One requisition and its approval trail.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/requisitions/{id}/submit","summary":"Submit for approval.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"POST","path":"/api/procurement/requisitions/{id}/approve","summary":"Approve at the caller's level in the routing.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"POST","path":"/api/procurement/requisitions/{id}/reject","summary":"Reject, with a reason.","auth":"jwt","body":{"comment":"Stock already on order"},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"POST","path":"/api/procurement/requisitions/{id}/cancel","summary":"Cancel a requisition.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"POST","path":"/api/procurement/requisitions/{id}/purchase-orders","summary":"Turn an approved requisition into purchase orders, split by vendor.","auth":"jwt","body":{"singleSupplierId":"<uuid>"},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/approval-rules","summary":"The approval routing — who approves what, at what value.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/approval-rules","summary":"Add an approval rule.","auth":"jwt","body":{"maxAmount":50000.0,"approverRoleId":"<uuid>","stepNo":2},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"DELETE","path":"/api/procurement/approval-rules/{id}","summary":"Remove an approval rule.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/vendors","summary":"Approved vendors with their ratings and B-BBEE levels.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"PUT","path":"/api/procurement/vendors","summary":"Approve a supplier as a vendor, or change their standing.","auth":"jwt","body":{"supplierId":"<uuid>","status":"approved","priority":1},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"DELETE","path":"/api/procurement/vendors/{id}","summary":"Withdraw vendor approval.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/vendors/unsourced","summary":"Parts flagged buy with no approved vendor — the list that stops an MRP run producing a usable answer.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"GET","path":"/api/procurement/vendors/{id}/price-breaks","summary":"A vendor's quantity price breaks.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"PUT","path":"/api/procurement/vendors/{id}/price-breaks","summary":"Set a vendor's price breaks.","auth":"jwt","body":{"breaks":[{"minQuantity":500.0,"unitPrice":79.9}]},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/contracts","summary":"Supply contracts and their coverage.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/contracts","summary":"Record a contract.","auth":"jwt","body":{"supplierId":"<uuid>","contractNumber":"SUP-2026-04","startDate":"2026-09-01","endDate":"2027-08-31"},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"PUT","path":"/api/procurement/contracts/{id}/status","summary":"Activate, suspend or close a contract.","auth":"jwt","body":{"status":"active"},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/rfqs","summary":"Requests for quotation.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/rfqs","summary":"Raise an RFQ.","auth":"jwt","body":{"responseDueDate":"2026-09-05","supplierIds":["<uuid>"],"lines":[{"productId":"<uuid>","quantity":500.0}]},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/rfqs/{id}","summary":"One RFQ and the quotes received.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/rfqs/{id}/send","summary":"Send the RFQ to its vendors.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"POST","path":"/api/procurement/rfqs/{id}/quotes","summary":"Capture a quote received off-platform.","auth":"jwt","body":{"supplierId":"<uuid>","lines":[{"productId":"<uuid>","unitPrice":81.0,"leadTimeDays":7}]},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"POST","path":"/api/procurement/rfqs/{id}/award","summary":"Award the RFQ and raise the order.","auth":"jwt","body":{"quoteId":"<uuid>"},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/purchase-orders","summary":"Purchase orders from the buyer's side, with their transmission state.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/purchase-orders/{id}/transmit","summary":"Send the order to the vendor.","auth":"jwt","body":{"channel":"email"},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/matches","summary":"The three-way match — order against receipt against bill — and what does not agree.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/matches/{billId}","summary":"Run the match for one bill.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"POST","path":"/api/procurement/matches/{billId}/resolve","summary":"Accept or write off a matching difference.","auth":"jwt","body":{"accept":true,"note":"Freight not on the order"},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/settings","summary":"Procurement settings — tolerances, required approvals, whether a receipt is mandatory before a bill.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"PUT","path":"/api/procurement/settings","summary":"Change the procurement settings.","auth":"jwt","body":{"priceTolerancePercent":2.0,"requireRequisitionForPo":true},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/bbbee-spend","summary":"Preferential procurement spend by supplier B-BBEE level, which feeds the scorecard element.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"PUT","path":"/api/procurement/suppliers/{id}/bbbee","summary":"Record a supplier's B-BBEE level and certificate expiry.","auth":"jwt","body":{"level":2,"certificateExpiry":"2027-03-31"},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"}]},{"name":"Supplier portal API","blurb":"A supplier holds a token scoped to one contacts row, sent as a bearer HEADER rather than in the URL path — a supplier signs in repeatedly and bookmarks the page. Revocation is a generation counter, and the per-supplier opt-in is checked on every request. V280 added the half a supplier opens on their own initiative: their price list, their compliance paperwork, an offer nobody asked for, a despatch advice, and what they are owed. None of it writes stock, a ledger entry or an approval — the buyer reads it and chooses to act.","endpoints":[{"method":"GET","path":"/supplier-api/v1/session","summary":"Who the token belongs to, which buyer they are dealing with, and what they may see.","auth":"supplier-token"},{"method":"GET","path":"/supplier-api/v1/orders","summary":"Purchase orders addressed to this supplier.","auth":"supplier-token"},{"method":"GET","path":"/supplier-api/v1/orders/{id}","summary":"One order with its lines and delivery requirements.","auth":"supplier-token"},{"method":"POST","path":"/supplier-api/v1/orders/{id}/acknowledge","summary":"Acknowledge an order, with a promised date.","auth":"supplier-token","body":{"promisedDate":"2026-09-08","note":"Full quantity available"}},{"method":"GET","path":"/supplier-api/v1/rfqs","summary":"RFQs this supplier has been invited to.","auth":"supplier-token"},{"method":"GET","path":"/supplier-api/v1/rfqs/{id}","summary":"One RFQ and its lines.","auth":"supplier-token"},{"method":"POST","path":"/supplier-api/v1/rfqs/{id}/quote","summary":"Submit a quote against an RFQ.","auth":"supplier-token","body":{"lines":[{"rfqLineId":"<uuid>","unitPrice":81.0,"leadTimeDays":7}],"validUntil":"2026-09-30"}},{"method":"GET","path":"/supplier-api/v1/invoices","summary":"Invoices this supplier has submitted, and their state.","auth":"supplier-token"},{"method":"POST","path":"/supplier-api/v1/invoices","summary":"Submit an invoice against an order. It arrives as a bill for the buyer to match, never as an approved payable.","auth":"supplier-token","body":{"purchaseOrderId":"<uuid>","invoiceNumber":"ACME-8841","issueDate":"2026-09-09","lines":[{"poLineId":"<uuid>","quantity":500.0,"unitPrice":81.0,"vatRate":15.0}]}},{"method":"POST","path":"/supplier-api/v1/invoices/{id}/withdraw","summary":"Withdraw a submitted invoice before the buyer processes it.","auth":"supplier-token"},{"method":"GET","path":"/supplier-api/v1/catalogue","summary":"The supplier's own price list, and which of their prices the buyer is ordering against.","auth":"supplier-token"},{"method":"POST","path":"/supplier-api/v1/catalogue","summary":"Add or correct one price-list item. Keyed on the supplier's own SKU, so re-sending a price list updates rather than duplicating.","auth":"supplier-token","body":{"description":"12mm mild steel plate","supplierSku":"MS-12-2412","unitPrice":1840.0,"minOrderQty":4.0,"orderMultiple":4.0,"leadTimeDays":5,"priceBreaks":[{"minQty":20.0,"unitPrice":1755.0}]}},{"method":"POST","path":"/supplier-api/v1/catalogue/{id}/withdraw","summary":"Stop offering an item. Withdrawn rather than deleted, so a price quoted last month stays explicable.","auth":"supplier-token"},{"method":"GET","path":"/supplier-api/v1/compliance","summary":"What is on file, what is about to lapse, and what the buyer has never had — the missing list included, because a supplier cannot send a document nobody asked for.","auth":"supplier-token"},{"method":"POST","path":"/supplier-api/v1/compliance/upload","summary":"Upload the scan of a document, up to 8MB. Returns an attachment id to quote on the submit. The server decides the content type, never the client.","auth":"supplier-token"},{"method":"POST","path":"/supplier-api/v1/compliance","summary":"File a compliance document. It arrives as submitted and changes nothing until a person accepts it; a B-BBEE level is held as a CLAIM until then.","auth":"supplier-token","body":{"kind":"bbbee_certificate","reference":"VA-2026-00184","issuedOn":"2026-03-01","expiresOn":"2027-02-28","claimedBbbeeLevel":4,"attachmentId":"<uuid>"}},{"method":"GET","path":"/supplier-api/v1/offers","summary":"Offers this supplier has made unasked, with the buyer's outcome and whether each may still be revised.","auth":"supplier-token"},{"method":"POST","path":"/supplier-api/v1/offers","summary":"Send a price nobody asked for, or revise one already sent. Idempotent on clientReference — two prices for the same work in front of a buyer is worse than a duplicate invoice.","auth":"supplier-token","body":{"clientReference":"offer-4471","title":"Steelwork, Bellville warehouse","validUntil":"2026-10-15","leadTimeDays":10,"lines":[{"description":"Portal frames","quantity":40.0,"unitPrice":8750.0,"vatRatePercent":15.0}]}},{"method":"POST","path":"/supplier-api/v1/offers/{id}/withdraw","summary":"Take an offer off the table. A different fact from the buyer rejecting it, so it does not overwrite the status.","auth":"supplier-token"},{"method":"GET","path":"/supplier-api/v1/orders/{id}/despatchable","summary":"What may still be declared as sent on an order — netting off what has been received AND what is already in transit.","auth":"supplier-token"},{"method":"POST","path":"/supplier-api/v1/orders/{id}/despatch","summary":"Declare that goods have left. Moves no stock and costs nothing; it pre-fills the buyer's goods receipt and gives their expediting screen a real arrival date.","auth":"supplier-token","body":{"clientReference":"asn-9912","despatchedOn":"2026-09-10","expectedArrival":"2026-09-12","carrier":"Own vehicle","waybill":"WB-33417","lines":[{"poLineId":"<uuid>","qty":40.0}]}},{"method":"GET","path":"/supplier-api/v1/despatches","summary":"Deliveries this supplier has declared, and whether each has been booked in.","auth":"supplier-token"},{"method":"GET","path":"/supplier-api/v1/statement","summary":"What the supplier is owed, when it falls due, and the payables control's verdict on anything held — from the buyer's own books.","auth":"supplier-token"},{"method":"GET","path":"/supplier-api/v1/scorecard","summary":"The supplier's own on-time rating, quote win rate and spend. The same figures the buyer's vendor list draws — a rating a supplier cannot see is one they cannot improve.","auth":"supplier-token"}]},{"name":"Supplier workspace, buyer side","blurb":"The buyer's half of what a supplier sends through /supplier-api/v1 (V280). Without these the portal's writes land in tables nobody reads, and a supplier who sends something into a void stops sending. Every write here is a PERSON deciding: adopting a catalogue item copies it into approved_vendors rather than aliasing it, and accepting a B-BBEE document is the only thing that writes the supplier's B-BBEE level.","endpoints":[{"method":"GET","path":"/api/procurement/supplier-offers","summary":"Quotes suppliers sent without being asked. Kept apart from RFQ responses, which belong beside their competitors.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/supplier-offers/{id}/decide","summary":"Accept or decline an offer. A reason is required on a decline and the supplier reads it — 'we went elsewhere' with no reason is why suppliers stop quoting.","auth":"jwt","body":{"outcome":"decline","note":"Went with a shorter lead time."},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/supplier-catalogue","summary":"What suppliers say they sell, with their volume breaks and order multiples.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/supplier-catalogue/{id}/adopt","summary":"Take an offered item into the approved source list. COPIES the figures, so a supplier editing their catalogue afterwards cannot move a price you have contracted at.","auth":"jwt","body":{"productId":"<uuid>"},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/supplier-compliance","summary":"Every supplier's paperwork with its expiry and what lapses with it — the 30-day question is a WHERE clause on one date.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"},{"method":"POST","path":"/api/procurement/supplier-compliance/{id}/review","summary":"Accept or reject a document. Accepting a B-BBEE certificate is the ONLY thing that writes contacts.bbbee_level, because an unverified level is an audit finding against the buyer who claimed the spend.","auth":"jwt","body":{"outcome":"accept"},"module":"procurement","plan":"Manufacturing","scope":"procurement:write"},{"method":"GET","path":"/api/procurement/incoming-despatches","summary":"What suppliers say is on its way, with a real expected arrival rather than a date typed at ordering time. Nothing here has moved any stock.","auth":"jwt","module":"procurement","plan":"Manufacturing","scope":"procurement:read"}]},{"name":"Logistics & fleet","blurb":"A TRIP is the parent, not the delivery note — one vehicle carries eight customers' goods, and one document is routinely delivered across two stops on two days. The proof belongs to the STOP.","endpoints":[{"method":"GET","path":"/api/logistics/board","summary":"Every vehicle with its state, its current trip and its last known position. The screen a fleet controller leaves open.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"GET","path":"/api/logistics/summary","summary":"Today's counts, plus what expires in 30 days — the licence disc, the certificate of fitness, the operator card, the driver's licence and the PrDP.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"GET","path":"/api/logistics/performance?from=2026-08-01&to=2026-08-31","summary":"On-time delivery, failure reasons, distance and cost per trip.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"GET","path":"/api/logistics/unassigned","summary":"Documents ready to deliver that no trip has picked up.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"GET","path":"/api/logistics/vehicles","summary":"Vehicles with their SA compliance dates as columns rather than a generic documents table.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"POST","path":"/api/logistics/vehicles","summary":"Add a vehicle. The operator card applies to goods vehicles over 3 500kg under the NRTA.","auth":"jwt","body":{"registration":"CA 123-456","licenceDiscExpiry":"2027-03-31","roadworthyExpiry":"2027-01-31","operatorCardExpiry":"2027-06-30"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"GET","path":"/api/logistics/vehicles/{id}","summary":"One vehicle with its trip and position history.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"PUT","path":"/api/logistics/vehicles/{id}","summary":"Update a vehicle.","auth":"jwt","body":{"registration":"Example","licenceDiscExpiry":"2028-03-31"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"DELETE","path":"/api/logistics/vehicles/{id}","summary":"Retire a vehicle.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"GET","path":"/api/logistics/drivers","summary":"Drivers with their licence code and PrDP category.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"POST","path":"/api/logistics/drivers","summary":"Add a driver. A driver is NOT a user account — the product deliberately does not meter seats, and a seat per driver would break that.","auth":"jwt","body":{"fullName":"Example","licenceCode":"EC","licenceExpiry":"2029-05-31","prdpExpiry":"2027-05-31"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"PUT","path":"/api/logistics/drivers/{id}","summary":"Update a driver.","auth":"jwt","body":{"fullName":"Example","prdpExpiry":"2029-05-31"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"DELETE","path":"/api/logistics/drivers/{id}","summary":"Retire a driver.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"POST","path":"/api/logistics/drivers/{id}/app-token","summary":"Mint a driver-app credential. Returned ONCE and never retrievable; issuing a new one retires whatever that driver is holding, by bumping drivers.portal_token_version. Add ?days= to shorten the 90-day default.","auth":"jwt","responseExample":{"token":"<driver-token, shown once>","driverId":"<uuid>","expiresAt":"2026-11-20T00:00:00Z"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"POST","path":"/api/logistics/drivers/{id}/app-token/revoke","summary":"Retire the driver's token now. A JWT is valid until it expires by construction; nothing else can retire one already on a phone.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"GET","path":"/api/logistics/trips","summary":"Trips with their stops and state.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"POST","path":"/api/logistics/trips","summary":"Plan a trip — one vehicle, one driver, one run. Numbered from the shared document_sequences series under doc_type 'trip'.","auth":"jwt","body":{"tripDate":"2026-08-25","vehicleId":"<uuid>","driverId":"<uuid>","routeName":"Northern suburbs"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"GET","path":"/api/logistics/trips/{id}","summary":"One trip with its ordered stops.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"PUT","path":"/api/logistics/trips/{id}","summary":"Change a trip's vehicle, driver or date.","auth":"jwt","body":{"tripDate":"2026-09-30","driverId":"<uuid>"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"DELETE","path":"/api/logistics/trips/{id}","summary":"Delete a planned trip.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"POST","path":"/api/logistics/trips/{id}/status","summary":"Move a trip through planned, loading, dispatched, in_progress, completed. Departure and completion both require an odometer reading.","auth":"jwt","body":{"status":"in_progress","odometerKm":184320.0},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"POST","path":"/api/logistics/trips/{id}/stops","summary":"Add a stop that is not a document — a collection, a return, a site visit or the depot leg.","auth":"jwt","body":{"stopType":"collection","contactName":"Depot","address":"12 Marine Drive","city":"Cape Town"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"POST","path":"/api/logistics/trips/{id}/stops/from-document","summary":"Add a stop built from a delivery note, invoice, pro-forma or purchase order — all three live in invoices, so one column covers them. Carries the customer, the address and every LINE, which is what lets a short delivery raise a credit note later.","auth":"jwt","body":{"documentId":"<uuid>","windowFrom":"2026-08-25T09:00:00Z","windowTo":"2026-08-25T12:00:00Z"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"GET","path":"/api/logistics/trips/{id}/suggested-sequence","summary":"A shorter order for the run, as advice. Read-only: it reorders nothing. Distances are straight-line between the stops' own coordinates rather than a road route, so the saving is reported and the planner decides; post the ids to /resequence to accept it. Refuses with a reason when the trip has already started, has fewer than three stops, or has fewer than three stops carrying coordinates.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"POST","path":"/api/logistics/trips/{id}/resequence","summary":"Reorder the stops.","auth":"jwt","body":{"stopIds":["<uuid>","<uuid>"]},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"GET","path":"/api/logistics/trips/{id}/track","summary":"The trip replay — every stored position in order.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"GET","path":"/api/logistics/stops/{id}","summary":"One stop with its lines and window.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"PUT","path":"/api/logistics/stops/{id}","summary":"Change a stop.","auth":"jwt","body":{"windowTo":"2026-08-25T14:00:00Z"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"DELETE","path":"/api/logistics/stops/{id}","summary":"Remove a stop.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"GET","path":"/api/logistics/stops/{id}/proof","summary":"The proof recorded at a stop, with its grade.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"POST","path":"/api/logistics/stops/{id}/proof","summary":"Record the outcome — who received it, the quantities that actually changed hands, and a drawn signature. Where the per-line quantities disagree with the tapped outcome, THE LINES WIN, and a blank line counts as not delivered. distanceFromStopM is computed server-side from two coordinate pairs and never accepted from a client.","auth":"jwt","body":{"outcome":"delivered","receivedByName":"T. Nkosi","capturedLatitude":-33.9249,"capturedLongitude":18.4241,"capturedAccuracyM":12.0,"clientReference":"<client-minted-uuid>","lines":[{"stopLineId":"<uuid>","qtyDelivered":4.0}]},"responseExample":{"id":"<uuid>","outcome":"delivered","grade":"STRONG","gradeStatement":"Signed for by a named person at the delivery address, inside the geofence, with a photograph.","distanceFromStopM":18.4,"capturedAt":"2026-08-25T09:52:00Z","recordedAt":"2026-08-25T09:52:04Z"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"POST","path":"/api/logistics/proofs/{id}/photos","summary":"Attach a photograph to a proof. At most six; the server recompresses.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"GET","path":"/api/logistics/proofs/{id}/html","summary":"The proof as a document — who signed, where they stood, when, the quantities, and an honest statement of what the evidence is worth. Do not improve a weak grade's wording.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"GET","path":"/api/logistics/exception-reasons","summary":"The failure reason list. The seeded set carries LOAD SHEDDING — the most common reason a South African delivery is turned away, and one no other product's standard list has.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"POST","path":"/api/logistics/exception-reasons","summary":"Add a reason code.","auth":"jwt","body":{"code":"site_closed","label":"Site closed on arrival"},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"PUT","path":"/api/logistics/exception-reasons/{id}","summary":"Relabel or retire a reason code.","auth":"jwt","body":{"code":"SKU-1001","label":"Example","active":true},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"POST","path":"/api/logistics/positions","summary":"Post GPS fixes from a third-party tracker. Identify the vehicle by Ledgr's id or by telematicsExternalId, which is the id your own platform already knows it by. A 60s floor is enforced against the previous fix's DEVICE time, future-dated fixes are refused, a batch caps at 500, and what was dropped is reported.","auth":"jwt","body":{"telematicsExternalId":"TRK-4092","source":"telematics","positions":[{"latitude":-33.9249,"longitude":18.4241,"speedKph":62.0,"recordedAt":"2026-08-25T09:14:00Z"}]},"responseExample":{"accepted":1,"droppedTooFrequent":0,"droppedFutureDated":0,"droppedNullIsland":0},"module":"logistics","plan":"Manufacturing","scope":"logistics:write"},{"method":"GET","path":"/api/logistics/fleet-map","summary":"The fleet map's framed backdrop plus the vehicles to draw on it. Send the pixel size you will render at — the server picks the zoom that frames every reporting vehicle inside exactly that box, and the client places markers with the same Web Mercator projection. Answers available=false with a reason, rather than failing, when no maps key is configured.","auth":"jwt","module":"logistics","plan":"Manufacturing","scope":"logistics:read"},{"method":"GET","path":"/maps/static","summary":"The street-map image itself, proxied so Google's key never reaches a client. Unauthenticated by construction: the HMAC in the query string is the credential and it covers the centre, zoom and size it authorises, so a signed URL cannot be edited into a different request. Registered only when a maps key is configured. Not called directly — GET /api/logistics/fleet-map mints the signed URL.","auth":"signed-url"}]},{"name":"Driver app API","blurb":"A published contract, outside authenticate(\"jwt-auth\"). Three rules shape it: one request per screen — a driver at a kerbside cannot make a second — nothing to resolve, and every write idempotent.","endpoints":[{"method":"GET","path":"/driver-api/v1/session","summary":"Who the token belongs to, what vehicle they usually drive, how often to send a position, and the SERVER's own clock so the app can measure its skew. Send Authorization: Bearer <driver token> — not a user JWT.","auth":"driver-token"},{"method":"GET","path":"/driver-api/v1/trips","summary":"The trips this driver may act on. Each manifest carries its stops, and each stop its lines, its address and its phone number.","auth":"driver-token"},{"method":"GET","path":"/driver-api/v1/trips/{id}","summary":"One manifest.","auth":"driver-token"},{"method":"POST","path":"/driver-api/v1/trips/{id}/start","summary":"Depart, with the odometer the trip's distance is measured from.","auth":"driver-token","body":{"odometerKm":184320.0}},{"method":"POST","path":"/driver-api/v1/trips/{id}/complete","summary":"Back at the depot. REFUSED while any stop still has no outcome — a trip closed with open stops leaves deliveries neither delivered nor failed, and therefore invisible to both the re-plan list and the failure report.","auth":"driver-token","body":{"odometerKm":184498.0,"fuelLitres":62.4,"fuelCost":1490.0}},{"method":"POST","path":"/driver-api/v1/stops/{id}/status","summary":"Mark a stop en route or arrived. The fix at arrival is the first half of the evidence; the geofence allows the device's own claimed error, capped at 150m, because failing an honest driver in a yard with a bad sky view is how a business learns to ignore the flag.","auth":"driver-token","body":{"status":"arrived","latitude":-33.9249,"longitude":18.4241,"accuracyM":9.0,"at":"2026-08-25T09:41:00Z"}},{"method":"POST","path":"/driver-api/v1/stops/{id}/proof","summary":"The proof as one offline-safe unit — outcome, receiver, quantities and the drawn signature in a single request. Send the same clientReference on a retry and the STORED proof comes back rather than a second one. captured_at is your clock, recorded_at is the server's; both are kept.","auth":"driver-token","body":{"outcome":"partial","receivedByName":"T. Nkosi","signatureBase64":"iVBORw0KGgo…","exceptionReasonCode":"short_delivered","capturedLatitude":-33.9249,"capturedLongitude":18.4241,"capturedAt":"2026-08-25T09:52:00Z","clientReference":"<client-minted-uuid>","lines":[{"stopLineId":"<uuid>","qtyDelivered":3.0}]}},{"method":"POST","path":"/driver-api/v1/stops/{id}/proof/photos","summary":"Attach a photograph to the proof just recorded. Raw image bytes as the body, ?caption= for the label.","auth":"driver-token"},{"method":"POST","path":"/driver-api/v1/positions","summary":"A batch of fixes, which is how a phone out of signal catches up. The vehicle comes from the driver's open trip and can never be named by the app.","auth":"driver-token","body":{"positions":[{"latitude":-33.9249,"longitude":18.4241,"speedKph":48.0,"batteryPercent":62,"recordedAt":"2026-08-25T09:20:00Z"}]}},{"method":"GET","path":"/driver-api/v1/exception-reasons","summary":"The failure code list, for offline caching. A driver with no signal and no reason list types \"other\" into a notes field, and a year later the failure report is a reading exercise.","auth":"driver-token"}]},{"name":"Multi-company & consolidation","blurb":"A tenant may hold several companies. Send X-Company-Id to act within one; company-scoped endpoints reject a request with no company when the tenant has more than one, rather than picking for you.","endpoints":[{"method":"GET","path":"/api/companies","summary":"Every entity under the tenant. This is the list X-Company-Id is chosen from.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"POST","path":"/api/companies","summary":"Add an entity. Companies are NEVER metered — that is the whole competitive argument against Sage's R410 per company.","auth":"jwt","body":{"name":"KMP Cloud (Pty) Ltd","registrationNumber":"2021/123456/07","vatNumber":"4123456789","parentCompanyId":"<uuid>"},"module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"GET","path":"/api/companies/{id}","summary":"One entity.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"PUT","path":"/api/companies/{id}","summary":"Update an entity.","auth":"jwt","body":{"name":"KMP Cloud (Pty) Ltd"},"module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"DELETE","path":"/api/companies/{id}","summary":"Remove an entity.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"GET","path":"/api/companies/{id}/users","summary":"Who may act in this entity.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"POST","path":"/api/companies/{id}/users","summary":"Grant a user access to this entity. An EMPTY company_users set grants — an owner whose assignments were never seeded must not be locked out.","auth":"jwt","body":{"userId":"<uuid>"},"module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"GET","path":"/api/company-hierarchy/tree","summary":"The group structure as a tree, parent to subsidiary.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"GET","path":"/api/company-hierarchy/picker","summary":"A searchable flat list for the entity switcher.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"GET","path":"/api/company-hierarchy/{id}/ancestors","summary":"The chain from an entity up to the group head.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"PUT","path":"/api/company-hierarchy/{id}/parent","summary":"Re-parent an entity. Refused if it would create a cycle.","auth":"jwt","body":{"parentCompanyId":"<uuid>"},"module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"POST","path":"/api/company-hierarchy/repair","summary":"Repair an inconsistent hierarchy — orphans and broken depth values.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"GET","path":"/api/company-hierarchy/{id}/consolidated/income-statement","summary":"Consolidated income statement at ANY node of the tree, not only the group head.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"GET","path":"/api/company-hierarchy/{id}/consolidated/balance-sheet","summary":"Consolidated balance sheet at any node.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"GET","path":"/api/company-hierarchy/{id}/consolidated/trial-balance","summary":"Consolidated trial balance at any node.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"GET","path":"/api/company-hierarchy/{id}/consolidated/eliminations","summary":"The inter-company eliminations applied, line by line.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"GET","path":"/api/intercompany-transactions","summary":"The inter-company transaction log.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"POST","path":"/api/intercompany-transactions","summary":"Record an inter-company transaction.","auth":"jwt","body":{"fromCompanyId":"<uuid>","toCompanyId":"<uuid>","transactionDate":"2026-08-31","amount":85000.0,"description":"Management fee"},"module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"GET","path":"/api/intercompany-transactions/{id}","summary":"One transaction.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"PUT","path":"/api/intercompany-transactions/{id}","summary":"Correct a transaction. Every field is replaced; an eliminated transaction is refused.","auth":"jwt","body":{"fromCompanyId":"<uuid>","toCompanyId":"<uuid>","transactionDate":"2026-08-27","description":"Management fee - corrected","amount":90000.0,"currency":"ZAR","exchangeRate":1.0,"accountIdFrom":"<uuid>","accountIdTo":"<uuid>"},"module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"DELETE","path":"/api/intercompany-transactions/{id}","summary":"Delete a transaction.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"POST","path":"/api/intercompany-transactions/{id}/match","summary":"Agree both legs of a transaction. PENDING only.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"POST","path":"/api/intercompany-transactions/{id}/settle","summary":"Eliminate an inter-company transaction for consolidation. MATCHED only. Wire alias of /{id}/eliminate; it does NOT record that one entity paid another - that is POST /{id}/payment.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"POST","path":"/api/intercompany-transactions/{id}/eliminate","summary":"Eliminate an inter-company transaction for consolidation. MATCHED only.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"POST","path":"/api/intercompany-transactions/{id}/payment","summary":"Record that one entity actually paid another. Separate from elimination - an amount is often eliminated and still unpaid.","auth":"jwt","body":{"paidOn":"2026-08-25","reference":"EFT 4471"},"module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"DELETE","path":"/api/intercompany-transactions/{id}/payment","summary":"Remove a payment record. Always permitted, so a mis-keyed settlement is correctable.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"POST","path":"/api/intercompany-transactions/eliminate-all","summary":"Eliminate every matched transaction for a period in one pass.","auth":"jwt","body":{"period":"2026-08"},"module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"GET","path":"/api/consolidation-groups","summary":"Consolidation groups and their members.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"POST","path":"/api/consolidation-groups","summary":"Define a consolidation group.","auth":"jwt","body":{"name":"KMP Group"},"module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"GET","path":"/api/consolidation-groups/{id}","summary":"One group.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"DELETE","path":"/api/consolidation-groups/{id}","summary":"Delete a group.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"POST","path":"/api/consolidation-groups/{id}/run","summary":"Run the consolidation and store the result.","auth":"jwt","body":{"asAt":"2027-02-28"},"module":"multi-company","plan":"Enterprise","scope":"multi-company:write"},{"method":"GET","path":"/api/consolidation-groups/{id}/entries","summary":"The consolidation entries produced by a run.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"GET","path":"/api/multi-company/overview","summary":"Per-entity dashboards — P&L summary, balance sheet and inter-company balances side by side.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"GET","path":"/api/multi-company/ic-matrix","summary":"The cross-entity outstanding balance matrix.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"},{"method":"GET","path":"/api/multi-company/shared-contacts","summary":"Contacts shared across entities, with a per-entity badge.","auth":"jwt","module":"multi-company","plan":"Enterprise","scope":"multi-company:read"}]},{"name":"Accountant workspace","blurb":"The practitioner side of the relationship. A Tax Practitioner role carries full SARS access including export, so an external accountant prepares and downloads every return for the entities they can reach.","endpoints":[{"method":"GET","path":"/api/practitioner/clients","summary":"The client console — every tenant this practitioner is linked to.","auth":"jwt"},{"method":"GET","path":"/api/practitioner/links","summary":"Practitioner links and their state.","auth":"jwt"},{"method":"POST","path":"/api/practitioner/links","summary":"Invite a practitioner, or request access to a client.","auth":"jwt","body":{"email":"accountant@example.co.za","accessLevel":"full"}},{"method":"DELETE","path":"/api/practitioner/links/{id}","summary":"End a link. Access stops immediately.","auth":"jwt"},{"method":"GET","path":"/api/practitioner/invitations","summary":"Invitations waiting on this practitioner.","auth":"jwt"},{"method":"POST","path":"/api/practitioner/accept/{id}","summary":"Accept an invitation.","auth":"jwt"},{"method":"GET","path":"/api/practitioner/queries","summary":"In-app queries between practitioner and client, in place of an email thread nobody can find later.","auth":"jwt"},{"method":"POST","path":"/api/practitioner/queries","summary":"Raise a query, optionally against a specific transaction.","auth":"jwt","body":{"question":"Example"}},{"method":"POST","path":"/api/practitioner/queries/{id}/answer","summary":"Answer a query.","auth":"jwt","body":{"answer":"Example"}},{"method":"POST","path":"/api/practitioner/queries/{id}/resolve","summary":"Close a query.","auth":"jwt"},{"method":"GET","path":"/api/practitioner/adjusting-journals","summary":"Adjusting journals waiting on the client's review.","auth":"jwt"}]},{"name":"Migration & take-on","blurb":"Bringing a business across from Sage, Xero, QuickBooks or a spreadsheet. Preview runs the same code as apply and returns the counts that will happen.","endpoints":[{"method":"POST","path":"/api/migration/start","summary":"Open a migration job and upload the source files.","auth":"jwt","body":{"sourceType":"xero","csvContent":"name,email\\nAndy Cameron,andy@example.co.za"}},{"method":"GET","path":"/api/migration/jobs","summary":"Migration jobs and their state.","auth":"jwt"},{"method":"GET","path":"/api/migration/jobs/{id}","summary":"One job, with what parsed and what did not.","auth":"jwt"},{"method":"DELETE","path":"/api/migration/jobs/{id}","summary":"Discard a job.","auth":"jwt"},{"method":"POST","path":"/api/migration/preview","summary":"Parse and classify without writing. Same code path as apply, so the counts are the counts that will happen.","auth":"jwt","body":{"csvContent":"name,email\\nAndy Cameron,andy@example.co.za"}},{"method":"POST","path":"/api/migration/apply-contacts","summary":"Write the parsed contacts. Duplicates are matched on email through the same contactMatchKey the CRM conversion uses.","auth":"jwt","body":{"csvContent":"name,email\\nAndy Cameron,andy@example.co.za"}},{"method":"POST","path":"/api/migration/apply-accounts","summary":"Write the parsed chart of accounts. Account-type aliases are normalised through AfsTypes so an imported Xero-shaped chart does not declare nil turnover on the ITR14.","auth":"jwt","body":{"csvContent":"name,email\\nAndy Cameron,andy@example.co.za"}},{"method":"POST","path":"/api/migration/apply-products","summary":"Write the parsed products.","auth":"jwt","body":{"csvContent":"name,email\\nAndy Cameron,andy@example.co.za"}},{"method":"POST","path":"/api/migration/apply-invoices","summary":"Write the parsed documents. Existing document numbers are checked against every series so a number on a customer's pre-migration quote is never reissued.","auth":"jwt","body":{"csvContent":"name,email\\nAndy Cameron,andy@example.co.za"}},{"method":"POST","path":"/api/migration/apply-trial-balance","summary":"Post the opening trial balance as a journal.","auth":"jwt","body":{"csvContent":"name,email\\nAndy Cameron,andy@example.co.za"}}]},{"name":"Offline sync","blurb":"What actually runs is an OUTBOX, not the CRDT loop — writes queue locally and replay as ordinary REST calls, made idempotent by a client-minted UUID that becomes the row's primary key. SyncEngine and these three endpoints are built but NOT WIRED; treat the vector-clock design as intended, not delivered.","endpoints":[{"method":"GET","path":"/api/sync/status","summary":"The tenant's sync cursor and clock. Built, and instantiated only in tests.","auth":"jwt"},{"method":"GET","path":"/api/sync/pull?since=<cursor>","summary":"Changes since a cursor. Built and not wired — no client calls it.","auth":"jwt"},{"method":"POST","path":"/api/sync/batch","summary":"Push a batch of local changes with their vector clocks. Built and not wired.","auth":"jwt","body":{"operations":[{"id":"<uuid>","entityType":"","entityId":"<uuid>","operation":"Example","payload":"Example","vectorClock":"Example","timestamp":1}],"nodeId":"<uuid>"}},{"method":"WEBSOCKET","path":"/ws/inventory","summary":"Real-time stock updates. The socket exists and no client connects to it. Bearer JWT on the handshake, in its own authenticate block so the licence and API-key interceptors do not run on it.","auth":"jwt"}]},{"name":"WhatsApp","blurb":"Receipts, reminders, payslips and statements over the WhatsApp Business API. An unconfigured channel fails silently per send, which is why /diagnostics reports whether it is configured at all.","endpoints":[{"method":"GET","path":"/api/whatsapp/config","summary":"Whether WhatsApp is configured, and which templates are approved.","auth":"jwt"},{"method":"PUT","path":"/api/whatsapp/config","summary":"Set the tenant's WhatsApp configuration.","auth":"jwt","body":{"enabled":true,"senderDisplayName":"Your Business"}},{"method":"GET","path":"/api/whatsapp/messages","summary":"Messages sent and their delivery state.","auth":"jwt"},{"method":"GET","path":"/api/whatsapp/messages/{id}","summary":"One message.","auth":"jwt"},{"method":"POST","path":"/api/whatsapp/send","summary":"Send a message against an approved template.","auth":"jwt","body":{"to":"2026-09-30","templateName":"Example","templateParams":{}}},{"method":"POST","path":"/api/whatsapp/test","summary":"Send a test message to prove the configuration, rather than discovering it at the first real send.","auth":"jwt","body":{"to":"2026-09-30","templateName":"Example","templateParams":{}}}]},{"name":"External ingest (API key)","blurb":"The one surface designed for a third-party system rather than for a Ledgr client. Authenticated by the key directly, with its own scopes, and idempotent by the caller's own reference.","endpoints":[{"method":"GET","path":"/external/contacts/{contactId}/transactions","summary":"Billable events already posted for a contact. Scope transactions:read plus a key bound to that contact.","auth":"api-key"},{"method":"POST","path":"/external/contacts/{contactId}/transactions","summary":"Post a billable event. Scope transactions:write plus a bound contact, set when the key is generated under Roles → API keys.","auth":"api-key","body":{"occurredAt":"2026-08-14","description":"1 200 API calls","quantity":1200.0,"unitPrice":0.85,"currency":"ZAR","vatRate":15.0,"externalRef":"usage-2026-08-14-a1"}},{"method":"DELETE","path":"/external/contacts/{contactId}/transactions/{id}","summary":"Remove an event before it has been invoiced.","auth":"api-key"},{"method":"POST","path":"/admin/billing/run-monthly","summary":"Send ONE aggregate for a (contact, month) — the external app does its own per-transaction bookkeeping and reports the final monthly total here. Calling again UPDATES it in place (200) until it has been invoiced; after that it returns 409 and the invoice must be voided first.","auth":"api-key","body":{"amount":1250.0,"source":"app-x","month":"2026-08","description":"August usage total","currency":"ZAR","vatRate":15.0},"responseExample":{"id":"<uuid>","contactId":"<uuid>","occurredAt":"2026-08-01","amount":1250.0,"externalRef":"monthly-aggregate:<contactId>:2026-08","status":"pending"}},{"method":"POST","path":"/external/leads","summary":"Web-to-lead. Scope crm:write. Made idempotent by the caller's own external_ref under a unique index — a website form that retries on a dropped response must not create the lead twice, because two reps then phone the customer.","auth":"api-key","body":{"name":"Sipho Dlamini","companyName":"Dlamini Logistics","email":"sipho@dlamini.co.za","phone":"+27821234567","source":"website","notes":"Enquiry from the website contact form."}}]},{"name":"Inbound webhooks","blurb":"Called BY payment gateways, not by you. Each is signature-verified and, for PayFast, confirmed server-to-server before anything is marked paid — an unsigned payload carrying an attacker-chosen tenantId must never settle an invoice. The two Yoco receivers are deliberately separate: one settles a tenant's customer invoices against the tenant's own merchant account, the other settles Ledgr's own licence purchases against Ledgr's. Merging them would give one secret the power to mark any tenant's invoice paid.","endpoints":[{"method":"POST","path":"/webhooks/payfast/itn/{tenantId}","summary":"PayFast's instant transaction notification. Signature-verified, confirmed with PayFast, then filed into the payments inbox. The merchant is resolved from the merchant_id PayFast echoes back, so a tenant may hold several.","auth":"none"},{"method":"POST","path":"/webhooks/yoco/invoice/{tenantId}","summary":"Yoco payment events for a TENANT'S OWN invoices. Verified as Standard Webhooks (HMAC-SHA256 over webhook-id.webhook-timestamp.body) against a secret THAT TENANT stored, never a deployment one. A Yoco webhook names no merchant, so the tenant travels in this path — which is routing, not authorisation: the signature must match one of the tenant's own secrets and the invoice reference in the metadata must name the same tenant.","auth":"none"},{"method":"POST","path":"/webhooks/yoco","summary":"Yoco payment events. HMAC-SHA256 over the raw body in X-Webhook-Signature, against LEDGR_WEBHOOK_SECRET_YOCO.","auth":"none"},{"method":"POST","path":"/webhooks/payfast","summary":"The generic PayFast receiver, signed with LEDGR_WEBHOOK_SECRET_PAYFAST.","auth":"none"},{"method":"POST","path":"/webhooks/yoco/billing","summary":"Yoco events for LEDGR'S OWN licence checkouts — this is what mints and emails a key after a purchase.","auth":"none"}]},{"name":"Platform administration","blurb":"Back office, not tenant-facing. A jwt-admin realm of its own: SA tax law and the price list are one platform-wide answer each, so neither may sit inside /api where a tenant could reach its own. The `/admin/tenants` tree is the support console: it exists for a customer on the telephone saying \"I am paying for payroll twice\", \"put me back on Starter\", \"our owner has left\" — so every route below answers one of those and returns the whole account afterwards, rather than an acknowledgement the operator would have to re-read.","endpoints":[{"method":"POST","path":"/admin/login","summary":"Issue an admin JWT. No prior auth.","auth":"none","body":{"username":"Example","password":"Example"}},{"method":"GET","path":"/admin","summary":"The admin portal itself. HTML — its plan dropdown and blurbs are generated from Plan and modulesFor at compile time rather than being a fourth copy of the price list.","auth":"none"},{"method":"GET","path":"/admin/licenses","summary":"Issued licence keys.","auth":"admin"},{"method":"POST","path":"/admin/licenses/generate","summary":"Mint a key for 1, 3 or 12 months — or never expiring, which is a supported option for partners and pilots. The expiry is derived SERVER-side from termMonths, and seats come from Plan.maxUsers and are not editable.","auth":"admin","body":{"plan":"business","termMonths":12}},{"method":"DELETE","path":"/admin/licenses/{code}","summary":"Revoke a key.","auth":"admin"},{"method":"GET","path":"/admin/orders","summary":"Licence purchases across every tenant.","auth":"admin"},{"method":"POST","path":"/admin/orders/{id}/sync","summary":"Re-poll Yoco for an order whose webhook never arrived.","auth":"admin"},{"method":"POST","path":"/admin/orders/{id}/resend","summary":"Re-send the key email for an order.","auth":"admin"},{"method":"GET","path":"/admin/statutory/tax-years","summary":"Published statutory overrides by year of assessment.","auth":"admin"},{"method":"GET","path":"/admin/statutory/tax-years/{year}","summary":"One year's brackets, rebates, thresholds, UIF ceiling, s11F cap and s6A credits.","auth":"admin"},{"method":"POST","path":"/admin/statutory/tax-years","summary":"Publish a tax year without a redeploy. Every row is re-validated ON LOAD, not just on publish, and each bracket's baseTax is recomputed from widths × rates. Resolution is override, then compiled built-in, then REFUSE — this is a way to give the engine a year before a release ships, not a way to make it guess.","auth":"admin","body":{"taxYearEndYear":2028,"brackets":[{"upTo":254000,"rate":18.0}],"primaryRebate":17820.0,"thresholdUnder65":99000.0}},{"method":"DELETE","path":"/admin/statutory/tax-years/{year}","summary":"Withdraw a published year, falling back to the compiled table.","auth":"admin"},{"method":"POST","path":"/admin/statutory/reload","summary":"Reload the registry now. It refreshes on a revision token every LEDGR_STATUTORY_REFRESH_INTERVAL_SEC anyway — the tick interval is the whole cross-replica exposure window.","auth":"admin"},{"method":"GET","path":"/admin/statutory/annual-review","summary":"The February review checklist — every figure that goes stale in a Budget, and where each is published.","auth":"admin"},{"method":"GET","path":"/admin/statutory/calendar","summary":"The filing calendar as published — the dates a tax year is made of.","auth":"admin"},{"method":"GET","path":"/admin/statutory/calendar/preview","summary":"What the calendar currently resolves to, and where each answer came from. Both halves in one response, because answering \"is our calendar right\" from two endpoints invites checking one of them.","auth":"admin"},{"method":"POST","path":"/admin/statutory/calendar/days","summary":"Publish a public holiday or non-business day.","auth":"admin","body":{"kind":"public-holiday","date":"2027-03-21","name":"Human Rights Day"}},{"method":"DELETE","path":"/admin/statutory/calendar/days/{kind}/{date}","summary":"Withdraw a published day.","auth":"admin"},{"method":"POST","path":"/admin/statutory/calendar/deadlines","summary":"Publish a filing season or deadline for a year.","auth":"admin","body":{"kind":"itr12-season","taxYearEndYear":2027,"dueDate":"2026-10-23"}},{"method":"DELETE","path":"/admin/statutory/calendar/deadlines/{kind}/{year}","summary":"Withdraw a published deadline.","auth":"admin"},{"method":"POST","path":"/admin/statutory/calendar/reload","summary":"Reload the calendar now.","auth":"admin"},{"method":"GET","path":"/admin/pricing","summary":"The price list as published, beside the compiled floor in Plans.kt that answers whenever no row is published.","auth":"admin"},{"method":"POST","path":"/admin/pricing/plans","summary":"Publish a plan price and its storage allowance. Everything else a tier carries — its module set, seats, companies, API keys, invoice quota and both meter allowances — is published through /admin/plans (V308), which keeps its own trail so a price and a definition revert separately.","auth":"admin","body":{"planId":"business","priceMonthlyCents":49900}},{"method":"DELETE","path":"/admin/pricing/plans/{planId}","summary":"Withdraw a published price; the compiled figure stands again.","auth":"admin"},{"method":"POST","path":"/admin/pricing/settings","summary":"Publish a storage or payroll uplift.","auth":"admin","body":{"key":"storage.overage.per.gb","amountCents":24900}},{"method":"DELETE","path":"/admin/pricing/settings/{key}","summary":"Withdraw a published setting.","auth":"admin"},{"method":"POST","path":"/admin/pricing/reload","summary":"Reload the price list now.","auth":"admin"},{"method":"GET","path":"/admin/pricing/notices","summary":"Price-change notices, drafted, released or cancelled.","auth":"admin"},{"method":"POST","path":"/admin/pricing/notices","summary":"Compose the notice that tells customers about a price change.","auth":"admin","body":{"effectiveFrom":"2026-11-01","subject":"A change to your Ledgr plan price","body":"…"}},{"method":"POST","path":"/admin/pricing/notices/{id}/release","summary":"Release a notice to affected tenants.","auth":"admin"},{"method":"POST","path":"/admin/pricing/notices/{id}/cancel","summary":"Cancel an unreleased notice.","auth":"admin"},{"method":"GET","path":"/admin/plans","summary":"Every plan's definition as published, beside the compiled floor in Plans.kt that answers whenever no row is published — plus the module catalogue the checkboxes are drawn from and the live tenant count per tier.","auth":"admin"},{"method":"POST","path":"/admin/plans/preview","summary":"What publishing this definition WOULD change, writing nothing: the modules added and removed, the live tenants on the plan, and any reason it could not be published. Shares one impact calculation with the publish path, so the numbers shown are the numbers the write acts on.","auth":"admin","body":{"planId":"business","modules":["dashboard","invoices","settings"],"maxUsers":10,"maxCompanies":1,"maxApiKeys":0,"monthlyInvoiceLimit":null,"includedWhatsAppConversations":250,"includedAiScans":200,"source":"Why this tier includes what it includes","effectiveFrom":"2026-09-01"}},{"method":"POST","path":"/admin/plans","summary":"Publish what a plan includes. 422 with reasons when the definition cannot be right — an unknown module key, or a set without 'settings', which carries the billing screen. 409 with the impact when it REMOVES a module and acknowledgeRemovals is not set: removal is immediate and retrospective, so every tenant on the tier answers 403 on the next request.","auth":"admin","body":{"planId":"business","modules":["dashboard","invoices","settings"],"maxUsers":10,"maxCompanies":1,"maxApiKeys":0,"monthlyInvoiceLimit":null,"includedWhatsAppConversations":250,"includedAiScans":200,"source":"PRICE-11 — Reports moves down to Starter","effectiveFrom":"2026-09-01","acknowledgeRemovals":false}},{"method":"DELETE","path":"/admin/plans/{planId}","summary":"Withdraw a published definition; the tier compiled into the build stands again. Not gated on an acknowledgement even though it can remove a module — getting back to the build must never be the harder path.","auth":"admin"},{"method":"POST","path":"/admin/plans/{planId}/reapply-seats","summary":"Copy the plan's seat allowance onto every live tenant on it, and report how many were updated. Its own action rather than a side effect of publishing: a seat count is copied onto the tenant when a licence is issued or renewed, so a published figure otherwise reaches them at their next renewal. Nobody loses access — the cap is enforced when a user is invited.","auth":"admin"},{"method":"POST","path":"/admin/plans/reload","summary":"Re-read the published definitions into THIS instance. The scheduler and the plan-config-refresh job do it on a revision token; this is the manual nudge.","auth":"admin"},{"method":"GET","path":"/admin/statutory/vat-rates","summary":"The VAT schedule as it will actually be applied, plus today's answer. Compiled and published rows in ONE list rather than two — the question is \"what will we charge\", and a screen showing published rows alone cannot answer it.","auth":"admin"},{"method":"GET","path":"/admin/statutory/vat-rates/on/{date}","summary":"The rate on a given date, which is the question a support query actually asks. {date} is an ISO YYYY-MM-DD and is the identity of a rate — there is no surrogate id, because what an operator reasons about is \"the rate from 1 May\".","auth":"admin"},{"method":"POST","path":"/admin/statutory/vat-rates","summary":"Publish a rate from a date. A wrong figure here charges the wrong tax to every customer of every tenant, on documents that look entirely normal — so a refusal comes back 422 naming each reason, never a bare 400. Changing the rate needs no migration and no backfill: every document stores the rate it was raised at.","auth":"admin","body":{"effectiveFrom":"2027-05-01","ratePercent":15.5,"notes":"Budget 2027"}},{"method":"DELETE","path":"/admin/statutory/vat-rates/{date}","summary":"Take a published rate out of service. The compiled schedule answers again.","auth":"admin"},{"method":"POST","path":"/admin/statutory/vat-rates/reload","summary":"Re-read the table on THIS instance. Not how a publish propagates — the scheduler converges every minute — but how an operator confirms what the instance answering them currently holds.","auth":"admin"},{"method":"POST","path":"/admin/billing/aggregate-monthly","summary":"Roll every pending billable event from the prior month into draft invoices, grouped by tenant × contact. Idempotent, and normally driven by Cloud Scheduler on the 1st.","auth":"admin","responseExample":{"tenantsProcessed":12,"invoicesCreated":47,"eventsInvoiced":312,"periodFrom":"2026-07-01","periodTo":"2026-07-31"}},{"method":"GET","path":"/admin/tenants","summary":"Every account, newest first, capped at 500 — with the one figure a customer quotes back on the telephone: what the next renewal costs today, add-ons and payroll banding included. `?q=` searches name, slug and owner email.","auth":"admin"},{"method":"GET","path":"/admin/tenants/{id}","summary":"One account with everything support can change: plan, seats, add-ons held, the users and their roles, and the licence's own validity.","auth":"admin"},{"method":"PATCH","path":"/admin/tenants/{id}","summary":"Fix the account's own fields. A slug that would collide with another tenant is refused plainly rather than surfacing as a 500 — the constraint would otherwise take down the account it clashed with.","auth":"admin","body":{"name":"Nkosi Tech (Pty) Ltd","slug":"nkosi-tech","licenseValidUntil":"2027-03-31"}},{"method":"PATCH","path":"/admin/tenants/{id}/users/{userId}","summary":"Correct a colleague's name, email, role or active flag from the back office. A duplicate email inside the tenant is refused for the same reason a duplicate slug is.","auth":"admin","body":{"email":"thandi@nkositech.co.za","role":"accountant","isActive":true}},{"method":"POST","path":"/admin/tenants/{id}/transfer-ownership","summary":"Move the owner seat on the customer's behalf. The tenant-facing route lets the owner do this themselves; a customer whose owner has already LEFT cannot, which is the case this exists for. Same invariant, enforced the same way: one transaction demotes every current owner/admin and promotes the target, so the account never has none or two.","auth":"admin","body":{"userId":"<uuid>"}},{"method":"POST","path":"/admin/tenants/{id}/plan","summary":"Upgrade or downgrade an account. Seats follow the tier rather than being typed — a plan and a seat cap that disagree is invisible until somebody cannot invite a colleague. Runs `pruneAddonsForPlan` in the SAME transaction, so an add-on the new tier already includes (or one below its floor) is cancelled at once and the bill cannot disagree with what the tenant can reach; the renewal schedule is moved to the new tier too.","auth":"admin","body":{"plan":"professional"}},{"method":"POST","path":"/admin/tenants/{id}/addons","summary":"Add an add-on or set its quantity. Refused where the plan already includes it or does not reach its floor, and clamped to the add-on's own maximum. Quantity must be at least 1 — removing one is a DELETE, because a zero-quantity holding grants nothing and still has a row somebody could bill.","auth":"admin","body":{"addonId":"seat-pack-5","quantity":2,"notes":"Agreed on the call"}},{"method":"DELETE","path":"/admin/tenants/{id}/addons/{addonId}","summary":"Cancel an add-on. Sets `cancelled_at` rather than deleting the row: an add-on held for four months of a twelve-month term is part of explaining that term's invoice.","auth":"admin"}]},{"name":"Operations & discovery","blurb":"Health, the machine-readable spec, and the background jobs as HTTP so the service can sit at min-instances 0. /health deliberately ignores the database; /readyz does not.","endpoints":[{"method":"GET","path":"/health","summary":"Liveness. Deliberately ignores the database — a liveness probe that fails during a Cloud SQL blip would kill every instance of a revision that was fine.","auth":"none","responseExample":{"status":"ok","service":"ledgr-api"}},{"method":"GET","path":"/readyz","summary":"Readiness, and the service's startup probe: 200 only when the startup database init reported ok AND a live SELECT 1 succeeds. This is what stops a container that boots with a broken migration from being promoted to 100% of traffic.","auth":"none"},{"method":"GET","path":"/diagnostics","summary":"Triage for \"the server is up but nothing works\": startup DB status, a live probe, the highest applied migration ordered NUMERICALLY, failed migrations, the environment, and whether email and WhatsApp are configured. Coarse statuses only — it is unauthenticated, so it must never leak internal error strings.","auth":"none","responseExample":{"service":"ledgr-api","status":"ok","startup_db_init":"ok","live_db_check":"ok","schema_version":"268","failed_migrations":0,"migrations_enforced":true,"environment":"production","email_configured":true,"whatsapp_configured":false}},{"method":"GET","path":"/openapi.json","summary":"The OpenAPI 3.1 document, at the conventional root path tools probe for. Generated from this same catalogue, so the two can never disagree.","auth":"none"},{"method":"GET","path":"/api-docs/openapi.json","summary":"The same document, on a path an integrator can be given without it looking like an internal admin URL.","auth":"none"},{"method":"GET","path":"/developers/playground/spec.json","summary":"This catalogue, as JSON, with each endpoint's module, plan and scope resolved. What the playground renders.","auth":"none"},{"method":"GET","path":"/admin/api-docs","summary":"Permanent redirect to /developers/playground/. The console moved out of /admin, and these URLs are in sent emails and whatever Google has already indexed.","auth":"none"},{"method":"GET","path":"/admin/api-docs/","summary":"The same redirect, with the trailing slash.","auth":"none"},{"method":"GET","path":"/admin/api-docs/spec.json","summary":"Permanent redirect to /developers/playground/spec.json.","auth":"none"},{"method":"GET","path":"/docs/search.json","summary":"The documentation search index, shipped as data for the browser to filter rather than as a search endpoint — the whole corpus is about 60 KB, so a request per keystroke would cost more than sending it once, and it keeps working on a flaky connection.","auth":"none"},{"method":"POST","path":"/internal/jobs/run","summary":"Run named background jobs. Verifies Cloud Scheduler's OIDC token itself, because the service must stay public for the website and the API.","auth":"oidc","body":{"jobs":"statutory-refresh,license-renewals,tenant-retention,crm-tasks,attachment-backfill"}},{"method":"POST","path":"/internal/reminders/run","summary":"Run the invoice and bill reminder sweep. Dedupe is a conditional UPDATE on the row itself, not a lease table.","auth":"oidc"}]}],"endpointCount":953}