← Back to Developer Blog
💻 DeveloperFebruary 27, 20268 min read

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.

By Raspib Technology Team

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

  1. Always queue - Don't block main request
  2. Verify signatures - Prevent fake webhooks
  3. Implement idempotency - Handle duplicates
  4. Retry with backoff - Handle temporary failures
  5. Log everything - Debug issues easily
  6. Return 200 quickly - Process async
  7. 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

Webhook Implementation Guide - Build Reliable Webhooks