Skip to content

Flutterwave

Setup guide for the flutterwave payments provider. The shared API (initiate_payment, verify_payment, refund_payment, webhooks, hooks, saved methods) is documented in Payments.

The flutterwave provider supports one-time payments (initiate_payment, verify_payment), refunds (refund_payment), subscriptions on a Flutterwave payment plan, cancelling them, reading chargebacks, saved cards, off-session charges and webhooks on Flutterwave’s API v3, in the currencies your Flutterwave account collects (for example NGN, GHS, KES, UGX, TZS, RWF, XOF, XAF, ZAR, USD, EUR, GBP). GET /payments/providers reports dispute_read, off_session, payment_methods, subscription_cancel and subscriptions.

Feature Status
One-time payments, verify, refunds Supported
Subscriptions (type: "subscription") Supported (a Flutterwave payment plan id); renewals are not recorded (see Known limits)
Cancel subscription Supported, immediately only (at_period_end: True is NOT_SUPPORTED, 501)
get_dispute, chargeback webhooks Supported (chargeback webhooks only when Flutterwave enables them)
submit_dispute_evidence Not supported (NOT_SUPPORTED, 501): answer chargebacks in the Flutterwave dashboard
Plan change, pause, billing portal Not offered by Flutterwave (NOT_SUPPORTED, 501)
Saved cards (while paying, one-time only), charge_off_session Supported; 3-D Secure by default, so most charges need the customer (see Known limits)
Saving a card without paying, attaching a token Not offered by Flutterwave (NOT_SUPPORTED, 501)

It needs no extra install: it calls https://api.flutterwave.com/v3 directly over HTTP, following Flutterwave’s v3 API reference as of September 2026 (developer.flutterwave.com, version v3.0.0). Flutterwave’s v4 API is not used; a Flutterwave account can use only one API version, so an account moved to v4 cannot use this provider.

Why plain HTTP (no Flutterwave SDK). Flutterwave’s Python package, rave_python, needed Python >= 3.10 when Kirak still supported 3.9, and targets Flutterwave’s older v2 APIs.

1. Get credentials. In the Flutterwave Dashboard, Settings -> API keys, copy the secret key: FLWSECK_TEST-... in test mode. Flutterwave uses the same API host for test and live; the key decides the mode.

2. Set the webhook. In Settings -> Webhooks:

  • set the URL to https://your-domain.com/payments/webhook/flutterwave (/payments/webhook/<instance> for another instance name);
  • set a secret hash (a long random string of your choosing). Flutterwave sends it back in the verif-hash header only when one is set, so Kirak requires it;
  • enable webhook retries (off by default): Flutterwave then retries a delivery that does not get a 200 within 60 seconds 3 times, 30 minutes apart. Without retries, a delivery Kirak fails (for example while Flutterwave’s API is unreachable) is not sent again; call verify_payment and check the dashboard.

Some events are sent only when Flutterwave support enables them for your account: ask (hi@flutterwavego.com) for refund webhooks and chargeback webhooks (chargeback.initiated, .accepted, .declined, .lost) if you want Kirak to see refunds made in the dashboard and new chargebacks.

3. Configure the instance and secrets. In kirak.json:

"flutterwave": {
"type": "flutterwave",
"environment": "test",
"success_url": "https://your-domain.com/payment/success",
"country": "NG"
}

environment is test (the default) or live. A test instance expects a key starting with FLWSECK_TEST- and a live one FLWSECK-; since Flutterwave documents only the test prefix, a mismatch is logged as a warning, not rejected. success_url is sent as the redirect_url the buyer returns to (Flutterwave appends status, tx_ref and transaction_id); only an absolute http(s):// URL is sent. country is your (the merchant’s) ISO country code, e.g. NG or KE (two letters, any case; anything else is a CONFIGURATION_ERROR at startup). Off-session charges need both country and an absolute success_url (Flutterwave requires them on a tokenized charge); without them charge_off_session raises CONFIGURATION_ERROR (500) before anything is saved. In the environment:

