Get in touch

Watch-Only Crypto Withdrawals: How We Pay Out Without Signing

10 min. to read
20.08.2026 updated
5.0 / 5.0

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.

When you finish
  • Tables withdrawals and withdrawal_messages.
  • TOTP-gated create that inserts moderation without changing balance.
  • Approve that debits frozen sum_in_fiat and writes ledger type = 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.

Left: ticket + human send. Right: server signs. Same word, different blast radius.

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.

Create only opens the queue.

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 moderationtransaction_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.

  1. Confirm address + amounts with the merchant.
  2. Send crypto from keys outside this app.
  3. Copy TXID from the explorer.
  4. POST .../approve/{id} with transaction_id + crypto amount.

Service: set success, overwrite crypto sumdecreaseBalance(sum_in_fiat), fire ledger type = withdrawal. Decline = declined, no debit. Admin reopen → moderation.

If coins were never sent, pasting a TXID is fraud theater.

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
No live in-progress middle column.

Naming trap

English UI Table Meaning
Refund request withdrawals Balance payout
Support tickets tickets Chat, not money
Invoice / payment invoices Money in
Do not invent invoice-linked refunds on this schema.

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.

  1. Debit on create — declined tickets already took money. Debit only in approve. Soft holds must be an explicit policy, not a fake transfer.
  2. 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.
  3. Hot wallet next to the ticket API — one leaked env drains funds. Store TXIDs; do not sign.
  4. “Refund” UI as invoice refund — no invoice_id on withdrawals. Different product.
  5. 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.
  6. Charset regex as full address validation — wrong-chain sends after approve. Add per-chain checks; still verify on explorer.
  7. Shipping in-progress in the deck — API never writes it. Document only statuses you assign.
  8. Merchant reopen with no backend — in our production only admin reopen exists.
  9. close vs declined — DB value is declined; constant name is historical.
  10. Approve from chat, not explorer — wrong TXID/amount; fiat debit still uses frozen sum_in_fiat.
  11. Ignoring open moderation in treasury — treat open tickets as committed intent even without a code hold.
  12. Sandbox mixed with production payout ledgers — separate DB / hosts / flags.
Debit on approve only.
Stuck in moderation? Assignment → explorer send → approve.

comments 0

No comments yet. Be the first to comment!

Content
Ready to build your own product?
Frequently Asked Questions

The app never signs. Ticket → human send → paste TXID → debit fiat + ledger row.

Rotating 6-digit authenticator code tied to google2fa_secret. Stops a stolen session from opening payout tickets unchecked.

No. Queues are for the deposit/watcher plane. Create and approve are MySQL + human broadcast.

Only on approve, by sum_in_fiat from the original request.

No. UI “refund” means withdraw merchant balance.

Let's build something great together
decor
decor
Drag & Drop Your Files or Browse
You can upload ZIP, PDF, PAGES, DOC, or DOCX up to 8 MB each.
Maksym Privalov
PRODUCT MANAGER, SENIOR BDM
manager
Share the basic information about your project — like expectations, challenges, and timeframes.
We’ll come back within 24 hours
We will sign the NDA if required, and start the project discussion
Get in touch
Valerii
Online
bg
Hi there 👋

How can I help you?