Skip to content

Paddle

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

The paddle provider (Paddle Billing) supports one-time payments (initiate_payment, verify_payment), refunds (refund_payment), subscriptions with cancel, plan change and pause (cancel_subscription, update_subscription, pause_subscription, resume_subscription), Paddle’s customer portal (get_billing_portal), one-time charges on an active subscription (charge_off_session), and webhooks, including chargeback notifications. GET /payments/providers reports subscriptions, subscription_cancel, subscription_update, subscription_lifecycle, pause, billing_portal and off_session for it. Not supported: disputes (get_dispute, submit_dispute_evidence) and saved payment methods (Paddle keeps the method on its customer; save_payment_method, setup_payment_method and attach_payment_method are NOT_SUPPORTED, 501). It needs no extra install: it calls Paddle’s REST API directly over HTTP.

Why plain HTTP (no Paddle SDK). The official paddle-python-sdk is not used: every release requires Python 3.11 or newer while Kirak supports 3.10, it is synchronous only, and the few endpoints Kirak calls are simple REST.

Merchant of record. Paddle sells to the customer and collects and remits VAT/sales tax. A completed transaction’s amount is Paddle’s grand total (tax included) and net_amount your earnings (after tax and Paddle’s fee), both in the transaction’s currency.

1. Get credentials. In your Paddle dashboard (sandbox or live), create an API key.

2. Set up checkout. Paddle does not host the checkout page for transactions made through the API: the url Kirak returns is your account’s default payment link plus ?_ptxn=<transaction id>, and that page must load Paddle.js, which opens the checkout. In Paddle -> Checkout -> Checkout settings, set the default payment link to a page on your site that includes Paddle.js; Paddle must approve that domain. Without a default payment link Paddle returns no checkout URL and initiate_payment fails with PADDLE_CHECKOUT_NOT_CONFIGURED (500); the transaction row is marked FAILED. To use another approved page, set checkout_url below.

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

"paddle": {
"type": "paddle",
"environment": "sandbox",
"default_tax_category": "standard"
}

environment is sandbox (the default) or production, and must match the Paddle account the key belongs to. Any other value fails when the instance is first used. default_tax_category (default standard) is the Paddle tax category of the product Kirak creates for a payment without a price_id (below). checkout_url (optional) is an approved page to send instead of the default payment link. In the environment:

Terminal window
KIRAK_PAYMENT_PADDLE_API_KEY=...
KIRAK_PAYMENT_PADDLE_WEBHOOK_SECRET=... # from step 4

4. Configure the webhook. In Paddle’s notification settings, add a destination with the URL https://your-domain.com/payments/webhook/paddle, subscribe it to transaction.completed, transaction.canceled, transaction.payment_failed, adjustment.created and adjustment.updated, and copy its secret key into KIRAK_PAYMENT_PADDLE_WEBHOOK_SECRET. Without it every webhook fails with WEBHOOK_SECRET_NOT_CONFIGURED (500). For subscriptions also subscribe subscription.created, .activated, .updated, .trialing, .paused, .resumed, .past_due and .canceled.

Each delivery’s Paddle-Signature (ts=...;h1=...) is checked locally: an HMAC-SHA256 of <ts>:<raw body> with the secret, compared in constant time; any h1 may match (Paddle sends several while a secret is rotated). A missing or malformed header is rejected with MISSING_WEBHOOK_SIGNATURE (400) and a mismatch with INVALID_WEBHOOK_SIGNATURE (400). A repeat of an event already handled (same event_id) returns already_processed: true. There is no time window on ts: Paddle retries a delivery for up to 3 days and does not document whether a retry is signed again, so replays are stopped by the event_id check instead.

5. Use it:

# One-time payment with a price from your Paddle catalog; send the buyer to data["url"]
result = await kirak.payments.initiate_payment({
"provider": "paddle",
"user_id": "42", "amount": 2999, "currency": "USD", # cents
"name": "Pro Plan", "type": "one_time",
"price_id": "pri_01gsz8x8sawmvhz1pv30nge1ke",
})
paddle_txn = result["data"]["paddle_transaction_id"] # txn_...
# Or without a price_id: a one-off price for amount/currency, named name
# (optional "description", else name)
# Refund (admin/system): payment_id is the Paddle transaction id; omit amount
# for a full refund
await kirak.payments.refund_payment({
"provider": "paddle", "payment_id": paddle_txn, "amount": 500, "reason": "Goodwill",
})
# Subscription -- price_id is a recurring Paddle price (required); the price
# sets the amount. Send the buyer to data["url"].
result = await kirak.payments.initiate_payment({
"provider": "paddle",
"user_id": "42", "amount": 1500, "currency": "USD",
"name": "Pro Monthly", "type": "subscription",
"price_id": "pri_01gsz91wy9k1yn7kx82aafwvea",
})
# One-time charge on the user's active subscription (admin/system)
await kirak.payments.charge_off_session({
"provider": "paddle", "subscription_id": "sub_01h04vsc0qhwtsbsxh3422wjs4",
"user_id": "42", "amount": 1000, "currency": "USD", "name": "Overage - September",
"idempotency_key": "overage-42-2026-09",
})

How a payment flows. initiate_payment saves a PENDING transaction, then creates a Paddle transaction with one item: the caller’s price_id (with quantity), or else a non-catalog unit price of amount in currency (description, and a product with name and default_tax_category) with quantity. custom_data carries kirak_transaction_id (the transaction id), kirak_instance (the instance name) and kirak_ref (a random reference kept in the row’s payment_meta; a custom_data fallback needs it, so another Kirak database on the account never completes this row). The Paddle transaction id is stored as the row’s transaction_id, and the result carries url, paddle_transaction_id and transaction_id. Paddle takes no idempotency key, so a retry after a timeout saves a second, independent row and can create a second Paddle transaction; each names its own row in custom_data, and whichever is paid completes only its own row. If Paddle rejects the call (4xx) the row is marked FAILED; a timeout, 5xx or 429 leaves it PENDING and raises, since the transaction may exist.

With a price_id the stored amount is what was passed in until the payment completes; completion replaces amount, net_amount and currency with Paddle’s figures. Paddle’s grand total covers every unit, so completion also sets quantity to 1 (keeping amount x quantity = what was charged) and keeps the quantity ordered in ipn_dump.quantity. Paddle may reject a quantity outside the allowed quantity range of the price (a catalog price’s own range); the call then fails with PADDLE_ERROR and the row is marked FAILED.

Webhooks find the transaction by the Paddle transaction id stored on a row of this instance. custom_data is only a fallback for a row that never stored an id, and only when its kirak_instance is this instance.

  • transaction.completed with origin api or web marks the row COMPLETED once (a redelivery reports event = None) with amount = the grand total, net_amount = earnings and currency, stores the Paddle subscription_id when there is one, and reports payment_completed (with amount and currency).
  • transaction.completed with origin subscription_recurring (a renewal of a subscription created by a recurring price) saves a subscription_renewal transaction, one per Paddle transaction id, and reports subscription_renewed. It is matched only by the subscription_id stored on the transaction that started the subscription, within this instance; that transaction is never changed. A renewal whose subscription no row holds is acknowledged and not recorded (the custom_data Paddle copies from the subscription is not used). It also sets the subscriptions row’s amount/net_amount to the renewal’s.
  • origin subscription_update (the immediate proration of a plan change) saves a subscription_update transaction the same way, one per Paddle transaction id, and reports event = None (no neutral event fits it; the plan change itself is reported by subscription.updated).
  • origin subscription_charge completes the charge_off_session transaction it pays (see “One-time charges on a subscription” below).
  • origin subscription_payment_method_change is acknowledged and not recorded. Subscription billings carry the original transaction’s custom_data and never complete it.
  • transaction.payment_failed changes nothing and reports event = None: the checkout stays open and the buyer can try again.
  • transaction.canceled (of a checkout, or of a charge_off_session charge) marks the row FAILED (unless it is already settled) and reports payment_failed; a repeat reports event = None.

verify_payment (payment_id = the Paddle transaction id) reads the transaction and returns transaction_id, paddle_transaction_id, paddle_status and status (the Kirak row’s status). It never completes the row; TRANSACTION_NOT_FOUND (404) for a transaction this instance did not create.