Terminal window
KIRAK_PAYMENT_FLUTTERWAVE_SECRET_KEY=FLWSECK_TEST-...
KIRAK_PAYMENT_FLUTTERWAVE_SECRET_HASH=<the secret hash set in the dashboard>

For an instance with another name the variables are KIRAK_PAYMENT_<INSTANCE>_SECRET_KEY and KIRAK_PAYMENT_<INSTANCE>_SECRET_HASH (see docs/reference/configuration.md). Both are required.

4. Use it:

# One-time payment -- send the buyer to data["url"] (Flutterwave's payment page)
result = await kirak.payments.initiate_payment({
"provider": "flutterwave",
"user_id": "42", "amount": 250000, "currency": "NGN", # kobo: NGN 2,500.00
"name": "Pro Plan", "type": "one_time",
"payment_email": "buyer@example.com", # required by Flutterwave
})
reference = result["data"]["reference"] # tx_ref, kirak-<32 hex>
# On the redirect page (?status=...&tx_ref=...&transaction_id=...): read the
# payment now instead of waiting for the webhook
await kirak.payments.verify_payment({
"provider": "flutterwave", "payment_id": reference,
"flutterwave_transaction_id": transaction_id, # optional, from the redirect
})
# Refund (admin/system): payment_id is the tx_ref; omit amount for a full refund
await kirak.payments.refund_payment({"provider": "flutterwave", "payment_id": reference, "amount": 50000})
# Subscription -- subscription_plan_id is a Flutterwave payment plan id (a
# number), created in Flutterwave first, in the same currency; a plan with an
# amount sets the price. Send the buyer to data["url"] (card only).
result = await kirak.payments.initiate_payment({
"provider": "flutterwave",
"user_id": "42", "amount": 500000, "currency": "NGN",
"name": "Monthly", "type": "subscription",
"payment_email": "buyer@example.com",
"subscription_plan_id": "3807",
})
# Cancel now (Flutterwave cannot cancel at the period end through its API)
await kirak.payments.cancel_subscription({"provider": "flutterwave", "subscription_id": "4147"})
# Read a chargeback (admin/system): dispute_id is the Flutterwave chargeback id
await kirak.payments.get_dispute({"provider": "flutterwave", "dispute_id": 1603})
# Save the card while paying (one-time only), then charge it later (admin/system)
await kirak.payments.initiate_payment({
"provider": "flutterwave", "user_id": "42", "amount": 250000, "currency": "NGN",
"name": "Pro Plan", "type": "one_time", "payment_email": "buyer@example.com",
"save_payment_method": True, "consent": {"text_version": "2026-09"},
})
result = await kirak.payments.charge_off_session({
"user_id": "42", "amount": 150000, "currency": "NGN", "name": "Usage - September",
"idempotency_key": "usage-42-2026-09",
})
# REQUIRES_ACTION (3-D Secure, the default): send the customer to result["data"]["action_url"]

How a payment flows. initiate_payment needs payment_email (else MISSING_PAYMENT_EMAIL, 400, nothing saved). It saves a PENDING transaction, stores a new tx_ref kirak-<32 random hex> as its transaction_id, then calls POST /v3/payments with tx_ref, amount x quantity in major units (a decimal string, e.g. "2500.00" NGN or "5000" UGX), currency, customer.email, the redirect_url (above) and meta kirak_transaction_id and kirak_instance. It returns url (the payment link), reference (the tx_ref) and transaction_id. The tx_ref is never derived from the caller’s idempotency_key or the row id, which another Kirak database on the same Flutterwave account could repeat. No link expiry or session duration is sent, so the page stays usable until the buyer pays. If Flutterwave rejects the call (4xx) the row is marked FAILED; a timeout, 5xx or 429 (or a reply without a link) leaves it PENDING and raises, since the link may exist.

