By William Beaumont, Head of Operations at PAYJN

A client in Jakarta opens your broker's cashier, picks "Bank transfer (VA)", pays IDR 5,000,000 from their mobile banking app and switches back to MetaTrader. Somewhere between the bank, the payment provider, your webhook endpoint and your back office, that payment has to become a balance operation on exactly one trading account, once.

Many deposit problems in a broker cashier are not payment failures. The money arrived, but the system credited it twice, credited the wrong account, or never credited it at all because one step assumed another had already done its job. This article walks through a deposit design for Indonesian QRIS and virtual account (VA) payments that avoids those three outcomes. The code is illustrative Node.js with invented field names. It is not any provider's API, and mt5.balance / mt5.findBalanceDeal stand for your own wrapper around the MT5 Manager or server API.

The three things that go wrong

  1. Double credit. The provider retries a webhook because your acknowledgement timed out, or two workers process the same event at once, and both post a balance operation to MT5.
  2. Wrong account. A client has two trading accounts, or a VA was reused, and the deposit lands on the account that happened to be "current" in the session.
  3. Silent miss. The webhook never arrives (or arrives while your endpoint is deploying), nobody reconciles, and the client opens a ticket three hours later with a screenshot.

Each one has a structural fix. None of them is "add more logging".

Choose the binding between payment and trading account first

Before writing any handler, decide what links a payment to a trading account.

Method Binding What to watch
VA, one per trading account The VA number itself Never reassign a VA to another account; amounts can differ from what the client typed in your cashier
VA, one per deposit Your deposit reference created before the VA is shown Expired VAs that still receive transfers
QRIS, dynamic per deposit Your deposit reference and the QR amount Payments attempted after the QR expired

A fixed VA per trading account is convenient for clients who top up often, but it means you must accept "any amount at any time" and decide how to handle it. A per-deposit VA or dynamic QRIS gives you an expected amount and an expiry, which makes mismatches easier to detect. Many brokers offer both: a fixed VA for regular clients and dynamic QRIS for quick deposits.

Whatever you pick, store the binding before you show payment instructions. If your system cannot tell which trading account a payment belongs to without looking at the client's session, the design is not finished.

A deposit table that refuses to credit twice

The core idea: the database, not the handler code, enforces "one provider payment, one credit".

CREATE TABLE deposits ( id BIGSERIAL PRIMARY KEY, deposit_ref TEXT UNIQUE NOT NULL, -- your reference, created first trading_login BIGINT NOT NULL, -- MT5 account method TEXT NOT NULL, -- 'va' | 'qris' expected_idr BIGINT, -- null for open-amount VA provider_txn_id TEXT UNIQUE, -- set once, from the verified event received_idr BIGINT, status TEXT NOT NULL DEFAULT 'awaiting_payment', mt5_deal_id TEXT UNIQUE, -- set once, after the balance operation review_reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ ); 

Two unique constraints do most of the work. provider_txn_id stops the same payment from being attached to two deposits. mt5_deal_id records that the balance operation already happened, so a retry of the crediting step can check before acting.

The webhook handler: verify, record, acknowledge, then credit

// Illustrative only. Field names are invented. app.post('/webhooks/payments', async (req, res) => { if (!verifySignature(req)) return res.status(401).end(); const evt = req.body; if (evt.status !== 'paid') return res.status(200).end(); // record, but never credit const result = await db.tx(async (t) => { const dep = await t.oneOrNone( 'SELECT * FROM deposits WHERE deposit_ref = $1 FOR UPDATE', [evt.merchant_ref]); if (!dep) return { action: 'unknown_ref' }; if (dep.provider_txn_id === evt.txn_id) return { action: 'duplicate' }; if (dep.provider_txn_id) return { action: 'second_payment' }; const late = dep.expires_at && new Date(evt.paid_at) > dep.expires_at; const mismatch = dep.expected_idr !== null && dep.expected_idr !== evt.amount_idr; const status = late || mismatch ? 'needs_review' : 'paid'; await t.none( `UPDATE deposits SET provider_txn_id = $1, received_idr = $2, status = $3, review_reason = $4 WHERE id = $5`, [evt.txn_id, evt.amount_idr, status, late ? 'paid_after_expiry' : mismatch ? 'amount_mismatch' : null, dep.id]); return { action: status, id: dep.id }; }); res.status(200).end(); // acknowledge fast if (result.action === 'paid') queue.add('credit-mt5', { depositId: result.id }); if (['unknown_ref', 'second_payment'].includes(result.action)) alertOps(evt, result); }); 

Three decisions are deliberate here:

  • The handler never calls MT5 directly. It records the verified payment and acknowledges. Crediting happens in a worker, so a slow trading server cannot make the provider retry the webhook.
  • A repeated event is a no-op, not an error. Return 200 so the provider stops retrying.
  • Mismatches go to people, not to code. An underpayment, an overpayment or a payment after expiry is moved to needs_review. Auto-crediting the received amount sounds helpful until a client pays an expired VA twice.

The crediting worker: check before you post

worker.process('credit-mt5', async ({ depositId }) => { const dep = await db.one('SELECT * FROM deposits WHERE id = $1', [depositId]); if (dep.status !== 'paid' || dep.mt5_deal_id) return; // already done or not creditable // Use the deposit reference as the comment so the deal can be found again. const existing = await mt5.findBalanceDeal(dep.trading_login, `DEP:${dep.deposit_ref}`); const deal = existing ?? await mt5.balance(dep.trading_login, toAccountCurrency(dep.received_idr), `DEP:${dep.deposit_ref}`); await db.none( `UPDATE deposits SET mt5_deal_id = $1, status = 'credited' WHERE id = $2 AND mt5_deal_id IS NULL`, [deal.id, dep.id]); }); 

The lookup before posting matters. If the worker crashes after MT5 accepted the balance operation but before your database update, a naive retry posts the deposit again. Searching for a deal with the same reference first turns the retry into a recovery.

Record the IDR amount received and the exchange rate you used to convert it into the account currency. Clients will ask why IDR 5,000,000 became a particular USD figure, and support needs the answer without recalculating it.

Reconciliation catches the silent misses

Webhooks are a notification, not a ledger. Run a job every few minutes that asks the provider for recent paid transactions and compares them with your deposits table:

  • paid at the provider, awaiting_payment locally → the webhook was lost; process it through the same handler logic;
  • paid locally for longer than a few minutes, no mt5_deal_id → the crediting worker is stuck;
  • paid at the provider with an unknown reference → a payment you cannot place; route to operations.

Run a full daily reconciliation against the provider's settlement report as well. That is where fees and returned transfers show up.

Withdrawals need the opposite instinct

Deposits fail safe by waiting for evidence. Withdrawals fail safe by not acting twice.

  • Pay only to a bank account or e-wallet in the client's own verified name, and review a newly added destination before the first payout to it.
  • Debit the trading account (or place a hold) before you send the payout request, with one withdrawal reference per request.
  • If the payout request times out, query its status with the original reference. A timeout does not prove that the transfer failed, and a blind retry can pay the client twice.

Test the ugly paths

Before production, replay the same verified webhook ten times. Deliver two events concurrently. Kill the worker after the MT5 call and before the database update. Pay an expired QRIS. Underpay a VA. Each test should end with exactly one credit, or an item in the review queue, never two.

Disclosure

I work at PAYJN, which provides IDR deposits and withdrawals for licensed forex brokers in Indonesia. The patterns above apply whichever provider you use. If you are planning an Indonesian cashier, our page on payments for forex brokers in Indonesia lists what we review before onboarding a broker.