Payments
Flutterwave
Flutterwave's v3 API with its hosted checkout, test cards, refunds, payment plans, transfers and virtual accounts.
danwo's Flutterwave emulator answers api.flutterwave.com in test mode and serves the hosted
checkout on checkout.flutterwave.com. Your app creates a Standard payment link, the browser pays
on the hosted page, and the emulator sends charge.completed to your webhook.
Set up
Add flutterwaveService. Any test-mode key, FLWSECK_TEST-…, is accepted, so your app keeps its
own. Seed the webhook with the secret hash your app checks:
import { appEnvironment, defineConfig, flutterwaveService } from "@danwo/sdk"
const url = "http://localhost:3000"
export default defineConfig({
testDir: "tests",
readiness: { mode: "page+backend", settle: true },
environment: () =>
appEnvironment({
app: { command: "npm run dev", url, env: { FLW_SECRET_HASH: "danwo-hash" } },
services: [
flutterwaveService({
seed: { webhook: { url: `${url}/api/webhooks/flutterwave`, secretHash: "danwo-hash" } },
}),
],
}),
})A key that isn't a test-mode key gets Flutterwave's 401, Invalid authorization key.
Seed it
flutterwaveService({
seed: {
webhook: { url: "http://localhost:3000/api/webhooks/flutterwave", secretHash: "danwo-hash" },
balances: { NGN: 250_000, USD: 1_000 },
plans: [{ id: 101, name: "Pro", amount: 5_000, interval: "monthly", currency: "NGN" }],
accounts: [{ bankCode: "044", accountNumber: "0690000099", accountName: "Ada Obi" }],
},
})| Field | What it seeds |
|---|---|
webhook | The dashboard's webhook URL and its secret hash. |
balances | Balances by currency, in major units; NGN 1,000,000 by default. |
plans | Payment plans, hourly to yearly, with an optional number of charges. |
accounts | Accounts that resolve, beside Flutterwave's own test accounts; blacklisted refuses transfers to one. |
Use it in a test
Pay on the hosted page, then check the transaction was recorded once:
import { type FlutterwaveFixtures, flutterwaveLedger, test as base } from "@danwo/sdk"
const test = base.extend<FlutterwaveFixtures>()
test("pays for an order once", async ({ app, screen, expect, flutterwave }) => {
await app.open("/checkout")
await screen.getByRole("button", { name: "Pay with Flutterwave" }).click()
await screen.getByLabel("Card number").fill("5377 2836 4507 7450")
await screen.getByLabel("Expiry date").fill("09/32")
await screen.getByLabel("CVV").fill("789")
await screen.getByRole("button", { name: /^Pay / }).click()
await expect(screen.getByText("Order paid")).toBeVisible()
const paid = flutterwaveLedger(flutterwave).paid("order-1")
expect(paid).toHaveLength(1)
expect(paid[0]?.status).toBe("successful")
})| Helper | What it's for |
|---|---|
flutterwaveLedger(flutterwave) | Transactions, refunds, transfers, subscriptions and balances. |
flutterwaveLedger(…).renewDue() | Charge every subscription that's due now. |
flutterwaveBank(flutterwave) | Pay into a virtual account, as the customer's bank would. |
Test cards
| Card number | What the hosted page does |
|---|---|
5377 2836 4507 7450 | Pays with no further step. |
5531 8866 5214 2950 | Asks for a PIN, then an OTP. |
4187 4274 1556 4246 | Asks for the bank's OTP (3-D Secure). |
4556 0527 0417 2643 | Asks for a billing address. |
5258 5859 2266 6506 | Refused for insufficient funds. |
5143 0105 2233 9965 | Refused with "Do Not Honour". |
OTP 5548 ends in "Wrong OTP". The page also offers bank transfer ("I've sent the money") and
mobile money, and "Cancel payment" goes back to your redirect_url.
What's covered
| Area | Covered |
|---|---|
| Payments | Standard payment links, the hosted page, disabling a link. |
| Transactions | Verify, list, events, fees, and sending the webhook again. |
| Refunds | Full and partial refunds. |
| Plans and subscriptions | Plans, subscriptions, and renewals on the emulator's clock. |
| Transfers | Transfers, fees and retries, settling as the test-mode references say. |
| Money in | Virtual account numbers and bank transfers into them. |
| Lookups | Banks, account resolution, beneficiaries and balances. |
Faults
| Call | Faults the matrix tries |
|---|---|
A write, such as POST /v3/payments | 429, 500, 503, a 10-second delay, connection reset, answer lost after Flutterwave acted |
GET /v3/transactions/:id/verify | 429, 500, connection reset, and a 200 whose body says the payment failed |
| Every webhook delivery | arrives twice, arrives 10 minutes late, forged signature, 1 hour stale |
Webhooks
Each delivery carries the secret hash in verif-hash and an HMAC of the body in
flutterwave-signature, as Flutterwave sends them.
| Event | Sent when |
|---|---|
charge.completed | A payment ends, paid or failed. |
transfer.completed | A transfer settles. |
subscription.cancelled | A subscription is cancelled. |
Fidelity
Live. danwo fidelity flutterwave checks 3 scenarios against a test-mode account. 2 match;
the bank list is skipped, since Flutterwave's test mode answers it with a provider error.
Not covered yet
- Direct card charges (
/v3/charges) and their encryption - Subaccounts and split payments
- Bill payments, airtime and settlements reports