API built with Kotlin + Ktor to receive payloads and deliver webhooks in an asynchronous and resilient way.
When an event is received, the API persists a delivery with status PENDING and immediately returns 202 Accepted. A background worker tries to deliver the webhook with exponential retries up to a limit of 5 attempts.
Flow summary:
- Receives
POST /v1/dispatch. - Saves a pending delivery in the database.
- Worker processes the queue every 1 minute.
- On success: marks as
SUCCESS. - On failure: schedules the next retry; after the limit, marks as
FAILED.
- Added
emailsupport on user creation (emailis optional and unique when provided). - Added authenticated route
GET /v1/users/meto retrieve user data and endpoints. - Webhook delivery now uses the
WebhookDispatcherport withKtorWebhookDispatcherimplementation. - Added migration
V4__add_email_column_in_users_table.sql. - Error handling now includes
EmailAlreadyExistsExceptionwith409 Conflict.
The project follows Hexagonal Architecture (Ports & Adapters):
Infrastructure -> Application -> Domain
domain: entities, commands, business rules, exceptions, and ports.application: use case implementations.infrastructure: HTTP (Ktor), persistence (Exposed), DI (Koin), migrations (Flyway), and HTTP dispatcher.
Application.kt is the composition root and registers plugins in this order:
configureDI()configureDatabases()configureSerialization()configureValidation()configureStatusPage()configureAuthentication()configureWorkers()configureRouting()
Current versions (from gradle.properties and build.gradle.kts):
- Kotlin
2.3.0 - Ktor
3.4.2 - Exposed
1.2.0 - PostgreSQL Driver
42.7.8 - HikariCP
6.3.3 - Flyway
12.3.0 - Koin
4.2.0 - MockK
1.14.9 - JVM Toolchain
21
Base path: /v1
Health check.
Response 200:
pong
Creates a user and returns an api_key.
Body:
{
"username": "myusername",
"email": "user@example.com"
}email is optional.
Validation rules currently implemented:
usernameis required.usernamemust not contain spaces.usernamelength must be between 5 and 16 characters.usernamemust only contain letters, numbers, and_.
Response 201:
{
"api_key": "550e8400-e29b-41d4-a716-446655440000"
}Common errors:
409when username already exists.409when email already exists.400for invalid payload.
Returns authenticated user data and endpoints.
Header:
X-API-Key: 550e8400-e29b-41d4-a716-446655440000Response 200:
{
"id": 1,
"username": "myusername",
"email": "user@example.com",
"endpoints": [
{
"id": 1,
"url": "https://example.com/webhook",
"nickname": "primary",
"user_id": 1
}
]
}Registers an endpoint for the authenticated user.
Body:
{
"url": "https://example.com/webhook",
"nickname": "My-Service"
}Response 201:
{
"id": 1,
"url": "https://example.com/webhook",
"nickname": "My-Service",
"user_id": 1
}Receives a payload and schedules asynchronous delivery.
Body:
{
"endpoint_id": 1,
"payload": {
"event": "user.created",
"data": {
"id": 99
}
}
}Response: 202 Accepted (empty body).
Protected routes use API Key via X-API-Key header.
- The key is parsed to
Uuid. - The user is fetched by
findByApiKey. - On authentication failure, it returns
400withMissing or invalid API key.
The worker runs in a coroutine (Dispatchers.IO) on startup and processes pending deliveries every 1 minute.
Retry behavior in DispatchWebhookUseCaseImpl:
maxRetries = 5- exponential delay:
2^attemptsminutes (1, 2, 4, 8) - after max attempts: final status
FAILED
HTTP sending is done through WebhookDispatcher with KtorWebhookDispatcher implementation.
Exceptions are centralized in StatusPages and return a standardized ErrorResponseDTO.
Main mappings:
UsernameAlreadyExistsException->409EmailAlreadyExistsException->409EndpointAlreadyExistsException->409UserNotFoundException->404EndpointNotFoundException->404UnauthorizedEndpointAccessException->403RequestValidationException->400- fallback
Throwable->500
Database: PostgreSQL (with Exposed + HikariCP + Flyway).
Migrations in src/main/resources/db/migration:
V1__create_users_table.sqlV2__create_endpoints_table.sqlV3__create_webhook_deliveries_table.sqlV4__add_email_column_in_users_table.sql
File: src/main/resources/application.yaml
Required environment variables:
DATABASE_URLDATABASE_USERDATABASE_PASSWORD
Prerequisites:
- JDK 21+
- Docker
- Start database:
docker compose up -d- Export environment variables:
export DATABASE_URL=jdbc:postgresql://localhost:5432/webhook_gateway_app
export DATABASE_USER=your_db_user
export DATABASE_PASSWORD=your_db_password- Start API:
./gradlew runUseful commands:
./gradlew test
./gradlew build
./gradlew buildFatJar- Improve full delivery lifecycle logging (ingestion -> retries -> final status).
- Email notifications will be implemented for users to inform whether a webhook was delivered successfully or, after all retry attempts are exhausted, failed definitively.