A checkout can have several attempts: after a failed attempt Flutterwave keeps the payment page open for another, and sends a charge.completed for each attempt, all with the same tx_ref and each with its own transaction id. So:

  • Webhooks and verify_payment find the row by the tx_ref stored on a row of this instance (never by the meta tag). An event whose tx_ref no row of this instance holds (another instance, another database, a renewal or other software on the account) is acknowledged, changes nothing and reports event = None.
  • charge.completed is never trusted on its own (verif-hash is a static secret, not a signature over the body): Kirak re-reads the transaction with GET /v3/transactions/{data.id}/verify (by verify_by_reference?tx_ref= when the event has no numeric id). Only a verified successful transaction for the same tx_ref, in the row’s currency, with an amount (not charged_amount, which includes fees the customer bears) of at least amount x quantity, and whose meta, where present, names this transaction and instance, marks the row COMPLETED, exactly once, and reports payment_completed with amount (the verified amount, in minor units) and currency. The Flutterwave transaction id, flw_ref, fees and raw amounts are kept in ipn_dump (flutterwave_transaction_id, gateway_amount, charged_amount, app_fee); transaction_id stays the tx_ref.
  • A verified failed attempt does not fail the row: it stays PENDING, the attempt is noted in ipn_dump.last_failed_attempt, and no event is reported (event_type charge.completed.failed_attempt). A later successful attempt completes it.
  • A verified transaction that does not match (other tx_ref, currency, amount or meta) writes nothing and reports no event (event_type charge.completed.unconfirmed). Any other status (pending, …), a transaction Flutterwave does not know, or a verify call that times out, is rate limited or gets a 5xx writes nothing and fails the webhook (500) so Flutterwave redelivers it (with retries enabled).
  • A successful attempt arriving after another one completed the row is a repeat (event = None), logged as a possible double payment, whether the row was already settled when it arrived or another delivery settled it first with a different attempt.

verify_payment (payment_id = the tx_ref, optional flutterwave_transaction_id from the redirect) reads the transaction by that id (checking it carries the tx_ref) or else by tx_ref, and returns transaction_id, reference, flutterwave_status, flutterwave_transaction_id and status (the row’s status after the call). A confirmed successful transaction (same checks as above) sets the row PROCESSING; only charge.completed completes it. It never marks a checkout FAILED: a failed attempt returns outcome: "attempt_failed" and the row stays PENDING, since the buyer can still pay on the same page (the redirect reports only one attempt); only a PROCESSING off-session charge is failed (see Off-session charges). A failed attempt of a payment that is already settled reports no outcome. Also returned: outcome: "not_confirmed" for a transaction that does not match and outcome: "not_found" when Flutterwave has none yet (only Flutterwave’s documented “No transaction was found” answer counts as none; any other 400 is an unknown outcome). TRANSACTION_NOT_FOUND (404) for a tx_ref this instance did not create; an unknown outcome raises and changes nothing. An abandoned checkout therefore stays PENDING.

Refunds. refund_payment (payment_id = the tx_ref) refunds a completed payment (TRANSACTION_NOT_COMPLETED, 409, before completion) with POST /v3/transactions/{id}/refund, in full or for amount (minor units of the transaction’s own currency, sent in major units). Flutterwave sends no refund webhook unless its support enables it, so a refund Flutterwave accepts (status completed, meaning started and pending disbursement, processing, pending-momo or completed-<channel>, meaning done) is recorded right away under Flutterwave’s refund id with its amount_refunded, and the row becomes REFUNDED, or PARTIALLY_REFUNDED while the refunds recorded so far are below amount x quantity (a REFUNDED row never moves back). The result has refund_id, status, transaction_id and payment_status. No event is reported for it (it is not a webhook). A card refund can take 3-15 days to reach the customer. If its payout later fails, the row stays refunded; the failure is only noted in ipn_dump.refund_issue, and only when refund webhooks are enabled (below). A refund Flutterwave answers as failed, or whose meta.disburse_status is failed, is not recorded and is noted the same way. A reply that is not an object is an unknown outcome (FLUTTERWAVE_UNAVAILABLE). A refund amount finer than the currency’s minor unit (Flutterwave can refund its fee too) is rounded half up, the raw value kept in ipn_dump.refund_gateway_amount. Flutterwave v3 takes no idempotency key: after a timeout, check the transaction’s refunds in the dashboard before retrying.

