Build1 publisher3 min readPublished
Spring Data's save() turns the duplicate-webhook guard into a silent row update
The Spring Boot dedupe most payment handlers ship relies on a unique-key exception. A dev.to walkthrough shows the insert that would raise it never runs, and gives the native ON CONFLICT statement that does claim the event.
The Engineer · Build desk

What happened
- A dev.to walkthrough argues that fulfilment must never be triggered by the browser success page, because the API call behind it can be sent by anyone, with curl, for free.
- Webhook delivery is at-least-once, so the same event comes back after a timeout, after your 500, and occasionally for no visible reason.
- The dedupe most Spring Boot developers write first records the event id with saveAndFlush inside a try block and treats a caught DataIntegrityViolationException as proof the event was already processed.
- The post replaces it with a native INSERT ... ON CONFLICT (event_id) DO NOTHING whose return value, 1 or 0, tells the handler whether it claimed the event or lost the claim.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure A dedupe that quietly updates the row instead of failing raises no exception and logs nothing, so the first evidence of repeat processing is duplicate business effects in the order table.
- cost One losing race in the exception-based version does not just fail once; the 500 at commit puts that event back into Stripe's retry queue for as long as three days, and the on-call engineer owns the noise.
- constraint Making the claim correct means leaving JPA's portable API for database-specific SQL, so a team on a database without ON CONFLICT has to write and test its own claim statement.
- decision Because one provider-side change can arrive as two events with different ids, teams have to decide separately how to make the effects safe to apply twice; the event-id table alone will pass both events through.
Start with the case where nothing throws. ProcessedEvent's primary key is the provider's event id, so your code assigns it and it is never null. Spring Data uses that to decide whether the entity is new, and with no `@Version` field to tell it otherwise, `save()` calls `merge()` instead of `persist()`. Merge loads the existing row and updates it. No constraint is violated, the catch block never runs, and the repeated event is applied a second time [10].
The other failure needs two deliveries at once. Both reach the insert, and one does get the duplicate-key error. That exception travels back through the repository's own transactional proxy, which marks the transaction rollback-only, and PostgreSQL has aborted the transaction anyway [11]. You catch it, return normally, and Spring throws `UnexpectedRollbackException` at commit [11]. The caller sees a 500, and Stripe retries a failed delivery for up to three days [12].
The replacement is a native query. `INSERT ... ON CONFLICT (event_id) DO NOTHING` returns the affected row count: 1 means this handler claimed the event, 0 means it was already processed, and a duplicate never raises [14]. `process()` returns early on 0, otherwise it calls `applyEffects` in the same transaction, so effects that throw roll the claim back with them [15]. Concurrent deliveries are safe because the second insert waits for the first transaction to finish and then inserts nothing [16].
None of that helps if the signature check is wrong, and the requirement there is the raw bytes. Verify before parsing, with the body bound to a String [5]. Re-serialise a DTO and the JSON comes back with different key order, whitespace or number formatting, so verification fails; Stripe's documentation says any change to the raw body makes verification fail [6]. When it fails that way, the post says, someone "temporarily" turns it off [6]. The signature also covers a timestamp, and Stripe's libraries reject anything older than five minutes by default [7]. Setting the tolerance to 0 disables the check [7]. A server clock more than five minutes out will reject events that are genuine [19].
For the trap to bite, two things have to hold, and the walkthrough names both: the id is assigned in your code, and the entity has no `@Version` field [10]. For the fix to transfer, the database has to support `ON CONFLICT`; the schema given is PostgreSQL, with `event_id text PRIMARY KEY` and a `timestamptz` column, and the query is marked `nativeQuery = true` [13][14][20]. The post is a walkthrough and does not include measured duplicate rates. Event-id dedupe also covers only one class of repeat, the same event arriving twice: one change on the provider side can produce two events with different ids, so applying the same change twice has to be harmless in the effects themselves [17]. The handler stores the claim, returns 200, and runs the slow work afterwards [18].
What to watch
- Whether a portable equivalent of the ON CONFLICT claim statement appears for teams not running PostgreSQL.
- Whether providers other than Stripe publish retry windows comparable to Stripe's three days for a failed delivery.
- Whether Stripe changes the five-minute default timestamp tolerance in its client libraries.