Accounting API for developers: the whole ledger over REST
Ledgeriano's accounting API gives your code everything the dashboard can do for one business: accounts, journal entries, fiscal years, parties, exchange rates, financial reports, webhooks and the audit trail. JSON in, JSON out, with an OpenAPI reference you can try in the browser.

- JSON over HTTPS, OpenAPI documented
- REST
- owner-managed API keys
- Bearer
- default rate limit per key
- 120/min
- webhook event types
- 12
Base URL
https://api.ledgeriano.com/api/v1Every business has its own keys; the key decides which business you are talking to.
From zero to a posted journal entry in three steps
- 1
Create a key
As the business owner, open the business settings, create an API key and choose its abilities: read, or read and write. Copy it right away: the full key is shown only once.
- 2
Make a read call
Send
GET /accountswithAuthorization: Bearer lgr_live_…. The response lists the chart of accounts of that business, and the headers show your remaining rate limit and credit balance. - 3
Post an entry
Send
POST /journal-entrieswith balanced lines and anIdempotency-Key. Use account codes or ids, add"status": "posted"to post immediately, or leave it as a draft for review.
Post a journal entry in curl, JavaScript or Python
The same request in three languages: a posted sales entry with a customer on the receivable line. Retrying with the same idempotency key returns the original entry instead of creating a second one.
Read accounts and reports
# Trial balance for the current fiscal year
curl https://api.ledgeriano.com/api/v1/reports/trial-balance \
-H "Authorization: Bearer $LEDGERIANO_API_KEY" \
-H "Accept: application/json" \
-H "Accept-Language: fa" # Persian error messages and labelsconst res = await fetch("https://api.ledgeriano.com/api/v1/accounts?postable=1", {
headers: {
Authorization: `Bearer ${process.env.LEDGERIANO_API_KEY}`,
Accept: "application/json",
},
});
const { data: accounts } = await res.json();
console.log(res.headers.get("X-RateLimit-Remaining"), accounts.length);import os
import requests
resp = requests.get(
"https://api.ledgeriano.com/api/v1/reports/balance-sheet",
params={"compare": 1}, # add the prior year column
headers={
"Authorization": f"Bearer {os.environ['LEDGERIANO_API_KEY']}",
"Accept": "application/json",
},
timeout=30,
)
report = resp.json()curl -X POST https://api.ledgeriano.com/api/v1/journal-entries \
-H "Authorization: Bearer $LEDGERIANO_API_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-10482" \
-d '{
"entry_date": "2026-09-25",
"voucher_type": "SAL",
"reference": "INV-10482",
"description": "Online order 10482",
"status": "posted",
"lines": [
{ "account_code": "1111", "debit": 1250.00, "party_code": "C001" },
{ "account_code": "4101", "credit": 1250.00 }
]
}'const res = await fetch("https://api.ledgeriano.com/api/v1/journal-entries", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LEDGERIANO_API_KEY}`,
Accept: "application/json",
"Content-Type": "application/json",
"Idempotency-Key": "order-10482", // same key on retry = same entry
},
body: JSON.stringify({
entry_date: "2026-09-25",
voucher_type: "SAL",
reference: "INV-10482",
description: "Online order 10482",
status: "posted",
lines: [
{ account_code: "1111", debit: 1250.0, party_code: "C001" },
{ account_code: "4101", credit: 1250.0 },
],
}),
});
if (!res.ok) {
const err = await res.json(); // { code, message, errors }
throw new Error(`${res.status} ${err.code}: ${err.message}`);
}
const { data: entry } = await res.json();
console.log(entry.number, res.headers.get("X-Credits-Balance"));import os
import requests
resp = requests.post(
"https://api.ledgeriano.com/api/v1/journal-entries",
headers={
"Authorization": f"Bearer {os.environ['LEDGERIANO_API_KEY']}",
"Accept": "application/json",
"Idempotency-Key": "order-10482",
},
json={
"entry_date": "2026-09-25",
"voucher_type": "SAL",
"reference": "INV-10482",
"description": "Online order 10482",
"status": "posted",
"lines": [
{"account_code": "1111", "debit": 1250.00, "party_code": "C001"},
{"account_code": "4101", "credit": 1250.00},
],
},
timeout=30,
)
if resp.status_code >= 400:
err = resp.json()
raise RuntimeError(f"{resp.status_code} {err['code']}: {err['message']}")
entry = resp.json()["data"]
print(entry["number"], resp.headers.get("X-Credits-Balance"))API keys that only the owner can create, rotate and revoke
Send the key as a bearer token: Authorization: Bearer lgr_live_…. The X-API-Key header works too. Keys belong to one business, so an integration for three companies uses three keys, and a leaked key never exposes the other books.
A key with the read ability can call read endpoints. Anything that changes data needs write; without it the API answers 403. Keys can have an expiry date, can be rotated (the old key stops working and a new one is issued with the same settings) and can be revoked at any time. Every call is logged with the key that made it.
Authorization: Bearer lgr_live_xxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/json
Accept-Language: en # or fa
Idempotency-Key: order-10482 # on createreadwriterotaterevokeexpires_atWhat the API covers
All paths are relative to the base URL. Lines in journal entries accept ids (account_id, party_id, cost_center_id, project_id) or codes (account_code, party_code and so on).
| Resource | Endpoints | Notes |
|---|---|---|
| Fiscal years | GET POST /fiscal-years, GET PUT DELETE /fiscal-years/{id}, POST /{id}/close, POST /{id}/reopen | Closing writes the closing and opening entries |
| Accounts | /accounts CRUD, GET /accounts/{id}/balance | Chart of accounts of the current fiscal year |
| Parties | /parties CRUD | Customers and suppliers |
| Cost centers | /cost-centers CRUD | Departments, branches |
| Projects | /projects CRUD | Jobs and contracts |
| Voucher types | /voucher-types CRUD | Codes such as SAL, PUR, PAY |
| Journal entries | /journal-entries CRUD, POST /bulk-post, POST /{id}/post, POST /{id}/reverse, POST /{id}/duplicate, /{id}/attachments | Drafts can be edited; posted entries are corrected by reversal |
| Exchange rates | GET POST /exchange-rates, DELETE /exchange-rates/{id} | Used when an entry has no rate of its own |
| Reports | /reports/trial-balance, balance-sheet, income-statement, cash-flow, equity-changes, general-ledger, journal, party-balances, dashboard | Same numbers as the dashboard |
| Webhooks | /webhooks CRUD, GET /{id}/deliveries, POST /{id}/test, POST /{id}/deliveries/{delivery}/redeliver | Signed with HMAC SHA-256 |
| Audit logs | GET /audit-logs | Who did what, when, from where |
Idempotency, errors, limits and credits
Idempotency keys
SendIdempotency-Key(oridempotency_keyin the body) when creating an entry. A retry with the same key returns the entry that already exists, with status 200 instead of 201, so a network timeout never books a sale twice.One error shape
Every error has a machine-readablecode, a humanmessagein English or Persian (Accept-Language: fa) and, for validation,errorskeyed by field such aslines.0.debit.Rate limits
Each key may make 120 requests per minute by default. WatchX-RateLimit-LimitandX-RateLimit-Remaining; a429carriesretry_afterin seconds.Credits in headers
Each request costs theapi.callprice and actions add their own, such asentry.create.X-Credits-ChargedandX-Credits-Balanceshow the cost and what is left; a402means the balance ran out.
Example error
HTTP/1.1 422 Unprocessable Entity
{
"message": "The entry is not balanced: debits 1,250.00 vs credits 1,200.00 (difference 50.00).",
"code": "validation_failed",
"errors": {
"lines": ["The entry is not balanced: debits 1,250.00 vs credits 1,200.00 (difference 50.00)."],
"lines.1.account_id": ["Account 9999 was not found in this fiscal year."]
}
}| Status | Meaning |
|---|---|
| 401 | Missing, invalid, expired or revoked API key |
| 402 | Not enough credits (insufficient_credits) |
| 403 | The key lacks the write ability or the permission |
| 404 | The resource does not exist in this business |
| 409 | Conflicts with the current state, such as editing a posted entry or writing into a closed year |
| 422 | Validation failed; see errors by field |
| 423 | The business or the owner's account is suspended |
| 429 | Rate limit reached; retry after retry_after seconds |
Signed webhooks for entries, accounts, parties and fiscal years
Subscribe a URL to the events you need and Ledgeriano sends a JSON POST for each one. Every delivery carries X-Ledgeriano-Event, a unique X-Ledgeriano-Delivery id, X-Ledgeriano-Timestamp and X-Ledgeriano-Signature.
The signature is sha256= followed by the hex HMAC SHA-256 of timestamp + "." + raw_body, keyed with your webhook secret. Failed deliveries are retried up to 5 times with growing delays (30 seconds up to 30 minutes), and you can inspect, test and redeliver them through the API.
Events
entry.createdentry.updatedentry.postedentry.reversedentry.deletedaccount.createdaccount.updatedaccount.deletedfiscal_year.createdfiscal_year.closedparty.createdparty.updated
A delivery
POST /webhooks/ledgeriano
X-Ledgeriano-Event: entry.posted
X-Ledgeriano-Delivery: 5f0c7b1e-2a41-4c1e-9d3a-8b7f7f1c2e90
X-Ledgeriano-Timestamp: 1790000000
X-Ledgeriano-Signature: sha256=9c1f...e04a
{
"id": "5f0c7b1e-2a41-4c1e-9d3a-8b7f7f1c2e90",
"event": "entry.posted",
"business_id": 12,
"created_at": "2026-09-25T10:42:07+00:00",
"data": { "entry": { "number": 318, "status": "posted", "lines": [ ... ] } }
}Verify the signature
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.LEDGERIANO_WEBHOOK_SECRET;
// Keep the raw body: the signature covers the exact bytes we sent.
app.post("/webhooks/ledgeriano", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Ledgeriano-Timestamp") ?? "";
const signature = req.get("X-Ledgeriano-Signature") ?? "";
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(timestamp + "." + req.body.toString("utf8")).digest("hex");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300; // 5 minutes
if (!valid || !fresh) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
// event.event === "entry.posted", event.data holds the entry
res.sendStatus(200);
});<?php
$secret = getenv('LEDGERIANO_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // raw body
$timestamp = $_SERVER['HTTP_X_LEDGERIANO_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_LEDGERIANO_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (! hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// $event['event'] === 'entry.posted', $event['data'] holds the entry
http_response_code(200);Accounting API questions
Is there a free way to test the accounting API?
Yes. New accounts get free credits. Create a business, generate a key and call the API; each request uses a small number of credits, shown in the X-Credits-Charged header.
Can one API key access several businesses?
No. Each key belongs to exactly one business. That keeps integrations isolated: revoking one key never affects the others.
How do I correct a posted entry through the API?
Call POST /journal-entries/{id}/reverse. Ledgeriano creates a posted reversal with debits and credits swapped and marks the original as reversed. Then post the corrected entry.
Does the API support foreign currencies?
Yes. Send currency_code and exchange_rate on the entry, or omit the rate and Ledgeriano uses the latest rate stored under /exchange-rates.
Where is the full endpoint reference?
The OpenAPI reference, rendered with Scalar, is at api.ledgeriano.com/docs/api. For a walkthrough with examples read the accounting API guide.
Build your integration today
Sign up, create a business and generate a key. Free credits cover your first tests; after that you pay per call as listed on the pricing page.