Save the instruction before dispatch

Create one business transfer ID and provider reference before the first call. Bind the amount, currency, source account, and recipient to it. Reserve the funds and save dispatch work in the same database transaction. A durable outbox closes the gap between saving the transfer and publishing its queue message. Workers must accept repeated messages and use the saved reference.

Paystack tells callers to reuse the same reference for an uncertain request. That is Paystack's contract; check the retry and lookup rules of the provider you use. A new reference creates a new instruction. A refresh, second tap, or worker restart must find the existing transfer rather than create one.

Keep funds held while the result is unknown

The table below defines a local state model. These names are not a list of provider status values. Map each provider response and verified webhook into this model. Read the transfer status field, not just HTTP 200: a successful API lookup can report a pending transfer.

Local transfer states and balance actions
StateFund actionNext action
Prepared, not dispatchedReserve onceDispatch saved work, or cancel under the same lock before dispatch starts.
Pending or unknownKeep the holdLookup the saved reference; retry only under the provider's safe contract.
SucceededConsume the hold onceRecord the provider result and one debit journal.
Confirmed failedRelease the hold onceRecord final failure evidence; any later attempt needs a new approved operation.
Cancel requested after dispatchKeep the holdWait for provider evidence. A local cancel request does not cancel a bank transfer.
Cancelled before dispatchRelease the hold oncePrevent every queued worker from dispatching it.
Reversed after a posted debitPost one linked reversalRestore value from verified reversal evidence; retain the first debit.
Reversed before a local debitRelease the held value onceRecord the reversal outcome without inventing a second credit.

Make workers, webhooks, and staff share one decision

A webhook handler, status worker, and support action can all reach the same transfer at once. Lock the operation row or use a versioned compare-and-set, then update state and its hold or journal in one transaction. Use unique economic event keys for success and reversal. A repeated success must not consume the hold twice; a repeated reversal must not restore funds twice.

To cancel before dispatch, both the worker and cancel action must check and change the prepared state through this guarded path. Once dispatch starts, cancellation becomes a request that needs provider confirmation. If success arrives while cancellation is being checked, verified success consumes the existing hold. Do not release funds from the user's cancel click.

A late response that conflicts with recorded final evidence goes to an exception case. Recheck the provider and keep an audit trail of the decision. Do not let whichever handler runs last overwrite the money result. PostgreSQL's isolation guide explains the locks and transaction rules behind concurrent updates.

Use provider evidence to resolve uncertainty

A missing response or unreachable lookup leaves the transfer unknown. A not-found answer also needs its documented scope and delay checked before it proves no transfer exists. Save the last lookup, provider reference, raw result, and next review time. Hold unresolved cases for review when the provider cannot give a safe answer.

Paystack's transfer flow covers its lifecycle, while its single-transfer guide describes success, failed, and reversed events. Verify webhook authenticity before using these events. A local failed API call and a verified final transfer failure are different facts.

Inject the failures that test mode does not create

Use a synthetic ₦20,000 transfer T-20 and a provider-valid saved reference. Paystack says its test transfers return success without real processing. A successful sandbox call therefore cannot prove your pending, timeout, reversal, or cancellation logic. Use a staging provider adapter, controlled proxy, or local fake to inject the states below. No real funds are needed.

  1. Crash after reserving funds and saving outbox work. Restart dispatch. Expect one hold and the original reference.
  2. Let the fake accept the transfer, then drop its reply. A lookup returns pending before success. Expect the hold to remain until success and one final debit.
  3. Run two workers and deliver success twice. Expect one accepted external instruction and one local debit.
  4. Race a cancel request with a success event. Expect one consumed hold, no premature release, and a visible cancel result.
  5. Return a verified reversal after success, then repeat it. Expect one linked reversal journal and restored funds once.
  6. Return confirmed failure while the hold is open. Expect one release. An unreachable lookup must keep the hold instead.

Case PAY-08 in the synthetic payment test pack starts with the lost reply. Retain state versions, dispatch attempts, provider lookups, hold changes, and journal IDs for each injected run. To review these paths in your service, see payment gateway testing.