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-hashheader 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_paymentand 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:
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 webhookawait 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 refundawait 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 idawait 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_paymentfind the row by thetx_refstored on a row of this instance (never by themetatag). An event whosetx_refno row of this instance holds (another instance, another database, a renewal or other software on the account) is acknowledged, changes nothing and reportsevent = None. charge.completedis never trusted on its own (verif-hashis a static secret, not a signature over the body): Kirak re-reads the transaction withGET /v3/transactions/{data.id}/verify(byverify_by_reference?tx_ref=when the event has no numeric id). Only a verifiedsuccessfultransaction for the sametx_ref, in the row’s currency, with anamount(notcharged_amount, which includes fees the customer bears) of at leastamountxquantity, and whosemeta, where present, names this transaction and instance, marks the rowCOMPLETED, exactly once, and reportspayment_completedwithamount(the verified amount, in minor units) andcurrency. The Flutterwave transaction id,flw_ref, fees and raw amounts are kept inipn_dump(flutterwave_transaction_id,gateway_amount,charged_amount,app_fee);transaction_idstays thetx_ref.- A verified
failedattempt does not fail the row: it staysPENDING, the attempt is noted inipn_dump.last_failed_attempt, and no event is reported (event_typecharge.completed.failed_attempt). A later successful attempt completes it. - A verified transaction that does not match (other
tx_ref, currency, amount ormeta) writes nothing and reports no event (event_typecharge.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_typerefund.unconfirmed); the row is not changed. - An accepted status (
completed,processing,pending-momoorcompleted-<channel>) records the refund under its refund id, with the running total above, and reportsrefund_completed(event_typerefund.completed) withamountandcurrency, unless it is already recorded (a refund made withrefund_payment), which reportsevent = None. failed, ameta.disburse_statusoffailed(the refund’smetais 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 inipn_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.completedis confirmed by verify exactly like a one-time payment (thetx_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 callsGET /v3/subscriptions?transaction_id=<id>. When exactly oneactivesubscription is listed on the row’s plan and email (any letter case), held by no other transaction of this instance and not saved as asubscriptionsrow for another user, it is bound: asubscriptionsrow is saved (statusACTIVE,plan_id= the plan id, the row’samountandcurrency; Flutterwave lists no next payment date, soexpires_onis not set) and its id becomes the row’ssubscription_id. The row is markedCOMPLETEDandpayment_completedcarriessubscription_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.cancellednames no subscription), soverify_paymenton aCOMPLETED, unbound subscription transaction runs the same lookup with the transaction that completed it and binds it then; its result carriessubscription_idonce bound. After apayment_completedfor a subscription withoutsubscription_id, callverify_paymentfor that payment (again later if it is still unbound). Acharge.completedof 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 asubscriptionsrow another request saved at the same moment is reused. - Renewals are not recorded. A renewal’s
charge.completedcarries atx_refFlutterwave 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 nosubscription_renewedfrom 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’ssubscriptionsrows on that plan with that email (any letter case); none is acknowledged withevent = Noneand 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 marksCANCELLEDonly the rows whosesubscription_idis listed as cancelled. It reportssubscription_cancelledwithsubscription_id(the first written),subscription_idsanduser_idonly when a row was written; a row alreadyCANCELLEDis 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 reportsevent = 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 staysPROCESSINGand the call returnsPROCESSING(COMPLETEDonly whencharge.completedcompleted the row first); thecharge.completedfor thetx_refcompletes the row exactly once after verify confirms it, as for a checkout, and reportspayment_completed. Fulfil on that event, not on the call’s answer.pendingwithdata.meta.authorization.redirect(3-D Secure, the default):REQUIRES_ACTION, andaction_urlis that redirect, the page where the customer authorizes this very charge (no recovery checkout is made; a replay returns it too). Nopayment_action_requiredevent is reported. When the customer authorizes it,charge.completedcompletes the row. A failed attempt there leaves the rowREQUIRES_ACTION(noted inipn_dump.last_failed_attempt, no event), like a checkout: a later successful attempt of the sametx_refstill completes it.pendingwithout a redirect, or any other status:PROCESSING.failed(HTTP 200) or a 400 (e.g. “Wrong token or email passed”):FAILED, withdecline_code= Flutterwave’sprocessor_responseor error message. Another 4xx (a wrong key) marks the rowFAILEDand raises.- A timeout, 5xx, 429 or unreadable reply:
PROCESSINGwithipn_dump.outcome"unknown", neverFAILED.
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_paymentand the dashboard are then the way to check a payment. - Which attempt
verify_by_referencereturns when atx_refhas several is not documented, so Kirak verifies by transaction id whenever it has one. - A
tx_refis 38 characters; Flutterwave documents no maximum length. - Renewals are not recorded: no
subscription_renewalrows and nosubscription_renewedevent (a renewal charge names no subscription).expires_onis 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_paymentis called for that payment (call it after apayment_completedwithoutsubscription_id; only acharge.completedof another attempt of the same checkout also retries); an unbound subscription’s cancellation is not recorded. subscription.cancelledis 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_subscriptionalways cancels immediately, and reports nosubscription_cancelledevent.- 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 asubscriptionsrow, 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_disputeor 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_ACTIONand 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 withsave_payment_methodto 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. metais 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 backFAILEDwith a 400.
Webhook events
Section titled “Webhook events”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.