Skip to content

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_ and after_ hooks; webhooks report one after_webhook hook with a provider-neutral event name.

Install:

Terminal window
pip install "kirak[payments-stripe]" # Stripe
pip install "kirak[payments-razorpay]" # Razorpay
pip install "kirak[payments-square]" # Square
pip install "kirak[all-payments]" # all gateways

PayPal, 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"])

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 provider uses default_provider.

  • Per call: pass "provider": "<instance name>" in the params dict to use another configured instance. The value is the instance name (the key under providers), not the type.

  • Allow-list: only instances listed in kirak.json can be used, including from request payloads. Anything else fails with PROVIDER_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_KEY for the instance named stripe.

  • Non-secret settings (location_id, environment, success_url, …) go in the instance entry. success_url and cancel_url can also be set once under payments as defaults for every instance.

  • The payment_service column on transactions, subscriptions, payment_methods and payment_customers stores the instance name.

  • Renaming an instance leaves its old rows under the old name: webhooks, verify_payment and 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 updating payment_service on 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 stripe instances or a stripe and a stripe_connect instance. 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"]}
]
}
}

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.


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.


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_CURRENCY from exponent(), surfaced as INVALID_PARAMS by validate_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_payment with an amount on such a row still fails with UNSUPPORTED_CURRENCY (400): refund it in full (omit amount) 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 optional overrides mapping 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 PaymentProvider declares its own gateway’s amount representation as class attributes – MAJOR_UNITS (does the gateway want major-unit decimals instead of minor-unit integers?) and CURRENCY_EXPONENTS (the overrides above) – and converts through self._to_gateway_amount( amount_minor, currency) / self._from_gateway_amount(value, currency), both defined on PaymentProvider. Stripe and Stripe Connect set CURRENCY_EXPONENTS = {"ISK": 2, "UGX": 2} (both transitioned to zero-decimal currencies but Stripe still requires a 2-decimal representation, decimal part always 00; 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 sets CURRENCY_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 sets CURRENCY_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 sets CURRENCY_EXPONENTS = {"COP": 0}, since it accepts only whole Colombian pesos. Xendit takes major-unit JSON numbers too and sets CURRENCY_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_transactions
SET amount = amount * 100, net_amount = net_amount * 100
WHERE payment_service IN ('stripe', 'square', 'stripe_connect');
UPDATE payments_subscriptions
SET amount = amount * 100, net_amount = net_amount * 100
WHERE 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.


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


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 here

Cancellation 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).


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

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 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_..."})

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_evidence also 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, so submit_dispute_evidence cannot 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_dispute works.
  • Paystack: not supported (NOT_SUPPORTED, 501); respond in the Paystack dashboard. get_dispute works.
  • Flutterwave: not supported (NOT_SUPPORTED, 501); accept or decline the chargeback in the Flutterwave dashboard. get_dispute works.
  • Mercado Pago: not supported (NOT_SUPPORTED, 501); send documentation in the Mercado Pago dashboard. get_dispute works.
  • Airwallex: not supported (NOT_SUPPORTED, 501); respond in the Airwallex web app. get_dispute works.
  • Omise: not supported (NOT_SUPPORTED, 501); respond in the Omise dashboard. get_dispute works.

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 completes
await 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 method
result = 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 | FAILED

A 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") becomes COMPLETED and payment_completed is 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 returns PROCESSING; 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 carries payment_meta.recovers_transaction_id); send it to the customer. Paystack and Flutterwave instead return their own page to authorize this very charge as action_url, and no recovery checkout is made; they do not report payment_action_required either. The webhook reports payment_action_required (Square does not report this event; its charge call returns REQUIRES_ACTION directly, synchronously).
  • FAILED: declined; result["data"]["decline_code"] says why. Every provider reports a decline this way; Razorpay’s is BAD_REQUEST_ERROR (its SDK gives no finer code; the message is on the row’s ipn_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).


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 Request
from 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).


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 result

data["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.

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

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.

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 params

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:

  1. Gateway event id. Every provider looks up the gateway’s event id in payments_webhook_events before processing. A known id is skipped without being processed again: the result has "already_processed": true and event = 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.
  2. Transaction status. Kirak marks the transaction COMPLETED with one conditional update: it only changes a transaction that is not already COMPLETED or REFUNDED. A repeat that gets past the first layer (a different event for the same payment, such as Square’s payment.created and payment.updated) finds the transaction already processed and reports event = None with "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.


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.


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_evidence and charge_off_session are Python-only API methods on kirak.payments – they have no built-in HTTP routes. Expose them from your own route handlers if needed. charge_off_session is 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.


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.

PaymentProvider (kirak/payments/providers/base.py) declares four abstract methods you must implement, four class attributes you set, and three CRUD helpers you inherit.

your_app/providers/acmepay.py
import hmac
import hashlib
import json
from kirak.payments.providers.base import PaymentProvider
from kirak.core.exceptions import ValidationError
from 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

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.

main.py
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.

"payments": {
"default_provider": "acmepay",
"providers": {
"acmepay": { "type": "acmepay" }
}
}
Terminal window
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/acmepay
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

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.

  1. kirak/payments/services/yourprovider.py – move your class here.
  2. kirak/payments/providers/registry.py – add one line to BUILTIN_PROVIDERS (dotted path and pip extra).
  3. pyproject.toml – add the optional dependency extra if your gateway needs an SDK.
  4. Tests – mirror tests/test_payments/test_stripe_webhook.py for signature verification and event dispatch.

See kirak/payments/services/stripe.py as the canonical reference implementation.