For developers

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.

Base URL https://aiakaun.com/api/v1 v1

Authentication

Every request carries an API token in the Authorization header. No keys in the query string, no cookies, no sessions.

bash
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
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

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

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

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

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

json
{
  "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
<?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

javascript
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

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.

bash
# 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.

bash
# 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.

bash
# 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.