Turn it on
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.
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 showshttps://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.
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. curlneeds-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).
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.
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.
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.
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).
GET /v1/health
Liveness check. Returns
{ "status": "ok" }.
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.
Reading data
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 }
]
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"
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.
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.