Fedha API
API Reference

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
Security: Never put the 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"}'
The 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:

rental
Property management, landlords
transport
Freight, logistics, cargo
generic
General business, e-commerce, schools
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

POST /admin/tenants
FieldTypeRequiredDescription
namestringyesDisplay name for the tenant
currencystringnoDefault "TZS". Any 3-letter currency code.

List all tenants

GET /admin/tenants

Get a single tenant

GET /admin/tenants/{tenant_id}

Deactivate a tenant

DELETE /admin/tenants/{tenant_id}

Sets is_active: false. Does not delete data.

Create an API key

POST /admin/tenants/{tenant_id}/keys
FieldTypeRequiredDescription
namestringyesLabel for this key, e.g. "production", "staging"
The raw 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

GET /admin/tenants/{tenant_id}/keys

Revoke an API key

DELETE /admin/tenants/{tenant_id}/keys/{key_id}

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

POST /accounts/seed?domain={domain}
ParamValuesDescription
domainrental | transport | genericWhich account template to load

Create a custom account

POST /accounts
FieldTypeRequiredDescription
codestringyesUnique numeric code e.g. "6010"
namestringyesHuman-readable name
typestringyesasset | liability | equity | revenue | expense
parent_codestringnoCode of the parent account
system_labelstringnoYour own label for journal lines

List accounts

GET /accounts
ParamTypeDefaultDescription
account_typestringFilter by type
active_onlybooleantrueSet false to include deactivated

Get / Update an account

GET /accounts/{account_code}
PATCH /accounts/{account_code}

Rental Domain — Full Account List

CodeNameTypeLabel
1010Cash on HandassetCASH_ON_HAND
1020Bank AccountassetPRIMARY_BANK
1025Mobile Money — M-PesaassetMPESA
1026Mobile Money — Tigo PesaassetTIGOPESA
1027Mobile Money — HaloPesaassetHALOPESA
1028Mobile Money — Airtel MoneyassetAIRTEL_MONEY
1030Petty CashassetPETTY_CASH
1110Rent ReceivableassetAR_CONTROL
1120Security Deposits HeldassetSECURITY_DEPOSIT_ASSET
1130Input VAT RecoverableassetINPUT_VAT
1310Buildings & Improvementsasset
1320Furniture & Fittingsasset
1390Accumulated DepreciationassetACCUM_DEPRECIATION
2010Accounts PayableliabilityAP_CONTROL
2020Security Deposits PayableliabilitySECURITY_DEPOSIT_LIABILITY
2030VAT PayableliabilityVAT_PAYABLE
2040Accrued ExpensesliabilityACCRUED_EXPENSES
2050Advance Rent ReceivedliabilityADVANCE_RENT
2110Loans PayableliabilityLOANS_PAYABLE
3010Owner's CapitalequityOWNER_CAPITAL
3020Retained EarningsequityRETAINED_EARNINGS
3030Reversal ClearingequityREVERSAL_CLEARING
4010Rent RevenuerevenueRENT_REVENUE
4020Late Fee RevenuerevenueLATE_FEE_REVENUE
4030Service Charge RevenuerevenueSERVICE_CHARGE_REVENUE
4040Other RevenuerevenueOTHER_REVENUE
5010Property MaintenanceexpenseMAINTENANCE_EXPENSE
5020Utilities — Common AreasexpenseUTILITIES_EXPENSE
5030InsuranceexpenseINSURANCE_EXPENSE
5040Property Tax & RatesexpensePROPERTY_TAX_EXPENSE
5050Management FeesexpenseMANAGEMENT_FEE_EXPENSE
5060Salaries & WagesexpenseSALARY_EXPENSE
5070Advertising & MarketingexpenseADVERTISING_EXPENSE
5080Legal & Professional FeesexpenseLEGAL_EXPENSE
5090DepreciationexpenseDEPRECIATION_EXPENSE
5100Office & AdminexpenseADMIN_EXPENSE
5190Miscellaneous ExpensesexpenseMISC_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

POST /journal
FieldTypeRequiredDescription
datestringyesTransaction date (YYYY-MM-DD)
descriptionstringyesHuman-readable description
linesarrayyesTwo or more lines (must balance)
source_typestringnoType of originating record, e.g. "payment"
source_idstringnoID of originating record in your system
referencestringnoAuto-generated if omitted