Refunds need Paddle’s approval. refund_payment creates a refund adjustment (reason from params, default Requested by merchant) and returns refund_id (the adjustment id), status (pending_approval) and transaction_id; nothing is recorded yet. Without amount the refund is full (the grand total); with amount (minor units, tax included) the transaction must have a single line item, which Kirak reads from Paddle, else PADDLE_PARTIAL_REFUND_NOT_SUPPORTED (400). Paddle refuses a refund while another refund of the same transaction awaits approval; its error is raised as PADDLE_ERROR. Paddle takes no idempotency key: after a timeout, check the transaction in Paddle before retrying.

  • adjustment.created with status pending_approval reports refund_pending and changes nothing.
  • An adjustment approved (by adjustment.updated, or already at adjustment.created) records the refund and marks the row REFUNDED, or PARTIALLY_REFUNDED while the refunds recorded so far are below the completed amount (a REFUNDED row never moves back), and reports refund_completed. An approval that arrives before the payment’s transaction.completed fails (500) so Paddle redelivers it after completion.
  • rejected (or reversed) records nothing and reports event = None.
  • Only adjustment.created reports refund_pending (and dispute_created, below); an adjustment.updated reports refund_completed when it approves a refund and event = None otherwise.

Adjustments carry no custom_data, so they are matched by the Paddle transaction id stored on the row. Paddle approves a refund automatically when the account is verified, the refund is at most about USD 400, within your balance and not for a bank transfer; others wait for review. Sandbox approves refunds every 10 minutes.

Chargebacks. adjustment.created with action chargeback reports dispute_created with dispute_id (the adjustment id), transaction_id, user_id, amount and currency; the row is not changed. Later updates of a chargeback report event = None. There is no dispute API: chargebacks arrive only as adjustments. Other adjustment actions (credits, chargeback warnings and reversals) report event = None.

How a subscription flows. initiate_payment with type: "subscription" needs price_id, a recurring Paddle price (else MISSING_PRICE_ID, 400, nothing saved; Kirak does not check that the price is recurring – with a one-time price Paddle creates no subscription). It creates the transaction the same way as a one-time payment (transaction_type subscription). When the buyer pays, Paddle creates the subscription and copies the transaction’s custom_data to it; transaction.completed completes the transaction (payment_completed) and stores the subscription id on it.

  • subscription.created, .activated, .updated, .trialing, .paused, .resumed, .past_due and .canceled all save the subscriptions row (created on the first one, with the transaction’s amount, net_amount and payment_email and the subscription’s currency): Paddle’s status upper case (ACTIVE, TRIALING, PAUSED, PAST_DUE, CANCELED), the first item’s price id as plan_id, its quantity, and expires_on = current_billing_period.ends_at (null while paused or canceled), else next_billed_at. An unreadable date is logged and ignored.
  • The event reported follows the new status, whatever the event’s name: PAST_DUE reports subscription_past_due, CANCELED subscription_cancelled, any other change subscription_updated. A delivery that changes neither status, plan nor quantity (a repeat, or only a new billing period, which is still written) reports event = None.
  • CANCELED is final: a later event never changes the row. Otherwise events are applied in the order they arrive; the subscriptions model has no field to order them by (Paddle’s updated_at is not stored).
  • Subscription events find the subscription by its stored id within this instance. The first one can arrive before transaction.completed: then the custom_data names a candidate (a subscription transaction of this instance, not yet settled and without a subscription id), which is bound only when it holds the Paddle transaction that subscription.created reports in transaction_id (the one that created the subscription). Other subscription events carry no transaction_id; for a candidate they fail (500) and Paddle redelivers them until transaction.completed has stored the subscription id. Anything else is acknowledged and not recorded.

Lifecycle. None of these write locally; the subscription webhooks keep subscriptions in sync. Each returns subscription_id and Paddle’s status (upper case).

  • cancel_subscription cancels immediately, or at the end of the billing period with at_period_end: True (Paddle then keeps it ACTIVE with a scheduled_change, returned as scheduled_change). A canceled Paddle subscription cannot be reinstated.
  • update_subscription replaces the subscription’s items with new_plan_id (a Paddle price id) at quantity (default: the stored quantity, else 1), billed per proration_billing_mode: prorated_immediately (the default), prorated_next_billing_period, full_immediately, full_next_billing_period or do_not_bill; anything else is INVALID_PRORATION_BILLING_MODE (400). A subscription with several items keeps only this one.
  • pause_subscription pauses immediately, or at the end of the billing period with at_period_end: True; Paddle refuses to pause a past_due subscription or one with a scheduled change. resume_subscription resumes immediately and starts a new billing period.

