Migrating from another provider
Read "the differences that break your code" before the mapping table
A field mapping table is an afternoon of work. Behavioural differences are what break existing code silently, and you find out at month-end reconciliation.
Three in particular sail through QA and then fail in production: not verifying callback signatures, having no version guard, and closing an order the moment expired appears.
This page describes patterns common across the market. It does not describe any one provider. Where your previous gateway differs from what is written here, follow the behaviour we describe.
12 differences that will break your existing code
Ordered by how likely each one is to bite.
1. The sender's bank account is required when the deposit is created
Before: send {amount, user_ref}, get a QR back. Here: no sender{} and no sender_id ⇒ 422 sender_required, and no order is created at all.
// ❌ old code — instant 422
await createDeposit({ amount_minor: 500000, currency: 'THB', end_user_ref: 'u_88213' });
// ✅ best path — let the user pick an already-verified account
const { data: senders } = await getSenders('u_88213');
await createDeposit({
amount_minor: 500000,
currency: 'THB',
merchant_reference: 'ORD-9981',
end_user_ref: 'u_88213',
sender_id: senders[0].sender_id, // guarantees no queueing for a name/account mismatch
client: { device_fingerprint: 'df_9a1c7e', ip: '203.0.113.44', user_agent: req.headers['user-agent'] }
});
// ✅ a new user with no account on file
await createDeposit({
amount_minor: 500000,
currency: 'THB',
merchant_reference: 'ORD-9982',
end_user_ref: 'u_90114',
sender: { bank_code: '002', account_number: '1234567890', account_name_th: 'สมชาย ใจดี' },
client: { device_fingerprint: 'df_9a1c7e', ip: '203.0.113.44', user_agent: req.headers['user-agent'] }
});account_name_th is the account name in Thai script, exactly as the bank holds it ("สมชาย ใจดี" is Somchai Jaidee) — it is what the slip gets matched against. bank_code 002 is Bangkok Bank; pull the authoritative list from GET /v1/pay/banks rather than hard-coding it.
Why we require it — our money lands directly in the withdrawer's personal bank account, with no house account in the normal path. The system has to know in advance which slip belongs to which order, and it matches on who the sender is rather than on a unique satang suffix.
The real work is not the payload; it is a new screen
The biggest task in this migration is the sender-account picker, which your old integration did not have. Start it on day one, alongside the request-signing work.
The wrong shortcut: pulling an account-holder name out of your own database just to get past the 422. The name will not match the real slip, every order lands in under_review, the review queue overflows on day one, and every user waits for a manual check.
Transferring from an account that was not registered is not rejected — it goes to a human review queue instead. The money is safe but slow, which is exactly why the right account has to be picked up front.
Delete this: any amount + random_cents generator used to identify orders, and any slip matcher keyed on the satang remainder. Our amounts must be round; a remainder returns 422 amount_not_supported.
2. We sign callbacks with a key pair, and your old code will "work" without verifying
This is the most dangerous item on the page, because nothing breaks. It passes QA and ships.
// ❌ old code like this still works against us — that is the problem
app.post('/cb', (req, res) => { credit(req.body); res.send('ok'); });Anyone who can guess your URL posts {"data":{"object":{"status":"settled"}}} and gets free goods.
Here: every webhook is signed with Ed25519. We hold the private key; you hold the public key, fetched from GET /v1/webhooks/signing-keys with no authentication. A breach of our database still cannot forge a webhook.
Four things you have to add:
import express from 'express';
import crypto from 'node:crypto';
const app = express();
// 🔴 you need the raw bytes — if the framework parses JSON first, the signature can never match
app.post('/webhooks/pg', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body; // raw Buffer, never re-serialize
// 1) parse the header as key=value pairs, not by position
const parts = Object.fromEntries(
(req.get('X-PG-Signature') ?? '').split(',').map((kv) => kv.split('='))
);
const { kid, t, s } = parts;
// 2) 300-second freshness window
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).end();
// 3) verify against the raw bytes before touching the body
const eventId = req.get('X-PG-Event-Id');
const signed = Buffer.concat([Buffer.from(`v1.${t}.${eventId}.`, 'utf8'), raw]);
const ok = crypto.verify(null, signed, await publicKeyFor(kid), Buffer.from(s, 'base64url'));
if (!ok) return res.status(401).end();
const event = JSON.parse(raw.toString('utf8'));
if (event.id !== eventId) return res.status(400).end(); // body must match the header
// 4) transactional inbox + outbox — this helper commits both rows together.
// Never markSeen and enqueue separately: a crash between them loses the event permanently.
await persistWebhookAtomically({ event, raw }); // schema: /en/webhooks/signature
res.status(200).end(); // answer only after commit
});A valid signature still does not mean "credit"
The signature proves only that the message came from us unmodified. It says nothing about whether you may credit.
Crediting still depends on data.object.status === 'settled'. Code that credits because event.type === 'deposit.settled' will double-credit when an old event is retried after a reversal.
persistWebhookAtomically uses UNIQUE(event_id) and retains inbox rows for at least 31 days. The outbox worker then commits the version guard, business database effect, and external-effect outbox rows in one transaction.
Key rotation: we announce webhook.key.rotating 7 days ahead, and that event cannot be unsubscribed — you need a handler that fetches the new key. Cache public keys for at most 24 hours; on an unknown kid, refetch once before deciding the signature is invalid.
3. Event order is not guaranteed, and the retry window runs ~92 hours
Before: callbacks arrive in order, so switch (type) and act is enough. Here: 11 retries spanning up to ~92 hours 36 minutes, so an older event arriving after a newer one is routine.
The most common case is a late slip:
event created arrives at your endpoint
10:04:41 deposit.expired v6 10:04:41 expired v6 ✓ (your endpoint then dies for 3 min → attempt 1 fails)
10:31:02 deposit.slip_received v7 10:31:02 slip_received v7 ✓
10:33:15 deposit.settled v9 10:33:15 settled v9 ✓ ← credit 5,000, release goods
10:35:10 expired v6 ← retry #4 finally lands!// ❌ broken — cancels an order that already settled; the customer really loses money
switch (event.type) {
case 'deposit.expired': await cancelOrder(id); break;
}The correct shape — persist with one UPDATE carrying a version guard, then hang side effects off rowCount:
UPDATE deposits
SET status = $2, version = $3, is_terminal = $4, updated_at = now()
WHERE deposit_id = $1 AND version < $3
RETURNING status, credited_user_at;rowCount = 0 ⇒ stale event ⇒ answer 200 and do nothing. This is safe because we guarantee two events for the same object never share a version.
The worst case is settled arriving after reversed — with no guard you credit and ship goods for an order that was already reversed.
4. Our ids look nothing like your old references
Before: short reference strings, sometimes with a type-revealing prefix. Here: dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B · wdr_… · evt_… · btxn_…
The quietest failure is a varchar(20) column — the id is truncated, the row writes successfully, no error is raised, and reconciliation cannot find anything for a whole month.
-- ✅ the column must be text, not varchar(20)
ALTER TABLE topups ADD COLUMN deposit_id text;
CREATE UNIQUE INDEX ON topups (deposit_id) WHERE deposit_id IS NOT NULL;🔴 Never parse them, never assume the prefix. Length and format can change. Code that infers a record type from the prefix should read event.type instead.
5. 202 on create — "no destination yet" is a state, not an error
Before: creation returns a QR immediately; anything else is an error. Here: if no withdrawer can be matched yet we answer 202 with a real, created order in status awaiting_liquidity and legs: [].
// ❌ old code gets undefined and renders a blank screen
const qr = res.data.legs[0].qr_payload;
// ✅ you need a "finding a route" state that is not an error page
if (res.status === 202) {
showWaitingScreen({
suggested: res.data.liquidity.suggested_amounts_minor, // amounts that would match right now
retryAfter: res.data.retry_after_seconds
});
// 🔴 do not POST again — idempotency returns the same order anyway
// wait for the deposit.matched webhook, or poll with backoff 3→5→10→30 seconds
}What the true dead end looks like — if no route can be found at all, you get the webhook deposit.canceled with cancel_reason_code: "no_liquidity_available". 🔴 Not deposit.expired, and not an HTTP 503 — we always create the order.
6. Successful durable slip intake answers 202; there is no immediate 409 duplicate_slip
Before: the slip is checked synchronously in the same call, so a duplicate returns 409 right away. Here: POST /v1/pay/deposits/{id}/slip answers 202 after capability auth/validation and confirmed storage + queue admission. Unconfirmed intake returns a retryable 503; retry the same logical evidence and key (evidence_fingerprint is stable across boundary, key-order, and base64-format changes).
// ❌ old code — the user sees "upload failed" while the slip is safely in our system
if (res.status !== 200) { showError('อัปโหลดไม่สำเร็จ'); return; } // "Upload failed"
// ❌ worse — code that retries because it reads 202 as failure ⇒ the button hangs and the user transfers again
// ✅ correct
if (res.ok) showPendingScreen('กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที');
// Thai copy above: "Checking the slip with the bank, under 1 minute."Where the verdict arrives — by webhook or from GET /v1/pay/deposits/{id}, and nowhere else.
- All checks pass ⇒
deposit.settled - Duplicate slip ⇒
deposit.under_reviewwithreview.reasons[0].code = "DUPLICATE_SLIP" - Unreadable image ⇒
deposit.slip_rejectedwithretryable: true— not a rejection of the money, just a request for a better photo
Why it is designed this way — by upload time the user's money has already left their account, so accepted is returned only after the file write and queue confirmation. If either is not confirmed, 503 must lead to a retry that reuses the same receipt, never to another transfer.
What you have to add: the screen after the upload must say "checking", not "success", and you need a deposit.under_review handler from day one.
7. A deposit has four core amounts, not one
Before: one number lands in the wallet. Here: ordered_amount_minor (X) · transferred_amount_minor (V) · amount_received_minor (A) · overpayment.amount_minor (E, or zero), plus separate fee_total_minor (and fee_breakdown[]) / credited_amount_minor values.
amount_received_minor = min(transferred_amount_minor, ordered_amount_minor)
0 ≤ fee_total_minor ≤ amount_received_minor
amount_received_minor − fee_total_minor = credited_amount_minor
same-slip overpay: transferred_amount_minor = amount_received_minor + overpayment.amount_minor- Credit the end user with
amount_received_minor, the amount accepted into the order (A). On exact/underpay it equalsV; on overpayment it is capped atX. - Move the merchant wallet by
credited_amount_minor. - If
overpayment{}is present, the order succeeded atA = X. Show theclaim_id, direct the user to support, and do not credit or refund the excess automatically.
Old single-number reconciliation code instinctively reaches for amount_received_minor because it looks most like the old field, which makes your books run high by exactly the fee on every order.
The withdrawal side is inverted: many providers deduct the fee from the withdrawal amount. We add it on top. You need available_minor ≥ amount_minor + fee_total_minor, otherwise 402 insufficient_balance and no order is created — so you need a branch that releases the user's hold in that case. Check ahead with GET /v1/pay/quote?channel=withdrawal&amount_minor=1000000.
The fixed fee attaches to the order, not to each fill.
8. Freshly credited money is not withdrawable for 72 hours
Before: money arrives in the wallet and a withdrawal can be created immediately. Here: it lands in the pending_clearing bucket first and moves to available at T+72 hours (you get a balance.cleared event then).
| What you want to do | Read from |
|---|---|
| Show a balance to the user | available_minor + pending_clearing_minor |
| Compute what can be withdrawn | 🔴 available_minor alone |
This is a cash-flow change, not a technical detail
Tell your finance team before go-live, not after — the time before money can be recycled moves by three days.
balance.cleared is a bucket move, not new money. Code that adds to the balance on every event will double-count.
9. expired and canceled are not always the end
Before: time runs out, the order is closed, done. Here: a slip is still accepted for another 24 hours, so you will get deposit.settled for an order you already marked failed.
// ❌ broken
if (obj.status === 'expired') closeOrder(id);
// ✅ check is_terminal, not the status name
if (obj.is_terminal) closeOrder(id);
else keepUploadButtonUntil(obj.late_slip_accepted_until);🔴 canceled can carry the same window — a user cancels and transfers at nearly the same moment, which happens several times a week. A deposit.canceled with late_slip_accepted_until and is_terminal: false must keep the upload button, exactly like expired.
Your handler must accept settled from every internal state you store, and it must be idempotent.
10. One withdrawal produces many callbacks
Before: 1 withdrawal = 1 callback at the end. Here: one withdrawal can produce withdrawal.filled up to 7 times, because the amount is filled by several different payers.
// ❌ broken — closes on the first fill; the user is short but your system says success
if (event.type === 'withdrawal.filled') closeWithdrawal(id);
// ✅ correct — these two do different jobs; you need both
// 1) record the fill row, deduped on leg_ref
await db.query(
`INSERT INTO withdrawal_fills (withdrawal_id, leg_ref, amount_minor, settled_at)
VALUES ($1,$2,$3,$4) ON CONFLICT (leg_ref) DO NOTHING`,
[obj.id, obj.fill.leg_ref, obj.fill.amount_minor, obj.fill.settled_at]
);
// 2) order totals — overwrite only when version is higher
const r = await db.query(
`UPDATE withdrawals SET status=$2, filled_minor=$3, remaining_minor=$4, version=$5
WHERE id=$1 AND version < $5`,
[obj.id, obj.status, obj.filled_minor, obj.remaining_minor, obj.version]
);
// 3) close only when both conditions hold
if (r.rowCount === 1 && obj.status === 'settled' && obj.remaining_minor === 0) closeWithdrawal(obj.id);🔴 Never SUM() your own fill table to drive money logic — events may not all have arrived. The fill table is for timelines and audit. The source of truth is filled_minor / remaining_minor read through the version guard.
And an expired order may still have paid out: 10,000 requested, 4,000 paid, then expiry ⇒ status: "expired" with filled_minor: 400000 ⇒ refund the user by refunded_minor, not by amount_total_minor.
11. Orders routed through a fallback carry no upstream reference
Before: logs and support screens show the upstream provider's reference. Here: that value does not exist. Every route is identified by our deposit_id alone.
The only field describing origin is liquidity_source ∈ internal | external_gateway, and 🔴 it must never drive a crediting decision. Reporting only.
⇒ Support screens and customer messages should quote deposit_id for every route, and make it copyable.
12. Idempotency is mandatory on every POST
Before: usually optional, so retry code could just fire again. Here: no Idempotency-Key header ⇒ 400 idempotency_key_required, every time.
| What you do | What you get |
|---|---|
| Same key + same body + the first request finished | The entire original response, including the original HTTP status, plus Idempotent-Replay: true |
| Same key + different body | 409 idempotency_key_reused |
| Same key + the first request has not finished | 409 idempotency_in_flight + retry_after_seconds: 1 (we do not block or hold) |
| Key older than 7 days, sent again | Treated as a brand new request ⇒ a second order is created |
🔴 Generate the key and persist it before you send. Every retry reads the same key back from the database. The key is a function of content, not of a session — if the user edits the amount and submits again, that is a new key. Your TTL must not be shorter than 7 days.
🔴 Do not confuse Idempotency-Key with PG-Nonce — a retry must use a new nonce (otherwise 401 nonce_replayed), but the key must stay the same or you get duplicate orders.
Field mapping table
Read this after you have understood the 12 points above.
| Old concept | Ours | Notes |
|---|---|---|
| A single API key in a custom header | Authorization: Bearer sk_live_* plus PG-Signature (HMAC-SHA256 over a 9-line canonical string) | The key alone is not enough · Idempotency-Key and PG-Version are part of the canonical string |
| Deposit reference string | deposit_id | 🔴 Different format and length; never parse it, never assume the prefix · the column must be text |
| The user id you sent the provider | end_user_ref | Also used with GET /v1/pay/senders |
| An amount with a satang suffix used for matching | amount_minor, always round | Matching is by sender identity · a remainder returns 422 amount_not_supported |
| — (did not exist) | sender{} or sender_id | 🔴 New, and required |
| One set of destination account details | legs[] (plus checkout_url) | Destinations are personal accounts, and there can be more than one |
| An expiry you calculated yourself | expires_at from the response | 🔴 Never hardcode · some routes really do expire in ~4 minutes, and the value can change mid-order |
| One number landing in the wallet | Four core amounts: ordered_amount_minor / transferred_amount_minor / amount_received_minor / overpayment.amount_minor, plus separate fee / merchant-credit values | See point 7 |
| A single balance figure | available_minor / pending_clearing_minor / reserved_minor | See point 8 |
| An unsigned raw callback body | Payload + X-PG-Signature (Ed25519) + X-PG-Event-Id + data.object.version | See points 2 and 3 |
A {code, message, result} envelope that always returns HTTP 200 | Real HTTP statuses (201 202 402 409 410 422 429 503) | See below |
| A leg id you could join across both sides | leg_ref | 🔴 The two sides get different values on purpose; cross-merchant joins are impossible |
Real HTTP statuses vs the old envelope
Old code written as if (res.ok) against an API that always returned 200 will read errors as success, or read 202 as failure.
Write the old-error → new-error mapping table before you touch any code, then make the changes. The full list lives at Every error code.
// a shape that survives codes we add later
if (res.ok) return body; // 200 · 201 · 202 are all successes
const err = body.error;
if (err.retryable) return scheduleRetry(err.retry_after_seconds);
throw new PermanentError(err.code, err.request_id); // 🔴 always log request_idRecommended rollout order
| Step | What to do | Why in this order |
|---|---|---|
| 1 | Connect to sandbox read-only first (GET /v1/pay/banks and /v1/pay/quote) until request signing passes | Prove signing before touching money · use all three test vectors from GET /v1/webhooks/test-vectors |
| 2 | Add the new columns to your existing tables as nullable | Deployable with zero impact on the current system |
| 3 | Write the new webhook receiver on a new path; do not modify the old one | Lets both systems run side by side during the move |
| 4 | Build the sender-account picker | The largest task and on the critical path ⇒ start it alongside step 1 |
| 5 | Send 5% of real traffic and watch the under_review rate | Above 10% means the sender screen still has a problem 🔴 do not increase the share |
| 6 | Go to 50%, then 100% | Once under_review is under 5% and the reconciliation job matches three days running |
| 7 | Turn off the old system | After nothing has been open for more than 24 hours (because of the late-slip window) |
Columns to add in step 2:
ALTER TABLE topups
ADD COLUMN deposit_id text,
ADD COLUMN version bigint,
ADD COLUMN is_terminal boolean,
ADD COLUMN late_slip_accepted_until timestamptz,
ADD COLUMN ordered_amount_minor bigint,
ADD COLUMN transferred_amount_minor bigint,
ADD COLUMN amount_received_minor bigint,
ADD COLUMN overpayment_amount_minor bigint NOT NULL DEFAULT 0,
ADD COLUMN fee_total_minor bigint,
ADD COLUMN credited_amount_minor bigint,
ADD COLUMN credited_user_at timestamptz, -- 🔴 prevents double-crediting
ADD COLUMN sender_bank_code text,
ADD COLUMN sender_account_masked text,
ADD COLUMN sender_id text;
CREATE UNIQUE INDEX ON topups (deposit_id) WHERE deposit_id IS NOT NULL;
CREATE TABLE pg_webhook_inbox ( -- event dedupe, keep ≥ 31 days
event_id text PRIMARY KEY,
event_type text NOT NULL,
event jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
CREATE INDEX ON pg_webhook_inbox (received_at);
-- Insert the outbox row in the same transaction as the inbox row; full schema: /en/webhooks/signatureOld code to delete
| Delete | Because |
|---|---|
The amount + random_cents generator | Our amounts must be round ⇒ 422 amount_not_supported |
| Any slip matcher keyed on the satang remainder | We match by sender identity |
Every if (res.status !== 200) throw | 201 and 202 are successes |
switch (callback.type) with side effects inline | Side effects must go through the version guard |
if (callback) closeWithdrawal() | One order gets many callbacks |
Reading legs[0] and stopping | One order can have several destinations |
| Hardcoded TTLs and countdowns | Use expires_at from the response |
| Logging or displaying the upstream provider's reference | That value no longer exists ⇒ use deposit_id |
Retrying a POST with a freshly generated Idempotency-Key | You get duplicate orders and duplicate fund locks |
Storing qr_payload in a database or a log | 🔴 Never store it, never log it, never forward it — render and discard |
Next
| If you are… | Go to |
|---|---|
| Starting the integration | Integrate in 15 minutes |
| Building the sender-account picker (the biggest task) | Sender account (required) |
| Rewriting your webhook handler | Verifying Ed25519 signatures · Ordering is not guaranteed |
Building the waiting screen for 202 | Waiting for liquidity (awaiting_liquidity) |
| Handling multiple fills on the withdrawal side | One withdrawal, many fills |
| Writing the old → new error mapping | Every error code |
| Testing every path before sending real traffic | Sandbox |
| About to go to production | Go-live checklist |
