A webhook delivery system enables real-time event propagation between services by asynchronously delivering HTTP requests to subscriber endpoints with robust error handling and retry mechanisms.
1. Problem Statement & Requirements
Clarifying questions
- What is the expected scale of webhook deliveries?
- Are we talking about hundreds of thousands of webhooks per day, or millions?
- What are the consistency and reliability requirements?
- Should all deliveries succeed, or is "at-least-once" acceptable?
- What is the read/write ratio for webhook configurations?
- Is configuration creation frequent, or mostly read operations?
- What are the latency requirements for webhook delivery?
- Is sub-second delivery acceptable, or is batched delivery preferred?
- What authentication model will be used for webhook endpoints?
- Will they use API keys, OAuth2, or other methods?
- Are there external integrations or dependencies?
- Will webhook endpoints be in-house services or third-party?
- What is the expected payload size and content type?
- Are payloads JSON, XML, or binary?
Functional requirements
- Webhook Configuration Management
- Create, read, update, and delete webhook configurations.
- Store endpoint URL, HTTP method (GET/POST), headers, authentication details, and retry policy.
- Webhook Delivery
- Deliver configured webhooks to their endpoints asynchronously.
- Retry failed deliveries based on configured policy (exponential backoff, max retries).
- Delivery Status Tracking
- Track delivery status (pending, success, failed, retrying).
- Store delivery logs and response metadata.
- Event Triggering
- Trigger webhook deliveries on specific events (e.g., user signup, order creation).
- Webhook Retry Logic
- Implement configurable retry logic with exponential backoff.
- Support dead-letter queue (DLQ) for permanently failed deliveries.
Non-functional requirements
- Latency Targets
- p50: < 100ms for webhook creation and status updates.
- p95: < 500ms for delivery initiation.
- p99: < 2s for full delivery lifecycle (including retries).
- Throughput
- Support at least 10,000 webhook deliveries per second.
- Availability
- 99.9% uptime for the webhook delivery system.
- SLA: 99.5% of all webhooks must be delivered successfully within 24 hours.
Constraints and assumptions
- Assumptions
- Webhook endpoints are assumed to be reachable over HTTP(S).
- Delivery failures due to network or timeout issues are retryable.
- Webhook payloads are not encrypted at rest (unless specified).
- Constraints
- System must support up to 10 million webhook configurations.
- Retry logic must not exceed 24 hours for failed deliveries.
- Delivery to endpoint should not block the main event processing thread.
Out of scope
- Webhook payload encryption (at rest or in transit)
- Webhook content filtering or transformation
- Webhook subscription management (e.g., filtering by event type)
- Webhook versioning or schema evolution
- Webhook rate limiting per endpoint (handled by the endpoint itself)
- Webhook delivery to different transport protocols (e.g., Kafka or SMS)
- Webhook payload validation (handled by the endpoint or consumer)
2. API Design
API Style Selection
gRPC with Protocol Buffers is selected for the webhook delivery system due to:
- Strong typing and schema validation at compile time
- Efficient binary serialization reducing payload size
- Built-in support for streaming and bidirectional communication
- Native support for versioning through proto file updates
- Better performance characteristics for high-throughput systems
Endpoint Definitions
| Method | Path | Description | Auth |
|---|---|---|---|
| CreateWebhook | POST /v1/webhooks | Register a new webhook endpoint | JWT |
| GetWebhook | GET /v1/webhooks/{id} | Retrieve webhook configuration | JWT |
| UpdateWebhook | PUT /v1/webhooks/{id} | Modify webhook endpoint settings | JWT |
| DeleteWebhook | DELETE /v1/webhooks/{id} | Unregister webhook endpoint | JWT |
| ListWebhooks | GET /v1/webhooks | Fetch all webhooks for user | JWT |
| DeliverWebhookEvent | POST /v1/events | Trigger webhook delivery | Service Auth |
| GetWebhookDelivery | GET /v1/deliveries/{id} | Retrieve delivery status | JWT |
| RetryWebhookDelivery | POST /v1/deliveries/{id}/retry | Retry failed delivery | JWT |
Request and Response Schemas
CreateWebhookRequest
{
"url": "https://example.com/webhook",
"secret": "base64-encoded-secret",
"events": ["user.created", "user.updated"],
"retryPolicy": {
"maxRetries": 3,
"backoffFactor": 2,
"initialDelayMs": 1000
},
"timeoutMs": 5000,
"headers": {
"Authorization": "Bearer token",
"Content-Type": "application/json"
}
}
CreateWebhookResponse
{
"id": "webhook-12345",
"url": "https://example.com/webhook",
"secret": "base64-encoded-secret",
"events": ["user.created", "user.updated"],
"status": "active",
"createdAt": "2023-01-01T00:00:00Z"
}
WebhookDeliveryResponse
{
"id": "delivery-67890",
"webhookId": "webhook-12345",
"eventId": "event-abcde",
"status": "success",
"attemptCount": 1,
"responseCode": 200,
"responseTimeMs": 150,
"deliveredAt": "2023-01-01T00:00:05Z",
"errorMessage": null
}
HTTP Status Codes and Error Format
Standard HTTP Status Codes:
- 200 OK - Successful GET, PUT, PATCH
- 201 Created - Successful POST (create)
- 204 No Content - Successful DELETE
- 400 Bad Request - Invalid input
- 401 Unauthorized - Missing or invalid auth
- 403 Forbidden - Insufficient permissions
- 404 Not Found - Resource not found
- 429 Too Many Requests - Rate limited
- 500 Internal Server Error - Unexpected server error
Standardized Error Response Format:
{
"error": {
"code": "WEBHOOK_URL_INVALID",
"message": "Webhook URL is invalid",
"details": {
"field": "url",
"value": "invalid-url"
},
"timestamp": "2023-01-01T00:00:00Z"
}
}
API Versioning Strategy
- Path-based versioning (
/v1/webhooks) to ensure backward compatibility - Major version changes require explicit migration path
- Minor versions are used for non-breaking enhancements
Pagination Design
Cursor-based pagination is selected because:
- More efficient for large datasets than offset-based
- Consistent results even when data changes during pagination
- Handles concurrent modifications gracefully
Authentication and Authorization
JWT Bearer Tokens with scopes:
webhook:write- Create/Update/Delete webhookswebhook:read- Read webhook configurations and deliverieswebhook:retry- Retry failed deliveries
Rate Limiting Strategy
Per-user rate limiting with:
- 1000 requests per minute for webhook management
- 5000 requests per minute for event delivery
- Token bucket algorithm with burst capacity of 100
Idempotency Design
Idempotency Key Strategy:
- For mutation endpoints, clients provide
X-Idempotency-Keyheader - System stores operation state with key as primary identifier
- Duplicate requests with same key return stored result within 5 minutes
- Implementation uses Redis with TTL of 300 seconds
gRPC Service Definition (Proto)
syntax = "proto3";
package webhook;
service WebhookService {
rpc CreateWebhook(CreateWebhookRequest) returns (CreateWebhookResponse);
rpc GetWebhook(GetWebhookRequest) returns (GetWebhookResponse);
rpc UpdateWebhook(UpdateWebhookRequest) returns (UpdateWebhookResponse);
rpc DeleteWebhook(DeleteWebhookRequest) returns (DeleteWebhookResponse);
rpc ListWebhooks(ListWebhooksRequest) returns (ListWebhooksResponse);
rpc DeliverWebhookEvent(DeliverWebhookEventRequest) returns (DeliverWebhookEventResponse);
rpc GetWebhookDelivery(GetWebhookDeliveryRequest) returns (GetWebhookDeliveryResponse);
rpc RetryWebhookDelivery(RetryWebhookDeliveryRequest) returns (RetryWebhookDeliveryResponse);
}
message Webhook {
string id = 1;
string url = 2;
string secret = 3;
repeated string events = 4;
RetryPolicy retry_policy = 5;
int32 timeout_ms = 6;
map<string, string> headers = 7;
string status = 8;
google.protobuf.Timestamp created_at = 9;
}
message RetryPolicy {
int32 max_retries = 1;
int32 backoff_factor = 2;
int32 initial_delay_ms = 3;
}
message WebhookDelivery {
string id = 1;
string webhook_id = 2;
string event_id = 3;
string status = 4;
int32 attempt_count = 5;
int32 response_code = 6;
int32 response_time_ms = 7;
google.protobuf.Timestamp delivered_at = 8;
string error_message = 9;
}
message CreateWebhookRequest {
string url = 1;
string secret = 2;
repeated string events = 3;
RetryPolicy retry_policy = 4;
int32 timeout_ms = 5;
map<string, string> headers = 6;
}
message CreateWebhookResponse {
Webhook webhook = 1;
}
message GetWebhookRequest {
string id = 1;
}
message GetWebhookResponse {
Webhook webhook = 1;
}
message UpdateWebhookRequest {
string id = 1;
string url = 2;
string secret = 3;
repeated string events = 4;
RetryPolicy retry_policy = 5;
int32 timeout_ms = 6;
map<string, string> headers = 7;
}
message UpdateWebhookResponse {
Webhook webhook = 1;
}
message DeleteWebhookRequest {
string id = 1;
}
message DeleteWebhookResponse {
bool deleted = 1;
}
message ListWebhooksRequest {
int32 page_size = 1;
string page_token = 2;
}
message ListWebhooksResponse {
repeated Webhook webhooks = 1;
string next_page_token = 2;
}
message DeliverWebhookEventRequest {
string webhook_id = 1;
string event_id = 2;
string payload = 3;
int32 attempt_count = 4;
}
message DeliverWebhookEventResponse {
string delivery_id = 1;
}
message GetWebhookDeliveryRequest {
string id = 1;
}
message GetWebhookDeliveryResponse {
WebhookDelivery delivery = 1;
}
message RetryWebhookDeliveryRequest {
string id = 1;
}
message RetryWebhookDeliveryResponse {
WebhookDelivery delivery = 1;
}
3. Data Model Design
Entity-Relationship Diagram
erDiagram
WEBHOOK {
string id PK
string url
string secret
string topic
string status
int retry_count
timestamp last_delivery_attempt
timestamp created_at
timestamp updated_at
boolean is_active
}
WEBHOOK_DELIVERY {
string id PK
string webhook_id FK
string event_id
string payload
timestamp delivered_at
timestamp created_at
string status
string error_message
int response_code
int delivery_duration_ms
}
WEBHOOK_EVENT {
string id PK
string topic
string payload
timestamp created_at
string status
}
WEBHOOK_DELIVERY_ATTEMPT {
string id PK
string delivery_id FK
timestamp attempt_at
int attempt_number
string status
string error_message
int response_code
int duration_ms
}
WEBHOOK ||--o{ WEBHOOK_DELIVERY : "has"
WEBHOOK_DELIVERY ||--o{ WEBHOOK_DELIVERY_ATTEMPT : "attempts"
WEBHOOK_EVENT ||--o{ WEBHOOK_DELIVERY : "triggers"
Table Schema Definitions
Webhook Table
CREATE TABLE webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url TEXT NOT NULL,
secret TEXT NOT NULL,
topic VARCHAR(255) NOT NULL,
status VARCHAR(50) DEFAULT 'active',
retry_count INTEGER DEFAULT 0,
last_delivery_attempt TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
INDEX idx_webhook_topic (topic),
INDEX idx_webhook_status (status),
INDEX idx_webhook_active (is_active),
INDEX idx_webhook_created_at (created_at)
);
WebhookDelivery Table
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
webhook_id UUID NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
event_id VARCHAR(255) NOT NULL,
payload TEXT NOT NULL,
delivered_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(50) DEFAULT 'pending',
error_message TEXT,
response_code INTEGER,
delivery_duration_ms INTEGER,
INDEX idx_delivery_webhook_id (webhook_id),
INDEX idx_delivery_event_id (event_id),
INDEX idx_delivery_status (status),
INDEX idx_delivery_created_at (created_at),
INDEX idx_delivery_delivered_at (delivered_at)
);
WebhookDeliveryAttempt Table
CREATE TABLE webhook_delivery_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
delivery_id UUID NOT NULL REFERENCES webhook_deliveries(id) ON DELETE CASCADE,
attempt_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
attempt_number INTEGER NOT NULL,
status VARCHAR(50) NOT NULL,
error_message TEXT,
response_code INTEGER,
duration_ms INTEGER,
INDEX idx_attempt_delivery_id (delivery_id),
INDEX idx_attempt_attempt_number (attempt_number),
INDEX idx_attempt_created_at (attempt_at)
);
WebhookEvent Table
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic VARCHAR(255) NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(50) DEFAULT 'pending',
INDEX idx_event_topic (topic),
INDEX idx_event_status (status),
INDEX idx_event_created_at (created_at)
);
Primary Key Strategy
UUIDs are used as primary keys for all tables. This choice provides:
- Global uniqueness across distributed systems
- No coordination required for ID generation
- Security benefits - prevents enumeration attacks
- Scalability - no contention on ID generation
Index Design
| Table | Index Name | Fields | Purpose |
|---|---|---|---|
| webhooks | idx_webhook_topic | topic | Fast lookup by topic |
| webhooks | idx_webhook_status | status | Filter active/inactive webhooks |
| webhooks | idx_webhook_active | is_active | Fast filtering of active webhooks |
| webhook_deliveries | idx_delivery_webhook_id | webhook_id | Fast lookup by webhook |
| webhook_deliveries | idx_delivery_event_id | event_id | Fast lookup by event |
| webhook_deliveries | idx_delivery_status | status | Filter pending/delivered failures |
| webhook_deliveries | idx_delivery_created_at | created_at | Time-based queries |
| webhook_deliveries | idx_delivery_delivered_at | delivered_at | Delivery time analysis |
| webhook_delivery_attempts | idx_attempt_delivery_id | delivery_id | Fast lookup by delivery |
| webhook_delivery_attempts | idx_attempt_attempt_number | attempt_number | Track retry attempts |
| webhook_events | idx_event_topic | topic | Fast event filtering |
| webhook_events | idx_event_status | status | Event state tracking |
Data Partitioning / Sharding Strategy
Shard by topic: Webhooks and deliveries will be partitioned by topic field to:
- Allow horizontal scaling of webhook processing
- Enable topic-specific maintenance windows
- Support multi-tenant architectures where topics are tenant-specific
Partitioning will be implemented at the database level using:
- PostgreSQL partitions by topic hash
- Application-level sharding with consistent hashing for cross-service consistency
Soft Delete vs. Hard Delete Decision
Soft delete is used for the following reasons:
- Auditability: Historical delivery attempts and errors must be preserved for debugging
- Compliance: Regulatory requirements may mandate data retention
- Replay capability: Failed deliveries can be retried with historical context
- Analytics: Delivery success/failure rates need to be tracked over time
Audit Trail Design
All data modifications are logged with:
created_atandupdated_attimestampsstatusfield changes for tracking webhook lifecycleretry_countupdates for delivery tracking
Data Archival Strategy and Retention Policy
| Data Type | Retention Period | Archive Location | Reason |
|---|---|---|---|
| WebhookDeliveryAttempt | 30 days | Cold storage (S3) | Debugging and analytics |
| WebhookDelivery | 90 days | Cold storage (S3) | Compliance and audit |
| WebhookEvent | 7 days | Cold storage (S3) | Event replay capability |
| Webhook | Permanently retained | Primary DB | Configuration data |
Archival will be implemented using:
- Database partitioning with time-based retention
- Automated archiving jobs running daily
- S3 bucket with lifecycle policies for cost optimization
4. Class Design & Service Layer
classDiagram
class WebhookService {
+deliverWebhook(Webhook, WebhookDeliveryRequest) WebhookDeliveryResponse
+retryWebhookDelivery(String) boolean
+getWebhookDeliveryHistory(String, int) List~WebhookDeliveryRecord~
+pauseWebhook(String) boolean
+resumeWebhook(String) boolean
}
class WebhookRepository {
+save(Webhook) Webhook
+findById(String) Optional~Webhook~
+findByStatus(WebhookStatus, int, int) List~Webhook~
+updateStatus(String, WebhookStatus) boolean
+updateLastDeliveryAttempt(String, Instant) boolean
}
class WebhookDeliveryRepository {
+save(WebhookDeliveryRecord) WebhookDeliveryRecord
+findByWebhookId(String, int, int) List~WebhookDeliveryRecord~
+updateStatus(String, WebhookDeliveryStatus) boolean
+findByStatusAndAttemptTime(WebhookDeliveryStatus, Instant, int) List~WebhookDeliveryRecord~
}
class WebhookProcessor {
+processWebhookDelivery(String) void
+handleDeliveryFailure(String, Exception) void
}
class WebhookEventPublisher {
+publish(WebhookDeliveryRecord) void
}
class WebhookRetryScheduler {
+scheduleRetry(String, int) void
}
class WebhookDeliveryStrategy {
+execute(WebhookDeliveryRequest) WebhookDeliveryResponse
}
class DefaultWebhookDeliveryStrategy {
+execute(WebhookDeliveryRequest) WebhookDeliveryResponse
}
class WebhookValidator {
+validate(Webhook) boolean
+validateDeliveryRequest(WebhookDeliveryRequest) boolean
}
class WebhookEvent {
-id: String
-eventType: String
-payload: String
-timestamp: Instant
-webhookId: String
}
class Webhook {
-id: String
-url: String
-secret: String
-status: WebhookStatus
-retryPolicy: RetryPolicy
-createdAt: Instant
-updatedAt: Instant
-lastDeliveryAttempt: Instant
}
class WebhookDeliveryRecord {
-id: String
-webhookId: String
-status: WebhookDeliveryStatus
-attemptNumber: int
-responseCode: int
-responseBody: String
-errorDetails: String
-createdAt: Instant
-attemptedAt: Instant
-completedAt: Instant
}
class WebhookDeliveryRequest {
-webhookId: String
-payload: String
-headers: Map~String, String~
-attemptNumber: int
}
class WebhookDeliveryResponse {
-success: boolean
-responseCode: int
-responseBody: String
-errorDetails: String
-durationMs: long
}
class RetryPolicy {
-maxRetries: int
-backoffMultiplier: double
-initialDelayMs: long
}
class WebhookStatus {
ACTIVE, PAUSED, FAILED, DISABLED
}
class WebhookDeliveryStatus {
PENDING, DELIVERED, FAILED, RETRYING
}
WebhookService --> WebhookRepository : uses
WebhookService --> WebhookDeliveryRepository : uses
WebhookService --> WebhookProcessor : delegates to
WebhookService --> WebhookEventPublisher : publishes events
WebhookService --> WebhookRetryScheduler : schedules retries
WebhookService --> WebhookDeliveryStrategy : uses strategy
WebhookService --> WebhookValidator : validates input
WebhookProcessor --> WebhookDeliveryStrategy : executes
WebhookProcessor --> WebhookEventPublisher : publishes events
WebhookDeliveryStrategy <|-- DefaultWebhookDeliveryStrategy : implements
WebhookDeliveryStrategy --> WebhookDeliveryRequest : accepts
WebhookDeliveryStrategy --> WebhookDeliveryResponse : returns
WebhookRepository --> Webhook : persists
WebhookDeliveryRepository --> WebhookDeliveryRecord : persists
WebhookEventPublisher --> WebhookEvent : creates
stateDiagram-v2
[*] --> ACTIVE
ACTIVE --> PAUSED : pauseWebhook()
PAUSED --> ACTIVE : resumeWebhook()
ACTIVE --> FAILED : deliveryFailure()
FAILED --> ACTIVE : retrySuccess()
ACTIVE --> DISABLED : disableWebhook()
DISABLED --> [*]
PAUSED --> [*]
Service Class Structure and Responsibilities
The core service layer for webhook delivery consists of:
- WebhookService - Main orchestrator that handles the complete webhook lifecycle including delivery, retry, and status management
- WebhookProcessor - Handles the actual HTTP delivery logic with retry mechanisms and error handling
- WebhookDeliveryStrategy - Strategy pattern for different delivery approaches (e.g., synchronous vs. async)
- WebhookValidator - Validates webhook configurations and delivery requests
- WebhookRetryScheduler - Manages scheduled retries for failed deliveries
Interface / Abstract Class Design
public interface WebhookDeliveryStrategy {
WebhookDeliveryResponse execute(WebhookDeliveryRequest request);
}
public abstract class AbstractWebhookService {
protected final WebhookRepository webhookRepository;
protected final WebhookDeliveryRepository deliveryRepository;
protected final WebhookEventPublisher eventPublisher;
protected final WebhookRetryScheduler retryScheduler;
protected final WebhookValidator validator;
public AbstractWebhookService(WebhookRepository webhookRepository,
WebhookDeliveryRepository deliveryRepository,
WebhookEventPublisher eventPublisher,
WebhookRetryScheduler retryScheduler,
WebhookValidator validator) {
this.webhookRepository = webhookRepository;
this.deliveryRepository = deliveryRepository;
this.eventPublisher = eventPublisher;
this.retryScheduler = retryScheduler;
this.validator = validator;
}
}
Business Logic Encapsulation Patterns
We use the Strategy Pattern for webhook delivery to support different delivery approaches:
public class DefaultWebhookDeliveryStrategy implements WebhookDeliveryStrategy {
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
public DefaultWebhookDeliveryStrategy(HttpClient httpClient, ObjectMapper objectMapper) {
this.httpClient = httpClient;
this.objectMapper = objectMapper;
}
@Override
public WebhookDeliveryResponse execute(WebhookDeliveryRequest request) {
try {
long startTime = System.currentTimeMillis();
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(request.getWebhookId()))
.timeout(Duration.ofSeconds(30))
.header("Content-Type", "application/json")
.header("X-Webhook-Id", request.getWebhookId())
.header("X-Attempt", String.valueOf(request.getAttemptNumber()));
// Add custom headers from request
request.getHeaders().forEach((key, value) ->
builder.header(key, value));
HttpRequest httpRequest = builder
.POST(HttpRequest.BodyPublishers.ofString(request.getPayload()))
.build();
HttpResponse<String> response = httpClient.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
long duration = System.currentTimeMillis() - startTime;
return WebhookDeliveryResponse.builder()
.success(response.statusCode() >= 200 && response.statusCode() < 300)
.responseCode(response.statusCode())
.responseBody(response.body())
.durationMs(duration)
.build();
} catch (Exception e) {
return WebhookDeliveryResponse.builder()
.success(false)
.errorDetails(e.getMessage())
.durationMs(System.currentTimeMillis() - startTime)
.build();
}
}
}
Critical-Path Method Implementation
public WebhookDeliveryResponse deliverWebhook(Webhook webhook, WebhookDeliveryRequest request) {
// Transaction boundary - all operations in single DB transaction
return transactionTemplate.execute(status -> {
try {
// Validate webhook and request
if (!validator.validate(webhook)) {
throw new ValidationException("Invalid webhook configuration");
}
if (!validator.validateDeliveryRequest(request)) {
throw new ValidationException("Invalid delivery request");
}
// Create delivery record
WebhookDeliveryRecord deliveryRecord = WebhookDeliveryRecord.builder()
.id(UUID.randomUUID().toString())
.webhookId(webhook.getId())
.status(WebhookDeliveryStatus.PENDING)
.attemptNumber(request.getAttemptNumber())
.createdAt(Instant.now())
.attemptedAt(Instant.now())
.build();
deliveryRepository.save(deliveryRecord);
// Execute delivery with strategy
WebhookDeliveryResponse response = webhookDeliveryStrategy.execute(request);
// Update delivery record based on result
if (response.isSuccess()) {
deliveryRecord.setStatus(WebhookDeliveryStatus.DELIVERED);
deliveryRecord.setCompletedAt(Instant.now());
deliveryRecord.setResponseCode(response.getResponseCode());
deliveryRecord.setResponseBody(response.getResponseBody());
} else {
deliveryRecord.setStatus(WebhookDeliveryStatus.FAILED);
deliveryRecord.setCompletedAt(Instant.now());
deliveryRecord.setErrorDetails(response.getErrorDetails());
// Schedule retry if needed
scheduleRetryIfApplicable(webhook, deliveryRecord);
}
// Update database state
deliveryRepository.updateStatus(deliveryRecord.getId(), deliveryRecord.getStatus());
// Publish event for monitoring and analytics
WebhookEvent event = WebhookEvent.builder()
.id(UUID.randomUUID().toString())
.eventType("WEBHOOK_DELIVERY")
.webhookId(webhook.getId())
.payload(request.getPayload())
.timestamp(Instant.now())
.build();
eventPublisher.publish(event);
return response;
} catch (Exception e) {
// Log and handle error
logger.error("Failed to deliver webhook: {}", webhook.getId(), e);
// Update delivery record with error
WebhookDeliveryRecord failedRecord = WebhookDeliveryRecord.builder()
.id(UUID.randomUUID().toString())
.webhookId(webhook.getId())
.status(WebhookDeliveryStatus.FAILED)
.attemptNumber(request.getAttemptNumber())
.errorDetails(e.getMessage())
.createdAt(Instant.now())
.completedAt(Instant.now())
.build();
deliveryRepository.save(failedRecord);
throw new WebhookDeliveryException("Webhook delivery failed", e);
}
});
}
Transaction Boundary Design
All webhook delivery operations are wrapped in a single database transaction to ensure consistency:
- Create delivery record
- Execute HTTP request
- Update status in database
- Publish event
The transaction ensures that if any part fails, the entire operation is rolled back.
Service Interface Contracts
public interface WebhookService {
WebhookDeliveryResponse deliverWebhook(Webhook webhook, WebhookDeliveryRequest request);
boolean retryWebhookDelivery(String deliveryId);
List<WebhookDeliveryRecord> getWebhookDeliveryHistory(String webhookId, int limit);
boolean pauseWebhook(String webhookId);
boolean resumeWebhook(String webhookId);
}
Saga / Orchestration Pattern
For complex webhook workflows involving multiple systems, we implement a saga pattern:
public class WebhookSaga {
private final List<WebhookStep> steps;
public void execute() {
for (WebhookStep step : steps) {
try {
step.execute();
} catch (Exception e) {
// Compensate previous steps
compensateSteps(steps, step);
throw new WebhookSagaException("Saga failed at step: " + step.getName(), e);
}
}
}
private void compensateSteps(List<WebhookStep> steps, WebhookStep failedStep) {
// Reverse the order and execute compensation logic
for (int i = steps.indexOf(failedStep) - 1; i >= 0; i--) {
steps.get(i).compensate();
}
}
}
Domain Object / Value Object Design
public class Webhook {
private final String id;
private final String url;
private final String secret;
private WebhookStatus status;
private RetryPolicy retryPolicy;
private final Instant createdAt;
private Instant updatedAt;
private Instant lastDeliveryAttempt;
// Constructor, getters, and setters
}
public class WebhookDeliveryRecord {
private final String id;
private final String webhookId;
private WebhookDeliveryStatus status;
private int attemptNumber;
private int responseCode;
private String responseBody;
private String errorDetails;
private final Instant createdAt;
private final Instant attemptedAt;
private Instant completedAt;
}
public class WebhookDeliveryRequest {
private final String webhookId;
private final String payload;
private final Map<String, String> headers;
private final int attemptNumber;
}
The design follows clean architecture principles with clear separation of concerns, proper encapsulation of business logic, and support for extensibility through the strategy pattern. The critical-path method demonstrates proper transaction handling, error propagation, and event publishing in a single coherent operation.
5. Concurrency & Thread Safety
Concurrency Model
The webhook delivery system operates in a multi-threaded environment with the following concurrency model:
- Thread Pool: Uses a fixed-size thread pool (e.g., 10-50 threads) for processing webhook deliveries, allowing concurrent processing of multiple webhooks without exhausting system resources.
- Async/Await Pattern: Leverages async I/O operations for HTTP calls to avoid blocking threads during network I/O, enabling efficient handling of high-volume webhook delivery.
- Event Loop: For systems with high throughput, an event loop pattern (e.g., Reactor pattern) can be used to handle multiple concurrent webhook deliveries with minimal thread overhead.
Race Condition Prevention Strategy
Race conditions are mitigated through:
- Atomic Operations: Critical sections like updating delivery status or incrementing retry counters use atomic operations or database-level atomic updates.
- Lock-Free Data Structures: Use of concurrent collections (e.g.,
ConcurrentHashMap,AtomicReference) for in-memory caches and state tracking. - Database-Level Atomicity: Use of database transactions and atomic UPDATE statements to ensure consistency when modifying delivery records.
Optimistic vs. Pessimistic Locking Selection
Optimistic Locking is selected for webhook delivery due to:
- High Concurrency: Multiple consumers may attempt to process the same webhook delivery simultaneously.
- Low Contention: Webhook delivery is typically not highly contended, making optimistic locking more efficient.
- Scalability: Avoids blocking threads and reduces system overhead.
Implementation approach:
// Example optimistic locking in delivery update
public boolean updateDeliveryStatus(UUID deliveryId, DeliveryStatus status, int expectedVersion) {
String sql = "UPDATE webhook_deliveries SET status = ?, version = version + 1 WHERE id = ? AND version = ?";
int rowsAffected = jdbcTemplate.update(sql, status.name(), deliveryId, expectedVersion);
return rowsAffected > 0;
}
Deadlock Prevention Strategy
Deadlock prevention mechanisms:
- Consistent Lock Ordering: All database operations follow a consistent order (e.g., always acquire locks in ID order).
- Timeout Configuration: Set reasonable timeouts (e.g., 5 seconds) for database locks to prevent indefinite blocking.
- Retry Logic: Implement exponential backoff retry logic with jitter for transient lock failures.
Idempotency Implementation for Concurrent Duplicate Requests
Idempotency is implemented using:
- Idempotency Keys: Unique keys derived from request metadata (e.g., payload hash, timestamp, source URL) to identify duplicate deliveries.
- In-Memory Cache: Use a concurrent cache (e.g., Caffeine) to store recent idempotency keys with TTL.
- Database Deduplication: Enforce uniqueness at database level using unique constraints on idempotency keys.
public class WebhookDeliveryService {
private final ConcurrentMap<String, Boolean> idempotencyCache = new ConcurrentHashMap<>();
public boolean isIdempotent(String idempotencyKey) {
return idempotencyCache.putIfAbsent(idempotencyKey, true) != null;
}
public void processDelivery(WebhookDelivery delivery) {
String idempotencyKey = generateIdempotencyKey(delivery);
if (isIdempotent(idempotencyKey)) {
logger.info("Skipping duplicate delivery: {}", idempotencyKey);
return;
}
// Proceed with actual delivery logic
deliverWebhook(delivery);
}
private String generateIdempotencyKey(WebhookDelivery delivery) {
return DigestUtils.sha256Hex(
delivery.getPayload() +
delivery.getSourceUrl() +
delivery.getTimestamp().toEpochMilli()
);
}
}
Thread Safety in Service Layer
All service methods are designed to be thread-safe:
- Immutable Objects: Delivery payloads and metadata are immutable to prevent concurrent modification.
- Thread-Local Storage: Use thread-local variables for temporary state (e.g., tracing IDs) without sharing across threads.
- Synchronized Access: Critical shared resources (e.g., retry counters) are protected using synchronized blocks or atomic operations.
Concurrency Control in Retry Logic
Retry logic uses:
- Rate-Limited Concurrent Processing: Limit concurrent retries to prevent overwhelming downstream services.
- Backoff Strategy: Exponential backoff with jitter to prevent thundering herd on retry.
- Batch Processing: Process deliveries in batches to reduce thread contention and improve throughput.
Memory Management Considerations
- Object Pooling: Reuse delivery objects to reduce GC pressure.
- Memory-Efficient Data Structures: Use primitive collections (e.g.,
IntMap) where possible. - Async Processing Queue: Use bounded queues to prevent memory overflow under high load.
Scalability Considerations
- Horizontal Scaling: Design service layer to be stateless for horizontal scaling across multiple instances.
- Partitioning Strategy: Distribute webhook processing across thread pools or worker nodes based on delivery type or source.
- Graceful Degradation: Implement circuit breaker patterns to prevent cascading failures during downstream service outages.
6. Sequence Diagrams for Key Flows
sequenceDiagram
participant U as User/Client
participant API as Webhook Service API
participant DB as Database
participant Queue as Message Queue
participant Worker as Webhook Worker
participant Target as Webhook Endpoint
U->>API: POST /webhooks
API->>DB: Create webhook record
DB-->>API: Return webhook ID
API-->>U: 201 Created {id: "webhook-123", url: "..."}
Note over API,DB: Webhook created with status "PENDING"
API->>Queue: Publish webhook event
Queue-->>Worker: Deliver to worker
Worker->>DB: Fetch webhook details
DB-->>Worker: Return webhook config
Worker->>Target: POST https://target.example.com/webhook
Target-->>Worker: 200 OK or 4xx/5xx
Worker->>DB: Update webhook status
DB-->>Worker: Acknowledge update
alt Success
Worker->>DB: Update status to "DELIVERED"
DB-->>Worker: Confirm update
else Failure
Worker->>DB: Update status to "FAILED"
DB-->>Worker: Confirm update
end
Worker->>Queue: Acknowledge message processing
sequenceDiagram
participant U as User/Client
participant API as Webhook Service API
participant DB as Database
participant Queue as Message Queue
participant Worker as Webhook Worker
participant Target as Webhook Endpoint
U->>API: POST /webhooks
API->>DB: Create webhook record with retry config
DB-->>API: Return webhook ID
API-->>U: 201 Created {id: "webhook-123", url: "..."}
Note over API,DB: Webhook created with retry policy
API->>Queue: Publish webhook event
Queue-->>Worker: Deliver to worker
Worker->>DB: Fetch webhook details
DB-->>Worker: Return webhook config
Worker->>Target: POST https://target.example.com/webhook
Target-->>Worker: 500 Internal Server Error
Worker->>DB: Update webhook status to "FAILED"
DB-->>Worker: Confirm update
Note over Worker: Retry logic triggered
Worker->>Queue: Publish retry event (after delay)
Queue-->>Worker: Deliver to worker
Worker->>Target: Retry POST request
Target-->>Worker: 200 OK
Worker->>DB: Update status to "DELIVERED"
DB-->>Worker: Confirm update
sequenceDiagram
participant U as User/Client
participant API as Webhook Service API
participant DB as Database
participant Queue as Message Queue
participant Worker as Webhook Worker
participant Target as Webhook Endpoint
U->>API: POST /webhooks
API->>DB: Create webhook record with timeout config
DB-->>API: Return webhook ID
API-->>U: 201 Created {id: "webhook-123", url: "..."}
API->>Queue: Publish webhook event
Queue-->>Worker: Deliver to worker
Worker->>DB: Fetch webhook details
DB-->>Worker: Return webhook config
Worker->>Target: POST https://target.example.com/webhook
Target-->>Worker: Timeout (5s)
Note over Worker: Timeout handling
Worker->>DB: Update status to "TIMEOUT"
DB-->>Worker: Confirm update
alt Retry logic enabled
Worker->>Queue: Publish retry event (after delay)
Queue-->>Worker: Deliver to worker
Worker->>Target: Retry POST request
Target-->>Worker: 200 OK
Worker->>DB: Update status to "DELIVERED"
DB-->>Worker: Confirm update
else Retry logic disabled
Worker->>DB: Update status to "FAILED"
DB-->>Worker: Confirm update
end
7. Caching Design
Cache Strategy Selection
For webhook delivery system, I'll use cache-aside pattern with write-through for critical data and write-behind for non-critical metadata. This approach provides the best balance between consistency, performance, and system resilience.
Cache Key Design
Cache keys will follow a structured namespace pattern:
webhook:{webhookId}:{type}:{keySuffix}
Examples:
webhook:12345:config:delivery_settingswebhook:12345:status:last_deliverywebhook:12345:metrics:delivery_stats
TTL Strategy
| Data Type | TTL | Reason |
|---|---|---|
| Webhook Configuration | 1 hour | Frequent updates, acceptable staleness |
| Delivery Status Cache | 30 minutes | Recent status important but can be refreshed |
| Retry Queue State | 24 hours | Long-lived retry state tracking |
| Rate Limiting Bucket | 5 minutes | Short-lived rate limiting window |
| Webhook Events | 1 hour | Recent events needed for debugging |
Cache Eviction Policy
LRU (Least Recently Used) with TTL-based expiration as primary policy. For high-traffic webhooks, we'll implement a hybrid approach:
- LRU for frequently accessed configurations
- TTL-based for time-sensitive delivery metrics
Cache Stampede Prevention
-
Probabilistic Early Expiry:
- Cache entries expire at 80% of TTL to prevent stampede
- Random jitter added to prevent synchronized refreshes
-
Distributed Locking:
public class CacheLockManager { private final RedisTemplate<String, String> redisTemplate; public boolean acquireCacheLock(String key, String lockValue, int ttlSeconds) { String lockKey = "cache_lock:" + key; Boolean acquired = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(ttlSeconds)); return acquired != null && acquired; } }
Distributed Cache Architecture
Using Redis Cluster with:
- 3 master nodes for high availability
- 3 replica nodes per master for read scaling
- Redis Sentinel for automatic failover
- Cache sharding by webhook ID hash
Cache Invalidation Strategy
Event-driven invalidation with explicit purges for critical updates:
-
Automatic Invalidation:
- When webhook config changes → invalidate all related cache entries
- When delivery status updates → invalidate status cache
-
Manual Invalidation:
- Admin API endpoint to force cache refresh
- Scheduled cleanup of expired entries
@Component
public class WebhookCacheInvalidator {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void invalidateWebhookCache(String webhookId) {
// Invalidate all cache entries for this webhook
String pattern = "webhook:" + webhookId + ":*";
Set<String> keys = redisTemplate.keys(pattern);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
public void invalidateWebhookConfig(String webhookId) {
// Invalidate configuration cache only
String configKey = "webhook:" + webhookId + ":config:*";
Set<String> keys = redisTemplate.keys(configKey);
if (keys != null && !keys.isEmpty()) {
redisTemplate.delete(keys);
}
}
}
Cache Read/Write Patterns
@Service
public class WebhookCacheService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public WebhookConfig getCachedWebhookConfig(String webhookId) {
String cacheKey = "webhook:" + webhookId + ":config";
// Cache-aside pattern
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return parseWebhookConfig(cached);
}
// Cache miss - fetch from database
WebhookConfig config = webhookRepository.findById(webhookId);
if (config != null) {
// Write-through to cache
redisTemplate.opsForValue().set(
cacheKey,
serializeWebhookConfig(config),
Duration.ofHours(1)
);
}
return config;
}
}
Cache Performance Considerations
- Read-through caching for frequently accessed webhook configurations
- Write-through caching for critical delivery metadata
- Batch cache updates to reduce Redis round trips
- Cache warming during system startup for hot webhooks
Cache Monitoring
- Track cache hit ratio (target: >85%)
- Monitor cache miss rates for hot keys
- Alert on cache eviction frequency spikes
- Track Redis memory usage and fragmentation
This caching strategy ensures optimal performance for webhook delivery while maintaining data consistency through event-driven invalidation and proper TTL management.
8. Repository / Data Access Layer
Repository Pattern Implementation
The repository pattern is implemented to abstract data access logic and provide a clean separation between domain objects and persistence concerns. Each entity has its own repository interface with standardized CRUD operations.
public interface WebhookRepository {
Webhook save(Webhook webhook);
Optional<Webhook> findById(UUID id);
List<Webhook> findByEndpointUrl(String endpointUrl);
Page<Webhook> findAll(Pageable pageable);
void deleteById(UUID id);
long countByStatus(WebhookStatus status);
}
public interface WebhookDeliveryRepository {
WebhookDelivery save(WebhookDelivery delivery);
Optional<WebhookDelivery> findById(UUID id);
List<WebhookDelivery> findByWebhookId(UUID webhookId);
Page<WebhookDelivery> findByWebhookId(UUID webhookId, Pageable pageable);
List<WebhookDelivery> findFailedDeliveriesBefore(Instant cutoffTime, int limit);
void updateStatus(UUID id, WebhookDeliveryStatus status);
void updateAttemptCount(UUID id, int attemptCount);
}
ORM vs. Raw Query Decisions
For the Webhook system, we use JPA/Hibernate for most operations due to its type safety and ease of maintenance. Raw queries are used for performance-critical batch operations:
// JPA Repository - Type-safe and maintainable
@Query("SELECT w FROM Webhook w WHERE w.status = :status AND w.nextRetryTime <= :now")
List<Webhook> findWebhooksNeedingRetry(@Param("status") WebhookStatus status,
@Param("now") Instant now);
// Raw Query - Performance-critical batch operations
@Query(value = "SELECT * FROM webhook_deliveries WHERE status = 'FAILED' AND created_at < :cutoff LIMIT :limit",
nativeQuery = true)
List<WebhookDelivery> findFailedDeliveriesBefore(@Param("cutoff") Instant cutoffTime,
@Param("limit") int limit);
Read vs. Write Separation
We implement CQRS pattern for better scalability and performance:
// Read-optimized view for querying webhook status
@Query("SELECT new com.company.webhook.dto.WebhookStatusView(w.id, w.status, w.endpointUrl) " +
"FROM Webhook w WHERE w.id = :id")
WebhookStatusView getWebhookStatus(@Param("id") UUID id);
// Write-optimized repository for mutations
public class WebhookWriteRepository {
@Modifying
@Query("UPDATE Webhook w SET w.status = :status, w.lastAttemptedAt = :attemptedAt " +
"WHERE w.id = :id")
void updateWebhookStatus(@Param("id") UUID id,
@Param("status") WebhookStatus status,
@Param("attemptedAt") Instant attemptedAt);
}
Connection Pool Configuration
Connection pool is configured with proper sizing and validation:
# application.yml - HikariCP configuration
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
validation-timeout: 5000
leak-detection-threshold: 60000
N+1 Query Prevention Strategy
We use eager loading and batch fetching to prevent N+1 issues:
// Prevents N+1 query in webhook delivery listing
@Query("SELECT w FROM Webhook w LEFT JOIN FETCH w.deliveries d WHERE w.id = :id")
Optional<Webhook> findByIdWithDeliveries(@Param("id") UUID id);
// Batch loading for webhook delivery history
@Query("SELECT d FROM WebhookDelivery d WHERE d.webhookId IN :webhookIds")
List<WebhookDelivery> findDeliveriesByWebhookIds(@Param("webhookIds") List<UUID> webhookIds);
Batch Operation Design
Batch operations are implemented for bulk webhook processing:
public class WebhookBatchProcessor {
@Transactional
public void processFailedDeliveries(List<UUID> deliveryIds) {
// Batch update status in single transaction
String updateQuery = "UPDATE WebhookDelivery d SET d.status = :status WHERE d.id IN :ids";
entityManager.createQuery(updateQuery)
.setParameter("status", WebhookDeliveryStatus.PENDING_RETRY)
.setParameter("ids", deliveryIds)
.executeUpdate();
// Batch save retry events
List<WebhookRetryEvent> events = deliveryIds.stream()
.map(id -> new WebhookRetryEvent(id, Instant.now()))
.collect(Collectors.toList());
eventRepository.saveAll(events);
}
}
Data Access Layer Implementation
@Repository
public class WebhookRepositoryImpl implements WebhookRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public Webhook save(Webhook webhook) {
if (webhook.getId() == null) {
entityManager.persist(webhook);
} else {
entityManager.merge(webhook);
}
return webhook;
}
@Override
public Optional<Webhook> findById(UUID id) {
return Optional.ofNullable(entityManager.find(Webhook.class, id));
}
@Override
public List<Webhook> findByEndpointUrl(String endpointUrl) {
return entityManager.createQuery(
"SELECT w FROM Webhook w WHERE w.endpointUrl = :endpointUrl",
Webhook.class)
.setParameter("endpointUrl", endpointUrl)
.getResultList();
}
@Override
public Page<Webhook> findAll(Pageable pageable) {
TypedQuery<Webhook> query = entityManager.createQuery(
"SELECT w FROM Webhook w ORDER BY w.createdAt DESC",
Webhook.class);
query.setFirstResult(pageable.getPageNumber() * pageable.getPageSize());
query.setMaxResults(pageable.getPageSize());
return new PageImpl<>(query.getResultList(), pageable, count());
}
private long count() {
return entityManager.createQuery("SELECT COUNT(w) FROM Webhook w", Long.class)
.getSingleResult();
}
}
9. Asynchronous Processing & Messaging
Event-driven boundaries in this feature
The webhook delivery system is designed to be highly asynchronous, with the core delivery logic decoupled from the initial request processing. The system uses a publish-subscribe model where:
- Synchronous boundary: Webhook creation and initial validation happen synchronously to ensure immediate feedback to the user.
- Asynchronous boundary: Actual delivery of webhooks to subscriber endpoints is handled asynchronously via a message queue.
- Event-driven triggers:
- New webhook creation → triggers
WebhookCreatedEvent - Delivery success/failure → triggers
WebhookDeliveryEvent - Retry attempts → trigger
WebhookRetryEvent
- New webhook creation → triggers
This decouples webhook creation from delivery, allowing the system to handle burst traffic and retry logic without blocking user operations.
Message schema design with a sample payload
{
"version": "1.0",
"eventId": "webhook-delivery-1234567890",
"eventType": "webhook.delivery",
"timestamp": "2023-10-15T14:30:00Z",
"payload": {
"webhookId": "wh_abc123def456",
"url": "https://api.example.com/webhook",
"payload": "{\"event\": \"user.created\", \"data\": {\"id\": \"user_123\"}}",
"headers": {
"Content-Type": "application/json",
"X-Webhook-Signature": "sha256=..."
},
"attemptNumber": 1,
"maxAttempts": 5
}
}
Dead letter queue strategy and poison message handling
Poison messages are handled through a dead letter queue (DLQ) pattern:
- Retry logic: Messages are retried up to 5 times with exponential backoff (1s, 2s, 4s, 8s, 16s)
- DLQ routing: Messages that fail after max retries are sent to a dedicated
webhook.dlqtopic - Manual intervention: DLQ messages are monitored and can be manually reprocessed by administrators
- Error categorization:
- Network timeouts → retryable
- HTTP 4xx errors (client errors) → non-retryable (e.g., 404, 403)
- HTTP 5xx errors → retryable
At-least-once vs. exactly-once delivery
At-least-once delivery is selected for the following reasons:
- Simplicity: Easier to implement and maintain than exactly-once semantics
- Idempotency in subscribers: Most webhook consumers are designed to handle duplicate events gracefully
- System reliability: Reduces complexity in maintaining state across multiple systems
- Performance: Avoids the overhead of transactional consistency mechanisms
The system relies on subscriber-side idempotency to handle duplicates, which is a common pattern in webhook systems.
Retry policy for consumers
public class WebhookRetryPolicy {
private static final int MAX_RETRIES = 5;
private static final long BASE_DELAY_MS = 1000;
private static final double BACKOFF_MULTIPLIER = 2.0;
public long getDelay(int attempt) {
if (attempt <= 0 || attempt > MAX_RETRIES) {
return -1; // No more retries
}
long delay = (long) (BASE_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, attempt - 1));
// Add jitter (±25%)
long jitter = (long) (delay * 0.25 * (Math.random() - 0.5) * 2);
return Math.max(100, delay + jitter);
}
public boolean shouldRetry(int attempt, int statusCode) {
if (attempt >= MAX_RETRIES) return false;
// Retry on server errors and timeouts
return statusCode >= 500 || statusCode == 408; // 408 = Request Timeout
}
}
Outbox pattern for reliable event publishing
The outbox pattern is implemented to ensure reliable event publishing:
- Transaction boundary: Events are written to an
outboxtable within the same database transaction as the business operation - Async processing: A separate background worker polls the outbox table and publishes messages to the message broker
- Guaranteed delivery: If publishing fails, the event remains in outbox and will be retried
- Idempotency: Events are uniquely identified by a
messageIdto prevent duplicate processing
// OutboxEvent entity
@Entity
@Table(name = "outbox_events")
public class OutboxEvent {
@Id
private String id;
@Column(name = "event_type")
private String eventType;
@Column(name = "payload")
private String payload;
@Column(name = "status")
private String status; // PENDING, PROCESSED, FAILED
@Column(name = "created_at")
private Instant createdAt;
}
Sequence diagram for async flows
sequenceDiagram
participant User
participant WebhookService
participant Database
participant OutboxRepository
participant MessageBroker
participant WebhookConsumer
User->>WebhookService: Create Webhook
WebhookService->>Database: Save Webhook
WebhookService->>OutboxRepository: Insert OutboxEvent
OutboxRepository->>Database: Commit Transaction
loop Polling for Outbox Events
MessageBroker->>OutboxRepository: Fetch Pending Events
OutboxRepository->>MessageBroker: Return Events
MessageBroker->>MessageBroker: Publish to Webhook Topic
end
MessageBroker->>WebhookConsumer: Deliver Webhook Event
WebhookConsumer->>WebhookConsumer: Process Delivery
WebhookConsumer->>Database: Update Delivery Status
10. Error Handling Design
Error Classification Hierarchy
The webhook delivery system implements a three-tier error classification:
- Validation Errors - Input data issues (e.g., invalid URL format, missing required fields)
- Infrastructure Errors - System-level failures (e.g., network timeouts, database connection issues)
- Business Logic Errors - Delivery-specific failures (e.g., HTTP 429 rate limiting, 500 server errors)
public enum WebhookErrorType {
VALIDATION_ERROR,
INFRASTRUCTURE_ERROR,
BUSINESS_LOGIC_ERROR
}
Error Propagation Strategy
Errors are handled at the service layer and propagated upward through well-defined exception hierarchies. Validation errors are immediately thrown to prevent further processing, while infrastructure and business logic errors are logged and potentially retried.
Retry Strategy with Exponential Backoff
Retry logic uses exponential backoff with jitter to prevent thundering herd problems:
public class WebhookRetryStrategy {
private static final int MAX_RETRIES = 5;
private static final long BASE_DELAY_MS = 1000;
private static final double JITTER_FACTOR = 0.2;
public boolean shouldRetry(WebhookDeliveryAttempt attempt) {
if (attempt.getFailureCount() >= MAX_RETRIES) {
return false;
}
long delay = (long) (BASE_DELAY_MS * Math.pow(2, attempt.getFailureCount()));
delay += (long) (delay * JITTER_FACTOR * (Math.random() - 0.5) * 2);
return System.currentTimeMillis() > attempt.getNextRetryTime() + delay;
}
}
Circuit Breaker Design
The system implements a circuit breaker pattern for each webhook endpoint to prevent cascading failures:
public class WebhookCircuitBreaker {
private static final int FAILURE_THRESHOLD = 5;
private static final long TIMEOUT_MS = 30000;
private static final int RESET_TIMEOUT_MS = 60000;
private volatile CircuitState state = CircuitState.CLOSED;
private int failureCount = 0;
private long lastFailureTime = 0;
public boolean canAttemptDelivery() {
if (state == CircuitState.OPEN) {
if (System.currentTimeMillis() - lastFailureTime > RESET_TIMEOUT_MS) {
state = CircuitState.HALF_OPEN;
} else {
return false;
}
}
return state != CircuitState.OPEN;
}
public void recordSuccess() {
if (state == CircuitState.HALF_OPEN) {
state = CircuitState.CLOSED;
}
}
public void recordFailure() {
failureCount++;
lastFailureTime = System.currentTimeMillis();
if (failureCount >= FAILURE_THRESHOLD) {
state = CircuitState.OPEN;
}
}
enum CircuitState {
CLOSED, HALF_OPEN, OPEN
}
}
Fallback Behavior Design
When retries are exhausted and circuit breaker is open, the system provides fallback behaviors:
- Notification Fallback: Send alert to admin dashboard
- Dead Letter Queue: Move failed deliveries to a DLQ for manual inspection
- Retry with Different Configuration: Attempt delivery with reduced payload or different headers
Error Logging Format
Structured logging includes trace context and error metadata:
public class WebhookErrorLogger {
private static final Logger logger = LoggerFactory.getLogger(WebhookErrorLogger.class);
public void logDeliveryError(String webhookId, String endpointUrl,
WebhookErrorType errorType, Exception exception) {
logger.error("Webhook delivery failed",
"webhook_id", webhookId,
"endpoint_url", endpointUrl,
"error_type", errorType.name(),
"error_message", exception.getMessage(),
"stack_trace", exception.getStackTrace(),
"timestamp", Instant.now().toString());
}
}
Alerting Thresholds
Critical alerts are triggered for:
- 5+ consecutive failures in 1 minute (critical alert)
- 20+ failures in 5 minutes (warning alert)
- Circuit breaker opens for >30 seconds (critical alert)
Error Budget Considerations
The system maintains error budgets to ensure reliability targets are met. For a 99.9% SLI, the system allows 0.1% error rate, with automatic alerts when this threshold is exceeded for sustained periods.
Retryable vs Non-Retryable Errors
public class RetryableErrorClassifier {
public static boolean isRetryable(WebhookDeliveryAttempt attempt) {
return attempt.getFailureReason() != null &&
(attempt.getFailureReason().contains("timeout") ||
attempt.getFailureReason().contains("502") ||
attempt.getFailureReason().contains("503") ||
attempt.getFailureReason().contains("429"));
}
}
Error Recovery Patterns
- Idempotent Delivery: System ensures same payload isn't delivered multiple times
- State Synchronization: Failed deliveries are periodically reprocessed from DLQ
- Manual Override: Admin can force retry of specific failed deliveries
Error Handling in Async Flows
In async delivery scenarios, errors are logged and the system transitions to a dead letter queue pattern where failed messages are moved to a separate processing queue for manual inspection and potential retry.
11. Security Design
Input Validation and Sanitization Strategy
All webhook payloads must undergo strict validation before processing. We'll implement a multi-layered approach:
- Content-Type Validation: Verify that the incoming request has a valid content-type (application/json, application/xml)
- Schema Validation: Use JSON Schema or XML Schema to validate payload structure against expected format
- Size Limits: Enforce maximum payload size (e.g., 10MB) to prevent resource exhaustion
- Sanitization: Remove or escape potentially dangerous characters in string fields
public class WebhookPayloadValidator {
private static final int MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB
public ValidationResult validate(WebhookRequest request) {
if (request.getContentLength() > MAX_PAYLOAD_SIZE) {
return ValidationResult.failure("Payload exceeds maximum size");
}
if (!isValidContentType(request.getContentType())) {
return ValidationResult.failure("Invalid content type");
}
// Validate schema using JSON Schema
if (!schemaValidator.isValid(request.getPayload())) {
return ValidationResult.failure("Invalid payload structure");
}
// Sanitize sensitive fields
sanitizePayload(request.getPayload());
return ValidationResult.success();
}
private boolean isValidContentType(String contentType) {
return "application/json".equals(contentType) ||
"application/xml".equals(contentType);
}
}
SQL Injection Prevention
Webhook delivery system stores webhook configurations and logs in database. All queries use parameterized statements to prevent SQL injection:
public class WebhookRepository {
private final JdbcTemplate jdbcTemplate;
public void saveWebhookConfig(WebhookConfig config) {
String sql = "INSERT INTO webhooks (id, url, secret, events, retry_policy) VALUES (?, ?, ?, ?, ?)";
jdbcTemplate.update(sql,
config.getId(),
config.getUrl(),
config.getSecret(),
config.getEvents().toString(),
config.getRetryPolicy().toJson());
}
public List<WebhookDelivery> getDeliveriesForWebhook(String webhookId) {
String sql = "SELECT * FROM webhook_deliveries WHERE webhook_id = ?";
return jdbcTemplate.query(sql, new Object[]{webhookId}, new DeliveryRowMapper());
}
}
Authentication Middleware Design
Webhook delivery system requires authentication for webhook configuration endpoints but not for actual webhook delivery (as it's a push mechanism). Authentication is handled via API keys or JWT tokens:
@Component
public class WebhookAuthFilter implements Filter {
private final JwtService jwtService;
private final ApiKeyValidator apiKeyValidator;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String authHeader = httpRequest.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtService.validateToken(token)) {
// Set user context in thread local or security context
SecurityContextHolder.getContext().setAuthentication(
jwtService.getAuthentication(token));
} else {
throw new AuthenticationException("Invalid JWT token");
}
} else if (authHeader != null && authHeader.startsWith("API_KEY ")) {
String apiKey = authHeader.substring(8);
if (!apiKeyValidator.isValid(apiKey)) {
throw new AuthenticationException("Invalid API key");
}
} else {
throw new AuthenticationException("Missing or invalid authentication");
}
chain.doFilter(request, response);
}
}
Authorization Enforcement
RBAC-based authorization ensures that only authorized users can configure webhooks:
@PreAuthorize("hasRole('WEBHOOK_ADMIN') || hasPermission(#webhookId, 'webhook', 'WRITE')")
public void updateWebhook(String webhookId, WebhookConfig config) {
// Implementation
}
@PreAuthorize("hasRole('WEBHOOK_ADMIN') || hasPermission(#webhookId, 'webhook', 'READ')")
public WebhookConfig getWebhook(String webhookId) {
// Implementation
}
public boolean hasPermission(String resourceId, String resourceType, String action) {
// Check user permissions against RBAC policy
return permissionService.hasPermission(currentUser(), resourceId, resourceType, action);
}
Sensitive Data Encryption
All sensitive data (webhook secrets, authentication tokens) is encrypted at rest using AES-256:
@Service
public class SensitiveDataEncryptionService {
private final Cipher cipher;
public String encrypt(String plaintext) {
try {
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey);
byte[] encrypted = cipher.doFinal(plaintext.getBytes());
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new EncryptionException("Failed to encrypt data", e);
}
}
public String decrypt(String encryptedText) {
try {
byte[] decoded = Base64.getDecoder().decode(encryptedText);
cipher.init(Cipher.DECRYPT_MODE, encryptionKey);
return new String(cipher.doFinal(decoded));
} catch (Exception e) {
throw new EncryptionException("Failed to decrypt data", e);
}
}
}
PII Handling and Masking Strategy
PII in webhook payloads is masked or redacted before logging:
public class PiiMaskingService {
private static final Pattern EMAIL_PATTERN =
Pattern.compile("\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b");
private static final Pattern PHONE_PATTERN =
Pattern.compile("\\b\\d{3}-\\d{3}-\\d{4}\\b");
public String maskPii(String input) {
if (input == null) return null;
// Mask email addresses
String masked = EMAIL_PATTERN.matcher(input).replaceAll("****@****.com");
// Mask phone numbers
masked = PHONE_PATTERN.matcher(masked).replaceAll("***-***-****");
return masked;
}
}
Secrets Management
Webhook secrets are managed through AWS Secrets Manager with automatic rotation:
@Service
public class SecretManagerService {
private final AWSSecretsManager secretsManager;
public String getWebhookSecret(String secretName) {
GetSecretValueRequest request = GetSecretValueRequest.builder()
.secretId(secretName)
.build();
GetSecretValueResponse response = secretsManager.getSecretValue(request);
return response.secretString();
}
public void rotateWebhookSecret(String secretName, String newSecret) {
UpdateSecretRequest request = UpdateSecretRequest.builder()
.secretId(secretName)
.secretString(newSecret)
.build();
secretsManager.updateSecret(request);
}
}
Audit Logging Design
All webhook configuration and delivery operations are logged with comprehensive audit trail:
@Component
public class WebhookAuditLogger {
private final AuditEventPublisher auditEventPublisher;
public void logWebhookConfigChange(String userId, String webhookId,
WebhookOperation operation, String details) {
AuditEvent auditEvent = AuditEvent.builder()
.userId(userId)
.resourceType("webhook")
.resourceId(webhookId)
.operation(operation)
.timestamp(Instant.now())
.details(details)
.build();
auditEventPublisher.publish(auditEvent);
}
public void logDeliveryAttempt(String webhookId, String deliveryId,
DeliveryStatus status, String error) {
AuditEvent auditEvent = AuditEvent.builder()
.userId("system")
.resourceType("webhook_delivery")
.resourceId(deliveryId)
.operation(WebhookOperation.DELIVERY_ATTEMPT)
.timestamp(Instant.now())
.details(String.format("Webhook %s delivery status: %s, error: %s",
webhookId, status, error))
.build();
auditEventPublisher.publish(auditEvent);
}
}
12. Testing Strategy
Unit Testing Scope and Coverage Targets
- 90%+ code coverage for service layer classes and business logic methods
- 100% coverage for critical-path operations (e.g., webhook delivery, retry logic)
- 95%+ coverage for data access layer components (repositories, mappers)
- Unit tests will be written using JUnit 5 and Mockito for mocking dependencies
- Each domain object and value object will have dedicated unit tests covering all valid and invalid states
Integration Testing Strategy
- Testcontainers for integration testing against real database instances (PostgreSQL 13+)
- Embedded Redis for caching layer integration testing
- WireMock for external HTTP service stubbing (e.g., webhook endpoint simulation)
- Embedded Kafka or Testcontainers with Kafka for async messaging integration testing
- Database integration tests will use separate test schemas to avoid data pollution
- End-to-end flow testing will validate complete webhook delivery lifecycle from creation to final state
Contract Testing
- Pact for consumer-driven contract testing between webhook service and external webhook endpoints
- Pact broker will be used to manage contracts across services
- Contract tests will validate:
- Expected request payload format and headers
- Response status codes and body structure
- Retry behavior expectations from webhook consumers
Performance Testing Tool and Key Scenarios
- k6 for load testing with realistic concurrent webhook delivery scenarios
- Key performance scenarios:
- Concurrent webhook delivery (1000+ concurrent webhooks)
- High-volume retry processing (5000+ failed deliveries)
- Batch webhook creation and processing (1000 webhooks in batch)
- Latency-sensitive edge case handling (500ms+ response times)
- Performance targets:
- p50 delivery time < 200ms
- p95 delivery time < 1s
- Throughput: 1000 webhooks/sec under load
Test Data Management Strategy
- Factory pattern for generating test data with realistic values and constraints
- Fixtures stored in JSON/YAML format for consistent test data across environments
- Database seeding scripts to populate test data in clean test schemas
- Test data isolation using separate schema per test suite to prevent cross-contamination
- Data factories for:
- Webhook entities with various statuses (pending, delivered, failed)
- Delivery attempts with different retry configurations
- Webhook consumers with various endpoint configurations and authentication methods
// Example test data factory for webhook creation
public class WebhookTestDataFactory {
public static Webhook createWebhook(String url, String secret) {
return Webhook.builder()
.id(UUID.randomUUID())
.url(url)
.secret(secret)
.status(WebhookStatus.PENDING)
.maxRetries(3)
.retryDelaySeconds(10)
.createdAt(Instant.now())
.build();
}
public static WebhookAttempt createAttempt(Webhook webhook, int attemptNumber) {
return WebhookAttempt.builder()
.id(UUID.randomUUID())
.webhookId(webhook.getId())
.attemptNumber(attemptNumber)
.status(AttemptStatus.PENDING)
.createdAt(Instant.now())
.build();
}
}
13. Observability Design
Structured Logging Schema
All logs must follow a consistent schema to enable effective search, filtering, and analysis:
{
"timestamp": "2023-10-27T14:30:45.123Z",
"level": "INFO|WARN|ERROR",
"service": "webhook-delivery-service",
"trace_id": "12345678-1234-1234-1234-123456789012",
"span_id": "abcdef12-3456-7890-abcd-ef1234567890",
"user_id": "user_12345",
"webhook_id": "wh_abc123",
"event_id": "ev_def456",
"message": "Webhook delivery attempt completed",
"error_code": "DELIVERY_TIMEOUT",
"error_details": {
"attempt_number": 3,
"retry_delay_ms": 60000
},
"duration_ms": 450,
"status_code": 200,
"endpoint_url": "https://api.example.com/webhook",
"payload_size_bytes": 1024
}
PII masking rules:
- User IDs and webhook IDs are logged as obfuscated UUIDs (e.g.,
user_***) - Endpoint URLs with sensitive paths or query parameters are redacted
- Payload content is never logged in full, only size and hash
Distributed Tracing Instrumentation
Using OpenTelemetry for end-to-end tracing:
Custom Spans:
webhook.delivery.attempt- Tracks individual delivery attemptswebhook.delivery.retry- For retry logic and exponential backoffwebhook.event.processing- End-to-end event processing flow
Attributes captured:
webhook.id- Unique identifier for webhookevent.id- Event that triggered deliveryattempt.number- Retry attempt count (0 for first)delivery.status- Success, retry, failurehttp.status.code- HTTP response code from target endpointdelivery.duration.ms- Time to complete delivery attempt
Metrics Design
RED Method Metrics:
| Metric Name | Type | Labels | Description |
|---|---|---|---|
webhook_delivery_attempts_total | Counter | webhook_id, status, http_status_code | Total delivery attempts by status |
webhook_delivery_duration_seconds | Histogram | webhook_id, status | Time to complete delivery |
webhook_delivery_retries_total | Counter | webhook_id, attempt_number | Retry count per webhook |
webhook_delivery_failures_total | Counter | webhook_id, error_type | Failure reasons by type |
webhook_delivery_rate_limiting_total | Counter | webhook_id, reason | Rate limiting events |
webhook_event_queue_size | Gauge | - | Current queue size |
webhook_delivery_concurrency | Gauge | - | Active delivery workers |
Additional Service Metrics:
webhook_delivery_success_rate(calculated from counters)webhook_retry_backoff_seconds(histogram of retry delays)webhook_payload_size_bytes(histogram)
Health Check Endpoint Design
/health:
description: "Liveness check - service is running"
status: "200 OK" when service can respond to health checks
/ready:
description: "Readiness check - service is ready to serve traffic"
status: "200 OK" when DB connection is healthy, queue system is accessible, and cache is available
/live:
description: "Startup check - service has completed initialization"
status: "200 OK" when all required components are initialized and stable
/startup:
description: "Detailed startup status"
status: "200 OK" with component health statuses
Alerting Thresholds
| Metric | Warning Level | Critical Level | Escalation Path |
|---|---|---|---|
webhook_delivery_success_rate | < 95% (5m) | < 80% (5m) | On-call engineer |
webhook_delivery_duration_seconds | > 5s (p95) | > 30s (p95) | SRE team |
webhook_delivery_failures_total | > 100 errors/min | > 500 errors/min | SRE team |
webhook_event_queue_size | > 10,000 events | > 50,000 events | SRE team |
webhook_delivery_concurrency | > 500 active workers | > 1,000 active workers | SRE team |
webhook_delivery_rate_limiting_total | > 1,000 events/min | > 5,000 events/min | SRE team |
On-call escalation:
- First alert → Slack notification to engineer
- Second alert within 30min → SMS to SRE team
- Third alert within 1hr → Escalate to engineering leadership
14. Open Questions, Risks & Trade-offs
Decisions requiring product / engineering alignment
- Webhook retry strategy vs. dead letter queue configuration: Should we implement a fixed exponential backoff with max retries (e.g., 5 attempts) or use a more sophisticated queue-based approach for failed deliveries? The choice affects system reliability vs. complexity.
- Delivery timeout thresholds: What should be the maximum time allowed for a webhook delivery before considering it failed? This impacts user experience and system load (e.g., 30s vs. 60s timeout).
- Event deduplication policy: Should we rely on client-provided
idempotency-keyheaders or implement our own deduplication logic based on payload content? This affects both reliability and performance. - Webhook payload size limits: What's the maximum acceptable payload size (e.g., 1MB vs. 5MB)? This impacts storage, network bandwidth, and downstream processing capabilities.
Known trade-offs and justifications
| Trade-off | Option A | Option B | Justification |
|---|---|---|---|
| Idempotency enforcement | Client-provided idempotency-key header | Server-side deduplication based on payload hash | Client-based is more flexible but requires client cooperation; server-based provides stronger guarantees but adds processing overhead |
| Delivery retry logic | Fixed exponential backoff (1s, 2s, 4s, 8s, 16s) | Dynamic retry with adaptive delays based on response codes | Fixed approach is simpler to implement and reason about; dynamic approach adapts better to service availability |
| Event persistence strategy | Store all events in database with full payload | Store only event metadata and compress payloads to external storage (S3) | Full persistence provides auditability but increases database load; external storage reduces DB pressure but adds complexity |
| Async processing model | Single-threaded async worker pool | Multi-threaded with thread-per-event pattern | Thread pool is more resource-efficient; per-event threading provides better isolation but increases context switching |
Risks and mitigation strategies
-
Risk: Webhook delivery failures causing cascading system issues
- Mitigation: Implement circuit breaker pattern per webhook endpoint, use dead-letter queue with exponential backoff, and provide admin dashboard to monitor and retry failed deliveries manually.
-
Risk: High-volume webhook traffic overwhelming the system
- Mitigation: Implement rate limiting per webhook endpoint, use queue-based processing with configurable batch sizes, and introduce priority queuing for critical events.
-
Risk: Payload size exceeding limits causing system instability
- Mitigation: Validate payload sizes before queuing, implement streaming-based processing for large payloads, and provide configuration options to set custom limits per webhook subscription.
-
Risk: Security vulnerabilities from untrusted webhook endpoints
- Mitigation: Implement signature verification using HMAC-SHA256, enforce TLS 1.3 for all webhook endpoints, and provide secure configuration options for customers to validate our system's authenticity.
Technical debt acknowledged
-
Debt: Manual retry handling in the current implementation
- The system currently uses a simple retry loop without proper circuit breaker or adaptive retry logic, which could lead to resource exhaustion under high load.
-
Debt: Inconsistent error handling across service boundaries
- Different services may return different error formats or HTTP status codes, making it harder to build a unified monitoring and alerting system.
-
Debt: Limited support for webhook payload transformations
- The current design assumes all payloads are delivered as-is, but some customers may require transformation or filtering before delivery.
-
Debt: Lack of granular metrics for individual webhook endpoints
- Currently, we only track overall delivery success/failure rates; granular metrics per endpoint would enable better troubleshooting and performance optimization.
-
Debt: No built-in webhook payload compression or encoding
- Without compression support, large payloads can cause network issues and increase delivery times significantly.
-
Debt: No support for webhook content filtering or transformation
- Customers cannot currently filter or transform payloads before delivery, which limits flexibility in use cases where specific data fields need to be masked or modified.