How a neobank turns an ACH or wire deposit into an available balance

When to show a deposit as pending, when it becomes available, and how to credit it exactly once from payin webhooks. With ledger entries and a test plan.

A neobank turns an ACH or wire deposit into an available balance in two steps: show it as pending when the deposit arrives, and make it available only when it has finished settling. With BlindPay, each deposit to a virtual account creates a payin. Credit pending on payin.new and available on a completed payin.complete, keyed by payin ID.

The previous guide gave each user US account details. This one covers what happens in your ledger when money lands on them. For the full product map, see how to build a neobank.

Key takeaways

  • Two balances, not one: pending (money seen) and available (money settled). Users can spend only the second.
  • On BlindPay, a virtual account deposit creates a payin automatically. Its webhooks drive your ledger.
  • Credit exactly once: dedupe on svix-id, then a unique constraint on the payin ID.
  • Fees are their own ledger line. Below $100.00 they land on your invoice, from $100.00 they come out of the deposit.
  • Every status needs a ledger action, including on_hold, failed, and refunded.

What happens between the deposit and the balance?

Money moves through four hands before your user sees it as available. Your ledger mirrors each step.

  1. The payer's bank sends an ACH or wire to the user's US account details.
  2. The deposit lands on the user's virtual account. BlindPay creates a payin in processing and fires payin.new.
  3. BlindPay converts the dollars and sends USDC or USDT to the wallet linked to that account. This conversion is an on-ramp: turning bank money into a stablecoin, a token that tracks the dollar one to one.
  4. The payin reaches completed and fires payin.complete. The stablecoins are in the user's wallet.

Your app shows dollars throughout. The wallet balance and your ledger should agree at the end of every day; virtual account reconciliation covers that check.

When is a deposit pending, and when is it available?

Pending from payin.new, available from a completed payin.complete. Everything in between is a hold, not a credit.

Payin status or eventLedger actionWhat the user sees
payin.new, status processingWrite a pending entry for the deposit"Deposit received, arriving soon"
payin.update, status on_holdKeep pending, flag for review"We're reviewing this deposit"
payin.complete, status completedRelease pending, credit receiver_amount as available, book the fee"Added to your balance"
payin.complete, status failedReverse the pending entry"This deposit didn't go through"
payin.complete, status refundedReverse the pending entry"Returned to the sender"

Don't let users spend pending money. A deposit that sits in review and then fails leaves you with a negative balance to chase.

How do ACH and wire deposits differ?

They differ in speed and in how they settle between banks, which changes what you tell users while a deposit is pending.

ACHWire (Fedwire)
How it movesBatches of credit and debit transfers between banksReal-time gross settlement, one transfer at a time
Finality at the bankBatch settlementImmediate, final, and irrevocable once processed
Fastest optionSame Day ACH, within hours on the same business daySame day
Typical payerPayroll, platforms, recurring paymentsLarger, time-critical amounts
BlindPay arrival windowUp to 5 business daysUp to 5 business days

The BlindPay window is the honest number for your "arriving by" copy. It covers the slow cases: a Friday send, a bank holiday, a payer's bank that batches late.

How do you credit a deposit exactly once?

With two keys and one rule. Webhooks can arrive twice and out of order, so your handler must be safe either way.

  1. Verify the signature first. BlindPay signs every webhook with Svix headers: svix-id, svix-timestamp, and svix-signature. Compute the HMAC-SHA256 over the ID, timestamp, and the raw body, as Svix describes. Re-serialized JSON breaks the signature. Reject old timestamps outside your tolerance.
  2. Deduplicate on svix-id. It stays the same across redelivery attempts. Seen it? Return 200 and stop.
  3. Key entries by payin ID. Put a unique constraint on (payin ID, entry type). A replay that slips past step 2 fails at the database.
  4. Only move forward. Store the payin status you last applied. If a processing event arrives after completed, ignore it.
  5. Respond fast. Return 200, then do slow work (push notifications, emails) in a queue.

Stablecoin API webhooks goes deeper on ordering, and idempotency keys covers the API side.

What do the ledger entries look like for one deposit?

Illustrative example. The fee below is invented for the arithmetic. Read real amounts from the payin, which carries them in minor units (cents).

A client sends a user $1,000.00 by ACH. Because the deposit is $100.00 or more, BlindPay charges its fee at the time of the transaction, so transaction_fee_amount is set and receiver_amount is the deposit minus the fee.

StepEventEntryPendingAvailable
1payin.newPending credit, deposit $1,000.00$1,000.00$0.00
2payin.complete, completedRelease pending$0.00$0.00
3same eventCredit receiver_amount $995.00$0.00$995.00
4same eventFee line, transaction_fee_amount $5.00

Steps 2 to 4 are one database transaction. If any line fails, none land.

Now a $40.00 deposit. It's below $100.00, so the fee goes to your invoice as billing_fee_amount and the user receives nearly the full $40.00. Your ledger books that fee as your cost, not the user's, unless your own pricing charges them. The neobank roadmap puts that pricing call in the money-in step.

What about holds, failures, and refunds?

Treat them as normal outcomes. Each gets a ledger action and a message.

  • on_hold. Compliance or risk review. Keep the pending entry and tell the user a review is in progress. Don't guess a time.
  • failed. The payin did not go through. Reverse pending, notify, and link to support.
  • refunded. The deposit went back to the sender. Reverse pending and tell the user to check with the payer.

When a user asks where a deposit is, start from virtual account deposit not received. For quote-based payins, why on-ramp deposits fail lists the usual causes.

How do you test this before production?

On a BlindPay development instance, payins complete on their own about 30 seconds after creation, using the USDB test token. Then work through this list:

  • A normal deposit: pending, then available.
  • A payin quote with request_amount 66600: ends failed. Your pending entry must reverse.
  • A payin quote with request_amount 77700: ends refunded. Same check.
  • The same webhook delivered twice: one credit, not two.
  • payin.new delivered after payin.complete: no change to the available balance.
  • A bad signature: rejected with no ledger write.

Common mistakes

  • Crediting available balance on payin.new.
  • Parsing JSON before verifying the signature.
  • Using the webhook's arrival time as the business date. Use the payin's own timestamps.
  • Netting the fee into the deposit with no fee line. Finance can't reconcile your invoice later.
  • Letting a late processing event overwrite a completed one.
  • Promising "instant" deposits on ACH.

How BlindPay fits

BlindPay gives each user a US virtual account and turns every deposit to it into a payin, with no checkout or payment instructions to show. The payin carries everything your ledger needs: the customer it belongs to, the account it landed on, sender_amount, receiver_amount, and separate fee fields in minor units, plus a status that moves through processing, on_hold, completed, failed, or refunded.

BlindPay converts the dollars and delivers USDC or USDT to the wallet linked to the account, then tells you through Svix-signed webhooks: payin.new, payin.update, and payin.complete. KYC, sanctions screening, and transaction review run before money moves, so an on_hold is BlindPay's review doing its job.

On a development instance, payins complete in about 30 seconds and sentinel amounts force failed and refunded outcomes, so you can test every branch of your ledger before a real dollar arrives.

What to do next

Write the five-row status table above into your ledger service as code, then run the six tests on a development instance. The payins docs list every status, and webhook verification has the signature steps.

FAQ