Webhook Inbox
Build a Laravel service that receives webhooks from other systems, like payment or Git providers. Every incoming request is checked for a valid signature, so fake requests are rejected, then stored in PostgreSQL right away and answered quickly with 200. The actual processing happens later in a background queue, with retries if it fails. The same event delivered twice is recognized and processed only once. An API shows every event and its status: received, processing, processed or failed. You'll learn HMAC signature verification, idempotency, Laravel queues and jobs, migrations and Eloquent models, and why webhook receivers should acknowledge first and process later.
- Estimate
- ~13h
- Steps
- 7
- Completed by
- 0
- Proposed by
- codeseed.app
Laravel · Composer · PostgreSQL
Project roadmap
- 01
Set up Laravel and the database
~1.5hCreate a Laravel project with Composer and configure it to use PostgreSQL through .env (commit .env.example only). Create a migration and an Eloquent model for webhook_events: id, source (for example github), external_event_id, event_type, payload (jsonb), status, attempts, last_error, received_at and processed_at, with a unique index on source plus external_event_id. Done when: php artisan migrate creates the table.
- 02
Receive and verify webhooks
~2.5hAdd POST /webhooks/{source}. Read each source's secret from config. Compute an HMAC-SHA256 of the raw request body with that secret and compare it with the signature header (for example X-Signature-256: sha256=...) using hash_equals, never ==. Reject missing or invalid signatures with 401 and bodies over 1 MB with 413. Exclude this route from CSRF protection. Done when: a correctly signed request is accepted and a tampered one gets 401.
- 03
Store first, answer fast
~1.5hAfter verifying, save the event with status received and return 200 immediately, without doing any real processing in the request. Take the event id from the payload or a header, and if the same source and event id already exist, return 200 without storing it again. Done when: sending the same signed event twice creates only one row, and both requests get 200.
- 04
Process events in a queue
~2.5hUse Laravel's database queue driver. After storing an event, dispatch a ProcessWebhookEvent job. The job sets the status to processing, runs a handler chosen by event type (write a simple example handler, such as logging the event summary), then sets it to processed. Unknown event types are marked processed with a note. Run the worker with php artisan queue:work. Done when: stored events are processed by the worker and their status changes.
- 05
Retry failures with backoff
~1.5hConfigure the job with $tries = 5 and a backoff array (for example 10, 30, 60, 120 seconds). On each failure, save the error message and increase attempts; after the last try, set the status to failed. Make handlers safe to run twice, since a retry can repeat work. Done when: a handler that throws an exception is retried and the event ends as failed with its error saved.
- 06
Add a status API
~1.5hAdd GET /events (filter by status and source, paginated), GET /events/{id} with the full payload and error, and POST /events/{id}/replay, which puts a failed event back in the queue. Protect these routes with a simple API token from config. Done when: failed events can be listed, inspected and replayed through the API.
- 07
Write tests
~2hWrite feature tests with PHPUnit or Pest: a valid signature is accepted, an invalid one is rejected, a duplicate event isn't stored twice, the job moves an event to processed, and a failing handler leaves it failed after the retries. Use Queue::fake or the sync queue driver in tests, and a separate test database. Done when: php artisan test passes.
Resources
Ready to build this?
Get a GitHub repo and start building. Your AI reviewer checks each step as you go.
Tech stack