If Flutterwave enables refund webhooks for your account, they arrive as a bare refund object without an event name; Kirak recognises one by its TransactionId and AmountRefunded and names it itself. It re-reads the transaction (GET /v3/transactions/{TransactionId}/verify, to find the row by tx_ref) and the refund (GET /v3/refunds/{id}, which must name that transaction), and trusts neither the body’s status nor its amount. The same rule as refund_payment applies: a refund is recorded on acceptance.

  • One that arrives before the payment was completed fails (500) so Flutterwave redelivers it after completion.
  • A refund of another transaction than the one that completed the row (another successful attempt of the same tx_ref, a double payment) is logged and reports no event (event_type refund.unconfirmed); the row is not changed.
  • An accepted status (completed, processing, pending-momo or completed-<channel>) records the refund under its refund id, with the running total above, and reports refund_completed (event_type refund.completed) with amount and currency, unless it is already recorded (a refund made with refund_payment), which reports event = None.
  • failed, a meta.disburse_status of failed (the refund’s meta is a JSON string or an object), or an unknown status changes no status and reports no event: it is logged as a warning and noted in ipn_dump.refund_issue. A refund already recorded stays recorded and the row stays refunded (also logged).

How a subscription flows. initiate_payment with type: "subscription" needs subscription_plan_id, a Flutterwave payment plan id (else MISSING_SUBSCRIPTION_PLAN_ID, 400; one that is not a number is INVALID_SUBSCRIPTION_PLAN_ID, 400; nothing saved either way). Before anything is saved Kirak reads the plan (GET /v3/payment-plans/{id}): Flutterwave requires the charge in the plan’s currency, so a plan in another currency than currency is PLAN_CURRENCY_MISMATCH (400), and a plan whose status is not active is PLAN_NOT_ACTIVE (400; a reply without a status is accepted); a plan Flutterwave does not know raises its error, and a timeout or a reply without a currency or with an amount that is not a number is an unknown outcome (FLUTTERWAVE_UNAVAILABLE). Flutterwave charges an amount sent with the first charge instead of the plan’s amount for that first charge only, so a plan with an amount sets the price: the row’s amount is the plan’s amount (quantity 1) and amount/quantity passed in are not used. A plan without an amount charges amount x quantity on every charge (the row then has that total and quantity 1). The checkout then works like a one-time payment (same tx_ref, same url) with payment_plan added to POST /v3/payments; Flutterwave fixes the payment method to card. The plan id is kept in the row’s payment_meta.subscription_plan_id.

  • The first charge’s charge.completed is confirmed by verify exactly like a one-time payment (the tx_ref, currency and an amount of at least the row’s): verify documents no plan field, and the row’s amount is the plan’s price. Flutterwave subscribes the customer on that charge, and its subscription list can be filtered by the charge’s transaction id, so Kirak then calls GET /v3/subscriptions?transaction_id=<id>. When exactly one active subscription is listed on the row’s plan and email (any letter case), held by no other transaction of this instance and not saved as a subscriptions row for another user, it is bound: a subscriptions row is saved (status ACTIVE, plan_id = the plan id, the row’s amount and currency; Flutterwave lists no next payment date, so expires_on is not set) and its id becomes the row’s subscription_id. The row is marked COMPLETED and payment_completed carries subscription_id. If the list call times out, is rate limited or gets a 5xx, nothing is written and the webhook fails (500) so Flutterwave redelivers it. If another delivery completed the row first, the subscription is still set on it, but only when that delivery completed it with the same transaction and the row holds no subscription yet.
  • Nothing is guessed. No listed subscription yet, several, or a reply of more than one page (the filter was not applied) binds nothing: the payment still completes, unbound, and the case is logged. No other Flutterwave event can bind it (there is no subscription-created event, and subscription.cancelled names no subscription), so verify_payment on a COMPLETED, unbound subscription transaction runs the same lookup with the transaction that completed it and binds it then; its result carries subscription_id once bound. After a payment_completed for a subscription without subscription_id, call verify_payment for that payment (again later if it is still unbound). A charge.completed of another successful attempt of the same checkout, arriving after completion (event = None), runs the same lookup, but a repeat of the same event is deduplicated before it is handled and Flutterwave does not redeliver after a 200, so do not rely on it. There, an unknown outcome of the lookup is only logged (the next call tries again), a subscription another request bound first is reported as the row holds it, and a subscriptions row another request saved at the same moment is reused.
  • Renewals are not recorded. A renewal’s charge.completed carries a tx_ref Flutterwave generates and nothing that names the subscription, so it is acknowledged like any foreign charge (event = None); one that carries a payment plan is logged at info level, with no lookup and no write. Apps get no subscription_renewed from Flutterwave; check renewals in the Flutterwave dashboard.
  • subscription.cancelled (sent when a subscription is cancelled, including after three failed renewal attempts) names only the customer’s email and the plan. Kirak looks for this instance’s subscriptions rows on that plan with that email (any letter case); none is acknowledged with event = None and no call. Otherwise the body is not trusted: Kirak lists the customer’s cancelled subscriptions on the plan (GET /v3/subscriptions?email=&plan= &status=cancelled, page by page until every row’s id is found or the list ends, at most 50 pages) and marks CANCELLED only the rows whose subscription_id is listed as cancelled. It reports subscription_cancelled with subscription_id (the first written), subscription_ids and user_id only when a row was written; a row already CANCELLED is never changed again (event = None). When the list has no cancelled subscription at all for that email and plan (Flutterwave’s list has not caught up yet), nothing is written and the webhook fails (500) so the redelivery checks again; when it lists others but none of this instance’s, the event is acknowledged. An unknown outcome of the list call fails the webhook (500). This event is not deduplicated on its body hash: its body has no id or time, so a second genuine cancellation (the customer resubscribed to the plan and cancelled again) is byte-identical to the first. A repeat changes nothing and reports event = None, since only rows that are not ended yet are written.

