---
title: "How a neobank turns an ACH or wire deposit into an available balance"
seoTitle: "Neobank deposits: from bank transfer to available balance"
description: "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."
date: "2026-10-10"
updated: "2026-10-10"
category: "payments"
author: "BlindPay Team"
howto:
  name: "How to credit a bank deposit to a neobank balance from BlindPay payin webhooks"
  steps:
    - name: "Verify the webhook signature"
      text: "Check the svix-id, svix-timestamp, and svix-signature headers against the raw request body before you parse anything."
    - name: "Deduplicate the event"
      text: "Store the svix-id. If you have seen it before, return 200 and stop. Redeliveries of the same event keep the same svix-id."
    - name: "Find the user"
      text: "Map the payin's customer_id, and the virtual account details it landed on, to the user in your app."
    - name: "Write a pending entry on payin.new"
      text: "Create one pending ledger entry keyed by the payin ID, with a unique constraint so a second write fails."
    - name: "Move to available on a completed payin"
      text: "When payin.complete arrives with status completed, release the pending entry and credit receiver_amount as available, with the fee as its own line."
    - name: "Reverse on failed or refunded"
      text: "When payin.complete arrives with status failed or refunded, reverse the pending entry and notify the user."
faq:
  - q: "When should a neobank show a deposit as available?"
    a: "Only when the deposit has finished converting and settling. With BlindPay, that is a payin.complete webhook whose payin status is completed, meaning the stablecoins reached the user's wallet. Before that, show the deposit as pending. Crediting available balance at payin.new lets users spend money that could still fail review or come back as refunded."
  - q: "How do I avoid crediting the same deposit twice?"
    a: "Use two keys. Deduplicate webhook deliveries on the svix-id header, which stays the same across redelivery attempts. Then key every ledger entry by the payin ID with a unique constraint, so a replayed or out-of-order event cannot write a second credit. Both checks are cheap, and together they make crediting idempotent."
  - q: "How long do ACH and wire deposits take to arrive?"
    a: "It depends on the sending bank and the rail. Same Day ACH can settle within hours on the same business day, and Fedwire transfers are final once processed. BlindPay documents a typical arrival window of up to 5 business days for ACH and wire payins, so show an honest range instead of a promise."
  - q: "What is a payin?"
    a: "A payin is BlindPay's record of money coming in: a fiat deposit arrives, BlindPay converts it, and stablecoins go out to a wallet. A deposit to a virtual account creates a payin automatically. Each payin has its own ID, amounts in minor units, fee fields, and a status: processing, on_hold, completed, failed, or refunded."
  - q: "Who pays the fee on a small deposit?"
    a: "On BlindPay, the fee on a virtual account deposit below $100.00 accrues to your monthly invoice in billing_fee_amount, so the user receives close to the full amount. From $100.00 up, the fee is deducted at the time of the transaction in transaction_fee_amount. Decide whether your own pricing passes the small-deposit fee to users."
  - q: "How do I test deposit crediting before production?"
    a: "Use a BlindPay development instance. Payins auto-complete about 30 seconds after creation using the USDB test token. To exercise your failure paths, create payin quotes with a request_amount of 66600 for failed or 77700 for refunded. Replay each webhook twice to prove your deduplication holds."
---

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](/resources/more/neobank-us-bank-account-for-every-customer) 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](/resources/more/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](/resources/more/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 event | Ledger action | What the user sees |
| --- | --- | --- |
| `payin.new`, status `processing` | Write a pending entry for the deposit | "Deposit received, arriving soon" |
| `payin.update`, status `on_hold` | Keep pending, flag for review | "We're reviewing this deposit" |
| `payin.complete`, status `completed` | Release pending, credit `receiver_amount` as available, book the fee | "Added to your balance" |
| `payin.complete`, status `failed` | Reverse the pending entry | "This deposit didn't go through" |
| `payin.complete`, status `refunded` | Reverse 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.

| | ACH | Wire (Fedwire) |
| --- | --- | --- |
| How it moves | [Batches of credit and debit transfers](https://www.federalreserve.gov/paymentsystems/fedach_about.htm) between banks | [Real-time gross settlement](https://www.federalreserve.gov/paymentsystems/fedfunds_about.htm), one transfer at a time |
| Finality at the bank | Batch settlement | Immediate, final, and irrevocable once processed |
| Fastest option | [Same Day ACH](https://www.nacha.org/content/same-day-ach), within hours on the same business day | Same day |
| Typical payer | Payroll, platforms, recurring payments | Larger, time-critical amounts |
| BlindPay arrival window | Up to 5 business days | Up 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](https://docs.svix.com/receiving/verifying-payloads/how-manual). 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](/resources/more/stablecoin-api-webhooks-reconciliation) goes deeper on ordering, and [idempotency keys](/resources/more/stablecoin-api-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.

| Step | Event | Entry | Pending | Available |
| --- | --- | --- | --- | --- |
| 1 | `payin.new` | Pending credit, deposit $1,000.00 | $1,000.00 | $0.00 |
| 2 | `payin.complete`, completed | Release pending | $0.00 | $0.00 |
| 3 | same event | Credit `receiver_amount` $995.00 | $0.00 | $995.00 |
| 4 | same event | Fee 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](/resources/more/how-to-build-a-neobank) 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](/resources/more/virtual-account-deposit-not-received). For quote-based payins, [why on-ramp deposits fail](/resources/more/why-crypto-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](/docs/payins) list every status, and [webhook verification](/docs/learn/webhooks-verification) has the signature steps.
