lukia
← All work

01 / STARTER KIT · SPRING BOOT

The part of a payments integration that actually breaks

Taking the payment is the easy half. Everything after the redirect — verifying the webhook, surviving a redelivery, noticing the notification that never came — is where integrations quietly go wrong. This is a Spring Boot starter that does that half properly.

The demo is a PayFast sandbox built from these kits and takes no real money. No source link: the repository is private, so a button here would 404 for every visitor.

The PayFast sandbox checkout built from these kits: an amount and item field above a Pay with PayFast button, over a note that no money moves.
FIG 01 — pay.iamlukia.com, the sandbox checkout
Sandbox demoJava 25 · Spring Bootcore + payfastREADMEs are the deliverable

01

The failure everyone ships

Every payment provider documents how to start a payment, and that part usually works first time. The webhook is different. It arrives once in testing, so nothing forces you to think about the second one; it arrives from a server rather than a browser, so localhost never sees it; and when your handler throws, the provider has no idea. The failures share a shape — a handler written as a controller method rather than as a small piece of infrastructure.

The specific ones this kit sets out to make impossible: an endpoint that trusts whatever POSTs to it; slow work done before acknowledging, so the provider times out and retries and the order ships twice; a redelivery treated as a new event; an exception logged and the payment silently never applied. Provider retry windows are finite. Once one closes, the money and the order have permanently disagreed, and nothing will tell you.

02

Three signature rules, and only one is alphabetical

PayFast signs with MD5 over a string of key=value pairs. Simple enough — except it does this in three different ways, documented pages apart, and never says they differ. The payment request uses a documented field order and excludes empty values. The webhook uses the order the fields arrived in and includes empty ones. The REST API sorts alphabetically and merges the passphrase into the sort rather than appending it.

Get one right and reuse it for the next and you get the worst possible outcome: checkout works, every webhook is rejected as forged, and the payments silently never complete. The documented request order also happens to start alphabetically, so sorting the fields appears to work right up until it reaches return_url and cancel_url.

The documentation site would not give up the exact field order, because it is a single-page app that returns an empty shell to anything that is not a browser. The content — every code sample, in three languages — is embedded in its JavaScript bundle. Reading that, plus their published PHP SDK, is what produced the real list, and it turned out the initial guess was missing ten fields — eight of them for subscriptions, which this milestone does not use, but two that a plain payment needs. It also surfaced that two of PayFast's own sample implementations disagree with each other about whether webhook values are trimmed.

03

Idempotency is a constraint, not a check

The obvious way to ignore a redelivered webhook is to look up its event id and insert if you find nothing. That has a race: two concurrent redeliveries both find nothing, both insert, and the payment is applied twice. Widening the transaction does not close it — two read-committed transactions happily read the same absence.

So the check is not a check. There is a unique constraint on (provider, provider_event_id), every webhook attempts the insert, and a duplicate-key violation is the answer. The database arbitrates, there is no window, and the logic cannot drift away from the constraint because it is the constraint.

04

Verify, persist, acknowledge — then do the work

PayFast documents four security checks. The interesting decision is not implementing them, it is which side of the acknowledgement each one runs on.

  • Signature matches

    Runs in: Verifier. Runs: Before the 200

  • Source IP is PayFast

    Runs in: Verifier. Runs: Before the 200

  • Server confirmation

    Runs in: Worker. Runs: After the 200

  • Amount matches the order

    Runs in: Worker. Runs: After the 200

The two cheap local computations run inline, before anything is stored. The two that need the network or the database run afterwards, on a background worker. Calling PayFast from inside the request that PayFast is waiting on would turn a brief outage on their side into a rejected webhook on this one — and a rejected webhook is one that gets retried until the window closes.

Nothing is persisted for a failed signature or source-IP check. An unverified body is not evidence, and storing it would hand an unauthenticated caller somewhere to write.

05

The retry that looks like it works

Failed webhooks retry with backoff and eventually park in a dead-letter state, which is ordinary. The part worth writing down is that the failure bookkeeping runs in a second, separate transaction.

Processing runs in one transaction. When it throws, that transaction rolls back — and if the attempt counter were incremented inside it, the rollback would discard the increment too. The event would then retry forever at attempt one, which looks exactly like a working retry loop until you notice it never ends. The event also has to be re-read in the second transaction rather than reused, because the in-memory copy carries changes that were just thrown away.

Everything runs off an injected clock rather than now(), so the tests advance time by exactly one backoff interval and assert the arithmetic, instead of sleeping and hoping.

06

The webhook that never arrives

When a buyer cancels a PayFast payment, PayFast sends nothing. Not a cancellation notice — nothing at all. The same is true if they close the tab, lose signal, or give up at the bank's 3-D Secure step.

So a system that only ever advances when a webhook arrives will hold those payments open forever, and cannot tell "abandoned two minutes ago" from "notification delayed" from "the endpoint was unreachable and the provider has stopped trying". The return page does not help either: it is a browser redirect, it fires whether or not the payment succeeded, and anyone can open it directly.

That is why there is a reconciliation job in a starter kit, which is not where you would normally expect to find one. It asks the provider what they think happened to anything still open past a grace period, and records the disagreements. It deliberately does not repair them — automatically correcting payment state from a reconciliation read turns one bug into two, and the interesting mismatches are exactly the ones a person should see.

07

Proving the signature against something other than itself

The anchor test recomputes the signature for a fixed webhook body and compares it to the expected value. Had that expected value been generated by the kit's own signing code, the test would prove only that it agrees with itself — which is the failure mode of a great many green test suites.

So the fixture's signature is produced by a separate script that implements PayFast's documented algorithm independently. Agreement then means agreement with PayFast, not with the kit. The same instinct caught a test elsewhere that asserted hex digits were uppercase using %40 — which contains no letters, so it is identical lowercased and the test could never have failed.

08

What has not been verified

Two things in the repository are documented as unproven, in the README rather than in a comment nobody reads. The webhook fixture is synthetic until a real one is captured from the sandbox. And the reconciliation client's signature rule is unit-tested against an independently computed vector, but its request and response shapes have never been round-tripped against the live API.

Saying so costs nothing and is worth more than the alternative. A starter kit that quietly presents unverified code as finished is exactly what this one was written against.

More work

Taking payments, and want the second half done properly?