Webhook providers can send the same event more than once, so the receiving service needs a way to recognize a repeat request before it writes the same result again. That behavior is normal for webhooks because a timeout, slow response, temporary server error, dropped connection, or retry rule can make the sender deliver the event a second time. Request deduplication gives a Spring Boot service a record of webhook requests it has already accepted, usually by creating a request fingerprint, checking that fingerprint against shared storage during a replay window, and filtering repeat deliveries before the business write runs again.
Duplicate Webhook Requests
Before the service touches domain data, duplicate protection should already be in place. By the time a webhook reaches a Spring Boot endpoint, the sender has already decided that an event needs delivery, but the receiver still has to decide if that event is new for its own system. We can treat the HTTP request as the carrier and the event identity as the part we care about, because the same event can arrive through more than one request.
The safest reading is to assume retries are part of the contract. We accept that the sender can repeat delivery, then we give the receiver a way to recognize the repeat before it creates order rows, payment rows, audit entries, email jobs, or downstream messages.
Repeated Delivery
Providers send webhooks through HTTP, so they only get the result that the network and receiver return to them. The receiver can accept the request, finish the database change, and fail before the response gets back to the provider. From the provider’s side, the delivery did not get a trusted success result, so a retry is a reasonable next step. Slow responses can lead to the same outcome. If the sender has a timeout of a few seconds and the receiver finishes after that timeout, the sender may retry an event that the receiver already handled. Temporary 500 responses, dropped connections, and gateway failures create the same pressure. The receiver may have already made a change, while the sender only sees a failed delivery attempt.
We should treat webhook processing as event acceptance rather than request acceptance alone. The request can be repeated, but the event inside it needs a stable identity. If that identity has already been accepted inside the replay window, the next delivery should not repeat the write.
Retries can also arrive close together. A retry can reach the service while the first delivery is still being processed, and a load balancer can route those two requests to different application instances. Local JVM memory does not cover that case because each instance has its own memory, and restarts remove it. The receiver needs a shared view of accepted event identities so every instance reads the same deduplication state.
We can start with a small request envelope that carries the values needed for duplicate detection:
package com.example.webhooks;
import java.time.Instant;
public record IncomingWebhook(
String source,
String deliveryId,
byte[] body,
Instant receivedAt) {
}We keep source, deliveryId, body, and receivedAt in one value so the request can move through early webhook checks without losing the identity data. The deliveryId usually comes from a provider header, while source can be the provider name or endpoint name.
Request Fingerprints
For deduplication, request fingerprints give us a compact value that represents one accepted webhook event. The best input is usually a provider-supplied event ID or delivery ID because that value should stay stable across retries. If the provider sends the same event again, that ID lets us recognize it without relying on business fields inside the payload.
The source should be part of the fingerprint too. Two providers can send the same ID value, and those events should still be treated as different deliveries. Tying the source to the fingerprint keeps provider A from colliding with provider B when both happen to send a value like evt_1001.
Payload hashes can help when the sender does not provide a stable ID, but the body needs care. JSON can carry the same meaning with different whitespace or field order, so hashing raw bytes treats formatting changes as different fingerprints. That can be fine when a provider retries the exact same body bytes. If the provider can reformat the payload between attempts, a stable event field from the JSON body is usually a better fingerprint input.
The fingerprint should not be stored as the full webhook payload. Webhook bodies can contain customer data, payment metadata, email addresses, order details, or other sensitive fields. A short HMAC value gives us a compact stored identity without copying the whole request body into the deduplication record.
We can build the fingerprint from the source, the provider delivery ID when present, or the request body as a fallback:
package com.example.webhooks;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class WebhookFingerprintService {
private final SecretKeySpec secretKey;
public WebhookFingerprintService(
@Value("${webhooks.fingerprint-secret}") String secret) {
secretKey = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8),
"HmacSHA256");
}
public String createFingerprint(
String source,
String deliveryId,
byte[] body) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(secretKey);
mac.update(source.getBytes(StandardCharsets.UTF_8));
mac.update((byte) '\n');
if (deliveryId != null && !deliveryId.isBlank()) {
mac.update(deliveryId.getBytes(StandardCharsets.UTF_8));
} else {
mac.update(body);
}
return HexFormat.of().formatHex(mac.doFinal());
} catch (NoSuchAlgorithmException | InvalidKeyException ex) {
throw new IllegalStateException("Unable to create webhook fingerprint", ex);
}
}
}We call createFingerprint with the provider name or endpoint name first, then the delivery ID if the provider sent one. If no delivery ID is available, the body becomes the fallback input. Mac is created inside the method because it carries calculation state while producing the HMAC.
Header lookup can stay separate so the fingerprint service only handles identity creation:
package com.example.webhooks;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
@Component
public class WebhookDeliveryIdResolver {
public String resolve(HttpHeaders headers) {
String primary = headers.getFirst("X-Webhook-Id");
if (primary != null && !primary.isBlank()) {
return primary;
}
return headers.getFirst("X-Event-Id");
}
}We can adjust the resolver to match the provider’s actual header names without changing the fingerprint algorithm. That separation keeps provider-specific header naming away from the HMAC calculation.
Replay Windows
The receiver treats the replay window as the length of time accepted fingerprints stay remembered. If the sender can retry for twenty-four hours, the fingerprint should stay available through that period. Longer sender retry schedules need a longer record of accepted events.
Interestingly the window also keeps deduplication storage from turning into permanent event history. The receiver only needs to remember fingerprints long enough to cover retries, late delivery, and small clock differences between services, then expired fingerprints can be removed through a scheduled job, database retention, or another maintenance process.
Replay windows should stay separate from signature freshness because provider timestamps answer a different question than deduplication. Provider signature timestamps help the receiver reject old copied requests, while deduplication checks if the receiver already accepted the event. Retried delivery can have a fresh signature timestamp while still carrying the same event ID, so the fingerprint should still point to the original accepted event.
Now we can keep the replay duration in configuration instead of burying it inside request-handling code:
package com.example.webhooks;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "webhooks.deduplication")
public class WebhookDeduplicationProperties {
private Duration replayWindow = Duration.ofHours(24);
public Duration getReplayWindow() {
return replayWindow;
}
public void setReplayWindow(Duration replayWindow) {
this.replayWindow = replayWindow;
}
}The value can live in application configuration:
webhooks.deduplication.replay-window=24hTwenty four hours is only a common starting value. The better value comes from the sender’s retry policy and the receiver’s tolerance for late duplicate delivery. Too short of a window lets delayed retries pass through after the receiver has already forgotten the fingerprint, while a longer window gives more protection at the cost of keeping deduplication records around for more time.
Early Filtering
Repeat detection should happen before domain changes run. The receiver can still reject bad requests first through signature validation, timestamp freshness checks, and required header checks. After those checks pass, duplicate detection can stop a valid retry from creating the same result twice. Some endpoints can identify a repeat from headers alone. In that case, we can prepare the fingerprint early and attach it to the request for later code. Servlet filters can do that without touching the request body, which avoids interfering with body reading later in the request.
you can keep the filter focused on webhook paths and header-based fingerprints:
package com.example.webhooks;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
public class WebhookFingerprintFilter extends OncePerRequestFilter {
public static final String ATTRIBUTE_NAME = "webhookFingerprint";
private final WebhookFingerprintService fingerprintService;
public WebhookFingerprintFilter(WebhookFingerprintService fingerprintService) {
this.fingerprintService = fingerprintService;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/webhooks/");
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String source = request.getRequestURI();
String deliveryId = request.getHeader("X-Webhook-Id");
if (deliveryId != null && !deliveryId.isBlank()) {
String fingerprint = fingerprintService.createFingerprint(
source,
deliveryId,
new byte[0]);
request.setAttribute(ATTRIBUTE_NAME, fingerprint);
}
filterChain.doFilter(request, response);
}
}The filter creates a fingerprint only when the provider supplied a delivery ID, then stores that value as a request attribute so later request handling can read it without rebuilding the same fingerprint. The registration below maps the filter to webhook paths, while shouldNotFilter keeps a second path guard inside the filter itself.
Registration can target webhook URLs only:
package com.example.webhooks;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
@EnableConfigurationProperties(WebhookDeduplicationProperties.class)
public class WebhookFilterConfiguration {
@Bean
FilterRegistrationBean<WebhookFingerprintFilter> webhookFingerprintRegistration(
WebhookFingerprintService fingerprintService) {
WebhookFingerprintFilter filter =
new WebhookFingerprintFilter(fingerprintService);
FilterRegistrationBean<WebhookFingerprintFilter> registration =
new FilterRegistrationBean<>(filter);
registration.addUrlPatterns("/webhooks/*");
registration.setOrder(10);
return registration;
}
}This filter stays header-based on purpose. If the fingerprint depends on the body, reading it inside a filter requires request-body buffering so the controller can still read the same body afterward. That belongs with the later request-handling flow rather than the early identity check.
Early duplicate detection is mostly about timing. We want the receiver to recognize a known event before it sends email, creates a payment record, changes inventory, or publishes a follow-up message. That early decision turns repeated delivery into an accepted duplicate response instead of a second business change.
Deduplication Flow in Spring Boot
When the request reaches this part of the flow, we already have enough identity data to decide if the event should continue. The Spring Boot side of deduplication is mostly about where that decision happens, how we record the first accepted event, and how we avoid running the business write for later deliveries with the same fingerprint.
The flow has two boundaries. The storage gate owns the accepted fingerprint record, while the controller boundary decides what to do with a new request, a duplicate request, and a failed processing attempt. Keeping those responsibilities separate makes the request path easier to read without spreading duplicate checks across unrelated service code.
Storage Gate
The storage gate needs to make the reservation decision through the database, with the insert acting as the gate. Reading first and inserting later leaves a gap where two matching requests can both see no record yet. The safer option is to let the database accept only the first fingerprint through a primary key or unique constraint. The first insert succeeds, and the second insert with the same fingerprint fails as a duplicate.
The table can stay small because it is not meant to be event history. We only need the fingerprint, the first time the receiver saw it, the expiration time, and a status value that tells us if processing is still in progress or already done.
create table webhook_deduplication_entry (
fingerprint varchar(128) primary key,
first_seen_at timestamp with time zone not null,
expires_at timestamp with time zone not null,
status varchar(20) not null
);
create index webhook_deduplication_entry_expires_idx
on webhook_deduplication_entry (expires_at);The primary key on fingerprint is the important part of the gate. We do not need a separate lookup before the insert because the table constraint already gives us the conflict rule. The expires_at index supports cleanup of old records after the replay window has passed.
Status values can be small and limited. We can model them in Java instead of passing raw text all through the service layer.
package com.example.webhooks;
public enum DeduplicationStatus {
PROCESSING,
DONE
}The result of a reservation attempt is also worth naming. That lets the controller read more naturally when it decides what response to return.
package com.example.webhooks;
public enum ReservationResult {
RESERVED,
DUPLICATE_PROCESSING,
DUPLICATE_DONE
}Now we can create a store that reserves the fingerprint. The insert path returns RESERVED, while a duplicate key exception means the store reads the current status and returns either DUPLICATE_PROCESSING or DUPLICATE_DONE.
package com.example.webhooks;
import java.sql.Timestamp;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
@Repository
public class WebhookDeduplicationStore {
private final JdbcClient jdbcClient;
private final Clock clock;
private final Duration replayWindow;
public WebhookDeduplicationStore(
JdbcClient jdbcClient,
WebhookDeduplicationProperties properties) {
this.jdbcClient = jdbcClient;
this.clock = Clock.systemUTC();
this.replayWindow = properties.getReplayWindow();
}
public ReservationResult reserve(String fingerprint) {
Instant now = clock.instant();
Instant expiresAt = now.plus(replayWindow);
try {
jdbcClient.sql("""
insert into webhook_deduplication_entry
(fingerprint, first_seen_at, expires_at, status)
values
(:fingerprint, :firstSeenAt, :expiresAt, :status)
""")
.param("fingerprint", fingerprint)
.param("firstSeenAt", Timestamp.from(now))
.param("expiresAt", Timestamp.from(expiresAt))
.param("status", DeduplicationStatus.PROCESSING.name())
.update();
return ReservationResult.RESERVED;
} catch (DuplicateKeyException ex) {
String status = jdbcClient.sql("""
select status
from webhook_deduplication_entry
where fingerprint = :fingerprint
""")
.param("fingerprint", fingerprint)
.query(String.class)
.single();
if (DeduplicationStatus.DONE.name().equals(status)) {
return ReservationResult.DUPLICATE_DONE;
}
return ReservationResult.DUPLICATE_PROCESSING;
}
}
public void markDone(String fingerprint) {
jdbcClient.sql("""
update webhook_deduplication_entry
set status = :status
where fingerprint = :fingerprint
""")
.param("status", DeduplicationStatus.DONE.name())
.param("fingerprint", fingerprint)
.update();
}
public void releaseProcessingReservation(String fingerprint) {
jdbcClient.sql("""
delete from webhook_deduplication_entry
where fingerprint = :fingerprint
and status = :status
""")
.param("fingerprint", fingerprint)
.param("status", DeduplicationStatus.PROCESSING.name())
.update();
}
}We call reserve before the domain write. If the insert succeeds, this receiver has reserved the event identity for the replay window. If the insert fails because the fingerprint already exists, the store checks the saved status. A DONE entry can be treated as an accepted duplicate without touching domain tables, while a PROCESSING entry means another request is still handling the same event.
The releaseProcessingReservation method handles failed processing. If we reserve a fingerprint and then the business action throws an exception before completion, deleting the PROCESSING row lets a later retry try again. Without that release step, the receiver could block the next delivery even though the first attempt never finished.
The store can also remove expired rows outside the main request path. Running cleanup separately keeps normal webhook requests focused on reservation and processing.
package com.example.webhooks;
import java.sql.Timestamp;
import java.time.Clock;
import java.time.Instant;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class WebhookDeduplicationCleanup {
private final JdbcClient jdbcClient;
private final Clock clock;
public WebhookDeduplicationCleanup(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
this.clock = Clock.systemUTC();
}
@Scheduled(fixedDelayString = "${webhooks.deduplication.cleanup-delay:PT1H}")
public void deleteExpiredEntries() {
Instant now = clock.instant();
jdbcClient.sql("""
delete from webhook_deduplication_entry
where expires_at < :now
""")
.param("now", Timestamp.from(now))
.update();
}
}The cleanup job does not decide if a current request is a duplicate. Its job is only to remove expired memory after the replay window has passed. The reservation method still owns the live duplicate decision.
Controller Boundary
The controller boundary connects the incoming HTTP request to the storage gate. At this point, the endpoint has the body, the provider header values, and the fingerprint service from the earlier request identity flow. The controller should reserve the fingerprint before calling the business processor. Completed duplicates can return a success response so the sender does not keep retrying an event the receiver already accepted, while in-progress duplicates can return a temporary conflict response without running the processor again.
The response for a completed duplicate delivery should not be an error by default. If the receiver already accepted and finished that event, a duplicate request is safe to ignore from the business side. Returning 200 OK or 202 Accepted tells the sender that the receiver does not need another retry for that event.
We can keep the controller focused on request handling and leave the domain update inside a separate processor.
package com.example.webhooks;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/webhooks/orders")
public class OrderWebhookController {
private final WebhookFingerprintService fingerprintService;
private final WebhookDeduplicationStore deduplicationStore;
private final OrderWebhookProcessor processor;
public OrderWebhookController(
WebhookFingerprintService fingerprintService,
WebhookDeduplicationStore deduplicationStore,
OrderWebhookProcessor processor) {
this.fingerprintService = fingerprintService;
this.deduplicationStore = deduplicationStore;
this.processor = processor;
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, String>> receive(
@RequestHeader(name = "X-Webhook-Id", required = false)
String deliveryId,
@RequestBody byte[] body) {
String fingerprint = fingerprintService.createFingerprint(
"orders",
deliveryId,
body);
ReservationResult reservation = deduplicationStore.reserve(fingerprint);
if (reservation == ReservationResult.DUPLICATE_DONE) {
return ResponseEntity.ok(Map.of("status", "duplicate"));
}
if (reservation == ReservationResult.DUPLICATE_PROCESSING) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("status", "processing"));
}
try {
processor.process(body);
} catch (RuntimeException ex) {
deduplicationStore.releaseProcessingReservation(fingerprint);
throw ex;
}
deduplicationStore.markDone(fingerprint);
return ResponseEntity.accepted()
.body(Map.of("status", "accepted"));
}
}We reserve the fingerprint before processor.process(body) runs. That ordering stops duplicate delivery before the business change, not after it. When the reservation result is DUPLICATE_DONE, the controller returns a successful response and skips the processor entirely. When the result is DUPLICATE_PROCESSING, the controller also skips the processor, but returns a temporary conflict response because the first request has not finished yet.
The try block covers the business processing step. If processor.process(body) fails, the reservation is released so a later sender retry can make a new attempt. If processing succeeds, markDone records that the accepted event finished. That behavior fits retry-based webhook delivery because a failed receiver attempt should not permanently block the event from being retried.
The processor can then focus on the domain action:
package com.example.webhooks;
import org.springframework.stereotype.Service;
@Service
public class OrderWebhookProcessor {
public void process(byte[] body) {
OrderWebhookEvent event = parse(body);
applyOrderChange(event);
writeAuditRecord(event);
}
private OrderWebhookEvent parse(byte[] body) {
return new OrderWebhookEvent();
}
private void applyOrderChange(OrderWebhookEvent event) {
}
private void writeAuditRecord(OrderWebhookEvent event) {
}
private static class OrderWebhookEvent {
}
}The controller protects process from duplicate execution. Inside the processor, the code can focus on parsing the webhook and applying the order change without checking the deduplication table again.
For high-value writes, the domain tables should still have their own safe constraints where the data model allows it. Deduplication protects the request path, while domain constraints protect the stored data. Payment IDs, external event IDs, and order transition records can still benefit from unique constraints that match the business rules. Those database rules provide a second layer of protection if a bug, manual replay, or separate import path bypasses the webhook controller.
The controller boundary also keeps response behavior aligned with the reservation state. New accepted events return an accepted response, completed duplicates return success without a second write, in-progress duplicates return a temporary conflict response, and failed processing lets the normal error handling path return the failure response. That gives the sender the right retry behavior while keeping duplicate writes out of the request flow.
Conclusion
In short, Spring Boot webhook deduplication comes down to a small request path with a firm order. We identify the event with a stable fingerprint, keep that fingerprint through the replay window, reserve it in shared storage before the business write, and treat completed repeats as accepted duplicates. That flow lets retries remain safe HTTP traffic instead of repeated database changes.


