← Home อ่านภาษาไทย →
Tang La
ตังค์ละ?

External API

Tang La can run a small local HTTP API so a script or another device on your Wi-Fi can read balances and add transactions without opening the app. This is the full reference. (Just adding one entry from a Home Screen shortcut, QR code, or NFC tag while you're holding the phone? The shortcut link below is simpler.)

01

Turn it on

Want to manage things from a computer without writing code? Use tangla-manage

tangla-manage is a ready-made web page that connects to Tang La on the same Wi-Fi, using the username and password you set in the app (see the steps below). View, add, edit and delete entries, accounts, categories, tags, budgets and recurring rules right from the browser — no API calls of your own. The rest of this page is for people writing their own scripts or tools.

Just adding one entry while you're holding the phone? Use the shortcut link instead

If you only need to add a single entry from something that already has the phone in your hand — a Home Screen shortcut, a QR code, an NFC tag — use the tangla://add?… link instead of this page. It's much simpler: no server, no token, no certificate to accept, and it works even when the app is fully closed. Turn it on in the app under Settings → External connections → "Add from a shortcut link" (a premium feature; the exact link format is shown right there).

Reach for the HTTP API on this page instead when you need to add multiple entries in one call (e.g. importing a bank statement) or the request comes from a device other than the phone itself — the shortcut link can't do either.

In Tang La, open Settings → External connection → "API ภายนอก" and turn on "เปิดใช้งาน API". If you haven't set a username/password before, the app prompts for one right away — nothing works without it, since a login JWT is the server's only credential (see Authentication). You can cancel that prompt and set credentials later from the card below; the server keeps running either way. The screen then shows:

  • Server address — one https://<ip>:<port> value. The port is random, chosen once, and reused on every later start, so you only copy it into your tool once. On LAN scope, Android, and a network that passes multicast, it also shows https://tangla.local:<port> — a stable mDNS name that keeps working after the Wi-Fi IP changes, so you trust the certificate once. Falls back to just the IP where mDNS doesn't resolve.
  • Web sign-in — set, change, or remove the username and password.

The server runs only while the app is open with its screen on — the moment the phone's screen turns off or you switch to another app, the server stops responding, well before the OS actually kills the app (this is deliberate: it's not wired to run as a background service). It restarts on its own the next time you open the app, if you'd left it enabled. If you're calling the API unattended (a scheduled shortcut or automation), you need to keep the app open and the screen awake for that whole window. You can set that up on the same screen, as described below.

Keeping the screen awake

The "Keep screen on" switch stops the screen from turning off while the server is on and the app is in front — switch to another app and the screen sleeps as usual, and the server stops responding as before. Holding the screen awake uses more battery, so it's best while charging. Turning the switch on reveals two more options, so you don't have to leave the screen at full brightness the whole time:

  • Dim the screen when untouched — the screen stays on but goes dark to save battery. Choose 15 seconds, 30 seconds (the default), 1 minute, or never dim. Tap anywhere to bring it back to your usual brightness; that first tap doesn't press whatever is under your finger.
  • Let the screen sleep when the web is idle — if no requests reach the server for this long, Tang La stops holding the screen on and lets it sleep as usual (which also means the server stops responding). Choose 5, 15 (the default), 30 or 60 minutes, or never.
02

HTTPS with a self-signed certificate

The server speaks HTTPS, and its certificate is self-signed (generated on the device on first use). So:

  • A browser shows a "not trusted" warning — click Advanced → Proceed once per IP:PORT. If your Wi-Fi changes and the IP changes with it, accept it again.
  • curl needs -k (insecure).
  • Use it on trusted home / local networks only.

The app shows the certificate's SHA-256 fingerprint on the "API ภายนอก" screen — open the device address directly in a new tab and compare it with the SHA-256 in the certificate details your browser shows there, to confirm you've reached the right device and no LAN middlebox is presenting its own certificate. (Not the certificate of the tangla-manage page itself — that one belongs to the website and will never match.) The same value is available at GET /v1/server/fingerprint (no token required — you need to be able to check it before logging in to confirm nothing is intercepting the connection).

03

Access scope

Set in the same screen. The server rebuilds when you change it.

