Receiving Webhooks
Webhooks let Flowwithlit notify your server the instant a payment event occurs — even if the customer closes their browser tab. Set a URL in your dashboard and we'll POST a signed JSON payload to it for every relevant event.
How It Works
- You set a Webhook URL in your Developer API dashboard.
- When a payment event happens (e.g.
charge.success), we POST a JSON payload to your URL. - We sign the payload with your Webhook Secret using HMAC-SHA256 and send the signature in the
X-Flowwithlit-Signatureheader. - Your endpoint verifies the signature, processes the event, and returns HTTP 200.
- If we don't receive a 200 within 10 seconds, we retry up to 3 times with exponential back-off.
Webhook Payload Structure
{
"event": "charge.success",
"data": {
"transaction_ref": "FLW_TXN_ABCD1234",
"amount": 500000,
"currency": "NGN",
"status": "successful",
"customer": {
"email": "john@example.com",
"name": "John Doe"
},
"meta": {
"order_id": "1042",
"product": "Premium Hoodie"
},
"is_test": true
}
}
Signature Verification
Every webhook POST includes an X-Flowwithlit-Signature header — a
HMAC-SHA256 hex digest of the raw request body, signed with your Webhook Secret
(shown as "Webhook Secret Hash" in the Webhooks section of your
Developer API dashboard —
a separate credential from your API keys, see Authentication).
Always verify this signature before processing any event. Without verification, anyone who knows your webhook URL could send fake payment confirmations to your server.
<?php
// webhook.php — your webhook receiver
define('WEBHOOK_SECRET', getenv('FLW_WEBHOOK_SECRET'));
// 1. Read raw body BEFORE any json_decode
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_FLOWWITHLIT_SIGNATURE'] ?? '';
// 2. Verify HMAC-SHA256 signature
$expected = hash_hmac('sha256', $rawBody, WEBHOOK_SECRET);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
// 3. Parse and handle the event
$event = json_decode($rawBody, true);
switch ($event['event']) {
case 'charge.success':
$ref = $event['data']['transaction_ref'];
$amount = $event['data']['amount'] / 100; // convert kobo → naira
$email = $event['data']['customer']['email'];
$orderId = $event['data']['meta']['order_id'] ?? null;
// fulfil the order in your DB
// sendReceiptEmail($email, $amount, $ref);
// markOrderPaid($orderId);
break;
// handle other events here
}
http_response_code(200);
echo 'OK';
// webhook.js — Express route
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
// Use express.raw() to get the raw Buffer — needed for HMAC
router.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-flowwithlit-signature'];
const expected = crypto
.createHmac('sha256', process.env.FLW_WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer here
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString());
switch (event.event) {
case 'charge.success': {
const { transaction_ref, amount, customer, meta } = event.data;
const amountNaira = amount / 100;
console.log(`Payment of ₦${amountNaira} from ${customer.email}`);
// fulfil order: await db.orders.markPaid(meta.order_id);
break;
}
}
res.status(200).send('OK');
});
module.exports = router;
# webhook.py — Flask route
import os, hmac, hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.getenv('FLW_WEBHOOK_SECRET', '')
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-Flowwithlit-Signature', '')
raw_body = request.get_data() # raw bytes
expected = hmac.new(
WEBHOOK_SECRET.encode(), raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401, 'Invalid signature')
event = request.get_json(force=True)
if event['event'] == 'charge.success':
data = event['data']
amount = data['amount'] / 100 # kobo → naira
email = data['customer']['email']
order_id = data.get('meta', {}).get('order_id')
print(f"Payment of ₦{amount} from {email} (order {order_id})")
# db.mark_paid(order_id)
return 'OK', 200
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
secret := []byte(os.Getenv("FLW_WEBHOOK_SECRET"))
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
sig := r.Header.Get("X-Flowwithlit-Signature")
if !hmac.Equal([]byte(expected), []byte(sig)) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var event struct {
Event string `json:"event"`
Data struct {
Ref string `json:"transaction_ref"`
Amount float64 `json:"amount"`
Customer struct {
Email string `json:"email"`
} `json:"customer"`
} `json:"data"`
}
json.Unmarshal(body, &event)
if event.Event == "charge.success" {
amountNaira := event.Data.Amount / 100
_ = amountNaira // fulfil order
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
Events Reference
| Event | When it fires |
|---|---|
| charge.success | A customer payment was successful via the hosted checkout |
| charge.failed | A customer payment attempt failed |
Retry Policy
If your endpoint does not return HTTP 200 within 10 seconds, we retry:
- Attempt 1 — immediately
- Attempt 2 — 5 minutes later
- Attempt 3 — 30 minutes later
You can also trigger a manual retry from the Webhook Logs tab in your Developer API dashboard.
transaction_ref as a unique key and skip
processing if you've already handled that reference.
Testing Webhooks Locally
Use a tunnelling tool to expose your local server to the internet during development:
# Install: https://ngrok.com/download
ngrok http 80
# Then set your webhook URL in the Flowwithlit dashboard to:
# https://abc123.ngrok-free.app/webhook.php
# Install: https://expose.dev
expose share http://localhost
# Then set your webhook URL in the Flowwithlit dashboard to:
# https://your-subdomain.sharedwithexpose.com/webhook.php