Webhooks aren't settlement

• Futurify Team

A webhook is how a provider tells you something might have happened. It is not the money moving, and it is not your ledger.

Webhooks aren't settlement — callback vs money moved

The pattern shows up the same way every time. Someone wires the happy path. Provider fires payment.succeeded. Your handler flips the invoice to paid, unlocks the feature, emails the customer. Demo works. Staging works. Then production gets a duplicate, or a late failed after you already said paid, or nothing at all for a transfer that settled fine on the provider side.

Nobody set out to treat a callback as settlement. It just felt like the event. And for a while, on low volume, it mostly is. Until it isn’t.

What a webhook actually is

What a webhook actually is — delivery hint, not funds

A webhook is a delivery attempt. The provider is saying: from our point of view, at the time we sent this, here is a status we want you to know about.

That is useful. It is also incomplete.

It does not mean the funds are in your account. It does not mean the bank has finished. It does not mean this is the only message you will get about this payment. It does not mean the message arrived once, in order, or at all.

If you promote provider status strings straight into customer-facing state — succeeded, captured, settled, whatever their docs call it this quarter — you have soaked their vocabulary into your product. When they rename a status, or add one, or send two that disagree, your product inherits the mess.

Out of order, twice, or never

Out of order, twice, or never — Duplicates, Out of order, Missing

These are not edge cases. They are how HTTP delivery works at scale.

Duplicates. Retries are normal. Your endpoint timed out, their side assumes failure, they send again. If your handler is not idempotent, you credit twice, unlock twice, or fire two fulfillment jobs for one payment.

Out of order. payment.updated can land before payment.created. A failed can arrive after you already acted on succeeded, because the first message was delayed and the second was not. If each message blindly overwrites state, the last writer wins — and the last writer is wrong.

Missing. Endpoints go down. Firewalls change. A subscription silent-fails. The customer got charged. Your system never heard. You only find out when support asks why the invoice is still open, or when Friday’s reconcile spreadsheet grows another row.

None of this means webhooks are bad. It means they are a hint you ingest carefully, not a source of truth you trust alone.

Don’t soak their status into yours

Don't soak their status — provider strings to your domain via translation layer

Map inbound webhooks into your domain events and your states.

Your product cares about things like: payment initiated, funds expected, funds confirmed against a report, refund requested, exception open. Those are yours. The provider’s charge.succeeded is an input to a translation layer, not a column you show the customer.

A small, fixed set of your own states beats a mirror of every string in the provider docs. When a new webhook type shows up, you decide which of your events it maps to — or you park it as unknown and investigate. You do not grow a parallel enum for every provider quirk.

Same idea for IDs. Keep your own payment reference end to end. Treat the provider’s id as a foreign key on the side, not as the identity of the money in your product. When matching and support ask “which payment,” you want one answer that you own.

Idempotent ingest, then a state machine

Idempotent ingest then state machine — store id, transitions, commit, side effects

Good webhook handling is boring on purpose.

Idempotent ingest. Every message has a delivery id or an event id from the provider. Store it before you do side effects. Same id twice → acknowledge and stop. No second credit. No second email. No second unlock.

Ordered by your rules, not arrival time. Don’t apply “last webhook wins.” Apply transitions your state machine allows. Paid → failed might be illegal without a refund or chargeback path. Initiated → confirmed might require a reconcile match, not only a webhook. Illegal transitions go to an exception queue, not into a silent overwrite.

Side effects after commit. Record the event, advance state, then fulfill. If fulfillment fails, you retry fulfillment from known state — you don’t re-interpret the webhook as if it never landed.

This is the same discipline as any reliable payments path. The webhook is just one input. Idempotency keys on the write side; idempotent handlers on the read side. Different tools, same idea: do the meaningful thing once.

You still pull and reconcile

Push path expectations vs Pull path facts — report wins for money

Webhooks tell you sooner. Reports tell you what actually settled.

Provider settlement files, balance reports, and payout summaries are where money movement shows up in a form you can match. If your ledger only moves when a webhook says so, you will drift — missing messages, partial refunds, fees you never modeled, returns that arrived as a file and never as a callback.

Run both:

  1. Push path — webhooks update expectations and kick work that should happen quickly (notify, lock inventory, start fulfillment that is safe to reverse).
  2. Pull path — scheduled fetch or file ingest confirms facts against your ledger and closes or opens exceptions.

Matching still wants your reference on the round trip. Deterministic match first, narrow fuzzy second, humans only on what survives. That is the same lesson as the Friday pile; webhooks don’t retire it.

When webhook state and report state disagree, the report usually wins for money, and the disagreement becomes an owned exception — not a quiet flip in the UI.

What good looks like

What good looks like — your id → webhook once → awaiting confirm → reconcile

A payment is initiated in your system with your id. The provider webhook arrives, you store the event once, and you move to a state that means “provider says yes, awaiting confirm.” Fulfillment that must be correct waits on reconcile, or is designed to reverse cleanly if the report disagrees.

Duplicates are no-ops. Out-of-order messages don’t corrupt state. A day with zero webhooks still reconciles from the report, and missing callbacks show up as exceptions with age and an owner — not as angry customers three days later.

Your admin screen shows your states. Provider strings live in the event log for debugging. Support can answer “are we sure?” by pointing at a matched report line, not at a single green callback.

Boundary

Futurify builds software. We don’t hold funds and we’re not a processor. We build the ledger, matching, webhook ingest, and ops around payment state — and under Run, we operate with you after go-live.

Next step

Do this part first — two minutes, useful whether or not you talk to us.

Pick one payment marked paid in the last week. Open the raw webhook (or event log) that flipped it, and the provider report or dashboard line for the same payment. Write down: did the webhook arrive once? Did anything arrive after? Does the report agree on amount and timing?

If those three answers are messy, you already know where the drift starts.

Send that sketch to hello@futurify.io if you want a second pair of eyes on the ingest and reconcile split. We’ll tell you what we’d harden first. If it turns into a longer conversation, good — that’s the work. If it doesn’t, you still have a clearer picture of what “paid” means in your system.

Ready to modernize your legacy system?

Let's talk about how we can help you identify and fix what's slowing you down.

Book a Call →