Lifecycle. cancel_subscription calls PUT /v3/subscriptions/{id}/cancel (subscription_id is the Flutterwave subscription id, a number, else INVALID_SUBSCRIPTION_ID, 400) and returns status CANCELLED; it cancels immediately. When Flutterwave answers the subscription with status cancelled, this instance’s subscriptions row is marked CANCELLED right away (unless it has already ended); the later subscription.cancelled then finds it ended and reports event = None, so a cancellation made through cancel_subscription reports no subscription_cancelled event (the caller already knows). Any other answer leaves the row to that webhook. Flutterwave’s API has no cancel at the period end, so at_period_end: True returns NOT_SUPPORTED (501). Flutterwave has no plan change, pause or customer portal: update_subscription, pause_subscription, resume_subscription and get_billing_portal return NOT_SUPPORTED (501). Customers can also cancel from the link in Flutterwave’s renewal reminder email (it can be turned off in the dashboard).

Disputes (read-only). get_dispute takes dispute_id, the Flutterwave chargeback id (a number, else INVALID_DISPUTE_ID, 400; the id chargeback webhooks carry and that this result returns). v3 has no endpoint for one chargeback, so Kirak reads GET /v3/chargebacks?id=<id> and returns dispute_id, status and the full dispute; DISPUTE_NOT_FOUND (404) when it is not listed. submit_dispute_evidence is not supported (501): accept or decline chargebacks (with proof) in the Flutterwave dashboard. With chargeback webhooks enabled, chargeback.initiated reports dispute_created and chargeback.accepted, .declined and .lost report dispute_updated; chargeback.won and .reversed are not documented events (only chargeback statuses) and are mapped to dispute_updated too, in case Flutterwave sends them. Their body has only the charge’s flw_ref, so Kirak re-reads the chargeback (GET /v3/chargebacks?flw_ref=, the one with the event’s id) for its tx_ref and transaction_id: it must be a tx_ref of this instance whose row was completed by that transaction (the flutterwave_transaction_id stored at completion, next to flw_ref); checkouts, subscription first charges and off-session charges alike. The result carries dispute_id, dispute_status and stage from the re-read chargeback, transaction_id, user_id, gateway_amount (the chargeback’s raw amount), and amount/currency (that amount read in the transaction’s currency: a chargeback has no currency of its own, and that it is the charge’s is assumed). The transaction row is not changed. When the list does not show the event’s chargeback yet (it lags behind the event), nothing is reported and the webhook fails (500) so Flutterwave redelivers it. Any other chargeback (more than one listed with the event’s id, another instance, another database, a renewal, another attempt of the same checkout) is acknowledged with event = None; an unknown outcome of the re-read fails the webhook (500).

