A watch-only crypto payment gateway can see deposits arrive and cannot spend them. Merchants still need a way to pull balances out. This article is how Peiko’s payout plane does that work — written so you can rebuild the same shape even if you are starting from zero.
You are not building a signing wallet here. You are building a ticket: the merchant asks, a human reviews, coins leave from keys outside the watching plane, an admin pastes the explorer TXID, and only then the API lowers fiat balance and writes a ledger row.
Companion: Crypto Payment Gateway Architecture (money in). This page is only money out.
- Tables
withdrawalsandwithdrawal_messages. - TOTP-gated create that inserts
moderationwithout changing balance. - Approve that debits frozen
sum_in_fiatand writes ledgertype = withdrawal. - Rabbit/watcher stay on the deposit plane. No signing next to this API.
The idea (read once)
Before you draw tables or wire routes, see the path as a whiteboard story. A watch-only gateway will not hold spending keys next to the API that watches the chain. Merchants still need to leave with their money, so payout becomes a moderated ticket — not an automated wallet that signs under the hood.
The merchant asks for an amount to an address. The API only inserts moderation; balance does not change yet. An operator sends coins from keys outside this codebase. An admin pastes the explorer TXID. Only then does the API set success, subtract the frozen fiat from create time, and write a ledger row. Decline closes with no debit. The English UI may say “Refund,” but the table is still withdrawals and means pull merchant balance — not reverse one invoice.
Step 1 — Database (in detail)
Payout is a stateful ticket, not a fire-and-forget transfer. The schema must answer who asked, how much in fiat and crypto, where to send, where you are in moderation, which TXID closed it, and what was said in the thread. That memory lives in Laravel MySQL — not in the Django watcher. Create or confirm withdrawals and withdrawal_messages; you are done when a hand-inserted row shows moderation, null transaction_id, and an unchanged balance.
A.1 withdrawals — column by column
| Column | Why | Who writes |
|---|---|---|
user_id |
Merchant who owns the balance | Create |
currency_id |
Which crypto to pay out | Create |
fiat_currency_id |
Which fiat was typed (default USD) | Create |
address |
Destination on-chain | Create |
sum |
Crypto amount (estimate → maybe overwrite on approve) | Create → Approve |
sum_in_fiat |
Fiat contract — this is debited on approve | Create (frozen) |
status |
Lifecycle | Create / close / approve / reopen |
transaction_id |
On-chain TXID | Approve only |
wallet_id |
Legacy; usually null on this path | — |
last_message_at |
Queue sort key | Create / reply |
Schema::create('withdrawals', function (Blueprint $table) {
$table->id();
$table->string('transaction_id')->nullable();
$table->string('address');
$table->string('status')->default('moderation');
$table->decimal('sum', 36, 18); // crypto
$table->decimal('sum_in_fiat', 16, 8)->default(0); // fiat debit later
$table->foreignId('wallet_id')->nullable()->constrained('wallets')->nullOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->foreignId('currency_id')->nullable()->constrained('currencies')->nullOnDelete();
$table->unsignedBigInteger('fiat_currency_id')->nullable();
$table->dateTime('last_message_at')->nullable();
$table->timestamps();
$table->index(['status', 'last_message_at']);
$table->index(['user_id', 'status']);
});
A.2 Why two money columns
Merchants think in fiat (“$100”). Operators send crypto. Rates move. sum_in_fiat is the accounting contract; sum is the crypto estimate (and later the actual sent amount).
A.3 Statuses you write
| Value | Meaning | Written by |
|---|---|---|
moderation |
Open queue item | store, admin reopen |
success |
Paid and closed | admin approve |
declined |
Closed without payout (constant name STATUS_CLOSE) |
merchant/admin close |
in-progress |
Model constant only | Never assigned in our service |
A.4 withdrawal_messages
Ticket chat — not the payout. JSON content: {"message":"…","files":[…]}. Cascade delete with the parent ticket.
Schema::create('withdrawal_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('withdrawal_id')->constrained('withdrawals')->cascadeOnDelete();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->json('content');
$table->softDeletes();
$table->timestamps();
});
A.5 Dependencies on other tables
| Dependency | Minimum | Used when |
|---|---|---|
users.balance |
decimal fiat | Debit on approve |
users.is_two_auth_enabled |
boolean | Gate create |
users.google2fa_secret |
string | Verify TOTP |
currencies |
id + symbol | currency_id |
| Default fiat (USD) row | id | fiat_currency_id |
payment_transactions |
ledger with type |
Approve listener |
Done when: both tables exist; you can insert a fake moderation row and list it ordered by last_message_at.
Step 2 — Merchant create (no debit, no queue)
Create is the hop people misunderstand most often. Debiting on create, publishing to RabbitMQ, or broadcasting a chain transaction from Laravel do not belong here. Create means validate, verify a live TOTP code, insert moderation, and optionally store a first chat message. Balance stays put until a human has sent coins and an admin has approved.
B.1 Auth boundary
| Credential | Used for | Create withdrawal? |
|---|---|---|
| Merchant session / user API token | Dashboard user | Yes, after TOTP |
| Store invoice API key | Create invoices | No |
B.2 What TOTP / 2FA means here
TOTP (Time-based One-Time Password): the server stores a shared secret (google2fa_secret). An authenticator app (Google Authenticator, Authy, 1Password, …) shows a new 6-digit code about every 30 seconds. On create, the merchant sends that code as two_factor_code; the server recomputes the expected value and compares (e.g. via pragmarx/google2fa-laravel).
| User column | Role |
|---|---|
is_two_auth_enabled |
Setup finished — create may proceed to code check |
google2fa_secret |
Secret used to verify the digits |
google2fa_code |
Optional hashed last-used / session marker after verify |
Order in the controller: (1) authenticated user → (2) if 2FA not enabled, reject → (3) verify two_factor_code → (4) only then WithdrawalService::store. Wrong or missing code → no row.
Product setup (prerequisites, not withdrawal routes): generate secret + QR → merchant confirms once → set is_two_auth_enabled = true. Without that, create always fails at the gate.
B.3 Request contract
POST /api/v1/user/withdrawal
Authorization: Bearer <user_access_token>
{
"address": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
"currency_id": 12,
"sum": 100,
"two_factor_code": "483920",
"message": "Pull to cold wallet"
}
| Field | Meaning | Notes |
|---|---|---|
sum |
Fiat (USD in our product) | gt:0, capped by balance when balance > 0 |
address |
Destination | Charset regex only — not full chain checksum |
two_factor_code |
Live TOTP | Required; field name must match (not 2fa_code) |
B.4 Service persistence — and what it must not do
$withdrawal = $user->withdrawals()->create([
'address' => $dto->address,
'sum' => Converter::toCrypto($dto->sum, $usd, $crypto),
'sum_in_fiat' => $dto->sum,
'currency_id' => $dto->currency_id,
'fiat_currency_id' => $usd->id,
'last_message_at' => now(),
]);
// status defaults to moderation; transaction_id stays null
Explicit non-goals: decreaseBalance, Rabbit publish, watcher/bitcoind/exchange HTTP, chain broadcast.
B.5 Why RabbitMQ appears in the gateway — and why create ignores it
| Action | RabbitMQ? | Watcher? |
|---|---|---|
| Create invoice address | Yes | Watches |
| Buyer pays invoice | Yes | Detects |
| Create withdrawal ticket | No | No |
| Approve withdrawal | No | No |
Done when: status moderation, transaction_id null, balance unchanged, no withdrawal traffic on deposit queues. Use a live authenticator code — hardcoded 123456 only works with a mocked verifier.
Step 3 — Lists, reply, close
Merchants and admins need to find tickets, talk on them, and sometimes cancel without moving money. Open tickets are this user’s moderation rows; history is success and declined. Closing while open sets declined with no debit. Replies append withdrawal_messages and bump last_message_at
Step 4 — Admin approve — where money moves
This is the only place in the happy path where fiat balance goes down. Confirm with the merchant, send crypto from keys outside the app, copy the TXID from an explorer, then approve with that TXID and the crypto amount actually sent. The service sets success, debits the frozen sum_in_fiat, and writes a ledger row type = withdrawal. Decline never debits. A second approve on an already-success ticket is rejected.
- Confirm address + amounts with the merchant.
- Send crypto from keys outside this app.
- Copy TXID from the explorer.
POST .../approve/{id}withtransaction_id+ cryptoamount.
Service: set success, overwrite crypto sum, decreaseBalance(sum_in_fiat), fire ledger type = withdrawal. Decline = declined, no debit. Admin reopen → moderation.
Status machine
Tickets only move through strings the API actually writes: create → moderation, then approve → success or close → declined, with admin reopen back to moderation. Do not draw an in-progress customer column this path never assigns. The constant STATUS_CLOSE stores the value declined.
[create] --> moderation
moderation --admin approve--> success
moderation --merchant/admin close--> declined
declined --admin reopen--> moderation
Naming trap
| English UI | Table | Meaning |
|---|---|---|
| Refund request | withdrawals |
Balance payout |
| Support tickets | tickets |
Chat, not money |
| Invoice / payment | invoices |
Money in |
Smoke test
Dress rehearsal on a throwaway database: balance 100, 2FA on, known TOTP secret. Prove create does not debit, approve debits once by sum_in_fiat, decline never debits, and bad 2FA never inserts a row.
| # | Action | Expect |
|---|---|---|
| 1 | Create sum 10 + valid TOTP | moderation, balance unchanged |
| 2 | Approve with TXID | success, balance −10 fiat |
| 3 | Ledger | type=withdrawal |
| 4 | Approve again | rejected |
| 5 | Create + close | declined, no debit |
| 6–7 | 2FA off / wrong TOTP | rejected, no row |
Mistakes when building a watch-only payout
These are the ways teams usually hurt themselves after the happy path works on a laptop: treating create like a transfer, wiring deposit Rabbit into payout, putting a hot wallet next to the ticket API, or confusing “Refund” with invoice reversal. Each item pairs a symptom with a fix.
Failure modes from copying a custodial mental model — or the deposit Rabbit plane — into this ticket product.
- Debit on create — declined tickets already took money. Debit only in approve. Soft holds must be an explicit policy, not a fake transfer.
- RabbitMQ / watcher on create or approve — deposit queues answer “did coins arrive?”; this feature answers “did a merchant ask a human to send later?” MySQL only.
- Hot wallet next to the ticket API — one leaked env drains funds. Store TXIDs; do not sign.
- “Refund” UI as invoice refund — no
invoice_idonwithdrawals. Different product. - Skipping or faking 2FA — stolen session spams tickets (or worse if you also auto-pay). Require enabled flag + live TOTP; explain setup in Security UI.
- Charset regex as full address validation — wrong-chain sends after approve. Add per-chain checks; still verify on explorer.
- Shipping
in-progressin the deck — API never writes it. Document only statuses you assign. - Merchant reopen with no backend — in our production only admin reopen exists.
closevsdeclined— DB value isdeclined; constant name is historical.- Approve from chat, not explorer — wrong TXID/amount; fiat debit still uses frozen
sum_in_fiat. - Ignoring open moderation in treasury — treat open tickets as committed intent even without a code hold.
- Sandbox mixed with production payout ledgers — separate DB / hosts / flags.
No comments yet. Be the first to comment!

