Complete Guide to SpringBoot Webhooks: Implementation, Security & Best Practices

9 minute read

Introduction to Webhooks

Webhooks operate under the principle of “event-driven” communication: the server sends data to the client when an event occurs, without the client needing to poll for updates.

Essentially, a webhook is an HTTP callback triggered by an event. It involves setting up an endpoint in your Spring Boot application that can accept and process incoming HTTP requests (usually POST).

Webhook vs Polling vs WebSockets

Aspect Webhooks Polling WebSockets
Communication Event-driven push Client pulls data Bidirectional real-time
Efficiency High (only when needed) Low (constant requests) High (persistent connection)
Real-time Near real-time Depends on poll interval Real-time
Complexity Medium Low High
Use Case External integrations Simple data sync Chat, live updates

When to Use Webhooks

✅ Perfect for:

  • Payment processing notifications (Stripe, PayPal)
  • CI/CD pipeline events (GitHub, GitLab)
  • Third-party service integrations
  • Order status updates
  • Inventory changes
  • User registration confirmations

❌ Not suitable for:

  • Real-time bidirectional communication
  • High-frequency events (>1000/sec)
  • Internal microservice communication
  • When immediate consistency is critical

Basic Webhook Implementation

Architecture Overview

flowchart TB
    A[External Service] -->|HTTP POST| B[Webhook Controller]
    B --> C[Signature Verification]
    C -->|Valid| D[Webhook Service]
    C -->|Invalid| E[Return 401]
    D --> F[Process Event]
    F --> G[Save to Database]
    F --> H[Business Logic]
    H --> I[Notifications]
    D --> J[Audit Logging]
    
    subgraph SpringBootApp ["Spring Boot Application"]
        B
        C
        D
        F
        G
        H
        I
        J
    end

Class Diagram

classDiagram
    class WebhookController {
        +handlePaymentWebhook(payload, headers)
        +handleOrderStatusWebhook(payload, signature)
        +ResponseEntity~WebhookResponse~
    }
    
    class WebhookService {
        +processPaymentWebhook(payload, headers)
        +processOrderStatusWebhook(payload)
        +verifySignature(request, signature)
        -handlePaymentCompleted(payload)
        -handlePaymentFailed(payload)
    }
    
    class WebhookSecurityService {
        +verifySignature(provider, payload, signature)
        +verifyStripeSignature(payload, signature)
        +verifyGithubSignature(payload, signature)
        -calculateHmacSha256(data, secret)
    }
    
    class WebhookEvent {
        -Long id
        -String eventId
        -String eventType
        -String payload
        -WebhookEventStatus status
        -LocalDateTime receivedAt
        -LocalDateTime processedAt
    }
    
    class PaymentWebhookPayload {
        -String transactionId
        -String orderId
        -PaymentStatus status
        -BigDecimal amount
        -String currency
    }
    
    class WebhookRetryService {
        +retryFailedWebhooks()
        -retryWebhookEvent(event)
        -reprocessWebhookEvent(event)
    }
    
    WebhookController --> WebhookService
    WebhookService --> WebhookSecurityService
    WebhookService --> WebhookEvent
    WebhookController --> PaymentWebhookPayload
    WebhookRetryService --> WebhookEvent
    WebhookRetryService --> WebhookService

Dependencies

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

Simple Webhook Controller

@RestController
@RequestMapping("/webhooks")
@Slf4j
public class WebhookController {
    
    @Autowired
    private WebhookService webhookService;
    
