Bank transfer lifecycle
State machine for bank transfers and batches — transitions, webhooks, and terminal states.
A bank transfer is a payout: money leaves your Mono account and lands in a beneficiary's Colombian bank account over one of three rails — ACH, Transfiya, or Mono Turbo. Every transfer you create through POST /transfers is automatically grouped into a batch, and both the transfer and the batch move through their own state machines in parallel. This page explains what each state means, what causes each transition, and which webhook event fires at each step.
Understanding both state machines matters because your webhook handler will receive batch events (was the batch authorized? sent?) and transfer events (was the individual transfer approved or rejected?) in an interleaved stream.
Transfer state machine
Transfer states at a glance
| State | Terminal? | Webhook event |
|---|---|---|
created | No | — (synchronous API response) |
in_progress | No | bank_transfer_fallback_routing fires if a routing retry occurs |
approved | Yes | bank_transfer_approved |
declined | Yes | bank_transfer_rejected |
cancelled | Yes | — (user-initiated before dispatch; no async event) |
duplicated | Yes | — (surfaced via batch_duplicated at the batch level) |
Final states can change
The bank_transfer_change_final_state event fires in the rare case where a transfer already
in approved or declined is moved to a different final state by the rail. Always handle
this event idempotently — update your local record to the new state even if you already
considered it settled.
Batch state machine
Every call to POST /transfers creates a batch, even when you submit a single transfer. The batch is the unit that goes through admin authorization (OTP) if your account requires it.
Batch states at a glance
| State | Terminal? | Webhook event |
|---|---|---|
created | No | — (synchronous API response) |
pending_otp | No | batch_authorization_requested |
verified_otp | No | — (internal; transitions immediately to processing_transactions) |
processing_transactions | No | batch_sent |
approved | Yes | — (individual transfers emit bank_transfer_approved) |
partially_approved | Yes | — (mix of bank_transfer_approved and bank_transfer_rejected per transfer) |
declined | Yes | — (individual transfers emit bank_transfer_rejected) |
duplicated | Yes | batch_duplicated |
canceled | Yes | batch_canceled |
Detailed walkthrough
Transfer: created
Initial state. The transfer row exists in the system with an id you can use for lookups. Mono has accepted the request but has not yet submitted it to the rail. Every transfer in the batch enters this state together.
What happens next: once the batch clears any OTP gate and reaches processing_transactions, each transfer moves to in_progress.
Transfer: in_progress
The transfer has been dispatched to the selected rail — ACH, Mono Turbo, or Transfiya. Funds are in transit; the source account balance has been debited but the destination has not yet confirmed credit.
If the primary rail rejects the attempt and you set a fallback_routing, Mono retries automatically. This fires bank_transfer_fallback_routing without changing the transfer state — the transfer stays in_progress through the retry.
Next states:
- →
approvedwhen the rail confirms the credit. - →
declinedwhen the rail rejects and no fallback succeeds.
Transfer: approved (terminal)
The destination bank has confirmed the credit. Funds are in the beneficiary's account. The bank_transfer_approved webhook fires with the final routing and amount. No further transitions occur under normal circumstances.
Transfer: declined (terminal)
The transfer was rejected. The declination_reason field on the webhook payload carries the rejection reason (for example, insufficient_funds or invalid_account_information). The bank_transfer_rejected webhook fires. No further transitions occur under normal circumstances.
Transfer: cancelled (terminal)
The transfer was canceled by a user before the batch reached processing_transactions. Canceling a batch cancels all its transfers. No async webhook fires for individual transfers — the batch emits batch_canceled.
Transfer: duplicated (terminal)
The entity_id on this transfer was already used in a previous batch. The transfer is not processed. The batch emits batch_duplicated.
Batch: created
The batch row exists in the system. All its transfers are in created. This state is synchronous — you get the batch id in the API response.
What happens next: Mono checks whether the batch requires OTP authorization.
Batch: pending_otp
The batch requires explicit authorization from an Administrator user before it can be processed. The batch_authorization_requested webhook fires. No transfers move until an admin authorizes.
Next states:
- →
verified_otpwhen an admin completes authorization. - →
canceledwhen an admin cancels.
Batch: verified_otp
The admin has authorized. Mono prepares the batch for dispatch. This state is transient — it moves to processing_transactions within seconds and no webhook fires for verified_otp itself.
Batch: processing_transactions
The batch is being dispatched. Each transfer moves to in_progress. The batch_sent webhook fires. From this point, individual transfer webhooks (bank_transfer_approved / bank_transfer_rejected) drive reconciliation.
Batch: approved (terminal)
All transfers in the batch were approved. Individual bank_transfer_approved events have already fired for each transfer.
Batch: partially_approved (terminal)
Some transfers were approved and some were declined. Each transfer has already emitted its own bank_transfer_approved or bank_transfer_rejected event. Reconcile transfer by transfer using the individual events.
Batch: declined (terminal)
All transfers in the batch were declined. Individual bank_transfer_rejected events have already fired.
Batch: duplicated (terminal)
Every transfer in the batch had an entity_id that already existed. No transfers are processed. batch_duplicated fires. Individual transfers move to duplicated.
Batch: canceled (terminal)
An admin canceled the batch before it reached processing_transactions. All transfers move to cancelled. batch_canceled fires.
Working with these state machines in your integration
-
Key off webhooks, not polling. Every meaningful transition emits a webhook. Subscribe to both transfer and batch events — they carry different information and arrive in interleaved order.
-
Handle out-of-order delivery. Webhook deliveries are at-least-once and may arrive out of order. If you receive
bank_transfer_approvedbeforebatch_sent, keep the later state and ignore the earlier one. A safe rule: if a transfer is already in a terminal state locally, ignore non-terminal webhooks for it. -
Build for the OTP gate. If your Mono account requires batch authorization, your batch will sit in
pending_otpuntil an admin acts. Plan your UX around this: expose the batch state to operators so they know when action is required. -
Distinguish transfer state from batch state. A batch in
partially_approveddoes not tell you which transfers succeeded — you must inspect individual transfer events. Reconcile transfer by transfer, not batch by batch. -
Handle
bank_transfer_change_final_state. It is rare, but a transfer already inapprovedordeclinedcan move to a different final state. Update your local record when this event arrives; do not treat a terminal state as immutable until you stop receiving events for that transfer. -
Use
entity_idfor idempotency. Submitting the sameentity_idtwice results induplicated. This is your safety net against double-submission — but it also means you must generate a freshentity_idfor every genuinely new transfer.
Next steps
- Webhooks: bank transfers — the full payload schemas for every event above.
- Sending transfers flow — the end-to-end sequence that moves a transfer through these states.
- Sandbox: bank transfers — simulate the full lifecycle without moving real money.