Webhooks: How to Build Them Without Breaking Things
Building webhooks for your API? Here's how to handle them reliably without losing data or breaking integrations.
Table of Contents
- Basic Webhook
- Better: Queue Webhooks
- Webhook Security
- 1. Signature Verification
- 2. IP Whitelisting
- 3. Idempotency
- Receiving Webhooks
- Retry Logic
- Exponential Backoff
- Manual Retry Endpoint
- Webhook Logs
- Testing Webhooks
- Local Testing with ngrok
- Mock Webhooks in Tests
- Real Project Example
- Webhook Best Practices
- Bottom Line
Webhooks: How to Build Them Without Breaking Things
Webhooks are simple in theory. POST request when something happens.
In practice? Lost events, duplicate processing, security issues.
Here's how to do it right.
Basic Webhook (Sending)
// When order is created
public function store(Request $request)
{
$order = Order::create($request->validated());
// Send webhook
Http::post('https://client.com/webhook', [
'event' => 'order.created',
'data' => $order,
]);
return response()->json($order, 201);
}
Problem: If webhook fails, order creation fails. Bad UX.
Better: Queue Webhooks
public function store(Request $request)
{
$order = Order::create($request->validated());
// Queue webhook (doesn't block)
SendWebhook::dispatch('order.created', $order);
return response()->json($order, 201);
}
// app/Jobs/SendWebhook.php
class SendWebhook implements ShouldQueue
{
public function __construct(
public string $event,
public Model $data
) {}
public function handle()
{
Http::timeout(10)
->retry(3, 100)
->post(config('webhooks.url'), [
'event' => $this->event,
'data' => $this->data,
]);
}
}
Order creates instantly. Webhook sends in background. If it fails, retries automatically.
Webhook Security
1. Signature Verification
// Sending webhook
$payload = json_encode(['event' => 'order.created', 'data' => $order]);
$signature = hash_hmac('sha256', $payload, config('webhooks.secret'));
Http::withHeaders([
'X-Webhook-Signature' => $signature,
])->post($url, json_decode($payload, true));
// Receiving webhook
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('X-Webhook-Signature');
$expected = hash_hmac('sha256', $payload, config('webhooks.secret'));
if (!hash_equals($expected, $signature)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// Process webhook
}
Prevents fake webhooks.
2. IP Whitelisting
public function handle(Request $request)
{
$allowedIps = ['52.31.139.75', '52.49.173.169']; // Paystack IPs
if (!in_array($request->ip(), $allowedIps)) {
return response()->json(['error' => 'Unauthorized IP'], 403);
}
// Process webhook
}
3. Idempotency
public function handle(Request $request)
{
$eventId = $request->input('id');
// Check if already processed
if (ProcessedWebhook::where('event_id', $eventId)->exists()) {
return response()->json(['message' => 'Already processed'], 200);
}
// Process webhook
$this->processPayment($request->all());
// Mark as processed
ProcessedWebhook::create(['event_id' => $eventId]);
return response()->json(['message' => 'Processed'], 200);
}
Prevents duplicate processing if webhook is sent twice.
Receiving Webhooks (Paystack Example)
Route::post('/webhooks/paystack', [WebhookController::class, 'paystack']);
public function paystack(Request $request)
{
// Verify signature
$signature = $request->header('X-Paystack-Signature');
$payload = $request->getContent();
$expected = hash_hmac('sha256', $payload, config('services.paystack.secret'));
if (!hash_equals($expected, $signature)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
// Get event
$event = $request->input('event');
// Handle different events
match($event) {
'charge.success' => $this->handleSuccessfulPayment($request->input('data')),
'charge.failed' => $this->handleFailedPayment($request->input('data')),
'transfer.success' => $this->handleSuccessfulTransfer($request->input('data')),
default => null,
};
return response()->json(['message' => 'Webhook received'], 200);
}
private function handleSuccessfulPayment(array $data)
{
$reference = $data['reference'];
$payment = Payment::where('reference', $reference)->first();
if (!$payment) {
Log::warning('Payment not found', ['reference' => $reference]);
return;
}
$payment->update(['status' => 'success']);
// Send confirmation email
Mail::to($payment->user)->send(new PaymentConfirmation($payment));
}
Retry Logic
Exponential Backoff
class SendWebhook implements ShouldQueue
{
public $tries = 5;
public $backoff = [10, 30, 60, 300, 900]; // 10s, 30s, 1m, 5m, 15m
public function handle()
{
Http::timeout(10)->post($this->url, $this->data);
}
public function failed(Throwable $exception)
{
// All retries failed, log it
Log::error('Webhook failed after 5 attempts', [
'url' => $this->url,
'error' => $exception->getMessage(),
]);
}
}
Manual Retry Endpoint
// Admin can manually retry failed webhooks
Route::post('/admin/webhooks/{id}/retry', function ($id) {
$webhook = FailedWebhook::findOrFail($id);
SendWebhook::dispatch($webhook->event, $webhook->data);
return response()->json(['message' => 'Webhook queued for retry']);
});
Webhook Logs
// Log all webhooks
class WebhookLog extends Model
{
protected $fillable = [
'event',
'url',
'payload',
'response_status',
'response_body',
'attempts',
'succeeded_at',
'failed_at',
];
}
// In SendWebhook job
public function handle()
{
$log = WebhookLog::create([
'event' => $this->event,
'url' => $this->url,
'payload' => $this->data,
'attempts' => 1,
]);
try {
$response = Http::timeout(10)->post($this->url, $this->data);
$log->update([
'response_status' => $response->status(),
'response_body' => $response->body(),
'succeeded_at' => now(),
]);
} catch (Exception $e) {
$log->update([
'failed_at' => now(),
'response_body' => $e->getMessage(),
]);
throw $e; // Retry
}
}
Track every webhook. Debug issues easily.
Testing Webhooks
Local Testing with ngrok
# Install ngrok
npm install -g ngrok
# Expose local server
ngrok http 8000
# Use ngrok URL in webhook settings
https://abc123.ngrok.io/webhooks/paystack
Mock Webhooks in Tests
public function test_successful_payment_webhook()
{
Http::fake();
$payment = Payment::factory()->create(['status' => 'pending']);
$response = $this->post('/webhooks/paystack', [
'event' => 'charge.success',
'data' => [
'reference' => $payment->reference,
'amount' => 10000,
],
]);
$response->assertStatus(200);
$this->assertEquals('success', $payment->fresh()->status);
}
Real Project Example
Client: Booking platform with payment webhooks
Implementation:
// Queue webhook with retry
SendWebhook::dispatch('booking.confirmed', $booking)
->onQueue('webhooks')
->retry(5)
->backoff([10, 30, 60, 300, 900]);
// Log everything
WebhookLog::create([...]);
// Verify signatures
hash_equals($expected, $signature);
// Idempotency check
if (ProcessedWebhook::exists($eventId)) return;
Results:
- Webhook success rate: 99.8%
- Lost events: 0
- Duplicate processing: 0
- Debug time: 80% reduction
Webhook Best Practices
- Always queue - Don't block main request
- Verify signatures - Prevent fake webhooks
- Implement idempotency - Handle duplicates
- Retry with backoff - Handle temporary failures
- Log everything - Debug issues easily
- Return 200 quickly - Process async
- Validate payload - Don't trust input
Bottom Line
Webhooks are powerful but need proper handling.
Queue them. Verify them. Retry them. Log them.
Do it right once, forget about it.
Building webhook integrations?
We integrate payment gateways and webhooks for Nigerian businesses. Paystack, Flutterwave, custom webhooks.
📞 WhatsApp: +234 708 711 0468
📧 info@raspibtech.com
📍 Lagos Island
Related:
Need Help with Your Project?
Let's discuss how Raspib Technology can help transform your business
Related Articles
Laravel 11: What Changed and Why You Should Care
Laravel 11 is out. Slimmer structure, better performance, and features that actually save time. Here's what matters.
Read more →Laravel 12: The Upgrade You've Been Waiting For
Laravel 12 brings major improvements. Here's what changed and why it matters for your projects.
Read more →Next.js 15: The Features That Actually Matter
Next.js 15 changed a lot. Here's what affects your projects, what breaks, and when to upgrade.
Read more →