Indeterminate transactions
indeterminate means we do not yet know whether a transaction happened. It is not a failure. Treating it as one is the most expensive mistake an integration can make.
A payout (POST /v1/partner/payouts) and a purchase (POST /v1/partner/purchases/…) can both return it, and every endpoint that moves money will be able to. Design for it from the first line of your integration — it is far cheaper than retrofitting after your first incident.
What it means
Moving money means instructing a payment network — a bank transfer, a bill payment, a crypto order. Usually the network answers clearly: done, or refused. Sometimes it does not: the request times out, the connection drops after the instruction was sent, or the answer is ambiguous.
At that moment nobody can know whether the money moved. The instruction may have been carried out a millisecond before the connection failed. So we do not guess. The transaction becomes indeterminate until we find out.
Why it is not a failure
failed | indeterminate | |
|---|---|---|
| Did the money move? | No. Definitely. | Unknown — it may have. |
| Is it final? | Yes. | No. It will become succeeded or failed. |
| Is it safe to refund your user? | Yes. | No. |
| Is it safe to try again? | Yes, as a new transaction. | No. |
Here is what goes wrong when the two are confused. A payout of ₦200,000 times out. Your system reads the timeout as a failure, refunds your user, and lets them try again. Both transfers land. Your user has received ₦400,000, been refunded ₦200,000, and you have paid out three times what they were owed — and recovering money from a bank transfer that completed is slow, uncertain and not in your control.
What we do
- We hold the money. The amount stays reserved against your balance. It is neither spent nor returned while the outcome is unknown.
- We ask; we never send it again. Our reconciliation process asks the payment network what happened to the original instruction, identified by our reference. It never submits the instruction again, because a second submission is exactly how a transfer happens twice.
- We never assume. No timeout, elapsed time or rule of thumb resolves a transaction. Only the network's answer does. If the network keeps failing to answer, the transaction is escalated for a person at VertexBridge to resolve.
- It ends in one of two states.
succeededif the money moved,failedif it did not — and when it isfailed, the held amount goes back into your balance. - Only reconciliation can resolve it. Not a retry, not a support request, not a manual override that skips the check. Our database refuses any other way out.
The four states
A transaction you receive from us is always in one of these:
| State | Final? | Means |
|---|---|---|
succeeded | Yes | The money moved. |
failed | Yes | It did not, and anything held for it has gone back to your balance. |
pending | No | Accepted and still being worked on. |
indeterminate | No | We do not know yet whether the money moved, and we are finding out. |
The list is deliberately short. Inside our platform a transaction passes through more steps than these, and they are all reported to you as pending: what you need to know is that it is still working, not where. A state is never added to this list quietly.
What you must do
- Do not refund your user. Not until the transaction is
failed. - Do not try again as a new transaction. If you must send the request again — because your own call timed out, for example — send it with the same idempotency key. A key can only ever create one transaction, so you get the original back instead of a second payment.
- Tell your user it is processing — not that it failed, and not that it succeeded.
- Wait for the final state, and act on that: fulfil on
succeeded, refund or retry onfailed. - Treat anything that is not
succeededorfailedas unfinished — including a state you do not recognise.
Finding out how it ended
GET /v1/partner/payouts/{reference} and GET /v1/partner/purchases/{reference} return a transaction's state as it is now — this is where the outcome appears. Sending the original request again with the same idempotency key is safe, but it returns the original answer: a transaction that was indeterminate stays indeterminate in every replay, however long ago it resolved. We are not sending webhooks yet, so for now, read.
Your balance meanwhile
The held amount is not part of your spendable balance from GET /v1/partner/balance, so a transaction waiting on an answer can never be spent twice. If it ends failed, the amount comes back; if it ends succeeded, it was spent.
Handling it in code
Branch on the final states explicitly, and let everything else fall through to “processing”:
switch (transaction.state) {
case 'succeeded':
// It happened. Fulfil the order, credit the user, send the receipt.
markSucceeded(transaction)
break
case 'failed':
// It definitely did not happen. Now — and only now — refund or retry.
markFailed(transaction)
break
case 'pending':
case 'indeterminate':
// Not final. Do NOTHING irreversible:
// no refund, no new attempt, no "failed" shown to your user.
markProcessing(transaction)
break
default:
// A state you do not recognise is not final. Treat it as processing.
markProcessing(transaction)
}The mistake to avoid is the one-line version — if (state !== 'succeeded') refund() — which refunds every indeterminate transaction that later turns out to have succeeded.
Related: Errors — an HTTP error and an indeterminate transaction are different things. An error means your request was not accepted; an indeterminate transaction was accepted and its outcome is not yet known.