DEV Community

Feezan Khattak
Feezan Khattak

Posted on Originally published at feezankhattak.com AI-assisted

Your Payment Webhook Handler Probably Processes Duplicates

The customer's browser reaches your success page and calls your API: "the payment worked, please ship my order."

Anyone can make that call. With curl. For free.

The fix everyone knows is "use webhooks." The part fewer people get right is what happens next: webhooks arrive more than once, out of order, and the most common duplicate check in Spring Boot doesn't catch duplicates at all.

TL;DR

  • Never fulfil an order because the browser said so. Only a verified webhook can tell you money moved.
  • Verify the signature over the raw request body, before parsing.
  • Deduplicate with INSERT ... ON CONFLICT DO NOTHINGnot save() and catch the exception. In Spring Data JPA that version quietly does nothing.
  • One change can produce two events with different ids. Make applying a change twice harmless.
  • Store, then return 200. Do the slow work later.

The browser can't be trusted — even when it's honest

Two separate problems:

  • It's forgeable. POST /orders/123/confirm can be sent by anyone who reads your JavaScript.
  • It's unreliable. The customer closes the tab, loses signal on the redirect, or finishes 3-D Secure and the callback never lands. They've paid, and you never heard about it.

The browser can hint — show a spinner, an optimistic message. It must never be what triggers fulfilment.

Verify the signature over raw bytes

