Repository navigation
Webhooks
The Webhook Component provides a complete, enterprise-grade solution for receiving, verifying, and processing webhooks from external providers like Stripe, GitHub, PayPal, Shopify, and more. It handles the complex parts of webhook integration so you can focus on business logic.
A webhook is a way for an application to provide other applications with real-time information. When an event happens in the source system (e.g., a payment succeeds in Stripe), it sends an HTTP POST request to a URL you specify (your webhook endpoint).
Without proper infrastructure, webhook handling is deceptively complex:
- Signature verification – proving the request really came from the provider
- Idempotency – providers retry failed deliveries, so you may receive the same event twice
- Concurrency – multiple requests for the same event may arrive simultaneously
- Async processing – responding quickly while heavy work happens in the background
- Failure handling – what happens when your handler throws an exception?
This component solves all of the above with battle-tested patterns.
| Feature | Description |
|---|---|
| Signature Verification | HMAC, HMAC-with-timestamp (Stripe-style), and custom verifiers |
| Idempotency | Cache-based locking prevents duplicate processing |
| Async Processing | Queue-based handling for long-running tasks |
| Multi-Provider | Support unlimited providers with per-provider config |
| Multiple Handlers | Route events to one or many handlers |
| Flexible Event Routing | Match exact events, wildcards, or custom resolvers |
| Failure Modes | Choose to stop or continue on handler failure |
| Code Generation | Artisan command scaffolds everything you need |
| Structured Logging | Full observability out of the box |
Register the component's service provider (if not auto-discovered):
// config/providers.php
return [
\Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookServiceProvider::class,
];The fastest way to get started is via the make:webhook command:
php artisan make:webhook StripeThis generates:
- ✅
App\Controllers\StripeWebhookController– receives HTTP requests - ✅
App\Webhooks\Handlers\StripeWebhookHandler– your business logic - ✅
App\Providers\StripeWebhookServiceProvider– registers the handler - ✅ Configuration entry in
config/core/webhooks.php
// routes/web.php
$router->post('/webhook/stripe', [StripeWebhookController::class, 'handle']);# .env
STRIPE_WEBHOOK_SECRET=whsec_your_secret_hereHere's a complete working example in 4 files:
return [
'providers' => [
'stripe' => [
'secret' => getenv('STRIPE_WEBHOOK_SECRET'),
'async' => false,
'verify_signature' => true,
'event_resolver' => ['type' => 'type'], // JSON path to event type
'handler' => \App\Webhooks\Handlers\StripeWebhookHandler::class,
'handler_failure_mode' => 'stop',
'verify' => [
'type' => 'hmac_timestamp',
'header' => 'Stripe-Signature',
'algo' => 'sha256',
'prefix' => '',
],
],
],
];<?php
namespace App\Controllers;
use Mlangeni\Machinjiri\Core\Http\HttpRequest;
use Mlangeni\Machinjiri\Core\Http\HttpResponse;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookPayload;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookManager;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookSubscriptionManager;
class StripeWebhookController
{
public function handle(HttpRequest $request): string
{
$payload = WebhookPayload::fromHttpRequest(
$request,
'stripe',
'Stripe-Idempotency-Key', // optional, but recommended
[
'event_resolver' => ['type' => 'type'],
]
);
$manager = resolve(WebhookManager::class);
return $manager->process($payload)->toJson();
}
}<?php
namespace App\Webhooks\Handlers;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookHandlerInterface;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookPayload;
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookResponse;
class StripeWebhookHandler implements WebhookHandlerInterface
{
public function handle(WebhookPayload $payload): WebhookResponse
{
match ($payload->getEventType()) {
'payment_intent.succeeded' => $this->onPaymentSucceeded($payload),
'payment_intent.failed' => $this->onPaymentFailed($payload),
default => null,
};
return WebhookResponse::ok();
}
public function supportsEvent(): string|array
{
return ['payment_intent.succeeded', 'payment_intent.failed'];
}
private function onPaymentSucceeded(WebhookPayload $payload): void
{
$amount = $payload->get('data.object.amount');
$customer = $payload->get('data.object.customer');
// ... fulfill order, send email, etc.
}
private function onPaymentFailed(WebhookPayload $payload): void
{
// ... notify customer, log, etc.
}
}// app/Providers/StripeWebhookServiceProvider.php
public function register(): void
{
$manager = resolve(WebhookSubscriptionManager::class);
$manager->registerHandler(new \App\Webhooks\Handlers\StripeWebhookHandler());
}That's it. You now have a production-ready webhook endpoint.
A provider represents an external service (Stripe, GitHub, etc.). Each provider has its own:
- secret for signature verification
- verification method (HMAC, Stripe-style, custom)
- event resolver (where to find the event type in the payload)
- handler(s) to process events
- sync/async mode
A handler implements WebhookHandlerInterface. It receives a WebhookPayload and returns a WebhookResponse. A handler declares which events it supportsEvent() — a specific list, or '*' for all events.
The WebhookPayload wraps the incoming HTTP request, exposing:
-
getRawBody()– the raw request body (essential for signature verification) -
getParsedData()– decoded body (JSON or form-encoded) -
getEventType()– resolved event name -
getProvider()– which provider sent this -
getIdempotencyKey()– unique key for deduplication -
get('data.object.amount')– dot-notation accessor
Providers retry failed webhooks. Without deduplication you could charge a customer twice. This component uses an IdempotencyStore (default: CacheIdempotencyStore) with two states per key:
- lock – "someone is processing this right now"
- done – "this was successfully processed"
The key is derived from provider + idempotencyKey. If no idempotency key exists in the request, deduplication is skipped (but this is not ideal — most providers send one).
| Mode | Behavior | Use When |
|---|---|---|
| Sync | Handlers run during the HTTP request | Handler is fast (<2s), e.g. updating a record |
| Async | Request returns 202 immediately, WebhookJob runs handlers on a queue |
Handler is slow (API calls, emails, PDFs) |
Always prefer async for anything non-trivial — most providers require a response within a few seconds or they'll retry.
┌───────────────────────────┐
POST /webhook/stripe ───► │ StripeWebhookController │
└──────────────┬────────────┘
│
▼
┌───────────────────────────┐
│ WebhookPayload │
│ (raw body, headers, │
│ event, idempotency key) │
└──────────────┬────────────┘
│
▼
┌───────────────────────────┐
│ WebhookManager │
│ ┌─────────────────────┐ │
│ │ 1. Verify signature │ │
│ ├─────────────────────┤ │
│ │ 2. Check idempotency│ │
│ ├─────────────────────┤ │
│ │ 3. Sync or async? │ │
│ └─────────────────────┘ │
└───────┬─────────────┬─────┘
│ │
sync │ │ async
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ dispatchToHandlers│ │ WebhookJob │
│ (in-process) │ │ (queue) │
└────────┬──────────┘ └────────┬─────────┘
│ │
└──────────┬───────────┘
▼
┌────────────────────────────┐
│ WebhookHandlerInterface │
│ (your business logic) │
└────────────────────────────┘
| Class | Responsibility |
|---|---|
WebhookManager |
Orchestrator — verifies, deduplicates, dispatches |
WebhookPayload |
Immutable value object wrapping the request |
WebhookResponse |
Response value object (status, headers, body) |
WebhookSubscriptionManager |
Config + handler registry |
WebhookHandlerInterface |
Contract for handlers |
WebhookSignatureVerifier |
Static verification helpers |
CacheIdempotencyStore |
Cache-backed idempotency store |
IdempotencyStore |
Idempotency contract (swap the backend) |
WebhookJob |
Async processing job |
WebhookCommand |
make:webhook scaffolder |
Signatures prove a webhook came from the real provider (not an attacker). Never skip this in production.
Three verification types are supported:
Used by GitHub, Shopify, and others. The provider sends an X-Signature header containing hash_hmac(algo, rawBody, secret).
'verify' => [
'type' => 'hmac',
'header' => 'X-Hub-Signature-256',
'algo' => 'sha256',
'prefix' => 'sha256=', // strip prefix before comparing
],Format: t=1234567890,v1=abc123def.... Prevents replay attacks by rejecting old timestamps.
'verify' => [
'type' => 'hmac_timestamp',
'header' => 'Stripe-Signature',
'algo' => 'sha256',
'prefix' => '',
'tolerance' => 300, // reject if older than 5 minutes
],For exotic providers, supply a callback:
'verify' => [
'type' => 'custom',
'callback' => function(string $payload, array $headers, string $secret): bool {
// Your logic
return true;
},
],$payload->get('data.object.amount'); // dot notation
$payload->get('data.object.customer', null); // with default
$payload->getParsedData(); // full array
$payload->getRawBody(); // raw string
$payload->getHeaders(); // header array
$payload->getEventType(); // "payment_intent.succeeded"
$payload->getProvider(); // "stripe"
$payload->getIdempotencyKey(); // null or stringreturn WebhookResponse::ok(); // 200
return WebhookResponse::ok(['id' => 42]); // 200 with JSON body
return WebhookResponse::accepted(); // 202
return WebhookResponse::badRequest('nope'); // 400
return WebhookResponse::unauthorized(); // 401
return WebhookResponse::notFound(); // 404Where is the event type in the JSON? Depends on the provider:
// Stripe: {"type": "payment_intent.succeeded", ...}
'event_resolver' => ['type' => 'type'],
// GitHub: {"action": "opened", ...}
'event_resolver' => ['type' => 'action'],
// Nested: {"data": {"type": "order.created"}}
'event_resolver' => ['type' => 'data.type'],
// Custom callback
'event_resolver' => [
'callback' => fn($request, $parsed) => $parsed['event_name'] ?? 'unknown',
],If no resolver is configured, the system falls back to headers: X-Event-Name, X-GitHub-Event, X-Stripe-Event.
A single event can trigger multiple handlers (e.g., analytics + fulfillment):
$manager = resolve(WebhookSubscriptionManager::class);
$manager->registerHandler(new PaymentFulfillmentHandler());
$manager->registerHandler(new PaymentAnalyticsHandler());
$manager->registerHandler(new PaymentAuditLogHandler());All three run in registration order. Control failure behavior with handler_failure_mode:
-
'stop'(default) — if any handler throws, stop and re-throw -
'continue'— log the error and run the remaining handlers
'handler_failure_mode' => 'continue',supportsEvent() can return '*' to receive every event for the provider:
public function supportsEvent(): string|array
{
return '*';
}You can mix wildcard and specific handlers — all matching handlers run.
return ['payment.succeeded', 'payment.failed']; // specific
return '*'; // all eventsFor slow handlers, enable async:
'async' => true,The request returns 202 Accepted immediately. Handlers run via WebhookJob on the webhooks queue. The job retries up to 3 times with 60s delay.
Important: with async, idempotency is enforced inside the job — not at the HTTP layer. This ensures that if the job is retried, it won't reprocess.
php artisan queue:work --queue=webhooks --tries=3Swap the cache backend by implementing IdempotencyStore:
class DatabaseIdempotencyStore implements IdempotencyStore
{
public function lock(string $key, int $ttl = 3600): bool { /* ... */ }
public function markDone(string $key): void { /* ... */ }
public function isDone(string $key): bool { /* ... */ }
}Then inject it into WebhookManager (requires a small override — see the constructor).
The idempotency header varies:
| Provider | Header |
|---|---|
| Stripe |
Stripe-Idempotency-Key or event id
|
| GitHub | X-GitHub-Delivery |
| Shopify | X-Shopify-Event-Id |
| PayPal | Paypal-Request-Id |
Pass the header name to WebhookPayload::fromHttpRequest():
WebhookPayload::fromHttpRequest($request, 'github', 'X-GitHub-Delivery');If a provider doesn't send an idempotency header, you can synthesize one in your controller:
$key = hash('sha256', $request->getBody());
// then build the payload manually, or pass a modified requestMost providers ship a CLI forwarder (e.g., stripe listen --forward-to localhost:8000/webhook/stripe). This is the recommended approach because it signs test requests correctly.
use Mlangeni\Machinjiri\Core\Components\Webhooks\WebhookPayload;
public function test_payment_succeeded()
{
$payload = new WebhookPayload(
rawBody: '{}',
headers: [],
parsedData: ['data' => ['object' => ['amount' => 1000, 'customer' => 'cus_123']]],
eventType: 'payment_intent.succeeded',
provider: 'stripe',
idempotencyKey: 'evt_test_1'
);
$handler = new StripeWebhookHandler();
$response = $handler->handle($payload);
$this->assertEquals(200, $response->getStatusCode());
}public function test_signature_rejected()
{
$request = new HttpRequest();
$request->setBody('{"type":"payment_intent.succeeded"}');
$request->setHeader('Stripe-Signature', 't=1,v1=invalid');
$payload = WebhookPayload::fromHttpRequest($request, 'stripe');
$response = resolve(WebhookManager::class)->process($payload);
$this->assertEquals(401, $response->getStatusCode());
}Logs are emitted under the webhook channel. Events logged:
| Level | Event |
|---|---|
debug |
Signature verified, handler completed |
info |
Duplicate webhook ignored, async queued |
warning |
No handler matched |
error |
Signature failure, handler exception |
Search for provider, event, and key in log context to trace a specific webhook lifecycle.
- ✅ Always verify signatures – set
verify_signature: true - ✅ Always use idempotency – most providers send a key; pass the correct header
- ✅ Use HMAC-timestamp when available – prevents replay attacks
- ✅ Rotate secrets – never commit secrets to source control
- ✅ Rate limit your endpoint – use a reverse proxy or middleware
- ✅ Log and alert on 401s – a spike usually means an attack
- ✅ Respond within 5 seconds – use
async: truefor anything slower
'providers' => [
'my_provider' => [
// REQUIRED — shared secret for signature verification
'secret' => getenv('MY_PROVIDER_SECRET'),
// REQUIRED — processing mode
// false = run handlers in the HTTP request
// true = return 202 and run handlers on the queue
'async' => false,
// OPTIONAL (default true) — set false to disable signature checks
// (only useful in development; NEVER disable in production)
'verify_signature' => true,
// OPTIONAL — where to find the event type in the body
// Accepts: ['type' => 'a.b.c'] or ['callback' => fn()]
'event_resolver' => ['type' => 'type'],
// OPTIONAL — full class name of the default handler
'handler' => \App\Webhooks\Handlers\MyWebhookHandler::class,
// OPTIONAL (default 'stop') — 'stop' or 'continue'
'handler_failure_mode' => 'stop',
// OPTIONAL — signature verification details
'verify' => [
// 'hmac', 'hmac_timestamp', or 'custom'
'type' => 'hmac',
'header' => 'X-Signature',
'algo' => 'sha256',
'prefix' => '',
// 'tolerance' => 300, // hmac_timestamp only
// 'callback' => fn(), // custom only
],
],
],| Status | Meaning | When to Return |
|---|---|---|
200 OK |
Processed successfully | Default for sync handlers |
202 Accepted |
Acknowledged, processing async | Automatic in async mode |
400 Bad Request |
Invalid payload | Malformed data |
401 Unauthorized |
Signature failed | Automatic |
404 Not Found |
No handler matched | Automatic when no handler found |
500 Server Error |
Handler threw | Automatic when handler_failure_mode: 'stop'
|
public function process(WebhookPayload $payload): WebhookResponseEntry point. Verifies signature, checks idempotency, dispatches sync or async.
public function dispatchToHandlers(WebhookPayload $payload): WebhookResponseRuns all matching handlers synchronously. Returns the first non-2xx, or the last response.
public static function fromHttpRequest(
HttpRequest $request,
string $provider,
?string $idempotencyHeader = 'X-Request-Id',
?array $providerConfig = null
): selfpublic function getRawBody(): string
public function getHeaders(): array
public function getParsedData(): ?array
public function getEventType(): string
public function getProvider(): string
public function getIdempotencyKey(): ?string
public function get(string $key, $default = null): mixed // dot notationpublic static function ok(?array $body = null): self
public static function accepted(): self
public static function badRequest(string $message = 'Bad request'): self
public static function unauthorized(string $message = 'Unauthorized'): self
public static function notFound(string $message = 'Endpoint not found'): self
public function getStatusCode(): int
public function getHeaders(): array
public function getBody(): ?array
public function toHttpResponse(): HttpResponse
public function toJson(): stringpublic static function verifyHmac(
string $payload,
string $secret,
string $header,
string $algo = 'sha256',
string $prefix = ''
): boolpublic static function verifyHmacTimestamp(
string $payload,
string $secret,
string $header,
string $algo = 'sha256',
string $prefix = '',
int $toleranceSeconds = 300
): boolpublic static function verifyCustom(
string $payload,
array $headers,
callable $verifier,
string $secret
): boolpublic function registerHandler(WebhookHandlerInterface $handler): void
public function getHandlersForEvent(string $event): array
public function getSecretForProvider(string $provider): ?string
public function getVerificationMethod(string $provider): array
public function isAsync(string $provider): bool
public function getEventResolver(string $provider): ?array
public function getHandlerFailureMode(string $provider): string
public function mustVerifySignature(string $provider): boolpublic function lock(string $key, int $ttl = 3600): bool;
public function markDone(string $key): void;
public function isDone(string $key): bool;public function handle(WebhookPayload $payload): WebhookResponse;
public function supportsEvent(): string|array;Never trust an incoming webhook. Attackers can POST to your endpoint. Signature verification is non-negotiable in production.
'verify_signature' => true,Even if the provider doesn't retry today, they will. Deduplicate on the provider's delivery ID.
Providers give you ~5 seconds. If your handler calls an API, sends an email, or writes to multiple services, use async: true.
// ❌ BAD — could timeout if provider is slow
public function handle(WebhookPayload $p): WebhookResponse
{
$user = $this->http->get("https://api.example.com/users/{$p->get('user_id')}");
$this->pdfGenerator->generate($user);
return WebhookResponse::ok();
}
// ✅ GOOD — enqueue and return
'async' => true,$amount = $payload->get('data.object.amount', 0);
$customer = $payload->get('data.object.customer');
if ($customer === null) {
$this->logger->warning('Missing customer in payload', ['event' => $event]);
return WebhookResponse::ok(); // ack but don't process
}public function handle(WebhookPayload $payload): WebhookResponse
{
match ($payload->getEventType()) {
'payment.succeeded' => $this->onSucceeded($payload),
'payment.failed' => $this->onFailed($payload),
default => $this->logger->info('Unhandled event', ['event' => $payload->getEventType()]),
};
return WebhookResponse::ok();
}- Use
'stop'when handlers are dependent on each other (e.g., fulfillment + email confirmation) - Use
'continue'when handlers are independent (e.g., analytics + audit log)
Add a health-check metric: count of 4xx vs 2xx over time. A sudden spike in 401s means either your secret was rotated (bad deployment) or someone is probing your endpoint.
To rotate without downtime:
- Add the new secret alongside the old
- Update the provider to use the new secret
- Wait for all in-flight retries to drain (24h+)
- Remove the old secret
Don't invent payloads — use the provider's documented examples or capture real ones (with secrets redacted).
Symptom: All requests rejected with "Invalid signature".
Causes & fixes:
-
Wrong secret – check your
.envand the provider's dashboard. They must match exactly (no trailing whitespace). -
Wrong header name – verify
verify.headermatches what the provider sends. -
Body was modified – some frameworks mutate the request body before you read it. Always verify against
getRawBody(). -
Wrong algo – Stripe uses
sha256, some legacy providers usesha1. -
Wrong prefix – GitHub uses
sha256=abc123. Setprefix: 'sha256='so the prefix is stripped before comparison. -
Clock skew – for
hmac_timestamp, if your server clock is off by more thantolerance, every request fails. Sync NTP.
Symptom: 200 OK, but your code doesn't execute.
Checks:
-
Is the handler registered?
$manager->registerHandler(new MyHandler());
-
Does
supportsEvent()match?public function supportsEvent(): string|array { return ['payment.succeeded']; }
-
Is the event resolving correctly?
error_log($payload->getEventType()); // should be what you expect
-
Is the queue worker running? (for async)
php artisan queue:work --queue=webhooks
Symptom: Same event processed twice.
Fixes:
-
Is
idempotencyKeynull? Check the header name passed tofromHttpRequest. - Is cache shared across workers? File-based cache on multi-server setups will fail. Use Redis.
-
Is
lock()returning false on retry but not on first call? EnsuremarkDone()is called after success. - For async: duplicate jobs are only prevented by the cache lock, not by the queue. Ensure your cache backend is atomic.
Symptom: Provider dashboard shows "delivered", no errors, but no business logic.
Check provider response in dashboard. Sometimes the provider thinks 200 is a fail (e.g., Stripe expects 2xx; a 302 redirect is treated as a delivery failure and retried).
Also check:
- Are you returning
WebhookResponsefrom the controller? (Not a plain string, not a redirect.) - Are you swallowing exceptions silently in your handler?
Symptom: Undefined property: statusTexts.
The HttpResponse class must define statusTexts. If your version doesn't, override toJson() in the controller:
$response = $manager->process($payload);
return json_encode([
'status' => $response->getStatusCode(),
'body' => $response->getBody(),
]);Symptom: webhooks queue growing unbounded.
Fixes:
-
Scale workers:
php artisan queue:work --queue=webhooks -
Split queues per provider: use
'queue' => "webhooks:{$provider}"inWebhookJob - Reduce handler time: profile slow handlers
- Return 200 before dispatch: if handler is slow because of downstream services, consider a second-level queue
Common causes:
- Different body encoding – some proxies re-encode JSON. Verify against raw bytes.
-
Different secret –
.envnot deployed, or the provider's "test" secret used in production. - Reverse proxy modifies body – some WAFs or body-inspection proxies rewrite whitespace. Disable for webhook paths.
- HTTPS to HTTP rewrite – if the provider sends a signature over the URL, ensure the URL matches exactly.
php artisan make:webhook <Name> [options]
Arguments:
name Provider name, e.g. Stripe, GitHub
Options:
--controller Generate the controller only
--config Add provider config only
--handler Generate the handler only
--provider Generate the service provider only
--all Generate everything (default if no options)
| File | Purpose |
|---|---|
app/Controllers/{Name}WebhookController.php |
HTTP entry point |
app/Webhooks/Handlers/{Name}WebhookHandler.php |
Business logic |
app/Providers/{Name}WebhookServiceProvider.php |
Handler registration |
config/core/webhooks.php |
Provider configuration (merged) |
The command prints reminders:
- Register the handler in the generated service provider
- Add the route
- Set the secret in
.env
You now have everything needed to handle webhooks from any provider with confidence:
- Secure – signature verification, replay protection
- Reliable – idempotency, lock-based concurrency control
- Scalable – queue-based async processing
- Maintainable – clean interfaces, swappable backends
- Observable – structured logs at every stage
Start with the make:webhook command, run it against the provider's CLI forwarder, and iterate. The component handles the hard parts — you focus on business logic.
Machinjiri Docs
- Home
- Learn
- Build
-
Tutorials
- Routing
- Controllers and Middleware
- Views and Templates
- Database ORM
- Eloquent Database ORM
- Errors and Exceptions
- Forms and Validation
- Http
- Events and Logging
- Services and DI
- Security
- Authentication
- Caching
- File System
- Date and Time
- Debugging
- Queue and Jobs
- Task Scheduling
- Transport (Mail, SMS)
- LDAP
- Notifications
- Webhooks
- UUID Generations
- Terminal
- Helper Function
- Reference