Each line in lines:

FieldTypeRequiredDescription
account_codestringeither/orNumeric code e.g. "1020"
account_labelstringeither/orSystem label e.g. "PRIMARY_BANK"
debitnumberyesDebit amount (0 if credit line)
creditnumberyesCredit amount (0 if debit line)
descriptionstringnoLine-level note
Each line needs either account_code or account_label — not both. Total debits must equal total credits.

Common Journal Patterns

Rent invoice raised
{
  "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}
  ]
}
Payment received (bank)
{
  "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}
  ]
}
M-Pesa payment received
{
  "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}
  ]
}
Expense paid (maintenance)
{
  "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}
  ]
}
Security deposit received
{
  "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}
  ]
}
Security deposit refunded
{
  "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

DELETE /journal/reverse?source_type={type}&source_id={id}

List journal entries

GET /journal
ParamTypeDescription
start_datestringFilter from date (YYYY-MM-DD)
end_datestringFilter to date
source_typestringFilter by source type
source_idstringFilter by source ID
searchstringCase-insensitive search on description or reference
limitintegerResults per page (default 50, max 200)
cursorstringPagination 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

GET /journal/{entry_id}

Financial Reports

All report endpoints require X-API-Key and return structured JSON.

Trial Balance

GET /reports/trial-balance?start_date=...&end_date=...

All accounts with their net debit/credit balances for the period. Used to verify the books balance.

Income Statement (P&L)

GET /reports/income-statement?start_date=...&end_date=...

Revenue and expenses for a period, resulting in net income.

Balance Sheet

GET /reports/balance-sheet?as_of_date=...

Assets, liabilities, and equity as of a specific date. Net income is folded into equity automatically.

Cash Position

GET /reports/cash-position?as_of_date=...

Sum of all cash and bank account balances. Perfect for a quick dashboard widget.

AR Aging Report

GET /reports/ar-aging?as_of_date=...

Outstanding receivables bucketed by age. Essential for following up on overdue rent.

BucketMeaning
current0–30 days overdue
days_31_6031–60 days overdue
days_61_9061–90 days overdue
over_90More than 90 days overdue

VAT Return

GET /reports/vat-return?start_date=...&end_date=...

Computes output VAT (collected from customers) and input VAT (paid to suppliers) for a period, with the net amount payable to TRA.

Tanzania VAT rules applied: 18% standard rate · Residential rent is exempt · Filing deadline: 20th of the following month · Authority: 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

POST /reconciliation
FieldTypeRequiredDescription
account_codestringyesAccount to reconcile e.g. "1020"
period_startstringyesStart of period
period_endstringyesEnd of period
statement_balancenumberyesClosing balance from bank statement

List lines / Toggle / Lock

GET /reconciliation/{recon_id}/lines
POST /reconciliation/{recon_id}/toggle-line
POST /reconciliation/{recon_id}/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

EventWhen it fires
journal.postedA journal entry is created or reposted (idempotent)
journal.reversedJournal entries are reversed for a source

Register a webhook endpoint

POST /webhooks
FieldTypeRequiredDescription
urlstringyesHTTPS URL Fedha will POST to
eventsarrayyesList 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"]}'
The secret in the response is shown only once. Store it securely — you need it to verify incoming payloads.

Manage webhooks

GET /webhooks
DELETE /webhooks/{webhook_id}
GET /webhooks/{webhook_id}/deliveries

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

StatusMeaningWhat to do
400Validation failedCheck the detail field for the specific reason
401UnauthorizedCheck your X-API-Key or X-Admin-Key header
404Not foundThe resource doesn't exist in your tenant
409ConflictDuplicate account code, or locked reconciliation
422UnprocessableMalformed request body (missing field, wrong type)
500Server errorUnexpected — check detail and report if persistent

Common Mistakes

"Provide either account_code or account_label" — Every line needs one of these. You left one blank.
"Journal must balance" — Add up your debits and credits — they must be equal.
401 on every request — Use X-API-Key, not Authorization: Bearer. The header name matters.
404 on account_label — The label doesn't exist. Run /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],
    ],
]);