Anatomy of an API request
GET https://bareledger.app/api/v1/reports/trial-balance Authorization: Bearer <your API key> X-Tenant: <organization id> Accept: application/json
- 1One base URL, one version. Everything lives under
/api/v1/, the same endpoints the web and mobile apps use. - 2Your API key is the bearer token. Create it under My profile → API keys. It acts as you, with your permissions.
- 3Say which organization. The tenant header picks the set of books. Copy the id from Organization settings (masked until you confirm an emailed code); only organizations you are registered in are ever visible.
- 4JSON in, JSON out. Predictable pagination, ISO-8601 dates, and a consistent error shape.
BareLedger supports self-service API keys for scripts and integrations, issued per user from My profile → API keys in the web app (or POST /api/v1/profile/api-keys with a first-party session).
Model
- A key is a scoped personal access token named
api:{label}. It acts as the user who created it: the same RBAC permissions and organization memberships apply. There are no tenant-level service accounts yet. - Scopes: read-only (
read) or read & write (read,write). Enforcement is method-based at a single middleware choke point (EnforceTokenAbilities): GET/HEAD/OPTIONS requireread, all mutating methods requirewrite. First-party web/mobile tokens carry['*']and are unaffected. - Optional expiry date; expired keys are rejected by bearer token itself.
- The plaintext key is returned exactly once at creation.
- An API key cannot create other API keys — only a first-party session can mint keys.
- Keys are listed with label/scope/last-used/expiry (never the hash) and can be revoked at any time. They are excluded from the "Sessions & devices" list.
Calling the API
GET /api/v1/reports/trial-balance
Authorization: Bearer <api key>
X-Tenant: <organization id> # Organization settings → Organization ID (or My profile → API keys)
Accept: application/json
Everything the first-party clients can do (within the key's scope and the user's permissions) is available under /api/v1. Rate limits are the same global per-user limiter.
Scope: only your own registered organizations
- Find your organization id under Settings → Organization settings or My profile → API keys. It is always shown masked; revealing or copying it requires a 6-digit code emailed to you, and each reveal is recorded in the audit log.
GET /api/v1/tenantsreturns only the organizations your account is registered in (your memberships). It is not a directory of tenants on the platform.- Every other endpoint returns data for the one organization named in
X-Tenant, and that value is accepted only if it is one of your own active memberships; anything else is a409. - There is no endpoint that lists, searches, or reaches other tenants. A record id that belongs to another organization is answered with
404, exactly as if it did not exist.
Feeding Google Sheets / Looker Studio (list exports)
Every record list has an export endpoint that returns the same rows the list page shows, as CSV or XLSX, honouring the list's filters. They need only a *.view permission, so a read-only API key is enough:
| List | Endpoint | Useful filters |
|---|---|---|
| Invoices / bills / quotations / sales orders | GET /api/v1/invoices/export, bills/export, quotations/export, sales-orders/export | status=posted, from=YYYY-MM-DD, to=, contact_id= |
| Payments | GET /api/v1/payments/export | status=, `direction=received |
| Bank statement lines | GET /api/v1/bank-accounts/{bank}/lines/export | status=, from=, to=, reconciliation_id= |
| Leads | GET /api/v1/leads/export | `stage=open |
| Projects | GET /api/v1/projects/export | `status=open |
Add format=csv (default) or format=xlsx. Columns are flat and spreadsheet-friendly: ISO dates, plain decimals, booleans as 1/0, document numbers and names; invoices carry payment_status, outstanding, overdue, days_overdue and project_code; payments list their allocations as INV-2026-000012:4500.0000; …; bank lines carry matched_total, remaining and the pending entry. Cells that would be read as a formula are prefixed with a quote (CWE-1236). Exports are capped at 50,000 rows (422) — narrow the date range. The same files download from the Export button on each list page.
Looker Studio and IMPORTDATA() cannot send headers, so they cannot call the API directly. The recipe is a Google Sheet filled by an Apps Script on a timer, which Looker then reads:
- My profile → API keys: create a key with scope read and an expiry; note your organization id.
- In the Sheet: Extensions → Apps Script, paste the script below, set the three constants (
API_KEYas a Script Property, not in the code), runrefreshAllonce to grant access, then Triggers → Add trigger → refreshAll, time-driven, every hour. - In Looker Studio: Add data → Google Sheets → this sheet; each tab is a table.
const BASE = 'https://bareledger.app/api/v1';
const TENANT = '<organization id>';
const LISTS = {
Invoices: 'invoices/export?format=csv&status=posted',
Bills: 'bills/export?format=csv&status=posted',
Payments: 'payments/export?format=csv&status=posted',
Leads: 'leads/export?format=csv&stage=open',
Projects: 'projects/export?format=csv',
};
function refreshAll() {
const key = PropertiesService.getScriptProperties().getProperty('API_KEY');
const ss = SpreadsheetApp.getActiveSpreadsheet();
Object.entries(LISTS).forEach(([tab, path]) => {
const res = UrlFetchApp.fetch(`${BASE}/${path}`, {
headers: { Authorization: `Bearer ${key}`, 'X-Tenant': TENANT, Accept: 'text/csv' },
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) throw new Error(`${tab}: HTTP ${res.getResponseCode()} ${res.getContentText().slice(0, 200)}`);
const rows = Utilities.parseCsv(res.getContentText());
const sheet = ss.getSheetByName(tab) || ss.insertSheet(tab);
sheet.clearContents();
if (rows.length) sheet.getRange(1, 1, rows.length, rows[0].length).setValues(rows);
});
}
There are no server-side scheduled exports by design: the key stays with you, the schedule stays in your Sheet, and revoking the key stops the feed.
What this is not
- No OAuth client-credentials flow, no API-key-per-tenant service accounts, and no developer portal — keys are user-bound by design (v1). See Known Limitations.
Attendance device credentials (signed requests)
Desktop attendance devices (kiosks, personal trays, biometric bridges) do not use API keys. Each machine is enrolled once with a one-time code (Settings → Devices; 15-minute, single-use, minted under a 2FA step-up) and generates its own Ed25519 key pair locally — the private key stays in the machine's OS keystore and the server stores only the public key, so neither a leaked database nor a copied config can impersonate a device.
Every request to /api/v1/device/* carries:
| Header | Value |
|---|---|
X-Device-Id | the device's id from enrollment |
X-Timestamp | unix seconds (±5 minutes of server time) |
X-Nonce | random, single-use within 10 minutes |
X-Signature | base64 Ed25519 over METHOD \n /path \n timestamp \n nonce \n sha256(body) |
Rejections are uniform 401s (unknown/revoked device, bad signature, skew, replay, IP outside the device's allowlist). A device's reach is exactly the device endpoints — roster names, punch delivery, its own config, key rotation, heartbeat — never the wider API. Revocation (Settings → Devices) applies to the very next request.
Contrast with user API keys (Profile → API keys): those act as the user that created them across the whole API, scoped read or read-write.
Capturing leads from your website or other tools
POST /api/v1/leads (permission crm.manage, so a read_write key) creates a lead. Send external_ref — your own id for the enquiry (a form submission id, a chat id) — and the call is idempotent: repeating it returns the existing lead with HTTP 200 instead of creating a second one. Minimum body:
{ "contact_name": "Maria Dela Cruz", "email": "maria@example.test", "phone": "+63 917 555 0101",
"title": "Kitchen renovation", "source": "api", "source_detail": "zapier:contact-form", "external_ref": "zap-8812",
"message": "Two bedrooms, budget 400k", "next_action_at": "2026-10-25T01:00:00Z", "next_action_note": "First call" }
An open lead with the same email or phone answers 409 duplicate_lead (with that lead in data); add "force": true when it really is a different enquiry. The no-code alternative is the public enquiry form link under Settings → Organization, which needs no key at all.