Customer portal. get_billing_portal (user_id) opens a Paddle customer portal session for the user’s newest subscription of this instance that is not CANCELED (else NO_ACTIVE_SUBSCRIPTION, 404). It reads the subscription for its Paddle customer id and returns portal_url (the portal overview), subscription_id, cancel_url and update_payment_method_url. The links sign the customer in with a temporary token: send them to the customer, do not store them.

One-time charges on a subscription (charge_off_session). Paddle keeps no saved methods Kirak can list, so it charges the payment method of an active subscription instead. Pass provider (the Paddle instance) and subscription_id; no payment_method_id is needed. The subscription must be this user’s and ACTIVE in subscriptions, else NOT_SUPPORTED (501, “Paddle can only charge an active subscription”), before anything is saved.

  • The off_session transaction is saved first (with subscription_id in payment_meta), then POST /subscriptions/{id}/charge bills a non-catalog price for amount/currency (name, description) immediately, with on_payment_failure: prevent_change. The request has no custom_data, so the price’s custom_data carries the transaction id, instance and kirak_ref; Paddle answers with the subscription, not the new transaction.
  • A success returns PROCESSING (no gateway_payment_id yet); transaction.completed with origin subscription_charge then finds the transaction by that price tag (an off_session transaction of this instance with no Paddle id yet, charged on the same subscription), completes it once with Paddle’s grand total and earnings, stores the Paddle transaction id, and reports payment_completed. A transaction.canceled for it marks it FAILED (never over a completed one) and reports payment_failed once, so a replay returns FAILED.
  • A refusal (4xx) returns FAILED with decline_code = Paddle’s error code. A timeout, 5xx or 429 leaves the transaction PROCESSING and returns PROCESSING; the webhook settles it if Paddle charged.
  • Paddle takes no idempotency key. A retry with the same idempotency_key is replayed from Kirak’s transaction; never charge again with a new key after an unknown outcome without checking the subscription in Paddle.
  • Merchant of record: the completed amount is Paddle’s grand total, tax included, so it can differ from the amount requested.

Currencies. Paddle takes 33 currencies: USD GBP EUR ARS AUD BRL CAD CHF CLP CNY COP CZK DKK HKD HUF ILS INR JPY KRW MXN NOK NZD PEN PLN RUB SGD SEK THB TRY TWD UAH VND ZAR. Its amounts are minor-unit strings with the ISO 4217 exponent for each (CLP, JPY, KRW and VND have none), so Kirak sends amount unchanged. Paddle rejects any other currency, and amounts below its per-currency minimum (for example USD 0.70).

Known limits.

  • A checkout whose creation failed with PADDLE_CHECKOUT_NOT_CONFIGURED reports no payment_failed event when Paddle later sends transaction.canceled for its transaction; the caller already got the error.
  • On a Paddle account shared by several Kirak databases whose instances have the same name, an abandoned subscription checkout row in one database can make the other database’s subscription events (which carry no transaction_id) fail, so Paddle redelivers each for up to 3 days. No data is written. Give each database’s instance a distinct name to avoid it.

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

Paddle reports payment_completed (transaction.completed of a checkout or of a charge_off_session charge), subscription_updated, subscription_past_due and subscription_cancelled (any subscription.* event, by the status it sets, with event_type subscription.updated, subscription.past_due or subscription.canceled), subscription_renewed (transaction.completed with origin subscription_recurring, event_type transaction.completed.subscription_recurring), payment_failed (transaction.canceled; transaction.payment_failed reports none, since the buyer can retry), refund_pending (adjustment.created awaiting approval, event_type adjustment.refund.pending_approval), refund_completed (an approved refund adjustment, event_type adjustment.refund.approved) and dispute_created (a chargeback adjustment, event_type adjustment.chargeback).