Open Source Webhook-as-a-Service (WaaS) platform that receives webhooks from third-party services and fans them out to user-defined destination URLs with automatic retries and a delivery dashboard.
The metaphor
A rail yard is a place where trains (incoming webhooks) arrive, get sorted by destination, and dispatched out on different tracks (endpoints). Cars can be re-routed if a track fails — just like retries. The yard master (the worker) orchestrates everything without the sender needing to know the details.
| Rail yard concept | WaaS concept |
|---|---|
| Incoming train | Inbound webhook (POST /in/{uuid}) |
| Rail yard | HookYard platform |
| Track / destination | Endpoint URL |
| Dispatch | Fan-out to endpoints |
| Re-routing on failure | Automatic retry (5 attempts) |
| Yard master | Queue worker |
| Cargo manifest | Delivery dashboard |
| Layer | Technology |
|---|---|
| Backend | PHP 8.4 / Symfony 7 |
| Frontend | React 18 + TypeScript + Vite + shadcn/ui + Tailwind CSS v4 |
| Database | PostgreSQL 17 |
| Queue | Symfony Messenger (AWS SQS) |
| Deployment | AWS Elastic Beanstalk (monolith) |
The backend follows Hexagonal Architecture (Ports & Adapters), keeping business logic completely isolated from the framework.
See diagram
graph TD
subgraph Driving["Driving Adapters (input)"]
HTTP["HTTP Controllers\nsrc/Controller/"]
Worker["Queue Worker\n(Messenger Handlers)"]
end
subgraph Application["Application Layer\nsrc/Application/"]
UC["Use Cases\nUseCase/"]
Ports["Ports (interfaces)\nPort/"]
end
subgraph Domain["Domain Layer\nsrc/Domain/"]
Entities["Entities & Value Objects\nUser, Source, Endpoint\nEvent, DeliveryAttempt"]
Rules["Business Rules\n& Exceptions"]
end
subgraph Driven["Driven Adapters (output)"]
DB["Doctrine Repositories\nsrc/Infrastructure/Persistence/"]
Security["Security Handlers\nsrc/Security/"]
Ext["External HTTP\n(outbound deliveries)"]
end
HTTP --> UC
Worker --> UC
UC --> Ports
UC --> Entities
Ports -.->|implemented by| DB
Ports -.->|implemented by| Ext
DB --> Security
style Domain fill:#1a3a2a,color:#fff,stroke:#2d6a4f
style Application fill:#1a2a3a,color:#fff,stroke:#2d4f6a
style Driving fill:#2a1a1a,color:#fff,stroke:#6a2d2d
style Driven fill:#2a2a1a,color:#fff,stroke:#6a5d2d
- Domain (
src/Domain/) — pure PHP: entities, value objects, domain exceptions. Zero imports fromSymfony\orDoctrine\. - Application (
src/Application/) — use cases that orchestrate domain objects through port interfaces. - Infrastructure (
src/Infrastructure/,src/Controller/,src/Security/) — Symfony/Doctrine adapters that implement the ports and expose HTTP endpoints.
POST /in/{uuid}
→ persist Event
→ enqueue one Messenger message per active Endpoint
→ return 200 OK immediately
Worker:
→ POST raw body + headers to Endpoint URL
→ adds X-Webhook-Event-Id header
→ 5 attempts: immediate → 30s → 5m → 30m → 2h
→ recomputes events.status atomically (pending / delivered / failed)
docker compose up -dphp bin/console doctrine:migrations:diff # generate migration from entity changes
php bin/console doctrine:migrations:migrate # apply pending migrations
php bin/phpunit # run all testsnpm run build # production build → public/
npm run watch # Vite dev watchUI is built with shadcn/ui components and Tailwind CSS v4. All components live in frontend/src/components/ui/.
See diagram
erDiagram
users {
int id PK
string email
string password
timestamp created_at
}
sources {
int id PK
int user_id FK
string name
uuid inbound_uuid
timestamp created_at
}
endpoints {
int id PK
int source_id FK
string url
timestamp created_at
}
events {
int id PK
int source_id FK
string method
json headers
text body
enum status
timestamp received_at
}
event_endpoint_deliveries {
int id PK
int event_id FK
int endpoint_id FK
enum status
timestamp created_at
timestamp updated_at
}
delivery_attempts {
int id PK
int event_id FK
int endpoint_id FK
smallint attempt_number
smallint status_code
text response_body
int duration_ms
timestamp attempted_at
}
users ||--o{ sources : owns
sources ||--o{ endpoints : has
sources ||--o{ events : receives
events ||--o{ event_endpoint_deliveries : tracks
endpoints ||--o{ event_endpoint_deliveries : tracks
event_endpoint_deliveries ||--o{ delivery_attempts : logs
- A Source belongs to one user and has many Endpoints and many Events.
- When an Event arrives, one
event_endpoint_deliveriesrow is created per active Endpoint (unique on(event_id, endpoint_id)). - Each delivery row accumulates up to 5
delivery_attempts(exponential backoff: immediate → 30s → 5m → 30m → 2h). events.status(pending/delivered/failed) is a denormalized cache recomputed atomically from all delivery rows every time a delivery row changes.
