DEV Community

Cover image for Deposit events arrive before the deposit does
Gruv AI
Gruv AI

Posted on

Deposit events arrive before the deposit does

At 14:02:17 our system marked invoice INV-2291 as paid. $18,640.00 from Halvorsen Freight AS, matched on the payment reference, closed, done.

Forty seconds later the deposit went on hold. The money did not reach a state anyone could use until three days after that, and five days later it was returned to the sender.

The invoice said paid the whole time.

Nothing in that sequence was a provider bug. Every event was accurate and arrived exactly once. We had simply written a handler that assumed two things about deposits that are not true: that any deposit event means the money is there, and that events arrive in the order they happened.

What is a receiving account actually telling you?

A virtual account gives a payer bank details that route into your balance, and then reports what happens to each transfer that lands against those details. It does not report one thing. It reports a sequence of states for the same deposit, and each state means something different for your books. Pending means a transfer has been announced or is in flight. Held means it has arrived but somebody needs more information before it can be used. Credited means it is available. Returned means it is going back. If you want the product view of that lifecycle, how receiving details and deposit statuses fit together is laid out on one page, and it is worth reading before you design a table around it.

Our handler treated the first of those as the last. A pending event carried an amount and a reference, the reference matched an open invoice, so the invoice closed.

Two states people get wrong

The first one is pending. It looks like money because it has an amount on it. It is not money. Treat it as a notice that money may be coming, and nothing that touches an invoice, a balance shown to a customer, or a release of goods should fire on it.

The second one is less obvious and I have seen it done wrong in code that was otherwise careful: credited is not final.

A credited deposit can still be returned. In our case the sender's bank recalled it on 22 September, five days after it was credited, and a surprising number of state machines have no transition out of credited at all. It gets drawn as the happy terminal box at the bottom of the diagram. Then a return arrives, the handler has no path for it, and it gets logged and dropped. The deposit stays credited in your database while the money has left. That half-fix is common because it is usually written in response to the ordering problem below, by someone ranking states and refusing to move "backwards" from the best one.

The order you receive is not the order it happened

Here is the real history for that deposit, by the time the provider says each event occurred, next to the order our endpoint received them:

Occurred at (UTC) Status Arrived
14 Sep 14:02:17 pending 1st
14 Sep 14:02:57 held 3rd
17 Sep 09:41:03 credited 2nd
22 Sep 11:15:48 returned 4th

The held event was delivered after the credited one. The first attempt to deliver it timed out and the provider retried, the same way any webhook sender does, and by the time the retry landed the deposit had moved on. A handler that writes whatever status arrives last will put a credited deposit back into held and leave it there. I wrote about the retry side of this in a post about a handler slow enough to get the same payout webhook twice, and the cause here is identical. Retries are normal, and a retry does not know what happened while it was waiting.

A handler that holds up

Two checks, both cheap. Ignore any event older than the last one you applied to that deposit. Then only accept a status change that is a legal move from where the deposit is now, with returned reachable from credited. The invoice follows the deposit state instead of following the event.

from dataclasses import dataclass

# What each state is allowed to become. "credited" is not the end.
ALLOWED = {
    "pending":  {"held", "credited", "returned"},
    "held":     {"credited", "returned"},
    "credited": {"returned"},
    "returned": set(),
}

@dataclass
class Deposit:
    id: str
    status: str = "none"
    last_event_at: str = ""   # ISO timestamps compare correctly as strings

@dataclass
class Invoice:
    id: str
    paid: bool = False


def naive_handler(deposit, invoice, event):
    # Last write wins, and any deposit event means the money is here.
    deposit.status = event["status"]
    invoice.paid = True


def guarded_handler(deposit, invoice, event):
    if event["occurred_at"] <= deposit.last_event_at:
        return "stale, ignored"
    if deposit.status != "none" and event["status"] not in ALLOWED[deposit.status]:
        return f"{deposit.status} -> {event['status']} not allowed, ignored"
    deposit.status = event["status"]
    deposit.last_event_at = event["occurred_at"]
    # Only credited money pays an invoice, and a return takes it back.
    invoice.paid = deposit.status == "credited"
    return f"now {deposit.status}"


# One deposit's real history, in the order the provider says it happened...
history = [
    {"status": "pending",  "occurred_at": "2026-09-14T14:02:17Z"},
    {"status": "held",     "occurred_at": "2026-09-14T14:02:57Z"},
    {"status": "credited", "occurred_at": "2026-09-17T09:41:03Z"},
    {"status": "returned", "occurred_at": "2026-09-22T11:15:48Z"},
]
# ...and the order our endpoint actually received it in.
arrived = [history[0], history[2], history[1], history[3]]

for name, handler in [("naive", naive_handler), ("guarded", guarded_handler)]:
    dep, inv = Deposit("dep_7Q2M"), Invoice("INV-2291")
    print(f"--- {name}")
    for ev in arrived:
        note = handler(dep, inv, ev) or ""
        print(f"{ev['occurred_at']}  {ev['status']:<9} deposit={dep.status:<9} "
              f"invoice_paid={inv.paid!s:<5} {note}")
Enter fullscreen mode Exit fullscreen mode

Run it and you get this:

--- naive
2026-09-14T14:02:17Z  pending   deposit=pending   invoice_paid=True
2026-09-17T09:41:03Z  credited  deposit=credited  invoice_paid=True
2026-09-14T14:02:57Z  held      deposit=held      invoice_paid=True
2026-09-22T11:15:48Z  returned  deposit=returned  invoice_paid=True
--- guarded
2026-09-14T14:02:17Z  pending   deposit=pending   invoice_paid=False now pending
2026-09-17T09:41:03Z  credited  deposit=credited  invoice_paid=True  now credited
2026-09-14T14:02:57Z  held      deposit=credited  invoice_paid=True  stale, ignored
2026-09-22T11:15:48Z  returned  deposit=returned  invoice_paid=False now returned
Enter fullscreen mode Exit fullscreen mode

The naive handler is wrong on every line after the first. It pays the invoice on a pending event, lets a late held event overwrite a credited deposit, and leaves the invoice paid after the money has gone back. The guarded one pays the invoice only while the deposit is credited, and reopens it on the return.

In production, the stored last_event_at and the status update need to happen in one transaction, with the deposit row locked, or two events for the same deposit arriving together will race each other past the first check.

Where this stops working

The timestamp check trusts the provider's clock and its idea of when something occurred. That is usually fine, since there is only one clock involved. It breaks if the provider stamps several events for one deposit with the same second, because the second event then looks stale. I have not run into a provider that does this, but I have not checked many, and a per-deposit sequence number is the safer key where one is offered.

The other approach is to treat every webhook as a doorbell: ignore its body, fetch the deposit, and store whatever the provider says the current state is. That removes the ordering problem entirely. It costs an API call per event and puts you at the mercy of rate limits during a busy settlement window, which is why we kept the guard and use the fetch only when the guard rejects something. When you are deciding which statuses your own code has to handle, the integration notes on deposit states and references list pending, credited, held, returned and unmatched, and unmatched is the one I would add next.

Before your next release, replay one deposit's events to your handler in the wrong order and check what your invoice says at the end.

Top comments (0)