---
title: "API Access"
description: "Create a user-bound API key with read-only or read and write access, keep it safe, and make your first authenticated call to the BareLedger API."
url: "https://bareledger.app/guides/api-access"
category: "Developers & API"
updated: "2026-10-29"
publisher: "BareLedger (Syneria Technologies OPC)"
---

# API Access

> Create a user-bound API key, choose read-only or read & write, and make your first call.

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 require `read`, all mutating methods require `write`. 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/tenants` returns **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 a `409`.
- 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|paid`, `from=`, `to=` |
| Bank statement lines | `GET /api/v1/bank-accounts/{bank}/lines/export` | `status=`, `from=`, `to=`, `reconciliation_id=` |
| Leads | `GET /api/v1/leads/export` | `stage=open|closed|<stage>`, `owner=`, `due=`, `search=` |
| Projects | `GET /api/v1/projects/export` | `status=open|<status>`, `search=` |

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:

1. *My profile → API keys*: create a key with scope **read** and an expiry; note your organization id.
2. In the Sheet: *Extensions → Apps Script*, paste the script below, set the three constants (`API_KEY` as a Script Property, not in the code), run `refreshAll` once to grant access, then *Triggers → Add trigger → refreshAll, time-driven, every hour*.
3. In Looker Studio: *Add data → Google Sheets* → this sheet; each tab is a table.

```javascript
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:

```json
{ "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.