Scope Binds to Reachable from
"เฉพาะเครื่องนี้"
(device)
127.0.0.1 only Tools on this device only — adb reverse, an on-device automation app/script. Never another device, even with a valid sign-in.
"ทั้งวง Wi-Fi/LAN"
(LAN)
0.0.0.0 Any device on the same Wi-Fi/LAN that can sign in.

On device scope the address shown in the app is 127.0.0.1; on LAN scope it's the phone's Wi-Fi address.

04

Authentication

There is one credential: POST /v1/auth/login with the username + password set in the app returns a bearer JWT (valid 60 minutes, no refresh — re-POST /v1/auth/login once it expires). Every other request — including /v1/health — must carry it:

Authorization: Bearer <token>

A missing or unrecognised token → 401. POST /v1/auth/login itself needs no token (it's how you get one), but answers 403 {"error":"Web sign-in is not configured"} if no username/password has been set in the app yet.

05

All routes

The full route list — everything reachable once signed in (see Authentication). Set the username + password from Settings → External connection → "API ภายนอก" (the "เข้าสู่ระบบผ่านเว็บ" card). The resulting JWT works on the LAN scope too — it's minted from your own password and expires, so it's safe on Wi-Fi in a way a static token wouldn't be. The companion browser app is tangla-manage.meowmeow-studio.com.

Login & token

POST /v1/auth/login with body {"username","password"} → {"token","tokenType":"Bearer","expiresIn":3600}. An HS256 JWT valid 60 minutes, no refresh — call login again on a 401. 403 if no credentials are set; 401 on a wrong username/password; 429 with Retry-After after 5 bad attempts. Changing or removing the credentials invalidates every outstanding JWT on the next server restart.

Send Authorization: Bearer <jwt> to:

Route Notes
GET /v1/entries ?from=&to= (epoch ms), or ?cycle=, or ?limit= (default 200, max 1000) → {"entries":[…],"count":N}; every entry carries tags:[id]
POST /v1/entries single-entry create; same body as one entries[] item (incl. autoCreate, idempotencyKey, tagIds)
PUT /v1/entries/<id> full replace — fields you omit are blanked (title → "", note → null); send the whole entry. timestamp, receipt photo, recurring/ installment provenance are kept. 404 for an unknown id.
DELETE /v1/entries/<id> reverses the balance effect; removes only that entry of an installment plan. 404 for an unknown id. 409 for an income/expense entry on a loan account (its debt or a balance adjustment, which only the app changes).
GET /v1/categories {"income":[…],"expense":[…]}; hidden ones included with isHidden: true
POST /v1/categories {name, type, colorIndex?, icon?} (type = income/expense). Duplicate name in the same type → 409.
PUT /v1/categories/<id> merge: name/colorIndex/icon/isHidden/sortOrder. type can't change; a rename cascades to every entry/rule/budget. 404/409.
DELETE /v1/categories/<id> 409 with a usage breakdown while still referenced; otherwise deletes.
POST /v1/accounts {name, type?, includeInTotal?, includeInNetWorth?, note?}. Always starts at balance 0 (no balance-adjust route — do that in the app). Duplicate name → 409.
PUT /v1/accounts/<id> merge: name/type/includeInTotal/includeInNetWorth/note/isHidden. balance and loan/goal fields are kept. A loan account, or a type change to/from loan → 400.
DELETE /v1/accounts/<id> 409 while any entry references it (hide it instead); otherwise a soft-delete.
GET /v1/tags every tag → [{id,name,tagType,colorIndex,sortOrder}]
POST /v1/tags {name, colorIndex?}. Always tagType:"custom". Duplicate name → 409.
PUT /v1/tags/<id> merge name/colorIndex/sortOrder. 404/409.
DELETE /v1/tags/<id> removes the tag and every row that wore it. A need/want tag can't be deleted (400).
GET /v1/budgets ?cycle=<ms> (default now) → that cycle's category budgets {cycle:{key,start,end}, budgets:[…]}
PUT /v1/budgets upsert one category's budget: {category, monthlyLimit, cycle?, muted?}. monthlyLimit ≥ 0; category must be an existing expense category.
DELETE /v1/budgets/<id> 404 if no such row.
GET /v1/recurring every recurring rule, soonest nextRun first. Each carries linkedLoan — a loan-linked rule is read-only here.
POST /v1/recurring create a plain recurring rule; entry-shaped body plus frequency (daily/weekly/monthly/yearly) and dayOfWeek (1–7, required for weekly) / dayOfMonth (1–28, required for monthly and yearly) / monthOfYear (1–12, required for yearly); optional occurrenceLimit (1–1000), postLeadDays (0–31), reminderLeadDays (0–30). A missing schedule field or out-of-range value is a 400. nextRun computed from the schedule if omitted.
PUT /v1/recurring/<id> merge; nextRun recomputed when a schedule field is in the body. 400 for a loan-linked rule.
GET /v1/invest/prices investment accounts that still hold something, with each held ticker's latest typed-in price → {accounts:[{id,name,currency,holdings:[{ticker,shares,price,priceUpdatedAt,stale}]}]}. Premium only (403 premium_required otherwise).
PUT /v1/invest/prices {accountId, prices:{TICKER:price,…}, asOf?} sets several prices at once. asOf is the prices' date (YYYY-MM-DD, never in the future). A ticker that isn't held or a price that isn't positive → 400, nothing written. Unknown account → 404. Premium only.
DELETE /v1/recurring/<id> 400 for a loan-linked rule, 404 for an unknown id.
GET /v1/server/fingerprint the certificate's SHA-256 fingerprint → {"sha256":"AB:CD:…"}
GET /v1/theme the palette flavor chosen in the app and its category/tag colors → {"flavor":"warm","categoryPalette":[{"bg":"#f5e7e3","fg":"#be4a42"},…]} (indexed by colorIndex)

tagIds — POST /v1/entries, PUT /v1/entries/<id> and every batch item accept tagIds: [<int>…]: omit to leave tags untouched, [] to clear them, a list to replace the set. An unknown id → 400.

A loan-type account is rejected (400) as an income/expense account or a transfer source — it may only be a transfer destination (a repayment).

06

GET /v1/health

Liveness check. Returns { "status": "ok" }.

07

POST /v1/entries/batch

Creates one or more transactions in one request — for multiple entries in a single call. For one entry at a time, POST /v1/entries (identical body shape to one entries[] item) is simpler; see the full route list.

{
  "autoCreate": false,
  "entries": [
    { "type": "expense", "amount": 60, "account": "SCB", "category": "Food" },
    { "type": "transfer", "amount": 500, "account": "SCB", "toAccount": "Wallet" }
  ]
}

entries must be a non-empty array, max 100 items. Each item:

Field Required Notes
type yes income, expense, or transfer (case-insensitive)
amount yes number ≥ 0
account yes existing account id (integer) or name (string) — the "from" account for a transfer
toAccount transfer only destination id or name; must differ from account
category income / expense only a category name of that type
title no free text, defaults to ""
note no free text
timestamp no epoch milliseconds; defaults to now
idempotencyKey no resend the same key → the original entry comes back ("idempotent": true), nothing new is created, even for a retry that arrives while the first request is still being written. If that entry has since been deleted the replay is a 400; use a new key. Kept in memory only (max 1000 keys, cleared on restart).
tagIds no array of tag ids (GET /v1/tags) — [] = no tags; an unknown id fails that item

Batch creation is best-effort — each item is validated and created independently, so one bad item doesn't block the others. It's a plain loop, not a database transaction.

Auto-creating accounts / categories

By default an unknown account/toAccount name or category name is a validation error for that item. Set "autoCreate": true at the top level of the request (it applies to the whole batch) to create what's missing instead:

  • Account / toAccount given as a name → a new account with that name, your first asset type (usually cash), balance 0. Passing an id never auto-creates — an unknown id is always rejected.
  • Category → a new category of the entry's type, with a colour derived from the name and a generic icon.

New accounts/categories are visible to later items in the same batch.

Response

201 when every item was newly created, 200 otherwise (some failed or were idempotent replays):

{
  "created": 1,
  "idempotent": 0,
  "failed": 1,
  "results": [
    { "success": true, "entry": { "id": 107, "title": "", "amount": 60.0,
      "type": "EXPENSE", "category": "Food", "account": 1, "toAccount": null,
      "note": null, "timestamp": 1786806400000, "currency": "THB", "tags": [] } },
    { "success": false, "error": "\"category\" must match an existing expense category" }
  ]
}

currency is always the app's primary currency (Settings → "สกุลเงินหลัก"); there's no per-request currency.

A 'loan'-type account used as an income/expense account, or as a transfer source, fails per-item with "A loan account can only be the destination of a transfer …" — a loan account may only ever be a transfer destination.

Whole-request errors: 400 if entries is missing / empty / not an array, exceeds 100 items, or the body isn't valid JSON; 413 if the body is over 1 MiB.

08

Reading data

Just want to glance at your balance? Use the home-screen widget (or tangla-manage) instead

These endpoints are for feeding the balance into something else — a script, another app's widget engine (KWGT/Zooper), a Tasker variable — not for just looking at it. If the goal is seeing the balance on this same phone, Tang La's own Balance/Budget home-screen widget does that much better — it reads the database directly on-device, no HTTP involved, so it keeps showing the last known balance even with the screen off or the External API switched off entirely.

Want to check it from another device on the LAN instead? Open tangla-manage directly rather than polling this endpoint from a script — regardless of scope, the server stops the moment the phone's screen goes off. Leaving "เปิดใช้งาน API" on is only a saved setting, not a guarantee the endpoint is reachable whenever you call it.

Reachable as soon as the API is on and you're signed in — log in and use the JWT to GET these two endpoints right away.

GET /v1/summary

?cycle=<epoch-ms> selects the billing cycle containing that instant (default: now). A credit-card payment counts as an expense, matching the home screen.

{
  "cycle": { "key": "2026-09", "start": 1788220800000, "end": 1790812799999 },
  "income": 15000.0,
  "expense": 4230.5,
  "net": 10769.5,
  "netBalance": 88123.0
}

netBalance = sum of every "include in total" account's balance.

GET /v1/accounts

Every account with its live balance.

[
  { "id": 1, "name": "Cash", "type": "cash", "balance": 1234.0,
    "currency": "THB", "includeInTotal": true, "includeInNetWorth": true,
    "isHidden": false, "note": null }
]
09

curl examples

HOST="<address from the app, e.g. https://192.168.1.117:53214>"
USERNAME="<username set on the API screen>"
PASSWORD="<password set on the API screen>"

# Sign in once, reuse the token for up to 60 minutes (-k: self-signed cert)
TOKEN=$(curl -sk -X POST "$HOST/v1/auth/login" \
  -d "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD\"}" \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])')

