checkyod
Monitors LINE messages from KBank Live and SCB Connect to capture Thai bank money-in notifications, enabling transaction recording, reconciliation, and automatic PromptPay bill confirmation.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@checkyodcreate a PromptPay bill for ฿1,250 and show me the QR payload"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
CheckYod — a headless Thai bank-reconciliation engine
Reads KBank Live and SCB Connect money-in notifications out of LINE, stores every transaction in SQLite, and auto-confirms PromptPay bills the moment a matching payment arrives. No payment gateway, no merchant account, no per-transaction fee — it watches the same LINE notifications a shop owner already gets on their phone.
Headless by design: REST, SSE, signed webhooks and an MCP server. There is no UI in this
repository, and there is deliberately no HTML in src/ — whatever renders a dashboard, a till or a
customer's payment page is your code talking to these routes.
Built on @evex/linejs (LINE SelfBot). The backend holds the LINE
session and never exposes its tokens.
LINE (KBank Live / SCB Connect OA) ──FLEX──▶ linejs client (data/storage.json)
on("message"), filtered to the OAs you enabled
│
▼
flex-parser ──▶ data/checkyod.db ──▶ reconcile ──┬──▶ SSE /api/stream
└──▶ POST your webhookHow the extraction works
A money-in message is a FLEX card whose payload is a JSON string in
message.contentMetadata.FLEX_JSON. The two banks lay theirs out completely differently:
KBank Live | SCB Connect | |
amount | pair | header text + the text right after it |
pair layout | box layout | box layout |
balance |
|
|
direction | sign of the amount | the header word |
src/flex-parser.ts walks the tree for each. Both parsers are strict enough to reject the other
bank's cards as well as their own bank's balance replies and marketing — because the failure mode of
a loose parser is not "nothing happens", it is an invented transaction that can mark a real
customer's bill paid for money that never arrived. npm run verify-parsers asserts both directions
against real captured cards in fixtures/.
Related MCP server: Thailand Payments MCP
Auto-confirmation, and why amounts are unique
Open a bill for ฿100 and you get a PromptPay QR. When a real money-in of exactly and uniquely
฿100 arrives, that bill becomes paid and your webhook fires.
That only works if the amount identifies the payment, so createBill never issues an amount another
live bill already holds — it walks up a satang at a time until it finds a free slot (฿100.00 →
฿100.01 → ฿100.02). The requested amount is used as-is whenever it is free, so the charged amount is
never less than asked and at most ฿0.99 more. amount_requested keeps what you asked for.
Two deadlines, on purpose. expires_at is what the customer's QR counts down to;
payable_until is BILL_GRACE_MINUTES later, and money keeps confirming until then — because the
bank's notification lands seconds to minutes after the transfer. Render the stretch between them
as "scan window closed, still watching for the money", not as expired.
If two bills somehow share an amount (rows imported from elsewhere), the payment is ambiguous and lands in a review queue instead of being attributed by a coin flip. A configured LLM can suggest which bill it settles; a human confirms.
Quick start
npm install
cp .env.example .env # set ADMIN_TOKEN and PROMPTPAY_ID — see the comments in that file
npm run login # QR in the terminal; scan with the LINE account that gets the bank alerts
npm startnpm run login writes a resumable session to data/storage.json. After that the server resumes it
with no QR. If attaching a terminal is awkward (a container, a remote box), pair over HTTP instead —
see below.
Then issue yourself an integration key and open a bill:
curl -X POST localhost:8090/api/keys \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"name":"my POS"}'
# → { "id":1, "name":"my POS", "prefix":"cyd_a1b2c3d4", "key":"cyd_…" } ← shown ONCE
curl -X POST localhost:8090/api/bills \
-H "Authorization: Bearer cyd_…" -H 'content-type: application/json' \
-d '{"amount":1250.00,"description":"table 4"}'
# → { "id":"CYD-4K7QP2", "payload":"00020101…", "share_token":"a3f9…", "status":"pending", … }
# payload = the PromptPay QR string. Render it as a QR; that is the whole integration.Docker
cp .env.example .env
docker compose run --rm checkyod npm run login # once, to create data/storage.json
docker compose up -d --build
docker compose logs -f checkyodThe container binds to 127.0.0.1 — put a reverse proxy with TLS in front of it. The management
credential is a bearer token on the wire; publishing this port without TLS hands it to the network.
Two credentials, deliberately different widths
credential | reaches | |
integration |
| bills · transactions · summary · SSE stream · |
management |
| LINE pairing · receiving accounts · key issuance · webhook config · review queue · LLM · usage |
The narrowing is the security model, not a style choice. An integration key that leaks must not be
able to mint another key, run up an LLM bill, redirect your webhooks, or start a LINE pairing —
whoever scans a pairing QR owns the LINE account afterwards, and that account is the one reading
your bank's notifications. A valid cyd_ key gets 403 on every management route, and
npm run verify-engine asserts it against a real server.
Keys are stored as sha256 only. The plaintext exists exactly once, in the response that created it.
ADMIN_TOKEN empty means the management surface is closed (503), never "open".
Endpoints
Method | Path | Auth | Purpose |
GET |
| none | liveness — not "the bank feed is alive" |
GET |
| none | public bill status for the customer (the token is the credential) |
POST |
| none | customer uploads a transfer slip |
POST · GET |
| key | open / list bills |
GET · POST |
| key | fetch / cancel one bill |
GET |
| key | the ledger |
GET |
| key | SSE, one |
POST |
| key | MCP endpoint (GET answers 405 — nothing to stream) |
GET |
| admin | is the bank feed actually alive |
POST |
| admin | pair LINE by QR over HTTP (background job + SSE) |
POST |
| admin | which banks to read; re-check; restart |
GET · POST |
| admin | receiving accounts |
GET · POST |
| admin | integration keys |
POST · GET |
| admin | webhook destination, history, one-shot test |
GET · POST |
| admin | ambiguous payments |
GET · POST |
| admin | LLM config (masked) and a test read |
GET |
| admin | quota this period |
Full request/response shapes, error codes and the SSE contract are in docs/integration/ — the same
documents the MCP server serves.
Pairing LINE over HTTP
For a box where npm run login is awkward:
ATTEMPT=$(curl -sS -X POST localhost:8090/api/line/connect \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -r .attemptId)
curl -N "localhost:8090/api/line/connect/$ATTEMPT/stream" \
-H "Authorization: Bearer $ADMIN_TOKEN"
# event: qr_url → open that URL on the LINE phone, or render it as a QR
# event: pin → may never fire (a stored qrCert can skip it); do not block on it
# event: done → { displayName, kbankFound, scbFound }Events are buffered and replayed on subscribe, so you cannot miss the QR by connecting late. Only one pairing may run at a time, and beginning one tears down the existing session first — two clients on one LINE account corrupt its auth token.
Webhooks
{ "event":"bill.paid", "id":"CYD-4K7QP2", "amount":1250.00,
"description":"table 4", "paid_at":1785566001390, "tx_id":"…" }Plus line.down, line.up and line.expiring. Wire those up. A dead LINE session is silent by
nature: /healthz stays green, bills keep opening, and not one of them will ever confirm.
Verify every delivery — reject anything that fails either check:
const ts = req.get("X-CheckYod-Timestamp");
const mine = crypto.createHmac("sha256", SECRET).update(`${ts}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(`sha256=${mine}`),
Buffer.from(req.get("X-CheckYod-Signature")))
&& Math.abs(Date.now() - Number(ts)) < 5 * 60_000; // reject replaysUse the raw body, not a re-serialised object. Retries are 3 attempts (2s/8s backoff) on network
errors and 5xx; a 4xx is taken as final. Delivery is fire-and-forget — a dead receiver never delays
or undoes marking a bill paid — and every attempt sequence lands in webhook_deliveries.
MCP
The engine hosts an MCP server at POST /mcp, so a coding agent can answer integration questions
from the real docs and exercise the real API in one conversation:
claude mcp add --transport http checkyod https://your-host/mcp \
--header "Authorization: Bearer cyd_…"Nine tools. search_docs / get_doc serve docs/integration/; create_bill, get_bill,
list_bills, cancel_bill, get_summary and list_transactions mirror the routes of the same
name; whoami is the one with no HTTP equivalent — it answers "how much quota is left, is there a
receiving account, is the bank feed alive, is a webhook configured" in a single call, which is the
whole checklist behind why did my integration not confirm anything.
It sits on the same guard as /api/bills, so a tool reaches exactly what a key reaches. Bills opened
this way are real bills and consume real quota — point the key at a test instance.
Checks
npm run typecheck # the general gate
npm run verify-parsers # real FLEX cards, both banks, both directions
npm run verify-engine # money logic + the guard boundary, against a real server
npm run verify-mcp # tool schemas, error codes, doc leaksNone of them touch data/. What they do not prove is that LINE capture works end to end — for
that, pair a real account and watch one real payment land.
Operational notes
data/is the whole system of record: the LINE session and the entire ledger. Back it up.One process per LINE account. Two clients on one account and one device slot race the auth token and corrupt it. A dev box sharing an account with a server should set
LINE_DEVICE=ANDROIDSECONDARY.TZis load-bearing. "Today" on the summary and the quota period are both derived from local midnight. A container on UTC puts the last seven hours of a Bangkok day in the wrong bucket.better-sqlite3must stay on^13. Older versions abort withAssertion failed: (env) != nullptrunder this workload.
Status and scope
This is the engine extracted from a working multi-tenant SaaS, reduced to a single account. It does
not include a UI, user accounts, plans, billing, or multi-tenancy — those were the product, this is
the part worth sharing. The invariants that survive here are the ones that cost real money when they
break; CLAUDE.md documents them for anyone (or any agent) changing this code.
MIT.
This server cannot be deployed
Maintenance
Related MCP Connectors
Thailand Leceipt e-Tax: AI agents create, poll and download e-Tax Invoices, stateless BYO.
Thailand payments for AI agents — PromptPay QR, cards, TrueMoney via Opn (Omise). Never holds funds.
Paid APIs and tokenized shares for agents. Prepaid funding, including authorized Instinct checkout.
- BankSyncOAuthio.banksync
Connect AI agents to bank accounts, transactions, balances, and investments.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables comprehensive payment processing through Omise APIs including charges, customers, transfers, refunds, disputes, recurring payments, and webhooks. Provides 51 tools covering all Omise API functionality for secure payment integration.3Apache 2.0
- AlicenseNot gradedqualityBmaintenanceA remote MCP server that lets any AI agent accept PromptPay QR, credit/debit cards, TrueMoney wallet, internet banking payments in Thailand, and check payment status.MIT
- AlicenseAqualityCmaintenanceEnables AI agents to operate Xental merchant accounts via natural language, provisioning virtual accounts, monitoring transactions, and executing payouts.13MIT
- AlicenseAqualityBmaintenanceEnables MCP-compatible AI tools to create checkouts, generate KHQR codes, check/list transactions, issue refunds, create payment links, and pull exchange rates via ABA Bank's PayWay API.125 npmMIT