Payments
The Payments module provides a unified API over Stripe, Razorpay, Square, PayPal, Paddle, Paystack, Flutterwave, Mercado Pago, Xendit, Airwallex, Omise and Telr. Every operation runs through dispatch and fires before_
Install:
pip install "kirak[payments-stripe]" # Stripepip install "kirak[payments-razorpay]" # Razorpaypip install "kirak[payments-square]" # Squarepip install "kirak[all-payments]" # all gatewaysPayPal, Paddle, Paystack, Flutterwave, Mercado Pago, Xendit, Airwallex, Omise and Telr need no extra: they call their REST APIs with httpx, which
the base install includes.
Enable:
app = create_kirak_app(models_path="...", modules=["payments"])Configuring Providers
Section titled “Configuring Providers”kirak.json lists the provider instances the app can use and names the default. Each instance has a name (the key) and a type (which implementation to use). Several instances can be active at the same time, including two of the same type.
{ "modules": ["payments"], "payments": { "default_provider": "stripe", "providers": { "stripe": { "type": "stripe" }, "razorpay": { "type": "razorpay" } } }}Built-in type |
Gateway |
|---|---|
stripe |
Stripe (see Stripe) |
razorpay |
Razorpay (see Razorpay) |
square |
Square (see Square) |
paypal |
PayPal (see PayPal) |
paddle |
Paddle Billing, merchant of record (see Paddle) |
paystack |
Paystack (see Paystack) |
flutterwave |
Flutterwave, API v3 (see Flutterwave) |
mercadopago |
Mercado Pago, Checkout Pro and subscriptions (see Mercado Pago) |
xendit |
Xendit Invoices (see Xendit) |
airwallex |
Airwallex Payment Links (see Airwallex) |
omise |
Omise (Opn Payments) Links (see Omise) |
telr |
Telr Hosted Payment Page (see Telr) |
stripe_connect |
Stripe Connect, marketplace payments (see Stripe Connect) |
-
Default: a call without a
providerusesdefault_provider. -
Per call: pass
"provider": "<instance name>"in the params dict to use another configured instance. The value is the instance name (the key underproviders), not the type. -
Allow-list: only instances listed in
kirak.jsoncan be used, including from request payloads. Anything else fails withPROVIDER_NOT_CONFIGURED(400). -
Webhooks: the route is
POST /payments/webhook/<instance name>. -
Secrets are never read from
kirak.json. Each instance reads its own env vars,KIRAK_PAYMENT_<INSTANCE>_<FIELD>, e.g.KIRAK_PAYMENT_STRIPE_SECRET_KEYfor the instance namedstripe. -
Non-secret settings (
location_id,environment,success_url, …) go in the instance entry.success_urlandcancel_urlcan also be set once underpaymentsas defaults for every instance. -
The
payment_servicecolumn ontransactions,subscriptions,payment_methodsandpayment_customersstores the instance name. -
Renaming an instance leaves its old rows under the old name: webhooks,
verify_paymentand the subscription operations only look at rows of the instance that acts, so users of the renamed instance can no longer cancel or verify what they started under the old name, and its webhooks no longer find those rows. Rename by updatingpayment_serviceon the existing rows of all four tables in the same deployment, or keep the old name. -
Several Stripe accounts can run in one app, for example two
stripeinstances or astripeand astripe_connectinstance. Each instance uses only its own secret key.
Discovering what a provider supports: GET /payments/providers (the
list_providers operation) lists every configured instance’s name, type, and
capabilities – so a caller/UI can check what’s available before calling a
capability-gated operation instead of finding out from a 501. With
payments.require_auth on (the default) it needs a signed-in caller, any
role; an anonymous request is refused with 401. capabilities includes the optional capability names from
PaymentProvider.capabilities that the resolved provider class actually
implements: the granular subscription_cancel/subscription_update (plus the
combined subscription_lifecycle for a provider that implements both), pause,
payment_methods, the granular dispute_read/dispute_evidence (plus the
combined disputes for a provider that implements both), off_session, and
subscriptions (from the SUPPORTS_SUBSCRIPTIONS class flag, True by
default – absent for a provider whose checkout cannot start a
subscription: currently Xendit, Airwallex, Omise, Telr and Stripe Connect). It also includes billing_portal,
connect, payment_method_setup and payment_method_attach, each detected by
an override rather than a mixin: billing_portal for a native self-service
portal, connect for the stripe_connect provider type, and
payment_method_setup/payment_method_attach for a provider that supports
saving a method without a payment / attaching a client-side token,
respectively.
{ "status": "success", "data": { "providers": [ {"name": "stripe", "type": "stripe", "capabilities": ["billing_portal", "dispute_evidence", "dispute_read", "disputes", "off_session", "pause", "payment_method_attach", "payment_method_setup", "payment_methods", "subscription_cancel", "subscription_lifecycle", "subscription_update", "subscriptions"]}, {"name": "razorpay", "type": "razorpay", "capabilities": ["dispute_evidence", "dispute_read", "disputes", "off_session", "pause", "payment_method_setup", "payment_methods", "subscription_cancel", "subscription_lifecycle", "subscription_update", "subscriptions"]}, {"name": "square", "type": "square", "capabilities": ["dispute_evidence", "dispute_read", "disputes", "off_session", "pause", "payment_method_attach", "payment_methods", "subscription_cancel", "subscription_lifecycle", "subscription_update", "subscriptions"]}, {"name": "paypal", "type": "paypal", "capabilities": ["dispute_read", "off_session", "pause", "payment_methods", "subscription_cancel", "subscription_lifecycle", "subscription_update", "subscriptions"]}, {"name": "paddle", "type": "paddle", "capabilities": ["billing_portal", "off_session", "pause", "subscription_cancel", "subscription_lifecycle", "subscription_update", "subscriptions"]}, {"name": "paystack", "type": "paystack", "capabilities": ["billing_portal", "dispute_read", "off_session", "payment_methods", "subscription_cancel", "subscriptions"]}, {"name": "flutterwave", "type": "flutterwave", "capabilities": ["dispute_read", "off_session", "payment_methods", "subscription_cancel", "subscriptions"]}, {"name": "mercadopago", "type": "mercadopago", "capabilities": ["dispute_read", "pause", "subscription_cancel", "subscription_lifecycle", "subscription_update", "subscriptions"]}, {"name": "xendit", "type": "xendit", "capabilities": []}, {"name": "airwallex", "type": "airwallex", "capabilities": ["dispute_read"]}, {"name": "omise", "type": "omise", "capabilities": ["dispute_read"]}, {"name": "telr", "type": "telr", "capabilities": []}, {"name": "stripe_connect", "type": "stripe_connect", "capabilities": ["connect", "off_session", "payment_method_attach", "payment_method_setup", "payment_methods"]} ] }}Provider Setup Guides
Section titled “Provider Setup Guides”Each provider needs its own dashboard account, API credentials, and webhook
endpoint. Each built-in provider has its own page covering exactly that. They
share the same Python API (initiate_payment,
verify_payment, refund_payment, handle_webhook) – only the setup
steps and a few provider-specific parameter names differ.
The examples on those pages assume an instance named after its type (stripe,
razorpay, square, paypal, paddle, paystack, flutterwave, mercadopago, xendit, airwallex, omise, telr, stripe_connect) as configured in “Configuring
Providers” above. If you name an instance differently, replace the name in
the env var (KIRAK_PAYMENT_<INSTANCE>_<FIELD>) and in the webhook URL.
- Stripe
- Razorpay
- Square
- PayPal
- Paddle
- Paystack
- Flutterwave
- Mercado Pago
- Xendit
- Airwallex
- Omise
- Telr
- Stripe Connect – marketplace/platform payments
Authentication & Authorization
Section titled “Authentication & Authorization”require_auth defaults to true. When enabled, every operation except
handle_webhook (the gateway signature is its authentication) requires a
caller identity: the caller’s own user ID (from the JWT/API key) must match
the user_id in the request, unless the caller holds the admin or
system role. This is enforced inside the operations, not only on the HTTP
routes – a call made while a request is being handled is checked wherever
it enters; calls from your own code with no request and no user context set
are refused the same way (as an unauthenticated guest), so a script or hook
that calls a Payments operation directly must set a user context first
(typically system), the same convention Auth and CRUD use.
refund_payment and PUT /payments/connect/merchant-fee are always
admin/system-only – a merchant must never be able to set their own
platform fee, and a refund is a backend decision on every gateway, not a
customer-facing one. verify_payment and the subscription operations
(cancel_subscription, update_subscription, pause_subscription,
resume_subscription) take no trusted user_id: the owner is read from the
transaction or subscription row of the provider instance that will act on it,
so a row with the same id on another instance grants nothing.
update_subscription moves the subscription to whatever new_plan_id the
caller names; register a before_update_subscription hook if users may only
switch between some of your plans.
{ "payments": { "require_auth": true }}Set "require_auth": false only for a trusted internal service-to-service
integration that is already authenticated at a different layer.
Money & Amounts
Section titled “Money & Amounts”amount and net_amount are always stored as integer minor units (cents,
paise, pence, …) in the transactions and subscriptions tables, for
every provider. Pass amounts to initiate_payment / refund_payment in
minor units too (e.g. 2999 for $29.99). Stripe, Razorpay, Square,
PayPal, Paddle, Paystack, Flutterwave, Mercado Pago, Xendit, Airwallex, Omise and Telr all keep payment_meta on the transaction row.
Amount and quantity. On a transaction, amount is the unit amount and
quantity the number of units (default 1): every provider charges
amount x quantity. Stripe, Stripe Connect, PayPal, Paddle, Paystack, Flutterwave, Mercado Pago, Xendit, Airwallex, Omise and Telr mark a transaction REFUNDED
(not PARTIALLY_REFUNDED) once the refunded total reaches that charge; Square
and Razorpay mark any refund REFUNDED. Rows a
webhook creates from a gateway total (renewals, subscription charges) have
quantity 1. Stripe Connect’s connect_checkout is the exception: it stores
the total (amount x quantity) as amount, next to quantity. Paddle’s
completion replaces amount with the tax-inclusive grand total and sets
quantity to 1 (see Paddle above).
amount is always an integer in ISO 4217 minor units of currency –
kirak/payments/utils/currency.py holds the full ISO 4217 table
(ISO_EXPONENTS) and is the single source of truth for how many decimal
places a currency has. A few rules follow from this:
- An unknown currency code is rejected with 400 (
UNSUPPORTED_CURRENCYfromexponent(), surfaced asINVALID_PARAMSbyvalidate_payment_params), never silently defaulted to 2 decimals. - Webhooks are the exception: an amount read back from the gateway for money
that already moved is never rejected. When its currency is missing or not
in the table (for example BGN, withdrawn from ISO 4217 on 2026-01-01, on a
row created before the upgrade), the amount is read best-effort as an
integer – unconverted from a minor-unit gateway, and assuming 2 decimals
from a major-unit one such as PayPal – and a warning is logged, so the
webhook still succeeds.
refund_paymentwith anamounton such a row still fails withUNSUPPORTED_CURRENCY(400): refund it in full (omitamount) or at the gateway. - An amount a gateway cannot represent in its own decimals is rejected with
400 (
AMOUNT_NOT_REPRESENTABLE) before any gateway call. It is never rounded – for example 1500.50 minor-unit-equivalent rupiah sent to a gateway that only accepts whole rupiah is an error, not a silent round to 1500 or 1501. to_gateway()/from_gateway()take an optionaloverridesmapping for currencies where a gateway’s own exponent disagrees with ISO 4217 (for example Stripe treating ISK as 2 decimals instead of ISO’s 0).- A
PaymentProviderdeclares its own gateway’s amount representation as class attributes –MAJOR_UNITS(does the gateway want major-unit decimals instead of minor-unit integers?) andCURRENCY_EXPONENTS(theoverridesabove) – and converts throughself._to_gateway_amount( amount_minor, currency)/self._from_gateway_amount(value, currency), both defined onPaymentProvider. Stripe and Stripe Connect setCURRENCY_EXPONENTS = {"ISK": 2, "UGX": 2}(both transitioned to zero-decimal currencies but Stripe still requires a 2-decimal representation, decimal part always00; HUF and TWD need no override – Stripe’s “must be a whole number” rule for those two is documented for manual payouts, not charges). Square and Razorpay use ISO 4217 minor units for every currency they support, so neither declares an override. PayPal takes major-unit strings (MAJOR_UNITS = True) and setsCURRENCY_EXPONENTS = {"HUF": 0, "TWD": 0}, since PayPal rejects decimals for those two (JPY is already 0 in ISO 4217). Paddle takes minor-unit strings with ISO 4217 exponents for every currency it supports (MAJOR_UNITS = False, no override). Paystack takes integer subunits of base amount x 100 for every currency (MAJOR_UNITS = False) and setsCURRENCY_EXPONENTS = {"XOF": 2, "RWF": 2}, since both are 0-decimal in ISO 4217 (1500 XOF is sent as 150000). Flutterwave takes major-unit decimal strings with ISO 4217 exponents for every currency (MAJOR_UNITS = True, no override; 250000 NGN minor units are sent as"2500.00", 5000 UGX as"5000"). Mercado Pago takes major-unit JSON numbers (MAJOR_UNITS = True, built exactly from the decimal) and setsCURRENCY_EXPONENTS = {"COP": 0}, since it accepts only whole Colombian pesos. Xendit takes major-unit JSON numbers too and setsCURRENCY_EXPONENTS = {"IDR": 0}(whole rupiah only). Airwallex takes major-unit JSON numbers with ISO 4217 decimals (no override). Omise takes integer minor units with ISO 4217 exponents (MAJOR_UNITS = False, no override). Telr takes major-unit decimal strings with ISO 4217 decimals (KWD, BHD, OMR and JOD have three; no override).
transactions and subscriptions both have a nullable currency column
(3-letter ISO code, upper case) alongside amount, so every stored amount
carries the currency it is denominated in. Every provider writes it on every
row it creates – checkout/payment-link/order rows, off-session charges
(PaymentProvider._save_off_session_transaction) and renewal rows created by
a webhook (Stripe invoice.payment_succeeded, Razorpay
subscription.charged). Rows written before this column existed stay NULL
– nothing is backfilled or guessed.
If you have transactions rows written by Stripe, Square, or Stripe
Connect before this convention was enforced everywhere, they were stored
in major units and need a one-time backfill:
UPDATE payments_transactionsSET amount = amount * 100, net_amount = net_amount * 100WHERE payment_service IN ('stripe', 'square', 'stripe_connect');
UPDATE payments_subscriptionsSET amount = amount * 100, net_amount = net_amount * 100WHERE payment_service IN ('stripe', 'square', 'stripe_connect');The payment_service column stores the provider instance name, so adjust the
IN (...) list if your instances are not named after their types.
Run each statement exactly once – it is not idempotent (running it twice multiplies by 10000, not 100). Adjust the multiplier for a zero-decimal currency (e.g. JPY, KRW), where minor units equal major units.
initiate_payment
Section titled “initiate_payment”Create a payment intent, Checkout session, or subscription.
result = await kirak.payments.initiate_payment({ "user_id": "42", # your internal user ID "amount": 2999, # amount in smallest currency unit (cents for USD) "currency": "USD", "name": "Pro Plan", "type": "one_time", # one_time | subscription "provider": "stripe", # optional -- override default
# For subscriptions: # "type": "subscription", # "subscription_plan_id": "price_abc123", # Stripe Price ID or Razorpay Plan ID})Response (Stripe Checkout):
{ "statusCode": 200, "status": "success", "message": "payment initiated successfully", "data": { "url": "https://checkout.stripe.com/pay/cs_...", "session_id": "cs_...", "transaction_id": 42 }}amount is a whole number of minor units: at least 1 for a one-time payment
(and for charge_off_session), 0 or more for a subscription, whose plan sets
the price. Anything else, including true/false, is INVALID_PARAMS (400).
A provider whose checkout cannot start a subscription (its
PaymentProvider.SUPPORTS_SUBSCRIPTIONS class flag is False – currently only
Stripe Connect, whose checkout is payment-mode only) raises NOT_SUPPORTED
(501) for "type": "subscription". Check the subscriptions capability from
GET /payments/providers before offering the option.
verify_payment
Section titled “verify_payment”Verify a completed payment (used in return URL handlers). Every provider reads the same key – the gateway’s own payment ID, not the checkout session ID.
result = await kirak.payments.verify_payment({ "provider": "stripe", "payment_id": "pi_...", # Stripe PaymentIntent ID # or: # "provider": "razorpay", "payment_id": "pay_...", # "provider": "square", "payment_id": "...",})refund_payment
Section titled “refund_payment”Refund all or part of a completed payment.
result = await kirak.payments.refund_payment({ "provider": "stripe", "payment_id": "pi_...", "amount": 1000, # optional -- partial refund. Omit for full refund. "currency": "USD", # optional -- see below})amount, when given, is in ISO 4217 minor units of the transaction’s own
currency (see “Money & Amounts”), the same as every other amount in this
API. If currency is omitted, it is looked up from the transactions row
whose transaction_id matches payment_id (preferring a row that belongs to
the resolved provider instance); if no row is found, the provider’s own
default currency applies, same as before this lookup existed. Always pass
currency explicitly when you already know it.
Pass an idempotency_key to make a retry after a timeout safe: the same key
never refunds twice. It is never sent to the gateway as it is, because a
gateway’s keys span the whole account. The key sent (kirak- and 32 hex
digits) is derived from yours, the operation and an id only this database’s
object has. So one key used for a checkout and later for its refund (an
order id, say) is two different keys at the gateway, and another Kirak
database on the account never sends the same one. The same holds for the
checkout, payment link and off-session keys Stripe, Stripe Connect and Square
receive. Paystack, Flutterwave, Omise and Paddle take no refund idempotency
key: after a timeout, check the refund in the gateway’s dashboard before
trying again.
get_billing_portal
Section titled “get_billing_portal”Generate a Stripe Customer Portal URL where the subscriber can manage their subscription, update payment methods, or cancel.
result = await kirak.payments.get_billing_portal({ "user_id": "42", "return_url": "https://app.example.com/account",})# result["data"]["portal_url"] -> redirect user hereCancellation from the portal triggers the customer.subscription.deleted webhook, which Kirak reports to after_webhook as the subscription_cancelled event.
Paddle also supports it: result["data"]["portal_url"] is a Paddle customer portal session for the user’s newest subscription that is not canceled (return_url is not used; see Paddle).
Paystack too: result["data"]["portal_url"] is Paystack’s manage link for the user’s newest subscription that has not ended, where the customer changes the card or cancels (return_url is not used; see Paystack).
Subscription Lifecycle and Disputes
Section titled “Subscription Lifecycle and Disputes”These six operations are capability-gated: they only work if the resolved
provider implements the relevant capability mixin (providers/capabilities.py).
cancel_subscription needs SupportsCancelSubscription, update_subscription
needs SupportsUpdateSubscription (SupportsSubscriptionLifecycle is the
combination of both – some gateways implement only one half, for example a
provider that can cancel a subscription but not change its plan). Likewise
get_dispute needs SupportsDisputeRead and submit_dispute_evidence needs
SupportsDisputeEvidence (SupportsDisputes combines both). pause_subscription/
resume_subscription need SupportsPause. Calling an operation against a
provider that hasn’t opted in raises NOT_SUPPORTED (501). Check
GET /payments/providers first, or catch the error, rather than assuming every
provider supports them – Stripe, Razorpay and Square implement every one of
these mixins; PayPal implements all but SupportsDisputeEvidence; Paddle
implements the subscription and pause mixins but no dispute mixin; Paystack
and Flutterwave implement only SupportsCancelSubscription and
SupportsDisputeRead; Mercado Pago implements the subscription and pause
mixins and SupportsDisputeRead; Airwallex and Omise implement only
SupportsDisputeRead; Xendit, Telr and Stripe Connect implement none of them.
cancel_subscription
Section titled “cancel_subscription”Cancel a subscription immediately, or at the end of the current billing period.
result = await kirak.payments.cancel_subscription({ "subscription_id": "sub_...", "at_period_end": True, # optional, default False (cancels immediately) "provider": "stripe", # optional -- override default})Ownership is checked against the subscriptions row itself (never a
request-supplied user_id), so the caller must own the subscription or hold
admin/system. Square always cancels at the end of the current billing
period – there is no immediate-cancel variant of Square’s API, so
at_period_end has no effect for a Square subscription. PayPal is the
opposite: it only cancels immediately, so at_period_end: True returns
NOT_SUPPORTED (501) for a PayPal subscription. Paystack, Flutterwave and
Mercado Pago are the same as PayPal.
update_subscription
Section titled “update_subscription”Change a subscription’s plan and/or quantity.
result = await kirak.payments.update_subscription({ "subscription_id": "sub_...", "new_plan_id": "price_...", # Stripe Price ID, Razorpay Plan ID, Square plan variation ID, or PayPal plan id "quantity": 3, # optional -- ignored by Square, which has no per-subscription quantity})PayPal needs the buyer to approve a plan change: send them to
result["data"]["url"]. The change applies after approval (see
PayPal). Mercado Pago changes only the amount (new_plan_id is a
preapproval plan with the same billing cycle and currency; see
Mercado Pago).
pause_subscription / resume_subscription
Section titled “pause_subscription / resume_subscription”Pause billing without cancelling, and resume it later.
result = await kirak.payments.pause_subscription({"subscription_id": "sub_..."})# ... later:result = await kirak.payments.resume_subscription({"subscription_id": "sub_..."})get_dispute / submit_dispute_evidence
Section titled “get_dispute / submit_dispute_evidence”Retrieve a gateway-side dispute (chargeback) and respond to it with evidence. Admin/system only – a dispute is filed with the customer’s bank, not through Kirak, so responding to one is a backend decision, never a customer-facing call.
result = await kirak.payments.get_dispute({"dispute_id": "dp_..."})
result = await kirak.payments.submit_dispute_evidence({ "dispute_id": "dp_...", "evidence": {"customer_communication": "..."}, # Stripe})The evidence dict is forwarded to the gateway as-is, so its shape is
provider-specific:
- Stripe: any of Stripe’s evidence fields (
customer_communication,receipt,shipping_documentation, …);submit_dispute_evidencealso marks it submitted for review in the same call. - Razorpay: document ids obtained beforehand via Razorpay’s Documents API
(
shipping_proof,billing_proof, …) plus"action": "submit"(or"draft"to save without submitting) – Razorpay calls this “contesting” a dispute. - Square:
{"evidence_text": "...", "evidence_type": "TRACKING_NUMBER"}(optional type). Square uploads the text and submits it for review in one call. Square also accepts file evidence (photos, PDFs) through a separate binary-upload endpoint that does not fit this dict-based interface, sosubmit_dispute_evidencecannot attach a file for Square. - PayPal: not supported (
NOT_SUPPORTED, 501). PayPal takes evidence only as a multipart file upload; respond in PayPal’s Resolution Center.get_disputeworks. - Paystack: not supported (
NOT_SUPPORTED, 501); respond in the Paystack dashboard.get_disputeworks. - Flutterwave: not supported (
NOT_SUPPORTED, 501); accept or decline the chargeback in the Flutterwave dashboard.get_disputeworks. - Mercado Pago: not supported (
NOT_SUPPORTED, 501); send documentation in the Mercado Pago dashboard.get_disputeworks. - Airwallex: not supported (
NOT_SUPPORTED, 501); respond in the Airwallex web app.get_disputeworks. - Omise: not supported (
NOT_SUPPORTED, 501); respond in the Omise dashboard.get_disputeworks.
Stripe reports charge.dispute.created/charge.dispute.closed, PayPal
CUSTOMER.DISPUTE.CREATED/.UPDATED/.RESOLVED, Paystack
charge.dispute.create/.remind/.resolve, Flutterwave
chargeback.initiated/.accepted/.declined/.lost (only when Flutterwave
enables them), Mercado Pago topic_chargebacks_wh notifications, Airwallex
payment_dispute.*, Omise dispute.*, and Paddle a chargeback
adjustment (dispute_created only; Paddle has no dispute API), through
after_webhook as the dispute_created/dispute_updated events (see the Events table under
“Webhook Hooks” below), so you can react to a new dispute without polling.
Razorpay and Square do not report a dispute event through after_webhook yet
– poll get_dispute or check the gateway dashboard.
Saved Payment Methods and Off-Session Charges
Section titled “Saved Payment Methods and Off-Session Charges”Save a customer’s payment method with the gateway once, then charge it later with no
customer present (usage billing, top-ups, overage fees). The card stays with the
gateway; Kirak stores only the gateway’s ids plus brand, last 4 digits and expiry in
the payment_customers and payment_methods models, so card data never reaches your
app.
Saving a method always needs the customer’s consent to future charges. Pass
consent={"text_version": "<version of the text they agreed to>"}; Kirak adds the
time, and the IP address and user agent when the call comes through an HTTP request,
and stores it on the method.
# While paying -- the method is saved when the payment completesawait kirak.payments.initiate_payment({ "user_id": "42", "amount": 1000, "currency": "USD", "name": "Pro", "type": "one_time", "save_payment_method": True, "consent": {"text_version": "2026-09"},})
# Without paying -- a hosted page that only saves the methodresult = await kirak.payments.setup_payment_method({ "user_id": "42", "consent": {"text_version": "2026-09"}, "provider": "stripe",})# result["data"]["url"] -> redirect the customer here
# From a gateway client-side token (e.g. Stripe.js PaymentMethod id, Square Web Payments token)await kirak.payments.attach_payment_method({ "user_id": "42", "payment_method_token": "pm_...", "consent": {"text_version": "2026-09"},})The first saved method becomes the user’s default. Manage methods with
list_payment_methods({"user_id"}), set_default_payment_method({"payment_method_id"})
and detach_payment_method({"payment_method_id"}) (removes it at the gateway, then marks
the row REVOKED). Removing the default makes the user’s newest other active method
(on any provider) the default. The owner or an admin may call these. A Stripe default
changed in Stripe itself (e.g. in the billing portal) is mirrored locally only while
the user’s default is a Stripe method of that instance, or the user has none.
A gateway method already saved on the instance for another user (a gateway can
hand the same card id to two users, e.g. one card and email on two accounts) is
never written for the second user while it is active or pending: that user’s row
is left unchanged, attach_payment_method fails with
PAYMENT_METHOD_OWNED_BY_ANOTHER_USER (409), and a save while paying or from a
setup page is skipped (logged as a warning) while the payment still completes. A
method the other user removed (REVOKED) authorizes nobody, so it is taken over:
the row becomes the new user’s, with their consent and meta.
Charging is admin/system only (it is merchant-initiated) and needs an
idempotency_key: a retry with the same key – also one sent at the same instant as the
first – returns the original transaction instead of charging again. The replay
carries the original transaction_id, status and gateway_payment_id, plus
decline_code (FAILED) or action_url (REQUIRES_ACTION). Only an earlier
charge_off_session with the key is replayed: a checkout (initiate_payment)
made with the same key, such as an order id used for both, does not stop the
charge, and neither does a charge stop a checkout. The key may be at most 200
characters; a longer one is rejected with INVALID_IDEMPOTENCY_KEY (400) before
anything is charged.
result = await kirak.payments.charge_off_session({ "user_id": "42", "amount": 1500, "currency": "USD", "name": "Usage - September", "idempotency_key": "usage-42-2026-09", # "payment_method_id": 17, # optional; the user's default method when omitted})# result["data"]["status"]: COMPLETED | PROCESSING | REQUIRES_ACTION | FAILEDA provider that charges off-session without keeping saved methods (Paddle, which
charges an active subscription) is used when named in provider; no payment method
is looked up and the transaction’s payment_meta.payment_method_id is null. Pass
what it needs instead (Paddle: subscription_id, see Paddle).
COMPLETED/PROCESSING: the gateway accepted the charge. The transaction row (transaction_type"off_session") becomesCOMPLETEDandpayment_completedis reported when the gateway’s webhook arrives – the webhook, not this reply, is the source of truth. If the gateway cannot be reached (a timeout or a server error), the charge also returnsPROCESSING; the webhook reports whichever outcome actually happened once it arrives.REQUIRES_ACTION: the bank wants the customer to authenticate (3-D Secure/SCA).result["data"]["action_url"]is a fresh hosted checkout for the same amount (its transaction carriespayment_meta.recovers_transaction_id); send it to the customer. Paystack and Flutterwave instead return their own page to authorize this very charge asaction_url, and no recovery checkout is made; they do not reportpayment_action_requiredeither. The webhook reportspayment_action_required(Square does not report this event; its charge call returnsREQUIRES_ACTIONdirectly, synchronously).FAILED: declined;result["data"]["decline_code"]says why. Every provider reports a decline this way; Razorpay’s isBAD_REQUEST_ERROR(its SDK gives no finer code; the message is on the row’sipn_dump.error_description).
Provider support (see GET /payments/providers for payment_methods, off_session,
payment_method_setup and payment_method_attach):
| Provider | Save while paying | Save without paying | Attach client token | Off-session charge |
|---|---|---|---|---|
| Stripe | Yes (one-time and subscription checkouts) | Yes (Checkout setup mode) | Yes (Stripe.js PaymentMethod id) | Yes |
| Square | Yes (card payments through payment links) | No | Yes (Web Payments SDK token) | Yes |
| Razorpay | No | Yes (registration link: card, UPI, e-mandate) | No | Yes (Recurring Payments must be enabled on the account) |
| Stripe Connect | No | Yes (setup mode on the platform) | Yes | Yes (destination charge with the merchant’s platform fee; pass merchant_user_id or stripe_account_id) |
| PayPal | Yes (PayPal account, one-time orders; Vault must be enabled on the account) | No | No | Yes (vaulted PayPal account) |
| Paddle | No | No | No | Partial (a one-time charge on an active subscription; pass provider and subscription_id) |
| Paystack | Yes (reusable cards, one-time payments) | No | No | Yes (charge_authorization with the saved email) |
| Flutterwave | Yes (card tokens, one-time payments) | No | No | Yes (tokenized charge with the saved email; 3-D Secure by default, so usually REQUIRES_ACTION) |
| Mercado Pago | No | No | No | No (a saved card needs its security code for every charge) |
| Xendit | No | No | No | No (not supported yet) |
| Airwallex | No | No | No | No (not supported yet) |
| Omise | No | No | No | No (not supported yet) |
| Telr | No | No | No | No |
Square keeps no concept of a default card at the gateway; set_default_payment_method
only updates the local payment_methods row. Square’s “save while paying” column
applies to one-time payment links only – initiate_payment raises NOT_SUPPORTED
(501) for save_payment_method on a Square subscription checkout; PayPal’s does
the same for a PayPal subscription and also has no gateway default (see
PayPal); so do Paystack’s and Flutterwave’s (see Paystack and Flutterwave). On Razorpay, Stripe Connect, Paddle, Mercado Pago, Xendit, Airwallex, Omise and Telr (“No” above) save_payment_method
raises NOT_SUPPORTED (501) instead of being ignored. Razorpay’s
registration_link.create call has no way to attach an existing customer – it takes
a nested customer object and returns the customer_id it resolved, which Kirak
saves (one payment_customers row per Razorpay customer id, so a link still open
under an earlier id is still matched); the token itself is saved once the registration link’s first payment webhook
arrives, since Razorpay’s token.confirmed event does not report a customer_id to
correlate it back to a Kirak user. Razorpay also has no default token at the gateway,
same as Square. Razorpay’s order API takes no idempotency key of its own; a retried
charge_off_session call is guarded only by the idempotency_key already stored on
the PENDING transaction row (see “Charging” above).
handle_webhook
Section titled “handle_webhook”Verify and process an incoming webhook from the payment provider. The module already
mounts POST /payments/webhook/{instance} for every configured instance; point the
gateway’s webhook URL there and no code is needed.
To call it yourself (for example from your own route), pass the same params the built-in
route does. Some providers read headers or query, so include them:
from fastapi import Requestfrom kirak import Kirak
@app.post("/my-webhooks/stripe")async def stripe_webhook(request: Request): kirak: Kirak = request.app.state.kirak return await kirak.payments.handle_webhook({ "service": "stripe", # the instance name "payload": await request.body(), "signature": request.headers.get("stripe-signature", ""), "headers": dict(request.headers), "query": dict(request.query_params), })Kirak verifies the webhook signature (KIRAK_PAYMENT_STRIPE_WEBHOOK_SECRET), parses the event, and fires after_webhook with the event named in result["data"]["event"]. before_webhook fires first and sees the raw, unverified request – use it for logging only and never trust its payload.
Pruning processed webhooks. Every processed webhook leaves one row in
payments_webhook_events, so a redelivery is recognized as a repeat; nothing
removes them on its own. Call await kirak.payments.prune_webhook_events()
from a scheduled job (daily is plenty) to delete those older than 30 days, or
pass older_than_days. It returns how many were deleted. Fewer than 7 days is
refused (INVALID_PARAMS): gateways retry for up to a few days, and an event
pruned before its last retry is processed again (completions and refunds are
still recorded once, but after_webhook runs again).
Webhook Hooks
Section titled “Webhook Hooks”Webhooks have one hook, after_webhook. It fires for every verified delivery, and
result["data"]["event"] names what happened the same way for every provider:
@kirak.payments.hook("after_webhook")async def on_webhook(result): data = result["data"] event = data["event"] if event == "payment_completed": # data always has transaction_id and payment_status; user_id and (Stripe # only) customer_id are also set -- fetch the transaction row for # amount/email if needed. await kirak.create("invoices", {"data": { "user_id": data["user_id"], "transaction_id": data["transaction_id"], "status": "paid", }}) elif event == "subscription_renewed": pass # extend the user's access... elif event == "subscription_cancelled": pass # downgrade the user to the free plan... return resultdata["event"] is None for an event Kirak does not map (data["event_type"] still
holds the gateway’s own name, so you can branch on that), and None for a repeated
delivery (data["already_processed"] is True), so a handler never fulfils the same
payment twice.
after_webhook hooks run as the system user: the gateway signature has already been
verified, so the hook may write with kirak.create(...) / kirak.update(...) (on models whose access block lists {"role": "system"} for that operation) and call
payment operations such as kirak.payments.update_merchant_fee(...). before_webhook
runs as a guest.
Events
Section titled “Events”data["event"] is one of payment_completed, payment_failed, subscription_renewed,
subscription_updated, subscription_past_due, subscription_cancelled,
refund_completed, refund_pending, merchant_account_updated, merchant_deauthorized,
dispute_created, dispute_updated, payment_method_saved, payment_method_removed and
payment_action_required. Which gateway events map to each name differs per provider; each
provider page lists its mapping under “Webhook events”:
| Provider | Mapping |
|---|---|
| Stripe | Stripe webhook events |
| Razorpay | Razorpay webhook events |
| Square | Square webhook events |
| PayPal | PayPal webhook events |
| Paddle | Paddle webhook events |
| Paystack | Paystack webhook events |
| Flutterwave | Flutterwave webhook events |
| Mercado Pago | Mercado Pago webhook events |
| Xendit | Xendit webhook events |
| Airwallex | Airwallex webhook events |
| Omise | Omise webhook events |
| Telr | Telr webhook events |
| Stripe Connect | Stripe Connect webhook events |
result["data"] for a payment_completed or subscription_renewed event also carries currency (upper case) when the gateway or transaction row has one – Stripe checkout completion, off-session payment_intent.succeeded and invoice.payment_succeeded, Razorpay payment.captured and subscription.charged, Square payment.updated/payment.created with status COMPLETED, PayPal PAYMENT.CAPTURE.COMPLETED and PAYMENT.SALE.COMPLETED, Paddle transaction.completed (checkouts, renewals and subscription charges), Paystack charge.success and invoice.update, Flutterwave charge.completed, Mercado Pago payment and subscription_authorized_payment, Xendit invoice callbacks, Airwallex payment_link.paid, Omise charge.complete, Telr sale advices, and Stripe Connect checkout completion and off-session charges. amount (Kirak minor units, what was charged) is carried next to it only by PayPal, Paddle, Paystack, Flutterwave, Mercado Pago, Xendit, Airwallex, Omise, Telr and the Stripe/Stripe Connect off-session payment_intent.succeeded; for the others, read the amount from the transaction row (transaction_id in the result, where given).
Migrating from the named hooks
Section titled “Migrating from the named hooks”after_payment_completed, after_payment_failed, after_subscription_renewed,
after_subscription_updated, after_subscription_cancelled, after_refund_completed,
after_merchant_account_updated and after_merchant_deauthorized were removed. A handler
registered under one of them is refused at startup. Register after_webhook and check
result["data"]["event"] instead, using the names listed under “Events” above.
Operation hooks
Section titled “Operation hooks”Each operation also fires before_<operation> and after_<operation> on the payments
module: list_providers, initiate_payment, verify_payment, refund_payment, get_billing_portal,
webhook, onboard_merchant, create_onboarding_link, get_merchant_status,
connect_checkout, update_merchant_fee, cancel_subscription, update_subscription,
pause_subscription, resume_subscription, get_dispute, submit_dispute_evidence,
setup_payment_method, attach_payment_method, list_payment_methods,
detach_payment_method, set_default_payment_method and charge_off_session
(cancel_subscription, update_subscription, pause_subscription, resume_subscription,
get_dispute, submit_dispute_evidence, setup_payment_method, attach_payment_method,
detach_payment_method, set_default_payment_method and charge_off_session raise
NOT_SUPPORTED (501) for a provider that has not implemented the
relevant capability mixin – see PaymentProvider.capabilities and
GET /payments/providers; initiate_payment raises the same error when called with
save_payment_method=True against a provider that cannot save a method during a
payment – see SupportsPaymentMethods.SAVES_DURING_PAYMENT). A
before hook receives the params and may
change them; an after hook receives the result. Only a sync hook can stop the
operation by raising – an async hook that raises is logged and skipped, and a hook that
takes longer than KIRAK_HOOK_TIMEOUT (5 seconds) is skipped.
@kirak.payments.hook("before_initiate_payment")def check_stock(params): # sync, so raising stops the payment if not in_stock(params["name"]): raise KirakException("Out of stock", code="OUT_OF_STOCK", status_code=409) return paramsRepeated deliveries
Section titled “Repeated deliveries”Payment gateways deliver a webhook at least once, so the same event can arrive twice (Stripe retries any response that is not 2xx, and Square sends payment.updated on every change). Kirak recognises a repeat in two layers:
- Gateway event id. Every provider looks up the gateway’s event id in
payments_webhook_eventsbefore processing. A known id is skipped without being processed again: the result has"already_processed": trueandevent = None. The id is recorded only after the event was processed successfully, so a delivery that failed is processed again on the gateway’s retry. - Transaction status. Kirak marks the transaction
COMPLETEDwith one conditional update: it only changes a transaction that is not alreadyCOMPLETEDorREFUNDED. A repeat that gets past the first layer (a different event for the same payment, such as Square’spayment.createdandpayment.updated) finds the transaction already processed and reportsevent = Nonewith"already_processed": true.
after_webhook fires for every verified delivery, repeats included.
The second layer covers the payment-completed event of Stripe, Stripe Connect, Razorpay, Square, PayPal, Paddle, Paystack, Flutterwave, Mercado Pago, Xendit, Airwallex, Omise and Telr, and the renewal events (subscription_renewed) of Stripe, Razorpay, PayPal, Paddle, Paystack and Mercado Pago (Flutterwave records no renewals). A renewal is recognised from its transaction, which is saved after the subscription has been extended, so a renewal that failed part-way is retried in full.
Limits: if the process stops after the transaction is saved but before your hook has run, that hook is not run again; two copies of the same event processed at the same instant both pass the first layer (the event id is recorded only after processing) and can both record a renewal, because transaction_id has no unique constraint.
Stripe Connect
Section titled “Stripe Connect”Marketplace/platform payments (onboard_merchant, create_onboarding_link,
get_merchant_status, connect_checkout, update_merchant_fee, and destination
off-session charges) are documented on the Stripe Connect page.
HTTP Endpoints
Section titled “HTTP Endpoints”The payments module also mounts HTTP endpoints:
| Method | Path | Description |
|---|---|---|
| POST | /payments/initiate |
Initiate a payment |
| GET | /payments/providers |
List each configured instance’s name, type, and capabilities |
| POST | /payments/webhook/{instance} |
Receive webhooks for any configured provider instance |
| POST | /payments/connect/onboard |
Onboard a Stripe Connect merchant |
| POST | /payments/connect/onboard/refresh |
Regenerate an expired onboarding link |
| GET | /payments/connect/account-status |
Fetch live merchant account status |
| POST | /payments/connect/checkout |
Create Connect checkout session with platform fee |
| PUT | /payments/connect/merchant-fee |
Update a merchant’s platform fee rate |
| POST | /payments/methods/setup |
Hosted page that saves a payment method without charging |
| POST | /payments/methods |
Save a payment method from a gateway client-side token |
| GET | /payments/methods?user_id= |
List a user’s saved payment methods |
| DELETE | /payments/methods/{id} |
Remove a saved payment method |
| POST | /payments/methods/{id}/default |
Make a saved method the default |
Note:
verify_payment,refund_payment,get_billing_portal,cancel_subscription,update_subscription,pause_subscription,resume_subscription,get_dispute,submit_dispute_evidenceandcharge_off_sessionare Python-only API methods onkirak.payments– they have no built-in HTTP routes. Expose them from your own route handlers if needed.charge_off_sessionis admin/system only, so a route handler that calls it must run it as the system user (set_user_context({"role": "system", "user_id": None, "token": None}), same as any other in-process admin/system call) after your own checks – there is no end-user identity to authorize against for a merchant-initiated charge.
Adding a Custom Payment Provider
Section titled “Adding a Custom Payment Provider”The shared contract, the credential check (
check()), entry points and testing are covered in Adding a Provider.
Every built-in gateway is a class that subclasses PaymentProvider. You can add any gateway the same way, without forking or modifying Kirak. There are three steps: write the class, register its type, and list an instance in kirak.json.
1. Subclass PaymentProvider
Section titled “1. Subclass PaymentProvider”PaymentProvider (kirak/payments/providers/base.py) declares four abstract methods you must implement, four class attributes you set, and three CRUD helpers you inherit.
import hmacimport hashlibimport jsonfrom kirak.payments.providers.base import PaymentProviderfrom kirak.core.exceptions import ValidationErrorfrom kirak.core.error_response import create_success_response
class AcmePayProvider(PaymentProvider): """Example custom provider -- replace with your gateway's SDK calls."""
TYPE_NAME = "acmepay" # the "type" in kirak.json SECRET_FIELDS = ("api_key", "webhook_secret") # read from env, never kirak.json WEBHOOK_SIGNATURE_HEADER = "x-acmepay-signature" # where the signature arrives EVENT_MAP = { # gateway event -> event reported to after_webhook "acmepay.payment.captured": "payment_completed", "acmepay.payment.failed": "payment_failed", "acmepay.refund.created": "refund_completed", }
def __init__(self, config: dict, payments=None): super().__init__(config, payments=payments) self._require("api_key") # fail early if the env var is unset self.api_key = config["api_key"] self.webhook_secret = config.get("webhook_secret", "") self.success_url = config.get("success_url", "/payment/success") self.cancel_url = config.get("cancel_url", "/payment/cancel") self.logger.info("[ACMEPAY] Provider initialised")
# ------------------------------------------------------------------ # Required: implement all four abstract methods # ------------------------------------------------------------------
async def initiate_payment(self, params: dict) -> dict: """ Start a checkout session. Always persist a PENDING transaction first via _kirak_create so hooks fire and the record exists before the user hits the gateway. """ saved = await self._kirak_create("transactions", { "user_id": params["user_id"], "payment_status": "PENDING", "transaction_type": params.get("type", "one_time"), "amount": params["amount"], # minor units -- see "Money & Amounts" "net_amount": params["amount"], "payment_service": self.instance_name, # the kirak.json instance name "quantity": params.get("quantity", 1), "payment_email": params.get("payment_email", ""), "ipn_dump": {}, }) db_tid = saved.get("data", {}).get("id")
# Call your gateway's SDK / REST API here checkout_url = f"{self.success_url}?tid={db_tid}" # replace with real gateway URL
return create_success_response(data={"url": checkout_url, "transaction_id": db_tid})
async def verify_payment(self, params: dict) -> dict: """Look up a payment by ID and return its current status.""" if not params.get("payment_id"): raise ValidationError("payment_id is required") return create_success_response(data={"status": "COMPLETED"})
async def refund_payment(self, params: dict) -> dict: """Issue a full or partial refund.""" if not params.get("payment_id"): raise ValidationError("payment_id is required") return create_success_response(data={"refund_id": "acme_ref_xxx"})
async def handle_webhook(self, params: dict) -> dict: """ Verify the webhook signature and return the normalised event.
The returned `event_type` is looked up in EVENT_MAP; the result is the event name after_webhook reports.
The params dict includes: service (instance name), payload (request body bytes), signature (from WEBHOOK_SIGNATURE_HEADER), headers (all request headers), and query (dict of query string parameters). """ payload: bytes = params.get("payload", b"") signature: str = params.get("signature", "")
expected = hmac.new(self.webhook_secret.encode(), payload, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): raise ValidationError("Invalid signature")
event_type = json.loads(payload).get("event") # e.g. "acmepay.payment.captured"
# Update DB records via the CRUD helpers so hooks fire correctly # await self._kirak_update("transactions", where={...}, data={...})
return create_success_response(data={"event_type": event_type})Class attributes:
| Attribute | Purpose |
|---|---|
TYPE_NAME |
The type string used in kirak.json; also the fallback for instance_name when a provider is built directly |
SECRET_FIELDS |
Config fields read from KIRAK_PAYMENT_<INSTANCE>_<FIELD>. A secret written in kirak.json is rejected |
EVENT_MAP |
Gateway event name -> the event name after_webhook reports (see “Webhook Hooks”). Owned by your class, so it can never collide with another provider’s events |
WEBHOOK_SIGNATURE_HEADER |
Request header the route reads the signature from |
CRUD helpers (inherited – never access the DB pool directly):
| Helper | Purpose |
|---|---|
_kirak_create(model, data) |
Insert a record; fires after_create hooks |
_kirak_update(model, where, data) |
Update records; fires after_update hooks |
_kirak_fetch(model, params) |
Fetch records; returns the data list directly |
2. Register the type with on_kirak_ready
Section titled “2. Register the type with on_kirak_ready”on_kirak_ready runs after Kirak is initialised, before routers are mounted, and before provider config is validated – so the type is known by the time kirak.json is checked.
from kirak import create_kirak_app
async def on_kirak_ready(kirak): from your_app.providers.acmepay import AcmePayProvider
kirak.payments.register_provider("acmepay", AcmePayProvider)
# Register payment hooks as usual @kirak.payments.hook("after_webhook") async def on_payment_done(result): if result["data"]["event"] == "payment_completed": pass # your business logic here return result
app = create_kirak_app( models_path="./models/", modules=["payments"], on_kirak_ready=on_kirak_ready,)register_provider can also be used as a decorator (@kirak.payments.register_provider("acmepay")). It raises ValueError if the type name is already taken (including by a built-in) and TypeError if the class does not subclass PaymentProvider.
3. List an instance in kirak.json
Section titled “3. List an instance in kirak.json”"payments": { "default_provider": "acmepay", "providers": { "acmepay": { "type": "acmepay" } }}KIRAK_PAYMENT_ACMEPAY_API_KEY=...KIRAK_PAYMENT_ACMEPAY_WEBHOOK_SECRET=...Use it exactly like any built-in provider:
result = await kirak.payments.initiate_payment({ "user_id": "123", "amount": 2999, "currency": "USD", "name": "Pro Plan", "type": "one_time", # "provider": "acmepay", # optional here: it is the default})
# Webhook -- the generic route handles it with no extra code# POST /payments/webhook/acmepayHow it works
Section titled “How it works”| Component | Location | Role |
|---|---|---|
PaymentProvider (ABC) |
kirak/payments/providers/base.py |
The 4-method contract, class attributes and CRUD helpers |
ProviderRegistry |
kirak/core/providers.py |
Maps type to a class: built-ins, then register_provider, then entry points |
ProviderSet |
kirak/core/providers.py |
The configured instances: default, allow-list, lazy creation, per-instance secrets |
POST /payments/webhook/{instance} |
kirak/payments/router.py |
Generic route; reads the signature from the provider’s WEBHOOK_SIGNATURE_HEADER |
on_kirak_ready |
kirak/kirak_instance.py |
Runs after init, before routers mount and config is validated |
Distributing a provider
Section titled “Distributing a provider”A provider can be published as its own pip package. Declare an entry point in the kirak.payments_providers group and users only need pip install plus a type in kirak.json – no register_provider call:
# pyproject.toml of your package[project.entry-points."kirak.payments_providers"]acmepay = "your_package.acmepay:AcmePayProvider"Only the entry point matching a configured type is imported. Built-in types and register_provider registrations take precedence, and two packages claiming the same name is an error.
Contributing a provider to Kirak core
Section titled “Contributing a provider to Kirak core”kirak/payments/services/yourprovider.py– move your class here.kirak/payments/providers/registry.py– add one line toBUILTIN_PROVIDERS(dotted path and pip extra).pyproject.toml– add the optional dependency extra if your gateway needs an SDK.- Tests – mirror
tests/test_payments/test_stripe_webhook.pyfor signature verification and event dispatch.
See kirak/payments/services/stripe.py as the canonical reference implementation.