Skip to main content
Glama
sunnypdater
by sunnypdater

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 webhook

How 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 horizontal

box layout baseline ← the one that bites

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 start

npm 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 checkyod

The 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

Authorization: Bearer cyd_…

bills · transactions · summary · SSE stream · /mcp

management

Authorization: Bearer $ADMIN_TOKEN

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

/healthz

none

liveness — not "the bank feed is alive"

GET

/api/pay/:token

none

public bill status for the customer (the token is the credential)

POST

/api/pay/:token/slip

none

customer uploads a transfer slip

POST · GET

/api/bills

key

open / list bills

GET · POST

/api/bills/:id · /cancel

key

fetch / cancel one bill

GET

/api/summary · /api/transactions

key

the ledger

GET

/api/stream

key

SSE, one tx event per captured transaction

POST

/mcp

key

MCP endpoint (GET answers 405 — nothing to stream)

GET

/api/line-status

admin

is the bank feed actually alive

POST

/api/line/connect + GET …/:id/stream

admin

pair LINE by QR over HTTP (background job + SSE)

POST

/api/line/banks · /api/line/verify-banks · /api/line/restart

admin

which banks to read; re-check; restart

GET · POST

/api/banks · /:id · /:id/delete · /:id/qr

admin

receiving accounts

GET · POST

/api/keys · /:id/revoke

admin

integration keys

POST · GET

/api/webhook · /deliveries · /test

admin

webhook destination, history, one-shot test

GET · POST

/api/review · /:id/suggest · /slip · /resolve

admin

ambiguous payments

GET · POST

/api/llm-settings · /api/llm/read-slip

admin

LLM config (masked) and a test read

GET

/api/usage

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 replays

Use 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 leaks

None 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.

  • TZ is 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-sqlite3 must stay on ^13. Older versions abort with Assertion failed: (env) != nullptr under 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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables 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.
    3
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    A 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
  • A
    license
    A
    quality
    C
    maintenance
    Enables AI agents to operate Xental merchant accounts via natural language, provisioning virtual accounts, monitoring transactions, and executing payouts.
    13
    MIT
  • A
    license
    A
    quality
    B
    maintenance
    Enables 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.
    12
    5 npm
    MIT