    @PostMapping("/payment")
    public ResponseEntity<WebhookResponse> handlePaymentWebhook(
            @RequestBody PaymentWebhookPayload payload,
            @RequestHeader Map<String, String> headers) {
        
        try {
            log.info("Received payment webhook: {}", payload.getEventId());
            webhookService.processPaymentWebhook(payload, headers);
            return ResponseEntity.ok(WebhookResponse.success("Payment webhook processed"));
            
        } catch (Exception e) {
            log.error("Error processing payment webhook", e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(WebhookResponse.error("Processing failed: " + e.getMessage()));
        }
    }
    
    @PostMapping("/order-status")
    public ResponseEntity<WebhookResponse> handleOrderStatusWebhook(
            @RequestBody @Valid OrderStatusWebhookPayload payload,
            @RequestHeader(value = "X-Signature", required = false) String signature) {
        
        if (!webhookService.verifySignature(payload, signature)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(WebhookResponse.error("Invalid signature"));
        }
        
        webhookService.processOrderStatusWebhook(payload);
        return ResponseEntity.ok(WebhookResponse.success("Order status updated"));
    }
}

Data Models

// Base webhook payload
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public abstract class BaseWebhookPayload {
    private String eventId;
    private String eventType;
    private LocalDateTime timestamp;
    private String source;
}

// Payment webhook payload
@Data
@EqualsAndHashCode(callSuper = true)
public class PaymentWebhookPayload extends BaseWebhookPayload {
    @NotNull private String transactionId;
    @NotNull private String orderId;
    @NotNull private PaymentStatus status;
    @DecimalMin("0.01") private BigDecimal amount;
    @NotBlank private String currency;
    private String customerId;
}

// Webhook response model
@Data
@AllArgsConstructor
public class WebhookResponse {
    private boolean success;
    private String message;
    private LocalDateTime timestamp;
    
    public static WebhookResponse success(String message) {
        return new WebhookResponse(true, message, LocalDateTime.now());
    }
    
    public static WebhookResponse error(String message) {
        return new WebhookResponse(false, message, LocalDateTime.now());
    }
}

Webhook Service Implementation

Service Layer Design

sequenceDiagram
    participant Client as External Service
    participant Controller as WebhookController
    participant Service as WebhookService
    participant Security as SecurityService
    participant DB as Database
    participant Business as BusinessLogic
    
    Client->>Controller: POST /webhooks/payment
    Controller->>Security: verifySignature(payload, signature)
    Security-->>Controller: true/false
    
    alt signature valid
        Controller->>Service: processPaymentWebhook(payload)
        Service->>DB: checkDuplicateEvent(eventId)
        DB-->>Service: false (not duplicate)
        Service->>DB: saveWebhookEvent(payload)
        Service->>Business: handlePaymentCompleted(payload)
        Business-->>Service: success
        Service->>DB: markEventProcessed()
        Service-->>Controller: success
        Controller-->>Client: 200 OK
    else signature invalid
        Controller-->>Client: 401 Unauthorized
    end

Core Service Implementation

@Service
@Transactional
@Slf4j
public class WebhookService {
    
    @Autowired
    private WebhookEventRepository webhookEventRepository;
    
    @Autowired
    private PaymentService paymentService;
    
    @Autowired
    private WebhookSecurityService securityService;
    
    public void processPaymentWebhook(PaymentWebhookPayload payload, Map<String, String> headers) {
        // Check for duplicate events (idempotency)
        if (isDuplicateEvent(payload.getEventId())) {
            log.info("Duplicate payment webhook event ignored: {}", payload.getEventId());
            return;
        }
        
        // Save webhook event for audit trail
        WebhookEvent webhookEvent = saveWebhookEvent(payload, "PAYMENT");
        
        try {
            switch (payload.getStatus()) {
                case COMPLETED -> handlePaymentCompleted(payload);
                case FAILED -> handlePaymentFailed(payload);
                case REFUNDED -> handlePaymentRefunded(payload);
                default -> log.info("Payment status {} - no action required", payload.getStatus());
            }
            
            webhookEvent.setStatus(WebhookEventStatus.PROCESSED);
            webhookEvent.setProcessedAt(LocalDateTime.now());
            
        } catch (Exception e) {
            webhookEvent.setStatus(WebhookEventStatus.FAILED);
            webhookEvent.setErrorMessage(e.getMessage());
            throw new WebhookProcessingException("Failed to process payment webhook", e);
        } finally {
            webhookEventRepository.save(webhookEvent);
        }
    }
    