# Health check
curl -k -H "Authorization: Bearer $TOKEN" "$HOST/v1/health"

# One expense, account by name
curl -k -X POST "$HOST/v1/entries/batch" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"entries":[{"type":"expense","amount":60,"account":"SCB","category":"Food","title":"Coffee"}]}'

# One transfer
curl -k -X POST "$HOST/v1/entries/batch" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"entries":[{"type":"transfer","amount":500,"account":"SCB","toAccount":"True Wallet"}]}'

# Create the account/category if missing, and dedupe with a key
curl -k -X POST "$HOST/v1/entries/batch" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"autoCreate":true,"entries":[
        {"type":"expense","amount":1000,"account":"Emergency fund",
         "category":"Investing","idempotencyKey":"sep-invest-2026-09"}
      ]}'

# This cycle's summary
curl -k -H "Authorization: Bearer $TOKEN" "$HOST/v1/summary"
10

Finding account & category names

Account and category names are exactly what the app shows (Settings → "บัญชีทรัพย์สิน" and "หมวดหมู่"). Ids (integers) also work but aren't shown in the UI — either call GET /v1/accounts / GET /v1/categories, or send a one-item batch with a deliberately-wrong category and autoCreate off: the results[0].error tells you whether the id was valid ("No account with id <n>" vs "category" must match …) and nothing is created.

11

Good to know

  • The server is reachable only while the app is running — there's no background mode.
  • Every create / update / delete goes through the same write core the manual add-transaction screen uses, so balances and side effects stay correct.
  • The ledger tracks one currency; there's no per-transaction currency.
  • See also the privacy policy for what this server does and doesn't expose.