Flowwithlit Docs
WEBHOOKS

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

  1. You set a Webhook URL in your Developer API dashboard.
  2. When a payment event happens (e.g. charge.success), we POST a JSON payload to your URL.
  3. We sign the payload with your Webhook Secret using HMAC-SHA256 and send the signature in the X-Flowwithlit-Signature header.
  4. Your endpoint verifies the signature, processes the event, and returns HTTP 200.
  5. If we don't receive a 200 within 10 seconds, we retry up to 3 times with exponential back-off.
ℹ️
Set your webhook URL in the Developer API dashboard. You can configure separate URLs for test and live modes.

Webhook Payload Structure

JSON — charge.success example
{
  "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

EventWhen 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.

⚠️
Make your webhook endpoint idempotent. Because of retries, your handler may be called more than once for the same event. Use the 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