The AiAkaun API
REST over HTTPS, JSON in and JSON out, authenticated with a Bearer token. Every endpoint here runs the same accounting engine as the dashboard — what you write through the API lands in your ledger, reports and tax immediately. The version is a contract: v1 keeps working when v2 arrives.
Authentication
Every request carries an API token in the Authorization header. No keys in the query string, no cookies, no sessions.
curl https://aiakaun.com/api/v1/me \ -H "Authorization: Bearer $AIAKAUN_TOKEN" \ -H "Accept: application/json"
Creating a token
Tokens are generated in your dashboard — we cannot create one for you, and no API endpoint creates another token.
- Go to Account → API & Webhooks.
- Pick the company, name the token, and tick only the scopes your integration genuinely needs.
- Optional: restrict the token to a list of IP addresses, and set an expiry date.
- Copy the raw token NOW. It is shown exactly once — we store its hash, not the token, so we cannot show it again.
One token, one company.
A token is bound to the single company you chose when you created it. There is no parameter to switch it, and no endpoint returns another company's data. If you run three companies you hold three tokens — so a leaked key exposes one set of books, not all three.
Effective permission is the intersection.
A token cannot grant its owner access they do not have themselves. Every request checks TWO things: the scopes the token holds, and the company permissions that human still holds today. Demote an accountant to read-only and the token they generated yesterday stops writing immediately.
Scopes
Scopes answer "what can this KEY do?". Company permissions answer "what can this HUMAN do?". Both must allow the request. The third column below shows the permission the token owner must also hold.
Company
| Scope | Allows | Company permission |
|---|---|---|
| read:company | Read company info | dashboard.view |
Accounting
| Scope | Allows | Company permission |
|---|---|---|
| read:accounts | Read chart of accounts | accounts.view |
| read:transactions | Read transactions | transactions.view |
| read:invoices | Read invoices | invoices.view |
| read:bills | Read supplier bills | bills.view |
| read:contacts | Read contacts | contacts.view |
| write:transactions | Create & edit transactions | transactions.create |
| write:invoices | Create & edit invoices | invoices.create |
| write:bills | Create supplier bills | bills.create |
| write:contacts | Create & edit contacts | contacts.create |
Operations
| Scope | Allows | Company permission |
|---|---|---|
| read:products | Read inventory | products.view |
| write:products | Create & edit inventory | products.create |
Document
| Scope | Allows | Company permission |
|---|---|---|
| read:documents | Read uploaded documents | documents.view |
| write:documents | Upload documents for AI | documents.create |
Reports
| Scope | Allows | Company permission |
|---|---|---|
| read:reports | Read financial reports | reports.view |
Ask for the fewest scopes that still get the job done. A script that only syncs invoices into your dashboard has no business holding write:transactions — and a leaked read-only token is an incident, not a catastrophe.
Rate limits
Limits are counted per TOKEN, not per IP address. Several customers share one office IP, and one runaway script should not lock out everybody behind the same router.
| Limit | Rate | Code when exceeded |
|---|---|---|
| All API requests | 120 / minute | rate_limited HTTP 429 |
| Document uploads POST /api/v1/documents | 10 / minute | upload_rate_limited HTTP 429 |
Uploads are far tighter because each one burns real AI tokens — a runaway loop costs money, not just CPU. The upload limit applies IN ADDITION to the 120/minute limit.
Headers, and how to handle a 429
X-RateLimit-Limit— how many requests the current window allows.X-RateLimit-Remaining— what is left in that window. Slow down as it approaches zero.Retry-After— on a 429 only: seconds until you may try again. RESPECT this value; do not retry immediately in a tight loop.
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
Retry-After: 37
Content-Type: application/json
{
"error": {
"code": "rate_limited",
"message": "Terlalu banyak permintaan. Cuba lagi sebentar."
}
}
Response shape
Three shapes only, identical on every endpoint. Data always lives under a data key; errors always under an error key. You never have to parse a tenth shape because we added an endpoint.
A single record
{
"data": {
"id": 1234,
"date": "2026-08-01",
"description": "Bayaran pelanggan — invois INV-0091",
"amount": 1250.00,
"type": "income",
"status": "confirmed",
"created_at": "2026-08-01T09:14:22+08:00"
}
}
A paginated collection
Control page size with per_page (default 50, maximum 200) and the page with page. Follow links.next until it is null — do not count pages yourself.
{
"data": [
{ "id": 1234, "date": "2026-08-01", "amount": 1250.00, "type": "income" },
{ "id": 1235, "date": "2026-08-02", "amount": 1800.00, "type": "expense" }
],
"meta": {
"current_page": 1,
"per_page": 50,
"total": 312,
"last_page": 7
},
"links": {
"next": "https://aiakaun.com/api/v1/transactions?page=2",
"prev": null
}
}
An error
{
"error": {
"code": "validation_failed",
"message": "Data yang dihantar tidak lengkap.",
"fields": {
"amount": ["Medan amount diperlukan."],
"date": ["Medan date bukan tarikh yang sah."]
}
}
}
Field types
- Money — a two-decimal number in the company currency, e.g. 1250.00. NOT cents. We store cents internally; misreading that is the quietest way to corrupt someone's books.
- Timestamps — ISO-8601 with an offset, e.g. 2026-08-01T09:14:22+08:00.
- Dates — Y-m-d with no time for fields that really are dates, e.g. a transaction date.
- Deletes — return 204 with no body.
Error codes
Codes are stable and machine-readable — branch on error.code, never on the message text, which changes and is translated.
| Code | HTTP | What it means |
|---|---|---|
| validation_failed | 422 | The request body failed validation. The fields object lists every offending field. |
| not_found | 404 | The record does not exist — or it does, but not for this token's company. We deliberately do not distinguish the two. |
| unauthenticated | 401 | No Authorization header, or the token is unknown or already expired. |
| token_revoked | 401 | This token was revoked in the dashboard. Create a new one. |
| insufficient_scope | 403 | The token is valid but does not hold the scope this route needs. The required_scope field tells you which. |
| insufficient_permission | 403 | The token holds the scope, but the HUMAN who owns it no longer holds the matching company permission. |
| account_suspended | 403 | The token owner's account is suspended. Every request stops until it is restored. |
| company_access_revoked | 403 | The token owner is no longer a member of that company. The token stays valid but is useless. |
| company_required | 403 | The token is not bound to any company — its company was probably deleted. |
| subscription_locked | 402 | The company subscription is inactive. GET keeps working; every POST, PATCH and DELETE is refused until it is renewed. |
| ip_not_allowed | 403 | The request came from an IP that is not on the token's allow-list. |
| rate_limited | 429 | You exceeded 120 requests a minute. See the Retry-After header. |
| upload_rate_limited | 429 | You exceeded 10 document uploads a minute. |
| period_locked | 409 | The accounting period is closed. Transactions dated inside it cannot be created or changed. |
A 402 subscription_locked only affects writes. GET keeps working even after a subscription lapses — you never lose read access to your own books because a card slipped.
Endpoint reference
This list is generated from the live routing table on every page load. It cannot drift from the real API — if an endpoint appears here, it exists.
The scope column lists EVERY scope the route requires. Where two are listed, your token needs both.
Account & token
| Method | Path | Scopes | Description |
|---|---|---|---|
| GET | /api/v1/me | none | Who this token is, which company, and which scopes it holds. |
Company
| Method | Path | Scopes | Description |
|---|---|---|---|
| GET | /api/v1/company | read:company | Company details: name, type, currency, financial year-end month. |
| GET | /api/v1/company/accounts | read:company read:accounts | The company's full chart of accounts. |
Transactions
| Method | Path | Scopes | Description |
|---|---|---|---|
| GET | /api/v1/transactions | read:transactions | List transactions. Filter by date range, account and status. |
| GET | /api/v1/transactions/{transaction} | read:transactions | A single transaction with its journal lines. |
| POST | /api/v1/transactions | write:transactions | Create a new transaction. |
| PATCH | /api/v1/transactions/{transaction} | write:transactions | Update an existing transaction. |
| POST | /api/v1/transactions/{transaction}/confirm | write:transactions | Confirm an AI-suggested transaction so it posts to the ledger. |
| DELETE | /api/v1/transactions/{transaction} | write:transactions | Delete a transaction. |
Contacts
| Method | Path | Scopes | Description |
|---|---|---|---|
| GET | /api/v1/contacts | read:contacts | List customers and suppliers. |
| GET | /api/v1/contacts/{contact} | read:contacts | A single contact. |
| POST | /api/v1/contacts | write:contacts | Create a new contact. |
| PATCH | /api/v1/contacts/{contact} | write:contacts | Update a contact. |
| DELETE | /api/v1/contacts/{contact} | write:contacts | Delete a contact. |
Invoices
| Method | Path | Scopes | Description |
|---|---|---|---|
| GET | /api/v1/invoices | read:invoices | List invoices. Filter by status and customer. |
| GET | /api/v1/invoices/{invoice} | read:invoices | A single invoice with its line items. |
| GET | /api/v1/invoices/{invoice}/pdf | read:invoices | Download the invoice PDF. |
| POST | /api/v1/invoices | write:invoices | Create a new invoice with its lines. |
| PATCH | /api/v1/invoices/{invoice} | write:invoices | Update an invoice. |
| POST | /api/v1/invoices/{invoice}/status | write:invoices | Change the invoice status (e.g. mark as paid). |
| DELETE | /api/v1/invoices/{invoice} | write:invoices | Delete an invoice. |
Supplier bills
| Method | Path | Scopes | Description |
|---|---|---|---|
| GET | /api/v1/bills | read:bills | List supplier bills. |
| GET | /api/v1/bills/{bill} | read:bills | A single supplier bill. |
| POST | /api/v1/bills | write:bills | Create a supplier bill. |
Inventory
| Method | Path | Scopes | Description |
|---|---|---|---|
| GET | /api/v1/products | read:products | List inventory items. |
| GET | /api/v1/products/{product} | read:products | A single inventory item. |
| POST | /api/v1/products | write:products | Create an inventory item. |
| PATCH | /api/v1/products/{product} | write:products | Update an inventory item. |
Documents
| Method | Path | Scopes | Description |
|---|---|---|---|
| GET | /api/v1/documents | read:documents | List uploaded documents and their AI reading status. |
| GET | /api/v1/documents/{document} | read:documents | A single document with its AI extraction result. |
| POST | /api/v1/documents 10/min | write:documents | Upload a receipt or statement for the AI to read (multipart/form-data). |
Financial reports
| Method | Path | Scopes | Description |
|---|---|---|---|
| GET | /api/v1/reports/profit-loss | read:reports | Profit & loss for a period. |
| GET | /api/v1/reports/balance-sheet | read:reports | Balance sheet as at a date. |
| GET | /api/v1/reports/trial-balance | read:reports | Trial balance for a period. |
| GET | /api/v1/reports/aging | read:reports | Receivables and payables ageing. |
Webhooks
| Method | Path | Scopes | Description |
|---|---|---|---|
| GET | /api/v1/webhooks | read:company | List this company's webhook endpoints. |
| GET | /api/v1/webhooks/{endpoint}/deliveries | read:company | Delivery log for one endpoint. |
Example: what /me returns
/me deliberately requires no scope. It tells a caller who they are, which company this token holds, and which scopes it carries — debugging an integration without that is guesswork.
{
"data": {
"user": { "id": 7, "name": "Ali bin Ahmad", "email": "ali@contoh.com" },
"company": { "id": 42, "name": "Perniagaan Ali Sdn Bhd", "currency": "MYR" },
"token": {
"name": "Integrasi kedai",
"last_four": "f3a9",
"read_only": false,
"scopes": ["read:transactions", "write:transactions", "read:reports"],
"expires_at": null
}
}
}
Webhooks
Instead of polling our API every minute, register a URL and we will push events to you as they happen. Add an endpoint in Account → API & Webhooks, pick the events you want, and keep the signing secret (it starts with whsec_) we show you.
Events
Event names are a public contract: once you write code against invoice.paid, that name will not change. New events get added; old ones are never renamed. You can also subscribe to a whole group with a wildcard, e.g. invoice.*, or to everything with *.
| Group | Event | Sent when |
|---|---|---|
| transaction.* | transaction.created | Transaction created |
| transaction.updated | Transaction updated | |
| transaction.confirmed | Transaction confirmed | |
| transaction.deleted | Transaction deleted | |
| invoice.* | invoice.created | Invoice created |
| invoice.updated | Invoice updated | |
| invoice.paid | Invoice paid | |
| invoice.deleted | Invoice deleted | |
| bill.* | bill.created | Supplier bill created |
| bill.paid | Supplier bill paid | |
| contact.* | contact.created | Contact created |
| contact.updated | Contact updated | |
| document.* | document.uploaded | Document uploaded |
| document.processed | AI finished reading document | |
| document.failed | AI failed to read document | |
| subscription.* | subscription.renewed | Subscription renewed |
| subscription.expiring | Subscription expiring | |
| subscription.expired | Subscription expired | |
| payment.* | payment.succeeded | Payment succeeded |
| payment.refunded | Payment refunded |
Payload
Every delivery is a JSON POST with the same envelope. The data key varies by event; everything around it stays put.
{
"id": "evt_01k2m9c7q4xz8b3vd6ntr5phaj",
"event": "invoice.paid",
"created_at": "2026-08-07T14:22:05+08:00",
"company": {
"id": 42,
"name": "Perniagaan Ali Sdn Bhd"
},
"data": {
"id": 91,
"number": "INV-0091",
"contact": { "id": 12, "name": "Kedai Runcit Maju" },
"total": 1250.00,
"paid_at": "2026-08-07T14:22:04+08:00",
"status": "paid"
}
}
Headers we send
| Header | Contents |
|---|---|
| X-AiAkaun-Event | The event name, e.g. invoice.paid. Route on this before parsing the body. |
| X-AiAkaun-Event-Id | A stable event id (evt_…). The SAME across every retry — this is your de-duplication key. |
| X-AiAkaun-Delivery | The delivery row id. Differs per endpoint; useful when reporting a problem to us. |
| X-AiAkaun-Timestamp | Unix seconds at the moment we signed. It is PART of the signature — you must include it when recomputing. |
| X-AiAkaun-Signature | Hex HMAC-SHA256 of "timestamp.raw body", keyed with your endpoint secret. |
| User-Agent | AiAkaun-Webhooks/1.0 |
Retries
Any 2xx response counts as success — a 204 is perfectly fine. Anything else, or a timeout, triggers a retry on this backoff schedule:
| Attempt | After the previous failure |
|---|---|
| Retry 1 | +30 seconds |
| Retry 2 | +2 minutes |
| Retry 3 | +10 minutes |
| Retry 4 | +1 hours |
| Retry 5 | +6 hours |
After the last retry is used up, the delivery is marked failed and stops. Every attempt — your real status code and response body — is visible in your endpoint delivery log, so "we never received that event" always has an answer.
Circuit breaker
After 15 CONSECUTIVE failures we disable the endpoint automatically and stop sending to it. One successful delivery resets the counter. Re-enable the endpoint in the dashboard once you have fixed your receiver.
Reply first, work later.
We wait 20 seconds for a response. Verify the signature, queue the work, and reply 2xx straight away. A receiver that does heavy work inline will time out, get retried, and eventually be disabled — even though it was actually processing every event correctly.
Verifying signatures
Anybody can POST to your webhook URL. The signature is what separates us from them — verify it on every request, before you trust a single byte of the payload.
The algorithm, exactly:
- Take the RAW request body — the bytes before any JSON parser touches them.
- Build the signed string: the timestamp, one dot, then the raw body.
- Compute HMAC-SHA256 over it, keyed with your endpoint secret. Compare as lowercase hex.
- Reject if the timestamp is more than 300 seconds from your clock. That is what stops a captured payload being replayed at you later.
- Compare with a constant-time comparison (hash_equals / timingSafeEqual / compare_digest) — never ==.
The single most common mistake: signing a body that was parsed and re-encoded. Key order and spacing shift slightly, the signature will not match, and you will spend an afternoon blaming your key. Keep the raw bytes.
PHP
<?php
// Verify an AiAkaun webhook — the SAME algorithm our server uses.
// The signature covers "{timestamp}.{raw body}", not the body alone.
$secret = getenv('AIAKAUN_WEBHOOK_SECRET');
$payload = file_get_contents('php://input'); // RAW body — do not parse it first
$signature = $_SERVER['HTTP_X_AIAKAUN_SIGNATURE'] ?? '';
$timestamp = (int) ($_SERVER['HTTP_X_AIAKAUN_TIMESTAMP'] ?? 0);
$tolerance = 300; // seconds; rejects replays
if (abs(time() - $timestamp) > $tolerance) {
http_response_code(400);
exit('timestamp outside tolerance');
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
// hash_equals, not ===. Constant-time comparison.
if (! hash_equals($expected, $signature)) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($payload, true);
// De-duplicate on $event['id'] — retries carry the same id.
// Reply 2xx FAST; do the heavy work on your own queue.
http_response_code(204);
Node.js
const crypto = require('crypto');
const express = require('express');
const app = express();
// express.raw, NOT express.json — we sign the raw bytes, and a JSON
// parser destroys them before you get a chance to check.
app.post('/webhook/aiakaun', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.AIAKAUN_WEBHOOK_SECRET;
const payload = req.body.toString('utf8');
const signature = req.get('X-AiAkaun-Signature') || '';
const timestamp = parseInt(req.get('X-AiAkaun-Timestamp') || '0', 10);
const tolerance = 300;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > tolerance) {
return res.status(400).send('timestamp outside tolerance');
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
// timingSafeEqual throws on a length mismatch — check that first.
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(payload);
// De-duplicate on event.id, then reply fast.
res.status(204).end();
});
app.listen(3000);
Python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/webhook/aiakaun")
def aiakaun_webhook():
secret = os.environ["AIAKAUN_WEBHOOK_SECRET"].encode()
payload = request.get_data() # RAW bytes, not request.json
signature = request.headers.get("X-AiAkaun-Signature", "")
timestamp = int(request.headers.get("X-AiAkaun-Timestamp", "0"))
tolerance = 300
if abs(int(time.time()) - timestamp) > tolerance:
abort(400, "timestamp outside tolerance")
signed = f"{timestamp}.".encode() + payload
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
# compare_digest, not ==. Constant-time comparison.
if not hmac.compare_digest(expected, signature):
abort(400, "invalid signature")
event = request.get_json(force=True)
# De-duplicate on event["id"], then reply fast.
return "", 204
Test your receiver before you depend on it: the "Send test" button on each endpoint in the dashboard sends a real signed event, and the delivery log shows exactly what your server replied.
Quick examples
Copy-paste ready. Set your token once first:
export AIAKAUN_TOKEN=aia_live_…
List transactions
Requires the read:transactions scope.
# Senarai transaksi Januari 2026, 100 setiap halaman. curl -G https://aiakaun.com/api/v1/transactions \ -H "Authorization: Bearer $AIAKAUN_TOKEN" \ -H "Accept: application/json" \ -d "from=2026-01-01" \ -d "to=2026-01-31" \ -d "per_page=100"
Create a transaction
Requires the write:transactions scope.
# Cipta satu perbelanjaan. Amaun ialah nombor perpuluhan (RM), bukan sen.
curl -X POST https://aiakaun.com/api/v1/transactions \
-H "Authorization: Bearer $AIAKAUN_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"date": "2026-08-07",
"description": "Sewa pejabat Ogos 2026",
"amount": 1800.00,
"type": "expense",
"account_code": "6200"
}'
Upload a receipt
Requires the write:documents scope, and is capped at 10 uploads a minute. The response comes back immediately with the document pending; subscribe to document.processed to learn when the AI has finished reading it.
# Muat naik resit untuk dibaca AI. multipart/form-data — JANGAN tetapkan # Content-Type sendiri; biar cURL menjananya berserta sempadan (boundary). curl -X POST https://aiakaun.com/api/v1/documents \ -H "Authorization: Bearer $AIAKAUN_TOKEN" \ -H "Accept: application/json" \ -F "file=@resit-ogos.pdf" \ -F "type=receipt"
Stuck?
Start with GET /api/v1/me —
it proves your token works, tells you which company it holds, and lists its scopes. Most "my API is broken" turns out to be one missing scope. If it still makes no sense,
get in touch.