In modern distributed systems, event-driven architectures are the standard for cross-application communication. When integrating with third-party providers like Stripe, GitHub, or Twilio, your system relies heavily on webhooks to receive real-time updates. A webhook is simply a reverse API: instead of polling for changes, the external service sends an HTTP POST request to your application when an event occurs.
However, webhook consumption is notoriously fragile. If your server is down, the provider will retry the payload. If you process an event slowly, the provider might timeout and retry, leading you to process the same payment twice. If you don’t verify the request, malicious actors can spoof payment confirmations.
In this article, we will explore the critical architectural patterns for building highly reliable webhook consumers. We will implement idempotency, signature verification, and asynchronous processing using Spring Boot 4 and PostgreSQL.
The Webhook Ingestion Problem
A naive webhook endpoint accepts a payload, processes it synchronously (e.g., updates the database, sends an email), and returns a 200 OK. This approach violates several distributed systems principles.

First, processing can be slow. If your database is under load and the transaction takes 5 seconds, the webhook provider might sever the connection due to a timeout. Because they didn’t receive a 200 OK, they will send the event again. Second, network blips are common. The provider might send duplicate events (at-least-once delivery). If your system is not idempotent, a duplicate “Charge Captured” webhook will result in a double charge to the customer.
Idempotency First Design
Idempotency means that processing the same event multiple times yields the same result as processing it once. The most robust way to achieve this is by persisting the raw webhook payload immediately upon receipt and checking for duplicate IDs.

When a webhook arrives, we extract a unique identifier provided by the sender (e.g., Stripe-Event-Id). We attempt to insert this into an events table. If the insert succeeds, we return 202 Accepted. If a unique constraint violation occurs (meaning we’ve seen this event before), we gracefully catch the exception and return 200 OK. It is crucial to return success for duplicates; otherwise, the provider will keep retrying.
Implementing Idempotency in Spring Boot 4
Let’s define a JPA entity to store our incoming webhook events. We will use Jakarta Data or standard Spring Data JPA with Hibernate 7.
@Entity
@Table(name = "webhook_events")
public class WebhookEvent {
@Id
@Column(name = "event_id", updatable = false, nullable = false)
private String eventId;
@Column(name = "payload", columnDefinition = "jsonb", nullable = false)
private String payload;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private EventStatus status = EventStatus.RECEIVED;
// Constructors, Getters, Setters
}
public enum EventStatus {
RECEIVED, PROCESSED, FAILED
}
Our controller logic becomes incredibly simple and fast. It doesn’t perform the business logic; it acts solely as an ingestion buffer.
@RestController
@RequestMapping("/api/webhooks")
public class WebhookController {
private final WebhookEventRepository repository;
public WebhookController(WebhookEventRepository repository) {
this.repository = repository;
}
@PostMapping("/stripe")
public ResponseEntity<Void> handleStripeWebhook(
@RequestHeader("Stripe-Signature") String signature,
@RequestBody String rawPayload) {
// 1. Verify Signature (Omitted for brevity, see next section)
String eventId = extractEventId(rawPayload);
try {
WebhookEvent event = new WebhookEvent(eventId, rawPayload);
repository.save(event);
} catch (DataIntegrityViolationException e) {
// Event already exists. We must return 200 OK to stop retries.
return ResponseEntity.ok().build();
}
// Return 202 Accepted to indicate successful ingestion
return ResponseEntity.accepted().build();
}
}
Securing the Endpoint with HMAC
Because webhook endpoints must be publicly accessible on the internet, anyone can send an HTTP POST to them. To guarantee that the payload actually originated from the trusted provider (and hasn’t been tampered with), providers use HMAC (Hash-based Message Authentication Code) signatures.

In Java, we can use standard cryptography libraries to compute the HMAC-SHA256 signature and compare it. It is critical to compute the signature using the raw string payload exactly as it was received, before any Jackson JSON parsing occurs, as parsing can alter whitespace and invalidate the hash.
public class HmacSignatureVerifier {
public static boolean verify(String rawPayload, String signatureHeader, String secret) {
try {
Mac sha256_HMAC = Mac.getInstance("HmacSHA256");
SecretKeySpec secret_key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
sha256_HMAC.init(secret_key);
byte[] hash = sha256_HMAC.doFinal(rawPayload.getBytes(StandardCharsets.UTF_8));
String expectedSignature = HexFormat.of().formatHex(hash);
// Use MessageDigest.isEqual to prevent timing attacks
return MessageDigest.isEqual(
expectedSignature.getBytes(),
signatureHeader.getBytes()
);
} catch (Exception e) {
return false;
}
}
}
Asynchronous Processing
Now that our ingestion is secure, fast, and idempotent, we need to actually process the business logic. We use a background worker that polls the database for RECEIVED events.

Using Spring’s @Scheduled, we can create a simple worker. For higher scale, you might integrate a proper job scheduler or push the IDs onto a Kafka topic.
@Service
public class WebhookProcessor {
private final WebhookEventRepository repository;
@Scheduled(fixedDelay = 5000)
@Transactional
public void processPendingEvents() {
List<WebhookEvent> pending = repository.findByStatus(EventStatus.RECEIVED);
for (WebhookEvent event : pending) {
try {
// Execute business logic (e.g., Update Order Status)
processBusinessLogic(event.getPayload());
event.setStatus(EventStatus.PROCESSED);
repository.save(event);
} catch (Exception e) {
// Handle failure, maybe increment a retry counter
event.setStatus(EventStatus.FAILED);
repository.save(event);
}
}
}
}
Best Practices and Considerations
- Prevent Timing Attacks: When comparing signatures, always use a constant-time string comparison function like
MessageDigest.isEqual(). A standardString.equals()returns early on the first mismatched character, allowing attackers to guess the hash character by character. - Timestamp Verification: Good webhook providers include a timestamp in the signature header. You should verify that the timestamp is within a reasonable tolerance (e.g., 5 minutes) to prevent replay attacks where an attacker captures a valid payload and resends it later.
- Dead Letter Queues: For events that fail processing (
EventStatus.FAILED), build an administrative dashboard or a Dead Letter Queue (DLQ) to allow developers to inspect and manually retry the failed events.
Conclusion
Building a robust webhook consumer requires shifting your mindset from synchronous API design to asynchronous event-driven architecture. By immediately persisting the raw payload, validating signatures carefully, and processing business logic in the background, you insulate your core systems from third-party outages and guarantee data consistency through idempotency.
Workshop Companion Code
You can find the complete, runnable Spring Boot 4 project for this article, including automated Testcontainer-based integration tests verifying idempotency and signature validation, on our GitHub repository.
