Design and Implement a Production‑Ready Spring Boot Webhook Callback System

This article walks through building a production‑grade webhook system with Spring Boot 3.5, covering webhook fundamentals, a decoupled event‑driven architecture, database schema, entity and repository definitions, a dispatcher service with HMAC signing, exponential‑backoff retry, asynchronous execution, and dead‑letter handling.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Design and Implement a Production‑Ready Spring Boot Webhook Callback System

1. Introduction

Webhook is a user‑defined HTTP callback that pushes events such as user creation, order placement, or payment success to a client‑provided URL via an HTTP POST request, eliminating polling and saving bandwidth. It decouples systems, allowing real‑time reactions without building custom event‑sourcing infrastructure.

2. System Architecture

Event Producer – publishes events to an internal store.

Event Registry – logical mapping of event types, backed by t_webhook_subscriptions table.

Event Dispatcher – reads subscriptions for a given event, sends signed HTTP POST requests asynchronously.

Subscriber Management – API for clients to register/unregister webhook URLs.

Retry Service – exponential‑backoff retry with fixed schedule.

Dead‑Letter Queue – stores events that exhaust retries for manual inspection.

Audit Log – records each delivery attempt for observability.

3. Database Design

CREATE TABLE t_webhook_subscriptions (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  event_type VARCHAR(50) NOT NULL,
  callback_url VARCHAR(2048) NOT NULL,
  secret_key VARCHAR(64) NOT NULL,
  active BOOLEAN NOT NULL DEFAULT TRUE,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uk_event_url (event_type, callback_url)
);

CREATE TABLE t_webhook_events (
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
  event_type VARCHAR(50) NOT NULL,
  payload JSON NOT NULL,
  status ENUM('PENDING','PROCESSING','SENT','FAILED','DEAD') NOT NULL DEFAULT 'PENDING',
  retry_count INT NOT NULL DEFAULT 0,
  next_retry_at TIMESTAMP NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  last_attempt_at TIMESTAMP NULL,
  last_error TEXT NULL
);

These tables separate subscription management from event delivery, improving scalability and maintainability.

4. Entity and Repository Definitions

@Entity
@Table(name = "t_webhook_subscriptions", uniqueConstraints = @UniqueConstraint(columnNames = {"event_type","callback_url"}))
public class WebhookSubscription {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  @Enumerated(EnumType.STRING)
  @Column(name = "event_type", nullable = false, length = 50)
  private EventType eventType;
  @Column(name = "callback_url", nullable = false, length = 2048)
  private String callbackUrl;
  @Column(name = "secret_key", nullable = false, length = 64)
  private String secretKey;
  @Column(nullable = false)
  private Boolean active = true;
  @Column(name = "created_at", nullable = false, updatable = false)
  private LocalDateTime createdAt = LocalDateTime.now();
}

@Entity
@Table(name = "t_webhook_events")
public class WebhookEvent {
  @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  @Enumerated(EnumType.STRING)
  @Column(name = "event_type", nullable = false, length = 50)
  private EventType eventType;
  @Column(nullable = false, columnDefinition = "JSON")
  private String payload;
  @Enumerated(EnumType.STRING)
  @Column(nullable = false)
  private EventStatus status = EventStatus.PENDING;
  @Column(name = "retry_count", nullable = false)
  private int retryCount = 0;
  @Column(name = "next_retry_at")
  private LocalDateTime nextRetryAt;
  @Column(name = "created_at", nullable = false, updatable = false)
  private LocalDateTime createdAt = LocalDateTime.now();
  @Column(name = "last_attempt_at")
  private LocalDateTime lastAttemptAt;
  @Column(name = "last_error", columnDefinition = "TEXT")
  private String lastError;

  public enum EventStatus { PENDING, PROCESSING, SENT, FAILED, DEAD }
}
public interface WebhookSubscriptionRepository extends JpaRepository<WebhookSubscription, Long> {
  List<WebhookSubscription> findByEventTypeAndActiveTrue(EventType eventType);
}

public interface WebhookEventRepository extends JpaRepository<WebhookEvent, Long> {
  List<WebhookEvent> findByStatusAndNextRetryAtBefore(EventStatus status, LocalDateTime now);
}

5. Dispatcher Service

@Service
public class WebhookDispatcherService {
  private static final Logger log = LoggerFactory.getLogger(WebhookDispatcherService.class);
  private final WebhookSubscriptionRepository subscriptionRepo;
  private final WebhookEventRepository eventRepo;
  private final RestClient restClient;
  private final HmacSignatureService signatureService;

  public WebhookDispatcherService(WebhookSubscriptionRepository subscriptionRepo,
                                 WebhookEventRepository eventRepo,
                                 RestClient.Builder restClientBuilder,
                                 HmacSignatureService signatureService) {
    this.subscriptionRepo = subscriptionRepo;
    this.eventRepo = eventRepo;
    this.restClient = restClientBuilder.build();
    this.signatureService = signatureService;
  }

