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 first — nothing works without it, since a login JWT is the server's only credential (see Authentication). If you back out of that prompt the server is not started and the switch stays off. Once they are set the server starts, and the screen 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?, currency?, includeInTotal?, includeInNetWorth?, note?, groupId?}.
Always starts at balance 0 (no balance-adjust route — do
that in the app). Duplicate name → 409.
groupId creates the account inside an existing
account group, which gives it the group's type (unknown group, or
a different type → 400). Creating or
editing the group itself is done in the app.
|
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}, currency, budgets:[…], spentByCategory:{…}}
|
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.
taxBucket (Premium) — the same three
routes accept a taxBucket of
life_insurance, health_insurance,
parent_health_insurance, pension_insurance,
rmf, thai_esg, social_security,
home_loan_interest, donation or
donation_double, linking an expense to
that deduction. Omit to leave it, null to unlink.
Free app → 403 premium_required; an
unknown key, or one on an income/transfer → 400.
split (split bill — free, expenses
only) —
{"mode":"equal"|"equal_others"|"custom","people":[{"personId":1}|{"name":"Ann"},…]}.
equal (the default) shares it equally including you,
equal_others only among the people listed,
custom needs an amount (what they owe)
per person. An unknown name is created only with
autoCreate: true (otherwise 400 — add
them with POST /v1/people first). The shares can't add
up to more than the entry's amount. On a PUT, omitting
split keeps the existing one (an equal split follows
the new amount); null removes it. It never moves an
account balance. Every entry returned carries taxBucket
and split (null when there is none).
GET /v1/people →
{"people":[{id,name,balance,lastActivity}]}
(positive balance = they owe you) ·
POST /v1/people {name} → 201;
a duplicate name → 409 with the existing
person.
GET /v1/people/<id> — one person's balance and
history, read-only. Returns
{person, history:[{id,amount,title,timestamp,note,linkedEntryId,
linkedEntryTitle,fromSplit,fromPayment}], outstanding:[{id,amount}]};
history is newest first, a positive amount
means they owe you and a negative one that they paid back, and
outstanding is what is still unpaid per debt line
(payments clear the oldest debts first). 404 for an
unknown id.
GET /v1/cashflow — the numbers behind the app's
statistics / cash-flow dashboard, read-only (last 12 budget cycles
and 52 weeks, main currency only, debt payments not counted as
spending). Returns
{currency, totalIncome, totalExpense, netCashFlow, savingsRatePercent,
months, weeks, expenseSlices, incomeSlices, fixedTotal, variableTotal,
seasonalAverage, weekdayAverages, health, needWant, anomalies,
subscriptions, upcomingBills}; periods are oldest first and the
last one is still running (inProgress), dates are epoch ms.
GET /v1/net-worth?currency=THB — net worth over time in
one currency (default: the main one), read-only. Returns
{currency, currencies, current:{assets,liabilities,netWorth},
snapshots:[{day,timestamp,assets,liabilities,netWorth}]};
each currency is its own series, nothing is converted,
current comes from the live accounts and
snapshots are the daily readings the app captured
(not reconstructed from before the feature was on).
400 for a currency that isn't tracked.
GET /v1/goals — the savings goals, newest first,
read-only (made and edited from their account in the app). Returns
{goals:[{id,name,icon,colorIndex,note,targetAmount,currentAmount,progress,
isComplete,targetDate,assetId,assetName,currency,status}]};
progress is 0–1 and follows the linked account's
balance, and status is the app's own verdict
(verdict, pacePerCycle,
requiredPerCycle, arrival,
planFraction, planGap,
points).
PUT /v1/people/<id> {name} renames a
friend (409 if the name is taken) ·
DELETE /v1/people/<id> removes them and their
history once nothing is owed (409 while a balance is
left) · POST /v1/people/<id>/items
{kind: "debt"|"payback", amount, title, timestamp?, note?}
notes that they owe you more (debt) or paid you back
(payback); amount is always positive and
it never touches an account balance or an entry ·
PUT/DELETE /v1/iou-items/<id> edit or
remove a line (changing a share of a split bill turns that split
custom).
payment — an income in the main
currency can be marked as a friend paying you back:
{"payment":{"personId":1 | "name":"Ann","amount":60}}.
The money still lands in the account and the friend's balance falls
by amount. Omit it on a PUT to keep it,
null to remove it; on an expense or a foreign-currency
account it is a 400. Every entry carries
payment.
GET /v1/account-groups ·
POST /v1/account-groups
{name, type, memberIds?, newCurrencies?} ·
PUT/DELETE /v1/account-groups/<id> —
account groups (one provider holding several currencies, as
ordinary accounts), made from existing accounts and/or one new
zero-balance account per currency. A loan, liability or investment
account can't be grouped, a group keeps at least one account, and
deleting a group leaves its accounts. Premium.
GET /v1/asset-types · POST ·
PUT/DELETE /v1/asset-types/<id> —
the account types (name, icon, colour, whether it is a debt). The
key never changes, isLiability can't be
flipped while accounts use the type, and a built-in or used type
can't be deleted (409).
GET /v1/tax?year=YYYY — the tax-deduction tracker for a
year (default: this one), read-only; caps and the income figure are
set in the app. Returns
{year, approximated, customCaps, income, incomeSource, salaryMonths,
totalDeductible, donationRate, buckets:[{bucket,used,cap,deductible,remaining}],
sharedCaps:[…], fundBuys:[…]}; cap and
remaining are null while a cap depends on an
income that isn't known · GET /v1/tax/<bucket>?year=YYYY
— the linked spending and fund purchases behind one deduction
(404 for an unknown bucket). Premium.
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,
"groupId": null, "groupName": null }
]
groupId / groupName say which account group the account is in (null when it is in none).
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.