Saved cards. Flutterwave saves no card without a charge, so a card is saved only while paying: initiate_payment with save_payment_method (and consent) on a one-time payment keeps the flag and consent in the row’s payment_meta. When that payment’s charge.completed is confirmed by verify (as above), and before the row is marked COMPLETED, the verified card’s token (data.card.token, which only verify returns: the webhook’s card has none) is saved: the token is the method’s gateway id, the Flutterwave customer id its gateway customer, with brand (the card type, e.g. MASTERCARD), last4, expiry (from card.expiry MM/YY), the consent, and in meta the customer’s email, card_identity and token_expires_at. The email is kept because a token charges only with the email of the charge that made it. A token is valid for one year, so token_expires_at is that charge’s created_at plus 365 days. A payment without a card token (mobile money, bank transfer) saves nothing and still completes. Flutterwave gives no card fingerprint, so card_identity is the first 6 digits, last 4 and expiry (553188-2950-09/32): when the user already has an active method of this instance with the same card_identity, only the token that expires last (token_expires_at) is kept, usually the one just saved, but not when an older checkout’s webhook is handled after a newer one’s. The kept token becomes the default first if a revoked one was, and the others are marked REVOKED locally (they stay chargeable at Flutterwave until they expire). A token that is already active on this instance for another user is not saved and that user’s method is left unchanged (logged as a warning; one that user removed is taken over, as for every provider; see “Saved Payment Methods”). A save that fails fails the webhook (500) before the row is completed, so the redelivery saves it; a row already settled is never saved again (a late delivery cannot bring back a removed card). Saving reports no event of its own. save_payment_method on a subscription is NOT_SUPPORTED (501), as for Paystack: Flutterwave renews the plan on its own, and removing that card in Kirak would not stop it. start_payment_method_setup and attach_payment_method are NOT_SUPPORTED (501). detach_payment_method makes no call (Flutterwave has no API to delete a card token): the row is marked REVOKED and the token stays usable at Flutterwave until it expires (logged). set_default_payment_method is local only.

