Webhooks
Flutterwave posts to your app whenever something happens: a payment completes, a transfer lands, a chargeback is raised. The package registers the route, checks the signature, re-verifies charges, ignores duplicates and turns each webhook into a Laravel event. You only write listeners.
Set it up
Pick a secret hash (any long random string) and put it in
.env:dotenvFLW_SECRET_HASH=choose-a-long-random-stringOn the dashboard, open Settings > Webhooks, enter the same secret hash, and set the URL to:
https://your-app.com/flutterwave/webhookExclude the route from CSRF. It's registered outside the
webgroup, so on a default app there's nothing to do. If you wrapped it inwebmiddleware yourself, addflutterwave/webhookto your CSRF exceptions.
Run php artisan flutterwave:doctor to check all of it.
Listen for events
use Flutterwave\Payments\Events\ChargeCompleted;
use Illuminate\Support\Facades\Event;
Event::listen(function (ChargeCompleted $event) {
if (! $event->isSuccessful()) {
return;
}
$order = Order::where('reference', $event->reference())->firstOrFail();
// $event->transaction is fresh from the API, not the webhook body.
if ($event->transaction['amount'] >= $order->total && $event->transaction['currency'] === $order->currency) {
$order->markPaid();
}
});| Webhook | Event |
|---|---|
charge.completed | ChargeCompleted |
transfer.completed | TransferCompleted |
subscription.cancelled | SubscriptionCancelled |
quote.completed | QuoteCompleted |
chargeback.* | ChargebackUpdated |
| every webhook | WebhookReceived |
See Events for each event's properties and helpers.
What happens to each webhook
- Signature check.
VerifyWebhookSignatureaccepts either header Flutterwave sends:flutterwave-signature(base64 HMAC-SHA256 of the raw body, keyed with your secret hash) or the olderverif-hash(the secret hash itself). Anything else gets a401. With noFLW_SECRET_HASHset, every webhook is rejected. - Dedupe. Flutterwave retries deliveries, so each webhook's event, id and status are remembered in your cache for a week, and repeats are acknowledged without firing events again.
- Re-verification. For
charge.completed, the transaction is fetched from the API beforeChargeCompletedfires, so a forged payload can't mark an order paid. If that fails, the response is a500and Flutterwave retries later. - Events.
WebhookReceivedalways fires, then the specific event.
Try the signature
Webhook signature tester
Signs a payload the way Flutterwave does: a base64 HMAC-SHA256 of the raw body, keyed with your secret hash. Check a header you received, or send a test webhook to your app. It all runs in your browser, but use a test secret hash here, not your live one.
Send it to your local app. A charge.completed with a made-up id gets a 500, because the package re-fetches every charge from the API before firing ChargeCompleted. Use a real test transaction id, or php artisan flutterwave:ping-webhook <id>.
curl -X POST 'http://localhost:8000/flutterwave/webhook' \
-H 'Content-Type: application/json' \
-H 'flutterwave-signature: ' \
--data-raw '{
"event": "charge.completed",
"data": {
"id": 4975363,
"tx_ref": "LARAVEL-aB3xQ1729000000",
"flw_ref": "FLW-MOCK-0f1c2d3e4f",
"amount": 5000,
"currency": "NGN",
"charged_amount": 5000,
"status": "successful",
"payment_type": "card",
"customer": {
"id": 2280112,
"email": "customer@example.com"
}
}
}'use Flutterwave\Payments\Facades\Flutterwave;
use Flutterwave\Payments\Testing\FlutterwaveFake;
it('marks the order paid when the charge completes', function () {
// The fake answers the package's re-verification of the charge.
Flutterwave::fake()->transaction(4975363, ['amount' => 5000, 'tx_ref' => 'ORDER-42']);
$this->postJson('/flutterwave/webhook', [
'event' => 'charge.completed',
'data' => ['id' => 4975363, 'tx_ref' => 'ORDER-42', 'status' => 'successful'],
], FlutterwaveFake::webhookHeaders())->assertOk();
});Queue it
Answer Flutterwave straight away and do the work on a queue:
FLW_WEBHOOK_QUEUE=webhooks
FLW_WEBHOOK_QUEUE_CONNECTION=redis # optionalThe job retries on failure. Keep a worker running: php artisan queue:work --queue=webhooks.
Dedupe store
Dedupe needs a cache that's shared between servers and survives restarts. The array and file stores don't qualify on multi-server setups, so point it at Redis or the database:
FLW_WEBHOOK_DEDUPE_STORE=redisTest locally
Flutterwave can't reach localhost. Two options:
# Sign and send a webhook for a real test transaction to your local app
php artisan flutterwave:ping-webhook 4975363
# Ask Flutterwave to resend one that failed, to your dashboard URL
php artisan flutterwave:resend-webhook 4975363ping-webhook defaults to this app's webhook route and FLW_SECRET_HASH. Use --event=transfer with a transfer id for transfer webhooks.
Options
// config/flutterwave.php
'webhook' => [
'enabled' => env('FLW_WEBHOOK_ENABLED', true),
'path' => env('FLW_WEBHOOK_PATH', 'flutterwave/webhook'),
'middleware' => [], // extra middleware for the route
'verify_charges' => true, // re-fetch charges before ChargeCompleted
'dedupe' => env('FLW_WEBHOOK_DEDUPE', true),
'dedupe_store' => env('FLW_WEBHOOK_DEDUPE_STORE'),
'dedupe_for' => 7 * 24 * 3600,
'queue' => env('FLW_WEBHOOK_QUEUE'),
'queue_connection' => env('FLW_WEBHOOK_QUEUE_CONNECTION'),
],Email alerts
Get an email when a chargeback needs a response or a payout fails:
FLW_NOTIFY_CHARGEBACKS=ops@your-app.com
FLW_NOTIFY_FAILED_TRANSFERS=finance@your-app.com,cto@your-app.com