  @Async("webhookTaskExecutor")
  public void dispatchEvent(WebhookEvent event) {
    List<WebhookSubscription> subscriptions = subscriptionRepo.findByEventTypeAndActiveTrue(event.getEventType());
    if (subscriptions.isEmpty()) {
      event.setStatus(WebhookEvent.EventStatus.SENT);
      eventRepo.save(event);
      return;
    }
    event.setStatus(WebhookEvent.EventStatus.PROCESSING);
    eventRepo.save(event);
    for (WebhookSubscription sub : subscriptions) {
      try {
        sendWebhook(sub, event);
      } catch (Exception e) {
        log.error("Failed to send webhook to {} for event {}: {}", sub.getCallbackUrl(), event.getId(), e.getMessage());
        event.setLastError(e.getMessage());
        event.setLastAttemptAt(LocalDateTime.now());
        event.setStatus(WebhookEvent.EventStatus.FAILED);
        eventRepo.save(event);
        return; // stop further attempts for this event
      }
    }
    event.setStatus(WebhookEvent.EventStatus.SENT);
    eventRepo.save(event);
  }

  private void sendWebhook(WebhookSubscription sub, WebhookEvent event) {
    String payload = event.getPayload();
    String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
    String signature = signatureService.generateSignature(payload, sub.getSecretKey(), timestamp);
    restClient.post()
      .uri(sub.getCallbackUrl())
      .contentType(MediaType.APPLICATION_JSON)
      .header("X-Webhook-Signature", signature)
      .header("X-Webhook-Timestamp", timestamp)
      .body(payload)
      .retrieve()
      .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
                (req, resp) -> { throw new HttpClientErrorException(resp.getStatusCode(), "Webhook delivery failed: " + resp.getStatusText()); })
      .toBodilessEntity();
  }
}

6. HMAC Signature Service

@Service
public class HmacSignatureService {
  public String generateSignature(String payload, String secret, String timestamp) {
    try {
      String message = timestamp + "." + payload;
      Mac mac = Mac.getInstance("HmacSHA256");
      SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
      mac.init(secretKeySpec);
      byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
      return Base64.getEncoder().encodeToString(hash);
    } catch (Exception e) {
      throw new RuntimeException("Failed to generate HMAC signature", e);
    }
  }

  public boolean verifySignature(String payload, String secret, String timestamp, String expectedSignature) {
    String computed = generateSignature(payload, secret, timestamp);
    return computed.equals(expectedSignature);
  }
}

7. Retry Mechanism

The service uses exponential back‑off with fixed delays: 1 min, 5 min, 15 min, 30 min. After the fourth failure the event status becomes DEAD and is placed in a dead‑letter queue for manual handling.

@Service
public class WebhookRetryService {
  private final WebhookEventRepository eventRepo;
  private final WebhookDispatcherService dispatcher;
  private static final long[] RETRY_DELAYS = {1, 5, 15, 30}; // minutes

  public WebhookRetryService(WebhookEventRepository eventRepo, WebhookDispatcherService dispatcher) {
    this.eventRepo = eventRepo;
    this.dispatcher = dispatcher;
  }

  @Scheduled(fixedDelay = 60_000)
  @Transactional
  public void processRetries() {
    LocalDateTime now = LocalDateTime.now();
    List<WebhookEvent> retryable = eventRepo.findByStatusAndNextRetryAtBefore(WebhookEvent.EventStatus.FAILED, now);
    for (WebhookEvent event : retryable) {
      int attempt = event.getRetryCount();
      if (attempt >= RETRY_DELAYS.length) {
        event.setStatus(WebhookEvent.EventStatus.DEAD);
        eventRepo.save(event);
        continue;
      }
      event.setStatus(WebhookEvent.EventStatus.PENDING);
      eventRepo.save(event);
      dispatcher.dispatchEvent(event);
    }
  }

  public void scheduleRetry(WebhookEvent event) {
    int attempt = event.getRetryCount();
    if (attempt >= RETRY_DELAYS.length) {
      event.setStatus(WebhookEvent.EventStatus.DEAD);
    } else {
      long delayMinutes = RETRY_DELAYS[attempt];
      event.setNextRetryAt(LocalDateTime.now().plusMinutes(delayMinutes));
      event.setRetryCount(attempt + 1);
      event.setStatus(WebhookEvent.EventStatus.FAILED);
    }
    eventRepo.save(event);
  }
}

8. Asynchronous Processing Configuration

@Configuration
@EnableAsync
public class AsyncConfig {
  @Bean(name = "webhookTaskExecutor")
  Executor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(50);
    executor.setQueueCapacity(500);
    executor.setThreadNamePrefix("webhook-");
    executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
    executor.initialize();
    return executor;
  }
}

The core pool of 10 threads, max 50, and queue capacity 500 prevent OOM; the CallerRunsPolicy provides back‑pressure by running tasks in the caller thread when the pool is saturated.

9. Dead‑Letter Handling

In this simplified example, events that exceed the retry limit are marked with status DEAD. In a production environment, a real message‑queue such as RabbitMQ or Kafka should be used to route these events to a dead‑letter queue for further investigation.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

javadatabaseasynchronousspring-bootretrywebhookHMAC
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.