    public boolean verifySignature(BaseWebhookPayload payload, String signature) {
        return securityService.verifySignature("generic", JsonUtils.toJson(payload), signature, null);
    }
    
    private boolean isDuplicateEvent(String eventId) {
        return webhookEventRepository.existsByEventId(eventId);
    }
    
    private void handlePaymentCompleted(PaymentWebhookPayload payload) {
        paymentService.markPaymentCompleted(payload.getTransactionId(), payload.getAmount());
        // Additional business logic...
    }
}

# Security Implementation

## Security Architecture

```mermaid
flowchart TD
    A[Incoming Webhook] --> B[Rate Limiter]
    B --> C[Signature Verification]
    C --> D{Valid Signature?}
    D -->|Yes| E\[Timestamp Check]
    D -->|No| F\[Return 401]
    E --> G{Within Time Window?}
    G -->|Yes| H\[Process Webhook]
    G -->|No| I\[Return 400 - Replay Attack]
    H --> J[Audit Log]
    
    subgraph SecurityLayers ["Security Layers"]
        B
        C
        E
    end

Signature Verification

@Component
@Slf4j
public class WebhookSecurityService {
    
    @Value("${webhook.secrets}")
    private Map<String, String> webhookSecrets;
    
    public boolean verifySignature(String provider, String payload, String signature, Map<String, String> headers) {
        String secret = webhookSecrets.get(provider);
        if (secret == null) {
            log.error("No webhook secret configured for provider: {}", provider);
            return false;
        }
        
        return switch (provider.toLowerCase()) {
            case "stripe" -> verifyStripeSignature(payload, signature, secret);
            case "github" -> verifyGithubSignature(payload, signature, secret);
            default -> verifyGenericHmacSignature(payload, signature, secret);
        };
    }
    
    private boolean verifyStripeSignature(String payload, String signature, String secret) {
        try {
            // Stripe signature format: t=timestamp,v1=signature
            String[] elements = signature.split(",");
            long timestamp = extractTimestamp(elements);
            String v1 = extractSignature(elements);
            
            // Check timestamp (prevent replay attacks)
            if (isTimestampExpired(timestamp, 300)) { // 5 minutes tolerance
                return false;
            }
            
            String expectedSignature = calculateHmacSha256(timestamp + "." + payload, secret);
            return MessageDigest.isEqual(v1.getBytes(), expectedSignature.getBytes());
            
        } catch (Exception e) {
            log.error("Error verifying Stripe signature", e);
            return false;
        }
    }
    
    private String calculateHmacSha256(String data, String secret) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        mac.init(secretKey);
        byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
        return Hex.encodeHexString(hash);
    }
}

Database Design

Entity Relationship Diagram

erDiagram
    WebhookEvent {
        Long id PK
        String eventId UK
        String eventType
        String payload
        String headers
        WebhookEventStatus status
        LocalDateTime receivedAt
        LocalDateTime processedAt
        String source
        String errorMessage
    }
    
    PaymentTransaction {
        Long id PK
        String transactionId UK
        String orderId
        PaymentStatus status
        BigDecimal amount
        String currency
    }
    
    Order {
        Long id PK
        String orderId UK
        OrderStatus status
        String trackingNumber
        String carrier
        LocalDateTime estimatedDelivery
    }
    
    WebhookEvent \||--o{ PaymentTransaction : triggers\_update
    WebhookEvent \||--o{ Order : triggers\_update

Database Entity

@Entity
@Table(name = "webhook_events")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WebhookEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(unique = true, nullable = false)
    private String eventId;
    
    @Column(nullable = false)
    private String eventType;
    
    @Column(columnDefinition = "TEXT")
    private String payload;
    
    @Enumerated(EnumType.STRING)
    private WebhookEventStatus status;
    
    private LocalDateTime receivedAt;
    private LocalDateTime processedAt;
    private String source;
    private String errorMessage;
}

public enum WebhookEventStatus {
    RECEIVED, PROCESSING, PROCESSED, FAILED, RETRY
}

Error Handling and Retry Mechanisms

Retry Strategy Flow

flowchart TD
    A[Webhook Processing Failed] --> B[Save Error State]
    B --> C[Check Retry Count]
    C --> D{Max Retries Reached?}
    D -->|No| E\[Calculate Delay]
    E --> F[Wait for Delay]
    F --> G[Retry Processing]
    G --> H{Success?}
    H -->|Yes| I\[Mark as Processed]
    H -->|No| A
    D -->|Yes| J\[Mark as Failed]
    
    subgraph RetryDelays ["Retry Delays"]
        K[5 minutes]
        L[15 minutes] 
        M[60 minutes]
    end

Retry Implementation

@Component
@Slf4j
public class WebhookRetryService {
    
    @Autowired
    private WebhookEventRepository webhookEventRepository;
    
    @Value("${webhook.retry.maxAttempts:3}")
    private int maxRetryAttempts;
    
    @Value("${webhook.retry.delayMinutes:5,15,60}")
    private List<Integer> retryDelays;
    
    @Scheduled(fixedDelay = 300000) // Every 5 minutes
    public void retryFailedWebhooks() {
        List<WebhookEvent> failedEvents = webhookEventRepository
            .findByStatusAndReceivedAtBefore(WebhookEventStatus.FAILED, 
                LocalDateTime.now().minusMinutes(5));
        
        for (WebhookEvent event : failedEvents) {
            retryWebhookEvent(event);
        }
    }
    
    private void retryWebhookEvent(WebhookEvent event) {
        int attemptCount = getAttemptCount(event);
        
        if (attemptCount >= maxRetryAttempts) {
            event.setStatus(WebhookEventStatus.FAILED);
            webhookEventRepository.save(event);
            return;
        }
        
        int delayIndex = Math.min(attemptCount, retryDelays.size() - 1);
        LocalDateTime nextRetryTime = event.getReceivedAt().plusMinutes(retryDelays.get(delayIndex));
        
        if (LocalDateTime.now().isBefore(nextRetryTime)) {
            return; // Not time to retry yet
        }
        
        try {
            reprocessWebhookEvent(event);
            event.setStatus(WebhookEventStatus.PROCESSED);
            event.setProcessedAt(LocalDateTime.now());
        } catch (Exception e) {
            event.setStatus(WebhookEventStatus.FAILED);
            event.setErrorMessage(e.getMessage());
        } finally {
            webhookEventRepository.save(event);
        }
    }
}

Testing Strategy

Testing Architecture

flowchart TB
    A[Unit Tests] --> D[Test Coverage]
    B[Integration Tests] --> D
    C[Contract Tests] --> D
    
    subgraph UnitTests ["Unit Tests"]
        A1[Controller Tests]
        A2[Service Tests]
        A3[Security Tests]
    end
    
    subgraph IntegrationTests ["Integration Tests"]
        B1[End-to-End Flow]
        B2[Database Integration]
        B3[External Service Mocks]
    end
    
    subgraph ContractTests ["Contract Tests"]
        C1[Webhook Provider Contracts]
        C2[Payload Validation]
        C3[Signature Verification]
    end
    
    A --> A1
    A --> A2
    A --> A3
    B --> B1
    B --> B2
    B --> B3
    C --> C1
    C --> C2
    C --> C3

Sample Test Implementation

@ExtendWith(MockitoExtension.class)
class WebhookControllerTest {
    
    @Mock
    private WebhookService webhookService;
    
    @InjectMocks
    private WebhookController webhookController;
    
    @Test
    void shouldProcessPaymentWebhookSuccessfully() throws Exception {
        // Arrange
        PaymentWebhookPayload payload = createValidPaymentPayload();
        Map<String, String> headers = Map.of("X-Signature", "valid_signature");
        
        doNothing().when(webhookService).processPaymentWebhook(payload, headers);
        
        // Act
        ResponseEntity<WebhookResponse> response = webhookController
            .handlePaymentWebhook(payload, headers);
        
        // Assert
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().isSuccess()).isTrue();
        verify(webhookService).processPaymentWebhook(payload, headers);
    }
    
    @Test
    void shouldReturnUnauthorizedForInvalidSignature() {
        // Arrange
        OrderStatusWebhookPayload payload = createValidOrderPayload();
        when(webhookService.verifySignature(any(), anyString())).thenReturn(false);
        
        // Act
        ResponseEntity<WebhookResponse> response = webhookController
            .handleOrderStatusWebhook(payload, "invalid_signature");
        
        // Assert
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
        assertThat(response.getBody().getMessage()).contains("Invalid signature");
    }
}

Configuration and Best Practices

Application Configuration

# application.yml
webhook:
  secret: ${WEBHOOK_SECRET:your-webhook-secret-key}
  retry:
    maxAttempts: 3
    delayMinutes: 5,15,60
  security:
    enabled: true
    rateLimitPerMinute: 100

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/webhookdb
    username: ${DB_USERNAME:webhook_user}
    password: ${DB_PASSWORD:webhook_password}
    
logging:
  level:
    com.example.webhook: INFO
    
management:
  endpoints:
    web:
      exposure:
        include: health,metrics

Best Practices Summary

Security Checklist

  • Always verify webhook signatures
  • Use HTTPS for webhook endpoints
  • Implement rate limiting
  • Validate all input data
  • Use environment variables for secrets

Reliability Checklist

  • Implement idempotency checks
  • Use database transactions
  • Implement retry mechanisms
  • Handle partial failures gracefully
  • Log all webhook events for audit

Performance Checklist

  • Process webhooks asynchronously when possible
  • Implement proper timeout handling
  • Monitor processing times
  • Scale webhook processing based on load

Common Webhook Provider Examples

Stripe Integration

@PostMapping("/stripe")
public ResponseEntity<String> handleStripeWebhook(
        @RequestBody String payload,
        @RequestHeader("Stripe-Signature") String signature) {
    
    try {
        Event event = Webhook.constructEvent(payload, signature, stripeWebhookSecret);
        
        switch (event.getType()) {
            case "payment_intent.succeeded" -> handlePaymentSuccess(event);
            case "payment_intent.payment_failed" -> handlePaymentFailure(event);
            default -> log.info("Unhandled Stripe event: {}", event.getType());
        }
        
        return ResponseEntity.ok("Webhook processed");
        
    } catch (SignatureVerificationException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid signature");
    }
}

GitHub Integration

@PostMapping("/github")
public ResponseEntity<String> handleGitHubWebhook(
        @RequestBody String payload,
        @RequestHeader("X-GitHub-Event") String event,
        @RequestHeader("X-Hub-Signature-256") String signature) {
    
    if (!gitHubService.verifySignature(payload, signature)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid signature");
    }
    
    switch (event) {
        case "push" -> gitHubService.handlePushEvent(payload);
        case "pull_request" -> gitHubService.handlePullRequestEvent(payload);
        default -> log.info("Unhandled GitHub event: {}", event);
    }
    
    return ResponseEntity.ok("Webhook processed");
}

Conclusion

This guide covers the essential aspects of implementing robust webhook systems in Spring Boot:

Key Takeaways

  • Event-driven architecture enables real-time integrations
  • Security first approach with signature verification
  • Resilience patterns like retry mechanisms and idempotency
  • Proper testing ensures reliability
  • Monitoring and observability for production readiness

When to Use Webhooks

  • ✅ External service integrations (payments, notifications)
  • ✅ Real-time event processing
  • ✅ Reducing polling overhead
  • ✅ Asynchronous communication patterns

Webhooks provide an efficient way to build event-driven systems that scale and integrate well with external services while maintaining security and reliability.