Omise Integration
services/omise.ts is a thin HTTP wrapper around Omise’s REST API. No
SDK — we use native fetch directly. The package omise-node exists
but is loosely maintained and has weak types.
Why no SDK?
- Native
fetch(Node 20+) has zero deps - Full TypeScript control over response types
- Easy to upgrade Omise API version (just bump env)
- No SDK breaking changes to worry about
- The API surface we use is small: 2 endpoints (
POST /charges,GET /charges/:id)
Setup
1. Get Omise TEST keys
Sign up at dashboard.omise.co. After email verification, navigate to
Settings → Keys. Copy:
- Public key (
pkey_test_...) — for client-side tokenization (we don’t use this — PromptPay + TrueMoney don’t need it) - Secret key (
skey_test_...) — for backend API calls
Save to backend/.env:
OMISE_PUBLIC_KEY=pkey_test_...OMISE_SECRET_KEY=skey_test_...OMISE_API_BASE_URL=https://api.omise.coOMISE_API_VERSION=2019-05-292. Roll webhook secret
In Omise dashboard Settings → Webhooks, click Roll secret. Copy the base64 string (only shown once — re-roll if missed).
Save to backend/.env:
OMISE_WEBHOOK_SECRET=<base64 secret>3. Register webhook URL
Same Webhooks page → set Webhook endpoint to:
https://api.tinadiet.com/webhooks/omiseFor local dev with cloudflared tunnel:
https://random-name.trycloudflare.com/webhooks/omiseClick Update. Omise will start delivering events for any charges created on this account.
API calls
Create a charge
const charge = await createOmiseCharge(user, 'promptpay');// orconst charge = await createOmiseCharge(user, 'truemoney');Internally calls POST https://api.omise.co/charges with:
PromptPay:
{ "amount": 15000, "currency": "thb", "source": { "type": "promptpay" }, "metadata": { "tinadiet_user_id": "2", "line_user_id": "Uxxxxxxxx..." }}TrueMoney:
{ "amount": 15000, "currency": "thb", "source": { "type": "truemoney_jumpapp" }, "return_uri": "https://app.tinadiet.com/premium?omise_return=1", "metadata": { "tinadiet_user_id": "2", "line_user_id": "Uxxxxxxxx..." }}Response (simplified):
{ "object": "charge", "id": "chrg_test_xxx", "status": "pending", "amount": 15000, "currency": "thb", "source": { "id": "src_test_xxx", "type": "promptpay", "scannable_code": { "image": { "download_uri": "https://api.omise.co/charges/.../downloads/..." } } }, "authorize_uri": "https://api.omise.co/payments/.../authorize", "expires_at": "2026-06-14T07:00:00Z", ...}Retrieve a charge
const charge = await retrieveOmiseCharge(chargeId);GET /charges/:id. Used by GET /api/v1/billing/omise/charge/:id polling
endpoint to sync state with Omise.
Sync helper
const payment = await syncChargeFromOmise(chargeId);Combines retrieve + DB update + grant application if status changed to
successful. Used as a safety net when webhooks might be delayed.
Charge lifecycle
pending → successful (paid) → failed (rejected by source) → expired (24h elapsed for PromptPay, less for TrueMoney) → reversed (post-success refund/reverse)Webhook events fired:
charge.create— initialcharge.complete— status changed tosuccessfulcharge.update— any other status change- Possibly more (e.g.
charge.refunded); we handle generically
Grant computation
When a charge becomes successful:
const grant = computeGrantWindow(user, PAYMENT_GRANT_DAYS, now);// grant.starts = max(now, user.premium_expires_at)// grant.ends = grant.starts + 30 days
paymentsRepository.markCompleted({ provider_charge_id: charge.id, completed_at: charge.paid_at, grant_starts_at: grant.starts, grant_ends_at: grant.ends, ...});
userRepository.applyPremium(user.id, grant.ends);The user is updated to plan='premium', premium_expires_at=<grant.ends>.
If user was already premium, the new premium_expires_at is later than
the previous one (stacking — see Overview).
TEST mode simulation
Since we don’t have real PromptPay QR or TrueMoney app in TEST:
PromptPay
- Create charge from LIFF
- QR shows with “TEST MODE” overlay
- In Omise dashboard
dashboard.omise.co/test/charges, find the charge - Click Capture / Mark as paid
- Omise fires
charge.completewebhook - Backend processes → premium extended
- LIFF polling detects success → modal shows success state
TrueMoney
- Create charge from LIFF
- LIFF redirects to
authorize_uri - Test simulator page appears with 3 buttons:
- Successful → triggers
charge.complete - Failed → triggers status
failed - Pending → leaves as
pending
- Successful → triggers
- User clicks Successful
- Omise redirects back to
return_uriwithomise_return=1query - LIFF detects query → reloads billing status
No actual TrueMoney app needed in TEST mode. The simulator is built into Omise’s test environment.
Going LIVE
- Client provides Thai business docs (ภพ.20 VAT cert, DBD business registration, bank statement, director ID/passport)
- Submit verification in Omise dashboard
- Wait approval (3-7 business days typical, up to 14 days)
- Request PromptPay + TrueMoney method enablement in submission form
- After approval, get LIVE keys from dashboard
- Set Railway env:
OMISE_PUBLIC_KEY=pkey_... (no _test_)OMISE_SECRET_KEY=skey_... (no _test_)OMISE_WEBHOOK_SECRET=... (new LIVE webhook secret)
- In Omise LIVE dashboard, register
https://api.tinadiet.com/webhooks/omiseas webhook endpoint, roll LIVE secret, set in Railway - No code change needed — backend behaves identically
- Test with a single LIVE 150 THB payment using your own card/wallet (refund yourself afterwards to verify refund flow works too)
Caveats
- Charge expires_at: PromptPay defaults to 24h, TrueMoney to ~10
minutes. Our LIFF modal countdown reflects Omise’s actual
expires_at. - TrueMoney requires app: in LIVE, user MUST have TrueMoney app installed. No fallback to web. Consider warning tooltip near method chip.
expires_atis UTC: convert for display in user TZ.charge.createarrives before LIFF returns from POST: backend may receive webhook before the POST response completes. The payment row is inserted by POST first; webhook upserts based on UNIQUE constraint (idempotent).