Fedha API
A multi-tenant, double-entry accounting engine. Any system that needs accounting — rental management, transport, e-commerce, schools — can plug in via HTTP. You post transactions, Fedha handles the ledger math and produces financial reports.
Core Concepts
Multi-tenancy
Every system that uses Fedha API is a tenant. A tenant's data is completely isolated — one tenant cannot see another's accounts, journals, or reports. You get one API key per tenant and include it on every request.
Your App ──→ POST /journal (X-API-Key: your-key) ──→ Fedha API ──→ Neon DB
Double-entry bookkeeping
Every financial event is recorded as a journal entry with two or more lines. Total debits must always equal total credits. The API enforces this — it rejects any unbalanced request.
Example: Customer pays rent of 500,000 TZS
DR Bank Account (1020) 500,000
CR Rent Receivable (1110) 500,000
Idempotency
If you post a journal entry with a source_type and source_id, the API remembers it. Posting the same source twice is safe — the old entry is automatically reversed and replaced. Retry on network failure without creating duplicates.
POST /journal {source_type: "payment", source_id: "pay_123"} → creates entry
POST /journal {source_type: "payment", source_id: "pay_123"} → reverses old, creates new
System Labels
Instead of memorising account codes, reference accounts by a label like PRIMARY_BANK or AR_CONTROL. Labels are consistent across all tenants that seeded the same domain, so your integration code is portable.
Authentication
Fedha uses two types of API keys. Keep them separate — the admin key provisions tenants, the tenant key does everything else.
Tenant API Key — for all accounting operations
Used for posting journals, fetching reports, and reconciliation.
X-API-Key: your-tenant-api-key
Admin Key — for provisioning only
Used only when creating tenants or API keys. This key stays on your server and is never shared with client apps.
X-Admin-Key: your-admin-secret-key
X-Admin-Key in frontend code or mobile apps. Only use it in server-side scripts or admin tooling.Quick Start
From zero to posting your first transaction in 4 steps.
Create a tenant
This creates an isolated accounting namespace for your application.
curl -X POST https://fedha-api.com/admin/tenants \
-H "X-Admin-Key: YOUR_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "My App", "currency": "TZS"}'
Save the id from the response — you need it next.
Create an API key
curl -X POST https://fedha-api.com/admin/tenants/{tenant_id}/keys \
-H "X-Admin-Key: YOUR_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "production"}'
key value is only shown once at creation. Store it immediately in your environment variables as FEDHA_API_KEY.Seed a Chart of Accounts
Pick the domain that matches your business:
curl -X POST "https://fedha-api.com/accounts/seed?domain=rental" \
-H "X-API-Key: fapi_xxxxxxxxxxxxxxxx"
Safe to call multiple times — it skips existing account codes.
Post your first journal entry
curl -X POST https://fedha-api.com/journal \
-H "X-API-Key: fapi_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"date": "2026-05-20",
"description": "Rent payment from John Doe",
"source_type": "payment",
"source_id": "pay_001",
"lines": [
{"account_label": "PRIMARY_BANK", "debit": 500000, "credit": 0},
{"account_label": "AR_CONTROL", "debit": 0, "credit": 500000}
]
}'
That's it. Pull reports from the endpoints below to see your numbers.
Admin — Tenants & API Keys
All admin endpoints require the X-Admin-Key header.
Create a tenant
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Display name for the tenant |
currency | string | no | Default "TZS". Any 3-letter currency code. |
List all tenants
Get a single tenant
Deactivate a tenant
Sets is_active: false. Does not delete data.
Create an API key
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Label for this key, e.g. "production", "staging" |
key is shown only once at creation. Store it immediately. Subsequent list calls expose only key_prefix (first 8 characters) for identification. Keys are stored as SHA-256 hashes — Fedha never holds the raw value.List API keys for a tenant
Revoke an API key
Sets is_active: false. Requests using this key will immediately receive 401.
Chart of Accounts
All endpoints require X-API-Key header.
Seed a full Chart of Accounts
| Param | Values | Description |
|---|---|---|
domain | rental | transport | generic | Which account template to load |
Create a custom account
| Field | Type | Required | Description |
|---|---|---|---|
code | string | yes | Unique numeric code e.g. "6010" |
name | string | yes | Human-readable name |
type | string | yes | asset | liability | equity | revenue | expense |
parent_code | string | no | Code of the parent account |
system_label | string | no | Your own label for journal lines |
List accounts
| Param | Type | Default | Description |
|---|---|---|---|
account_type | string | — | Filter by type |
active_only | boolean | true | Set false to include deactivated |
Get / Update an account
Rental Domain — Full Account List
| Code | Name | Type | Label |
|---|---|---|---|
1010 | Cash on Hand | asset | CASH_ON_HAND |
1020 | Bank Account | asset | PRIMARY_BANK |
1025 | Mobile Money — M-Pesa | asset | MPESA |
1026 | Mobile Money — Tigo Pesa | asset | TIGOPESA |
1027 | Mobile Money — HaloPesa | asset | HALOPESA |
1028 | Mobile Money — Airtel Money | asset | AIRTEL_MONEY |
1030 | Petty Cash | asset | PETTY_CASH |
1110 | Rent Receivable | asset | AR_CONTROL |
1120 | Security Deposits Held | asset | SECURITY_DEPOSIT_ASSET |
1130 | Input VAT Recoverable | asset | INPUT_VAT |
1310 | Buildings & Improvements | asset | — |
1320 | Furniture & Fittings | asset | — |
1390 | Accumulated Depreciation | asset | ACCUM_DEPRECIATION |
2010 | Accounts Payable | liability | AP_CONTROL |
2020 | Security Deposits Payable | liability | SECURITY_DEPOSIT_LIABILITY |
2030 | VAT Payable | liability | VAT_PAYABLE |
2040 | Accrued Expenses | liability | ACCRUED_EXPENSES |
2050 | Advance Rent Received | liability | ADVANCE_RENT |
2110 | Loans Payable | liability | LOANS_PAYABLE |
3010 | Owner's Capital | equity | OWNER_CAPITAL |
3020 | Retained Earnings | equity | RETAINED_EARNINGS |
3030 | Reversal Clearing | equity | REVERSAL_CLEARING |
4010 | Rent Revenue | revenue | RENT_REVENUE |
4020 | Late Fee Revenue | revenue | LATE_FEE_REVENUE |
4030 | Service Charge Revenue | revenue | SERVICE_CHARGE_REVENUE |
4040 | Other Revenue | revenue | OTHER_REVENUE |
5010 | Property Maintenance | expense | MAINTENANCE_EXPENSE |
5020 | Utilities — Common Areas | expense | UTILITIES_EXPENSE |
5030 | Insurance | expense | INSURANCE_EXPENSE |
5040 | Property Tax & Rates | expense | PROPERTY_TAX_EXPENSE |
5050 | Management Fees | expense | MANAGEMENT_FEE_EXPENSE |
5060 | Salaries & Wages | expense | SALARY_EXPENSE |
5070 | Advertising & Marketing | expense | ADVERTISING_EXPENSE |
5080 | Legal & Professional Fees | expense | LEGAL_EXPENSE |
5090 | Depreciation | expense | DEPRECIATION_EXPENSE |
5100 | Office & Admin | expense | ADMIN_EXPENSE |
5190 | Miscellaneous Expenses | expense | MISC_EXPENSE |
Journal Entries
The core of Fedha API. Every financial event — a payment, an expense, an invoice — becomes a journal entry.
Post a journal entry
| Field | Type | Required | Description |
|---|---|---|---|
date | string | yes | Transaction date (YYYY-MM-DD) |
description | string | yes | Human-readable description |
lines | array | yes | Two or more lines (must balance) |
source_type | string | no | Type of originating record, e.g. "payment" |
source_id | string | no | ID of originating record in your system |
reference | string | no | Auto-generated if omitted |
Each line in lines:
| Field | Type | Required | Description |
|---|---|---|---|
account_code | string | either/or | Numeric code e.g. "1020" |
account_label | string | either/or | System label e.g. "PRIMARY_BANK" |
debit | number | yes | Debit amount (0 if credit line) |
credit | number | yes | Credit amount (0 if debit line) |
description | string | no | Line-level note |
account_code or account_label — not both. Total debits must equal total credits.Common Journal Patterns
{
"date": "2026-05-01",
"description": "Rent invoice — Unit 3A — May 2026",
"source_type": "invoice",
"source_id": "inv_001",
"lines": [
{"account_label": "AR_CONTROL", "debit": 750000, "credit": 0},
{"account_label": "RENT_REVENUE", "debit": 0, "credit": 750000}
]
}
{
"date": "2026-05-10",
"description": "Rent payment — Unit 3A — John Doe",
"source_type": "payment",
"source_id": "pay_001",
"lines": [
{"account_label": "PRIMARY_BANK", "debit": 750000, "credit": 0},
{"account_label": "AR_CONTROL", "debit": 0, "credit": 750000}
]
}
{
"date": "2026-05-10",
"description": "M-Pesa rent payment — Unit 3A",
"source_type": "payment",
"source_id": "pay_002",
"lines": [
{"account_label": "MPESA", "debit": 750000, "credit": 0},
{"account_label": "AR_CONTROL", "debit": 0, "credit": 750000}
]
}
{
"date": "2026-05-15",
"description": "Plumbing repair — Block B",
"source_type": "expense",
"source_id": "exp_001",
"lines": [
{"account_label": "MAINTENANCE_EXPENSE", "debit": 120000, "credit": 0},
{"account_label": "PRIMARY_BANK", "debit": 0, "credit": 120000}
]
}
{
"date": "2026-05-01",
"description": "Security deposit — Unit 3A — John Doe",
"source_type": "deposit",
"source_id": "dep_001",
"lines": [
{"account_label": "PRIMARY_BANK", "debit": 1000000, "credit": 0},
{"account_label": "SECURITY_DEPOSIT_LIABILITY", "debit": 0, "credit": 1000000}
]
}
{
"date": "2026-05-31",
"description": "Security deposit refund — Unit 3A — John Doe vacated",
"source_type": "deposit_refund",
"source_id": "ref_001",
"lines": [
{"account_label": "SECURITY_DEPOSIT_LIABILITY", "debit": 1000000, "credit": 0},
{"account_label": "PRIMARY_BANK", "debit": 0, "credit": 1000000}
]
}
Reverse a journal entry
List journal entries
| Param | Type | Description |
|---|---|---|
start_date | string | Filter from date (YYYY-MM-DD) |
end_date | string | Filter to date |
source_type | string | Filter by source type |
source_id | string | Filter by source ID |
search | string | Case-insensitive search on description or reference |
limit | integer | Results per page (default 50, max 200) |
cursor | string | Pagination cursor from next_cursor in previous response |
The response is wrapped in a pagination envelope:
{
"items": [...],
"total": 142,
"has_more": true,
"next_cursor": "eyJkYXRlIjogIjIwMjYtMDUtMTAiLCAiY3JlYXRlZF9hdCI6IC..."
}
Pass next_cursor as cursor on the next request to fetch the next page.
Get a single journal entry
Financial Reports
All report endpoints require X-API-Key and return structured JSON.
Trial Balance
All accounts with their net debit/credit balances for the period. Used to verify the books balance.
Income Statement (P&L)
Revenue and expenses for a period, resulting in net income.
Balance Sheet
Assets, liabilities, and equity as of a specific date. Net income is folded into equity automatically.
Cash Position
Sum of all cash and bank account balances. Perfect for a quick dashboard widget.
AR Aging Report
Outstanding receivables bucketed by age. Essential for following up on overdue rent.
| Bucket | Meaning |
|---|---|
current | 0–30 days overdue |
days_31_60 | 31–60 days overdue |
days_61_90 | 61–90 days overdue |
over_90 | More than 90 days overdue |
VAT Return
Computes output VAT (collected from customers) and input VAT (paid to suppliers) for a period, with the net amount payable to TRA.
Output VAT is sourced from credits to VAT_PAYABLE (2030). Input VAT is sourced from debits to INPUT_VAT (1130). If is_refund: true, input exceeds output and TRA owes you a refund.
{
"period_start": "2026-05-01",
"period_end": "2026-05-31",
"vat_rate": "0.18",
"filing_deadline": "2026-06-20",
"taxable_sales": 1000000,
"output_vat": 180000,
"taxable_purchases": 200000,
"input_vat": 36000,
"net_vat_payable": 144000,
"is_refund": false,
"output_lines": [...],
"input_lines": [...]
}
Bank Reconciliation
Match your accounting records against your bank statement to ensure no transactions are missing or duplicated.
Workflow
Create a reconciliation session
Specify the account, period, and statement closing balance.
List journal lines
View all journal lines for that account in the period.
Toggle lines as cleared
Mark each line that appears on your bank statement.
Lock the reconciliation
Finalize the period to prevent further changes.
Create a reconciliation
| Field | Type | Required | Description |
|---|---|---|---|
account_code | string | yes | Account to reconcile e.g. "1020" |
period_start | string | yes | Start of period |
period_end | string | yes | End of period |
statement_balance | number | yes | Closing balance from bank statement |
List lines / Toggle / Lock
A locked reconciliation returns 409 Conflict if you try to toggle lines.
Webhooks
Get real-time notifications when accounting events occur. Register a URL and Fedha will POST a signed JSON payload every time a matching event fires.
Events
| Event | When it fires |
|---|---|
journal.posted | A journal entry is created or reposted (idempotent) |
journal.reversed | Journal entries are reversed for a source |
Register a webhook endpoint
| Field | Type | Required | Description |
|---|---|---|---|
url | string | yes | HTTPS URL Fedha will POST to |
events | array | yes | List of event names to subscribe to |
curl -X POST https://fedha-api.com/webhooks \
-H "X-API-Key: fapi_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"url": "https://yourapp.com/hooks/fedha", "events": ["journal.posted", "journal.reversed"]}'
secret in the response is shown only once. Store it securely — you need it to verify incoming payloads.Manage webhooks
The deliveries endpoint shows HTTP status, response time, success flag, and error messages for each delivery attempt.
Verifying webhook signatures
Every request from Fedha includes an X-Fedha-Signature: sha256=<hex> header. Always verify it before processing.
import hmac, hashlib
def verify_fedha_webhook(payload_body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), payload_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
# In your webhook handler:
sig = request.headers.get("X-Fedha-Signature", "")
if not verify_fedha_webhook(request.body, sig, FEDHA_WEBHOOK_SECRET):
return 401
const crypto = require("crypto");
function verifyFedhaWebhook(payload, signatureHeader, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(signatureHeader)
);
}
Payload shape
{
"event": "journal.posted",
"tenant_id": "tenant-uuid",
"data": {
"id": "entry-uuid",
"date": "2026-05-20",
"description": "Rent payment — Unit 3A",
"source_type": "payment",
"source_id": "pay_001",
"lines": [...]
}
}
Error Reference
| Status | Meaning | What to do |
|---|---|---|
| 400 | Validation failed | Check the detail field for the specific reason |
| 401 | Unauthorized | Check your X-API-Key or X-Admin-Key header |
| 404 | Not found | The resource doesn't exist in your tenant |
| 409 | Conflict | Duplicate account code, or locked reconciliation |
| 422 | Unprocessable | Malformed request body (missing field, wrong type) |
| 500 | Server error | Unexpected — check detail and report if persistent |
Common Mistakes
X-API-Key, not Authorization: Bearer. The header name matters./accounts/seed first, or check spelling.Code Examples
import os
import requests
BASE_URL = "https://fedha-api.com"
API_KEY = os.environ["FEDHA_API_KEY"]
def post_journal(date, description, source_type, source_id, lines):
response = requests.post(
f"{BASE_URL}/journal",
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json={
"date": str(date),
"description": description,
"source_type": source_type,
"source_id": str(source_id),
"lines": lines,
},
timeout=10,
)
response.raise_for_status()
return response.json()
def void_journal(source_type, source_id):
response = requests.delete(
f"{BASE_URL}/journal/reverse",
headers={"X-API-Key": API_KEY},
params={"source_type": source_type, "source_id": str(source_id)},
timeout=10,
)
response.raise_for_status()
return response.json()
def get_cash_position(as_of_date):
response = requests.get(
f"{BASE_URL}/reports/cash-position",
headers={"X-API-Key": API_KEY},
params={"as_of_date": str(as_of_date)},
timeout=10,
)
response.raise_for_status()
return response.json()
# Usage
post_journal(
date="2026-05-20",
description="Rent payment — Unit 3A",
source_type="payment",
source_id="pay_001",
lines=[
{"account_label": "PRIMARY_BANK", "debit": 750000, "credit": 0},
{"account_label": "AR_CONTROL", "debit": 0, "credit": 750000},
]
)
const BASE_URL = "https://fedha-api.com";
const API_KEY = process.env.FEDHA_API_KEY;
const headers = {
"X-API-Key": API_KEY,
"Content-Type": "application/json",
};
async function postJournal({ date, description, sourceType, sourceId, lines }) {
const res = await fetch(`${BASE_URL}/journal`, {
method: "POST", headers,
body: JSON.stringify({ date, description,
source_type: sourceType, source_id: String(sourceId), lines }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function voidJournal(sourceType, sourceId) {
const params = new URLSearchParams({ source_type: sourceType, source_id: String(sourceId) });
const res = await fetch(`${BASE_URL}/journal/reverse?${params}`, { method: "DELETE", headers });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Usage
await postJournal({
date: "2026-05-20", description: "Rent payment — Unit 3A",
sourceType: "payment", sourceId: "pay_001",
lines: [
{ account_label: "PRIMARY_BANK", debit: 750000, credit: 0 },
{ account_label: "AR_CONTROL", debit: 0, credit: 750000 },
],
});
<?php
define('BASE_URL', 'https://fedha-api.com');
define('API_KEY', getenv('FEDHA_API_KEY'));
function fedha_post(string $path, array $body): array {
$ch = curl_init(BASE_URL . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HTTPHEADER => ['X-API-Key: ' . API_KEY, 'Content-Type: application/json'],
]);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
fedha_post('/journal', [
'date' => '2026-05-20', 'description' => 'Rent payment — Unit 3A',
'source_type' => 'payment', 'source_id' => 'pay_001',
'lines' => [
['account_label' => 'PRIMARY_BANK', 'debit' => 750000, 'credit' => 0],
['account_label' => 'AR_CONTROL', 'debit' => 0, 'credit' => 750000],
],
]);