@PostMapping("/webhooks/stripe")
public ResponseEntity<Void> handle(
        @RequestBody String payload,                        // raw String, not a DTO
        @RequestHeader("Stripe-Signature") String signature) {

    Event event;
    try {
        event = Webhook.constructEvent(payload, signature, webhookSecret);
    } catch (SignatureVerificationException e) {
        log.warn("rejected webhook with bad signature");
        return ResponseEntity.badRequest().build();
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

The signature covers the exact bytes the provider sent. Bind the body to a DTO first and you've lost them — re-serialising produces different JSON (key order, whitespace, number formatting), verification fails, and someone "temporarily" turns it off. Stripe's docs say it plainly: any change to the raw body makes verification fail.

The signature also covers a timestamp, which stops a captured request being replayed later. Stripe's libraries reject anything older than five minutes by default. Keep your server clock synced, and never set the tolerance to 0 — that disables the check.

The duplicate check that doesn't check

Delivery is at-least-once. You'll get the same event again after a timeout, after your 500, and occasionally for no visible reason. So you record processed event ids — and this is what most of us write first:

@Transactional
public void process(Event event) {
    try {
        processedEvents.saveAndFlush(new ProcessedEvent(event.getId(), Instant.now()));
    } catch (DataIntegrityViolationException duplicate) {
        return;                       // already processed
    }
    applyEffects(event);
}
Enter fullscreen mode Exit fullscreen mode

It looks right. It fails in two different ways.

1. It never sees the duplicate. ProcessedEvent's id is the provider's event id — assigned by you, never null. With no @Version field, Spring Data can't tell it's new, so save() calls merge() instead of persist(). Merge loads the existing row and updates it. No exception, and the repeated event gets processed again.

2. When it does throw, you can't continue. Two deliveries racing each other can both reach the insert, and one does get the duplicate-key error. But that exception passes through the repository's own transactional proxy, which marks your transaction rollback-only — and PostgreSQL has already aborted the transaction anyway. You catch it and return normally, and Spring throws UnexpectedRollbackException at commit. That's a 500, which the provider retries — for up to three days, in Stripe's case.

What works: let the database answer

CREATE TABLE processed_webhook_events (
    event_id     text PRIMARY KEY,
    processed_at timestamptz NOT NULL
);
Enter fullscreen mode Exit fullscreen mode
public interface ProcessedEventRepository extends Repository<ProcessedEvent, String> {

    // 1 = we claimed it, 0 = already processed. Never throws on a duplicate.
    @Modifying
    @Query(value = """
            INSERT INTO processed_webhook_events (event_id, processed_at)
            VALUES (:eventId, now())
            ON CONFLICT (event_id) DO NOTHING
            """, nativeQuery = true)
    int markProcessed(@Param("eventId") String eventId);
}
Enter fullscreen mode Exit fullscreen mode
@Transactional
public void process(Event event) {
    if (processedEvents.markProcessed(event.getId()) == 0) {
        return;                       // already processed
    }
    applyEffects(event);              // same transaction: if this throws, the claim rolls back too
}
Enter fullscreen mode Exit fullscreen mode

Three things this gets right:

  • Duplicates are a normal result, not an error. The transaction stays usable.
  • Concurrent deliveries are safe. The second insert waits for the first transaction to finish, then inserts nothing.
  • Half-processed events aren't marked done. The claim commits or rolls back with the effects, so a failure gets retried properly.

I checked the SQL behaviour for this post on PostgreSQL (via PGlite): the first delivery claims one row, a duplicate returns zero with no error and leaves the transaction usable, a plain INSERT duplicate aborts the transaction, and a rolled-back claim is processed again on retry.

Event ids aren't the whole story

Stripe notes that it sometimes generates two separate event objects for the same change — with different ids. Your id table won't catch those. Which leads to the more robust idea...

Handle events as target states, not steps

Events don't arrive in order either. Stripe's own example: creating a subscription can produce customer.subscription.created, invoice.created, invoice.paid and charge.created in any order. And don't sort by the event's created field — it's in whole seconds, so events can tie.

So a succeeded event should mean "this payment is now captured", not "advance one step". And guard against moving backwards:

public enum PaymentState {
    INITIATED(0),
    REQUIRES_ACTION(1),
    AUTHORIZED(2),
    CAPTURED(3),
    REFUNDED(4);

    final int rank;   // every constant must declare one - the compiler enforces it

    PaymentState(int rank) { this.rank = rank; }
}

private void applyState(Payment payment, PaymentState incoming) {
    if (incoming.rank <= payment.getState().rank) {
        return;       // stale, out of order, or a repeat - ignore it
    }
    payment.setState(incoming);
}
Enter fullscreen mode Exit fullscreen mode

Two small choices matter here:

  • The rank lives on the enum, not in a Map. A map returns null for a state someone adds next year, and the unboxing throws inside your webhook handler.
  • Repeats become harmless. A second CAPTURED is ignored whatever its event id — which covers the two-events-one-change case.

Failure states don't fit on a line: a failed payment can still succeed when the customer tries another card. For those, fetch the object's current state from the provider's API. The API is the source of truth; events are notifications.

Return 200 fast — but only after storing

Providers treat a slow response as a failure and retry. Do fulfilment, emails and invoices inline, and you've built your own retry storm.

@PostMapping("/webhooks/stripe")
public ResponseEntity<Void> handle(@RequestBody String payload,
                                   @RequestHeader("Stripe-Signature") String sig) {
    Event event = verify(payload, sig);     // fast
    inbox.store(event.getId(), payload);    // INSERT ... ON CONFLICT (event_id) DO NOTHING
    return ResponseEntity.ok().build();     // ack - a duplicate still gets a 200
}
Enter fullscreen mode Exit fullscreen mode

Then process the inbox asynchronously, using the markProcessed claim above.

The status code cuts both ways:

  • Return a 500 on a transient problem and you get a free retry.
  • Return a 200 on an event you didn't store and it's gone for good — the provider thinks it was delivered.

So: acknowledge once the event is durably stored, not once it's fully processed. And make the inbox insert conflict-safe too, or a duplicate delivery hits the primary key and gets a 500.

Checklist

  • [ ] Nothing fulfils on a browser-side success signal
  • [ ] Webhook body bound as a raw String, verified before parsing
  • [ ] Signature timestamp tolerance left on (not 0)
  • [ ] Event ids claimed with INSERT ... ON CONFLICT DO NOTHING, in the same transaction as the effects
  • [ ] No save()-and-catch for dedupe
  • [ ] Handlers set a target state and ignore regressions
  • [ ] Store, then 200; process asynchronously; duplicates still get a 200
  • [ ] livemode checked, so test events can't touch production
  • [ ] Amounts and order ids checked against your own records

Your provider's webhook is the only party that can tell you money moved. Verify it, expect it twice, expect it out of order — and let the database, not an exception handler, decide what's a duplicate.


Part 3 of a series on building payment systems as a backend engineer. The full version on my site also covers validating event amounts against your own records. If you're debugging signatures right now, there's a free webhook signature verifier that runs entirely in your browser — nothing is uploaded.

Top comments (0)