Off-session charges. charge_off_session on a Flutterwave card first checks the configuration (country and an absolute success_url, above), that the card has a saved email (else MISSING_PAYMENT_EMAIL, 400) and that neither the card (its expiry month is past) nor the token (token_expires_at is past) has expired (else PAYMENT_METHOD_EXPIRED, 400); an unknown expiry is not checked. None of these saves anything. It then saves the PENDING off_session transaction, stores a new random tx_ref kirak-<32 hex> as its transaction_id, and calls POST /v3/tokenized-charges with the token, the saved email, amount in major units, currency, country, that tx_ref, redirect_url (the success_url, where the customer returns after a 3-D Secure challenge) and meta naming the transaction and instance. The tx_ref is never derived from the idempotency_key, which another Kirak database on the same account could repeat; Flutterwave v3 takes no idempotency key, and a retry with the same key is replayed from the row without calling Flutterwave. Kirak does not check the currency against the card’s first charge: Flutterwave decides, and a refusal is a decline (below). A reply naming another tx_ref than the one sent is logged as a warning. The outcome:

  • successful (only for accounts Flutterwave has approved for no-auth tokenized charges): the row stays PROCESSING and the call returns PROCESSING (COMPLETED only when charge.completed completed the row first); the charge.completed for the tx_ref completes the row exactly once after verify confirms it, as for a checkout, and reports payment_completed. Fulfil on that event, not on the call’s answer.
  • pending with data.meta.authorization.redirect (3-D Secure, the default): REQUIRES_ACTION, and action_url is that redirect, the page where the customer authorizes this very charge (no recovery checkout is made; a replay returns it too). No payment_action_required event is reported. When the customer authorizes it, charge.completed completes the row. A failed attempt there leaves the row REQUIRES_ACTION (noted in ipn_dump.last_failed_attempt, no event), like a checkout: a later successful attempt of the same tx_ref still completes it.
  • pending without a redirect, or any other status: PROCESSING.
  • failed (HTTP 200) or a 400 (e.g. “Wrong token or email passed”): FAILED, with decline_code = Flutterwave’s processor_response or error message. Another 4xx (a wrong key) marks the row FAILED and raises.
  • A timeout, 5xx, 429 or unreadable reply: PROCESSING with ipn_dump.outcome "unknown", never FAILED.

A PROCESSING off-session row has no customer to try again, so a verified failed charge.completed for its tx_ref (confirmed like a success: currency, amount and meta) marks it FAILED with decline_code and reports payment_failed (event_type charge.completed.off_session_failed), once; verify_payment does the same (outcome: "failed") when the charge Flutterwave reports for the tx_ref failed. This applies only to the charge the row holds: a row holding a success that verify confirmed (through verify_payment), or another charge than the failed one (a late failed attempt of the same tx_ref), is not failed and the attempt is only noted. A success only the tokenized-charges answer reported does not block it: verify saying that same charge failed is the outcome. A failed charge.completed that arrives while the row is still PENDING (the tokenized call has not answered yet) fails the webhook (500) so Flutterwave redelivers it. A later verified success still completes a FAILED row. Call verify_payment for a PROCESSING off-session charge whose outcome was unknown (ipn_dump.outcome "unknown") if no webhook settles it. If it returns outcome: "not_found" (Flutterwave has no charge for the tx_ref, most likely because the call never reached it), check the tx_ref in the Flutterwave dashboard, then charge again with a new idempotency_key (the old key replays the PROCESSING row). A row that stays PENDING (the process stopped after calling Flutterwave, before recording the answer) is not recovered by Kirak: a successful charge still completes it through charge.completed, but a failed one is redelivered until Flutterwave stops; check the charge by its tx_ref in the Flutterwave dashboard.

Webhooks. Kirak compares verif-hash with the configured secret hash in constant time. A missing header is rejected with MISSING_WEBHOOK_SIGNATURE (400) and a mismatch (including a non-ASCII header) with INVALID_WEBHOOK_SIGNATURE (400). There is no timestamp. Flutterwave events carry no id: a delivery whose raw body was already handled (same sha256) returns already_processed: true (except subscription.cancelled, above), and every handler is idempotent on the row as well. Kirak handles charge.completed, the refund webhook, subscription.cancelled and chargeback.*; every other event (transfer.completed, the dashboard’s test_assess, …) is acknowledged with event = None.

Currencies. Flutterwave amounts are major-unit decimals; Kirak converts with the ISO 4217 exponents (Flutterwave documents no deviation): pass "amount": 5000, "currency": "UGX" for UGX 5,000 (sent as "5000") and "amount": 250000, "currency": "NGN" for NGN 2,500.00 (sent as "2500.00"). Amounts Flutterwave returns are read through their decimal text, never as floats; a verified amount finer than the currency’s minor unit does not confirm a payment.

