# TUNGKAO API > Verify Thai bank transfer slips ("สลิป"), record customer deposits paid by slip, and top up credits. One REST API, JSON, Bearer API key. Machine-readable spec: https://api.tungkao.com/openapi.json (OpenAPI 3.1). This file is the short version. ## Rules an integration must follow - Base URL https://api.tungkao.com. Send `Authorization: Bearer ` and `Content-Type: application/json`. - `qr_payload` is the TEXT decoded from the QR code on the slip. Decode the image yourself; never send the image. - Decide on `status`, not on HTTP code: HTTP 200 + `success:false` is a normal "slip rejected" answer. - Accept a payment ONLY when `status` is `VALID` (verify) or `deposit.status` is `succeeded` (deposit). - Always pass `receivers` (or configure them on the key) and `expected_amount`; otherwise any genuine slip to any account passes. - On timeout or HTTP 5xx, retry with the SAME `Idempotency-Key` header: you get the first result and are not charged twice. - HTTP 429: wait `Retry-After` seconds. HTTP 402 `INSUFFICIENT_CREDITS`: nothing was charged; top up. - Amounts are THB strings with 2 decimals ("500.00"). 1 THB = 100 credits. - `Accept-Language`: th, en, lo, my, zh-CN, ko, ja, vi, km, id (default th). ## API key lifetime Each key has a lifetime chosen at creation (1/2/3/6 months, 1 year, 2 years, or never). Webhooks `api_key.expiring` (30d, 7d, 1d before) and `api_key.expired`. Expired key → HTTP 403 `KEY_EXPIRED`; renew in the portal (same key string keeps working). ## Test mode (sandbox) Test keys (`tk_test_…`) run in sandbox mode: no real bank lookup and no credits charged; responses carry `"sandbox": true`. Control the simulated result with tokens inside `qr_payload`: `AMT=250.50` (amount, default 100.00), `ACC=1234567890` (receiver account), `PP=0812345678` (receiver PromptPay), `BANK=KBANK`, `NAME=…`, `AGE=90` (minutes since transfer), `STATUS=NOT_FOUND|FRAUD|PENDING|EXPIRED|DOWN` (DOWN simulates an outage → `SERVICE_UNAVAILABLE`). The same `qr_payload` twice on one key returns `DUPLICATE`. Deposits and webhooks work the same way. Top-ups always need a real slip. Example: `{"qr_payload":"SANDBOX AMT=500.00 ACC=1234567890","expected_amount":"500.00","receivers":[{"bank":"KBANK","account_number":"1234567890"}]}` → `VALID`. ## Endpoints - GET /health — no auth. `{"status":"ok"}`. - GET /v1/account — credits left and price per status. - GET /v1/banks — bank codes (`bank_code` "004", `bank_short` "KBANK"). - POST /v1/slip/verify — key product `slip_verify`. Body: `qr_payload` (required), `expected_amount`, `receivers[]`, `max_age_minutes`. - POST /v1/deposits/slip — key product `slip_deposit`. Body: as verify plus `customer_ref` (required) and `metadata`. A destination account is mandatory (request or key). Sends a webhook. - GET /v1/deposits/{deposit_id} — a deposit created with this key. - POST /v1/topups/slip — any key. Body: `qr_payload` of a slip paid to the platform top-up account. Free; a slip works once. ## Sign-in for your website (one key per provider) Products: `google_login`, `line_login`, `facebook_login`, `tiktok_login`, `discord_login`. Register site name + exact redirect URIs in the portal (Sign-in members page). 1. Browser: redirect to GET /oauth/authorize?client_id=&redirect_uri=&state=. 2. User returns to redirect_uri?code=…&state=… (or error=access_denied). Compare state with the session. 3. Server: POST /v1/login/token {code, redirect_uri} with Bearer key within 2 minutes → {user, email, name, picture, is_new_user, …}. Use `user` as the member id. - GET /v1/login/users — members list. TikTok gives no email. Credits: per sign-in + per new member. ## Notifications - Telegram (`telegram_notify`): POST /v1/telegram/link {ref} → url for the user to tap; POST /v1/telegram/send {ref|refs, text, parse_mode?, button?}; GET /v1/telegram/recipients; DELETE /v1/telegram/recipients/{ref}; GET /v1/telegram/messages. - Discord (`discord_notify`): POST /v1/discord/webhooks {ref, url}; POST /v1/discord/send {ref|refs, text, username?}; GET /v1/discord/webhooks; DELETE /v1/discord/webhooks/{ref}; GET /v1/discord/messages. - Charged per delivered message; failed deliveries are refunded. ## Crypto endpoints (PREVIEW — not enabled in production yet) Key product `crypto`. Amounts are decimal strings in asset units ("12.5"); never use floats. Assets: USDT-TRC20, BTC, USDT-BEP20. - GET /v1/crypto/assets — limits, fee percent, confirmations per asset. - POST /v1/crypto/addresses — body `asset`, `customer_ref`. One permanent deposit address per customer; same call returns the same address. - GET /v1/crypto/deposits — optional `customer_ref`. Credit your customer only when `status` is `confirmed` (or on webhook `crypto.deposit.confirmed`). - GET /v1/crypto/balance — withdrawable balance per asset. - POST /v1/crypto/withdrawals — body `asset`, `to_address`, `amount`. Deducted at once, approved manually, `net = amount − fee`. Use `Idempotency-Key`. - GET /v1/crypto/withdrawals — your requests and their status (requested, approved, rejected, sent). ## Receiver object `{"bank":"KBANK","account_number":"1234567890"}` or `{"promptpay":"0812345678"}` and/or `{"name_th":"…","name_en":"…"}`. `bank` alone is rejected. A slip passes if it matches any one receiver. ## Status values (field `status`) - VALID: Slip is genuine and passed every check you asked for. The slip is now marked as used for this API key. - RECEIVER_MISMATCH: Slip is genuine but was paid to a different account than `receivers`. - AMOUNT_MISMATCH: Slip is genuine but the amount differs from `expected_amount`. - DATE_MISMATCH: Slip is genuine but older than `max_age_minutes`. - DUPLICATE: This slip was already accepted (VALID) for this API key before. - NOT_FOUND: The bank has no such transaction. - FRAUD: The slip is forged or altered. - PENDING: The bank has not finished processing. Call again later. - EXPIRED: The slip is too old for the bank to verify. - INVALID_INPUT: `qr_payload` is not a slip QR code. - SERVICE_UNAVAILABLE: All upstream verifiers are down (HTTP 503). Nothing is charged; retry later. ## Example ```bash curl -X POST https://api.tungkao.com/v1/slip/verify \ -H "Authorization: Bearer tk_live_xxx" -H "Content-Type: application/json" -H "Idempotency-Key: order-1001" \ -d '{"qr_payload":"","expected_amount":"500.00","receivers":[{"bank":"KBANK","account_number":"1234567890"}],"max_age_minutes":30}' ``` ```json {"success":true,"request_id":"req_…","status":"VALID","message":"Slip is valid", "data":{"slip_id":"…","trans_ref":"…","amount":"500.00","currency":"THB","paid_at":"2026-11-12T03:00:00.000Z", "sender":{"bank_code":"004","bank_short":"KBANK","name_th":"…","name_en":null,"account_masked":"xxx-x-x1234-x","proxy_masked":null}, "receiver":{"bank_code":"014","bank_short":"SCB","name_th":"…","name_en":null,"account_masked":"xxx-x-x5678-x","proxy_masked":null}, "checks":{"duplicate":false,"receiver":"pass","amount":"pass","date":"pass"}}, "billing":{"credits_charged":250,"credits_remaining":99750}} ``` ## Errors Shape: `{"success":false,"request_id":"…","error":{"code":"…","message":"…","hint":"…","docs_url":"…"}}` - UNAUTHORIZED (HTTP 401): API key is missing or wrong. Send header `Authorization: Bearer `. - KEY_DISABLED (HTTP 403): The API key is paused, or the merchant account is suspended. - DOMAIN_NOT_VERIFIED (HTTP 403): Live keys created in the portal must verify their website domain first (upload the verification .html file or add the DNS TXT record). One key = one domain. - BAD_SIGNATURE (HTTP 401): This key requires signed requests and the signature is missing, older than 5 minutes or wrong. Sign `...` with HMAC-SHA256 using the signing secret. - INVALID_CODE (HTTP 400): Sign-in code is invalid, expired (2 minutes) or already used. Start the sign-in again. - KEY_EXPIRED (HTTP 403): The API key passed its expiry date. Renew it in the merchant portal; the key string stays the same. - IP_NOT_ALLOWED (HTTP 403): The caller IP is not in the key's IP whitelist. - WRONG_PRODUCT (HTTP 403): The key belongs to another product (e.g. a slip_verify key used on /v1/deposits/slip). - RATE_LIMITED (HTTP 429): Too many requests for this key. Wait the number of seconds in the `Retry-After` header. - VALIDATION_ERROR (HTTP 400): The request body is malformed. `message` says which field. - INSUFFICIENT_CREDITS (HTTP 402): Not enough credits. Nothing was charged and no provider was called. Top up and retry. - NOT_FOUND (HTTP 404): Unknown route or resource. - BODY_TOO_LARGE (HTTP 413): Request body exceeds 64 KB. - KYC_REQUIRED (HTTP 403): Crypto only: identity verification must be approved before the first withdrawal. - INSUFFICIENT_BALANCE (HTTP 402): Crypto only: the asset balance is too low. Nothing was deducted. - INTERNAL_ERROR (HTTP 500): Unexpected server error. Report the `request_id`. ## Webhooks (slip deposits) - Events: deposit.succeeded, deposit.failed, deposit.pending. Body: `{"id","type","created_at","data":{deposit_id, customer_ref, status, slip_status, amount, trans_ref, paid_at, sender, receiver, metadata}}`. - Header `X-Tungkao-Signature: t=,v1=`. Valid when `v1 == hex(HMAC_SHA256(webhook_secret, t + "." + raw_body))` and |now − t| ≤ 300 s. Compare in constant time, use the raw body bytes. - Reply 2xx within 10 s. Retries after 1, 5, 30, 120, 360, 720, 1080 minutes. The same `id` can arrive more than once: make your handler idempotent. ```js // Node.js signature check import { createHmac, timingSafeEqual } from "node:crypto"; function verify(secret, header, rawBody) { const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header); if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false; const want = createHmac("sha256", secret).update(m[1] + "." + rawBody).digest("hex"); return timingSafeEqual(Buffer.from(want), Buffer.from(m[2])); } ```