Donations had to be added to a live website that was staying exactly as it was. Every gift would pass through three systems: the donor's browser, the payment processor, and the organisation's own records. Each of them can report success while the gift is lost somewhere between them. A lost gift is not a missing row in a table. It is a person who gave money and never heard back, a thank-you letter that was never sent, and a donor who quietly decides not to give again. Nobody notices, because every system involved says it worked.
A card payment has three participants, and the browser is the one nobody can rely on.
When a donor clicks give, three systems take part: the organisation's server, the payment processor, and the donor's browser sitting between them. The server begins by asking the processor to create a payment intent. A payment intent is a record held by the processor that says this amount, in this currency, is expected from someone. It has an identifier and a status, and the status moves as the payment progresses: waiting for card details, perhaps waiting for the donor's bank to confirm, then succeeded or failed. One intent can succeed at most once, which is what stops a double click or a refreshed page from charging the donor twice.
The card details travel from the browser straight to the processor and never pass through the organisation's server. The processor talks to the card network and the donor's bank, which may ask the donor for an extra confirmation. When that is done, the processor sends the browser back to the organisation's thank-you page. Separately, and independently, it sends a message directly to the organisation's server describing what happened to the intent. That server-to-server message is the webhook.
The two messages look redundant, and they are not. The redirect travels through the donor's browser, which can be closed, lose its connection, run out of battery, or sit on a phone that switches networks at the wrong moment. If the redirect is lost, the money has still moved, and nothing on the organisation's side knows. The webhook travels between two servers and is retried until the receiver acknowledges it. It sometimes arrives late, sometimes twice, and sometimes out of order, but it arrives.
Retrying has a consequence that surprises people the first time. If the receiver processes a message and its acknowledgement is lost on the way back, the sender cannot tell the difference between that and a message that never arrived, so it sends it again. Any system that never loses a message must therefore sometimes deliver one twice. This is what at-least-once delivery means, and it is why the code receiving webhooks has to be idempotent: running it twice on the same event must have the same effect as running it once.
- Server creates a payment intentan amount and currency, held by the processor
- Browser sends card to processorthe card never touches the organisation's server
- Bank may ask for confirmationthe donor often leaves the page here
- Processor redirects the browserlost if the tab closes or the signal drops
- Processor sends the webhookserver to server, retried until acknowledged
The donate button is the visible part of this work and the smallest part. The substance is keeping three systems that never coordinate in agreement about one fact: did this gift happen. The payment processor's own behaviour sets the terms. It retries webhook delivery for up to three days, it can deliver the same event more than once, and it does not promise to deliver events in order. A single payment intent produces at most one successful charge. And the processor's guidance says plainly that the page a donor lands on after paying must not be what confirms the gift.
Payment integrations lose money in the hand-offs, never inside a single system.
Each system in a donation flow is usually tested on its own, and usually works on its own. The site renders the form, the processor charges the card, the records system stores what it is told. The failures live in the hand-offs, where one system assumes another has done something. Because every system's own logs show success, these failures are invisible to anyone who looks at one system at a time.
The second pattern is testing in conditions that hide the hazards. In a test environment the browser always comes back, webhooks arrive once and in order, and nobody closes the tab halfway through a payment. Every one of those conditions is false in production, and code written against a clean test run quietly depends on all of them.
The reflex, and the fix.
Confirm the gift on the thank-you page
It is immediate, it needs no extra infrastructure, and it is right most of the time.
It misses every gift where the browser closed, the connection dropped or the redirect failed. In each of those cases the money moved and the organisation has no record of it, which is exactly the case the processor's documentation warns about.
Confirm on the webhook, idempotently
A webhook handler and a reconciliation job to build and keep running, added to a site that was not going to be rebuilt.
Gifts recorded whether or not the browser came back, repeat deliveries absorbed without creating a second gift, and a reconciliation pass that turns any gap into a discrepancy someone can see.
The processor is the only system that knows whether money moved. So the gift is confirmed by the processor's own message to the server, and the organisation's records are checked against the processor on a schedule.
The webhook confirms the gift
The thank-you page is a courtesy to the donor. It is not the record of the transaction, because the donor's browser can close, lose signal or fail to redirect at any point after payment, and a system cannot depend on a participant it does not control.
A handler that can safely run twice
Delivery is at-least-once and can repeat for up to three days. The handler is keyed on the processor's event, so a repeat delivery is recognised and does nothing, and one donation never becomes two.
Reconciliation against the processor
A standing pass compares the organisation's records with the processor's. A gap shows up as a named discrepancy that someone can act on. That is the difference between a missing gift and a gift nobody knew to look for.
A small addition to the existing site
The live site keeps working as it did. What was added is the donate flow and nothing else. Rebuilding was never on the table, and on a site taking real money every extra change is one more thing that can break.
What each choice cost, and what it bought.
AThe webhook as the only thing that confirms a gift
The record of a gift is created from the processor's webhook and from nowhere else. That cost an endpoint and its verification on a site that was otherwise not being touched, and it means the record can appear a moment after the donor sees the thank-you page. It bought a record that does not depend on the donor's browser at all. The tempting middle path was to record from both the page and the webhook. That creates two writers for one fact, and every disagreement between them becomes a merge problem with no obvious winner.
BIdempotency keyed on the processor's event
The handler remembers each event it has processed and treats a repeat as a no-op. That needs a durable memory of seen events and the discipline to hang every side effect, the record, the receipt, the thank-you, off that one check. In return, retries become harmless, which means the handler is free to fail loudly on anything it does not understand and let the processor try again. The alternative, spotting duplicates by matching amount, donor and time, is a guess. Two genuine gifts of the same amount from the same person on the same day are entirely possible, and a guess would merge them.
CReconciliation as a standing job
A scheduled comparison against the processor runs whether or not anything looks wrong. It is one more moving part, and somebody has to own the discrepancy report it produces. What it buys is that the webhook path no longer has to be perfect. Relying on webhooks alone means any gift whose delivery failed beyond the three-day retry window is gone for good, with no signal that it ever existed.
DThe smallest possible change to the live site
Only the donate flow was added. That meant fitting the flow inside the existing site's limits and leaving some improvements for later. It also meant the risk of the work was confined to the new path. Folding a partial rebuild into the donation work would have made every existing page part of the blast radius of a payments change.
A single headline number hides where a system fails. This work was scored on the dimensions that actually decide whether it holds in production, measured on real, held-out cases rather than the demo path.
No measured figures are published for this engagement. Outcomes are described qualitatively.
A donate button that confirms on the thank-you page, quietly loses every gift where the browser closed, and leaves the organisation confident that its records are complete.
Gifts confirmed by the processor's webhook, a handler that absorbs repeat deliveries, reconciliation against the processor as the source of truth, and a minimal addition to a live site that was left standing.
What it owns, and what it hands to a person.
Reconciliation makes a missing gift visible and recoverable. It cannot produce a receipt for a donor who never finished paying, because there is no gift to record. It also does not make the organisation's records the authority. The processor stays the source of truth, and the records are only as right as the last reconciliation says they are.
Any payment added to an existing site follows the same three rules. Confirm on the webhook, never on the page. Make the handler safe to run twice. Reconcile against the processor, because it is the only party that knows whether money moved.
The page a payer lands on after paying is a courtesy. The record of the payment should come from the processor's server-to-server message, because the browser can disappear at any moment.
Any message that is retried until acknowledged will sometimes arrive twice. Make every handler safe to run twice by keying it on the sender's event identifier.
Spotting duplicates by matching amount, payer and time is a guess, because two genuine payments can look identical. Use the identifier the sender gives you.
Retries have a window, and anything lost beyond it is lost for good. A scheduled reconciliation against the processor is what makes a missed message recoverable.
When several systems each report success and money still goes missing, the fault is in the hand-offs. Test the hand-offs under hostile conditions, not each system alone.