Known limits.

  • API v3 only. An account switched to v4 cannot use this provider.
  • Refund webhooks arrive only if Flutterwave support enables them; without them, refunds made in the Flutterwave dashboard are not recorded in Kirak.
  • A refund is recorded when Flutterwave accepts it. If its payout later fails, the row stays refunded; the failure is only noted in ipn_dump.refund_issue, and only when refund webhooks are enabled.
  • A refund of a second successful attempt of the same checkout (a double payment) is not recorded on the row, which holds only the attempt that completed it.
  • Without webhook retries enabled in the dashboard, a delivery that failed (500) is lost; verify_payment and the dashboard are then the way to check a payment.
  • Which attempt verify_by_reference returns when a tx_ref has several is not documented, so Kirak verifies by transaction id whenever it has one.
  • A tx_ref is 38 characters; Flutterwave documents no maximum length.
  • Renewals are not recorded: no subscription_renewal rows and no subscription_renewed event (a renewal charge names no subscription). expires_on is not set on Flutterwave subscriptions.
  • A subscription is bound only through the subscription list filtered by its first charge’s transaction id. One that is not listed yet when the first charge completes stays unbound until verify_payment is called for that payment (call it after a payment_completed without subscription_id; only a charge.completed of another attempt of the same checkout also retries); an unbound subscription’s cancellation is not recorded.
  • subscription.cancelled is matched through the customer’s email + plan list. It is retried while that list is empty, but when the list shows other cancelled subscriptions of the customer on the plan and not yet this one, the event is acknowledged and the row is not updated.
  • cancel_subscription always cancels immediately, and reports no subscription_cancelled event.
  • A subscription checkout paid twice (two successful attempts of one tx_ref): the row is completed and bound by one attempt; the losing attempt’s subscription still gets a subscriptions row, which no transaction points to. Cancel that subscription in the dashboard.
  • Disputes are read-only, and chargeback webhooks arrive only if Flutterwave enables them; without them, poll get_dispute or check the dashboard. A chargeback event that redelivers with different bytes is reported again. A chargeback the list still does not show after Flutterwave’s last retry is not reported.
  • No billing portal.
  • 3-D Secure is the default for tokenized charges, so most off-session charges return REQUIRES_ACTION and need the customer; charging with no customer present needs Flutterwave’s approval for no-auth tokenized charges (hi@flutterwavego.com).
  • A card can be saved only while paying, on a one-time payment (no setup without a charge, 501).
  • A token charges only with the email of the charge that made it; a customer who changes email keeps the card chargeable only under the old one.
  • A token is valid for one year; Kirak refuses to charge it after token_expires_at, and the customer must pay once more with save_payment_method to save a new one.
  • Removing a saved card, or replacing an older token of the same card, is local only: Flutterwave has no API to delete a token, so it stays chargeable there until it expires.
  • Two different cards with the same first 6 and last 4 digits and expiry are taken for one card: the older token is revoked in Kirak.
  • Whether Flutterwave accepts a token in another currency than its first charge’s is not documented; a refusal is returned as FAILED.
  • meta is not a documented field of a tokenized charge. Kirak sends it (it is checked on verify only when it comes back); if Flutterwave rejected it, every off-session charge would come back FAILED with a 400.

after_webhook receives these provider-neutral event names (see Webhook Hooks):

Flutterwave reports payment_completed (charge.completed, once Flutterwave’s verify endpoint confirms it, including a subscription’s first charge, which carries subscription_id when bound, and a charge_off_session charge; an unconfirmed one reports none, with event_type charge.completed.unconfirmed, and a failed attempt none, with event_type charge.completed.failed_attempt), payment_failed (a verified failed charge.completed of a PROCESSING off-session charge, with event_type charge.completed.off_session_failed and decline_code), refund_completed (the refund webhook, once the re-read refund is accepted, with event_type refund.completed; refund.unconfirmed and refund.issue report none), subscription_cancelled (subscription.cancelled, only when a row was written, so not for a cancellation made with cancel_subscription, which already marks the row), dispute_created (chargeback.initiated) and dispute_updated (chargeback.accepted, .declined, .lost, and the undocumented chargeback.won/.reversed, mapped in case Flutterwave sends them); renewals report no subscription_renewed, since Flutterwave does not link them to a subscription.