Webhook System Implementation for Site Integrations
Webhook — HTTP callback: external service sends POST request to specified URL when event occurs. Fundamentally differs from polling: not website periodically asking "any new data?", but source itself notifies on change. Webhook system on site can work in two roles: receive incoming events from external services and send outgoing events to third-party subscribers.
Incoming Webhooks: Receive and Process
Basic architecture for receiving events from external systems (payment gateways, CRM, marketplaces):
// routes/api.php
Route::post('/webhooks/{provider}', WebhookController::class);
class WebhookController extends Controller
{
public function __invoke(Request $request, string $provider): JsonResponse
{
// 1. Log raw request before any processing
WebhookLog::create([
'provider' => $provider,
'headers' => $request->headers->all(),
'payload' => $request->getContent(),
'ip' => $request->ip(),
'received_at' => now(),
]);
// 2. Verify signature
$handler = WebhookHandlerFactory::make($provider);
if (!$handler->verify($request)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// 3. Put in queue — don't process synchronously
ProcessWebhookJob::dispatch($provider, $request->all())
->onQueue('webhooks');
// 4. Respond quickly — external service waits 3–10 seconds
return response()->json(['ok' => true]);
}
}
Critical: response must go within 3–10 seconds (limit for most providers). Any heavy logic — in queue.
Signature Verification
Each provider signs payload differently. Several examples:
Stripe / HMAC-SHA256:
$secret = config('services.stripe.webhook_secret');
$sigHeader = $request->header('Stripe-Signature');
$payload = $request->getContent();
// Stripe uses timestamp + HMAC
[$t, $v1] = $this->parseStripeSignature($sigHeader);
$signed = hash_hmac('sha256', "{$t}.{$payload}", $secret);
if (!hash_equals($signed, $v1)) {
throw new InvalidSignatureException();
}
GitHub / SHA-256 with prefix:
$secret = config('services.github.webhook_secret');
$signature = $request->header('X-Hub-Signature-256');
$expected = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret);
if (!hash_equals($expected, $signature)) {
abort(401);
}
Simple token in header (many CRM):
$token = $request->header('X-Webhook-Token');
if (!hash_equals(config('services.crm.webhook_token'), $token)) {
abort(403);
}
Always use hash_equals instead of === — timing attack protection.
Idempotency and Deduplication
External services may send one event several times: timeout, restart, retry policy. Need deduplication:
class ProcessWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue;
public string $uniqueFor = 3600; // uniqueness 1 hour
public function handle(): void
{
$eventId = $this->payload['id'] ?? $this->payload['event_id'] ?? null;
if ($eventId && WebhookEvent::where('external_id', $eventId)->exists()) {
Log::info("Webhook duplicate skipped: {$eventId}");
return;
}
// Save event_id before processing
WebhookEvent::create(['external_id' => $eventId, 'processed_at' => now()]);
// Main logic
$this->process();
}
}
Outgoing Webhooks: Send Events to Subscribers
If site itself is event source (e.g., e-commerce notifying partners of order changes), need subscription management:
// Subscribers table
Schema::create('webhook_subscriptions', function (Blueprint $table) {
$table->id();
$table->string('url');
$table->string('event'); // 'order.created', 'order.status_changed'
$table->string('secret'); // for HMAC signature
$table->boolean('active')->default(true);
$table->integer('failures')->default(0);
$table->timestamp('last_error_at')->nullable();
$table->timestamps();
});
Send event to all subscribers:
class WebhookDispatcher
{
public function dispatch(string $event, array $payload): void
{
$subscriptions = WebhookSubscription::active()
->where('event', $event)
->get();
foreach ($subscriptions as $subscription) {
SendWebhookJob::dispatch($subscription, $payload)
->onQueue('webhooks-outgoing');
}
}
}
class SendWebhookJob implements ShouldQueue
{
public int $tries = 5;
public array $backoff = [60, 300, 900, 3600, 10800]; // exponential backoff
public function handle(): void
{
$body = json_encode($this->payload);
$timestamp = time();
$signature = hash_hmac('sha256', "{$timestamp}.{$body}", $this->subscription->secret);
$response = Http::withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-Event' => $this->payload['event'],
'X-Webhook-Timestamp'=> $timestamp,
'X-Webhook-Signature'=> "sha256={$signature}",
])
->timeout(10)
->post($this->subscription->url, $this->payload);
if (!$response->successful()) {
$this->subscription->increment('failures');
if ($this->subscription->failures >= 10) {
$this->subscription->update(['active' => false]);
// notify subscription owner
}
$this->fail("HTTP {$response->status()}");
} else {
$this->subscription->update(['failures' => 0]);
}
}
}
Monitoring Dashboard
For debugging need interface to view incoming/outgoing events: status, time, payload, response. Minimal — webhook_logs table + simple CRUD controller in admin. Advanced — Laravel Telescope integration or separate page with provider statistics.
Retry and Dead Letter Queue
Unprocessed tasks after all attempts go to separate queue for manual review:
// queue.php
'connections' => [
'redis' => [
'driver' => 'redis',
'queue' => 'webhooks',
'retry_after' => 90,
],
],
// In job
public function failed(Throwable $e): void
{
WebhookFailure::create([
'provider' => $this->provider,
'payload' => $this->payload,
'error' => $e->getMessage(),
]);
// alert in Slack/Telegram
}
Timeline
Basic webhook receipt system from one provider with verification and queue: 1 working day. Full platform with subscription management, dashboard, retry logic, monitoring: 3–5 working days depending on provider count and UI requirements.







