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:
KIRAK_PAYMENT_PADDLE_API_KEY=...KIRAK_PAYMENT_PADDLE_WEBHOOK_SECRET=... # from step 44. 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 refundawait 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.completedwithoriginapiorwebmarks the rowCOMPLETEDonce (a redelivery reportsevent = None) withamount= the grand total,net_amount= earnings andcurrency, stores the Paddlesubscription_idwhen there is one, and reportspayment_completed(withamountandcurrency).transaction.completedwithoriginsubscription_recurring(a renewal of a subscription created by a recurring price) saves asubscription_renewaltransaction, one per Paddle transaction id, and reportssubscription_renewed. It is matched only by thesubscription_idstored 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 (thecustom_dataPaddle copies from the subscription is not used). It also sets thesubscriptionsrow’samount/net_amountto the renewal’s.originsubscription_update(the immediate proration of a plan change) saves asubscription_updatetransaction the same way, one per Paddle transaction id, and reportsevent = None(no neutral event fits it; the plan change itself is reported bysubscription.updated).originsubscription_chargecompletes thecharge_off_sessiontransaction it pays (see “One-time charges on a subscription” below).originsubscription_payment_method_changeis acknowledged and not recorded. Subscription billings carry the original transaction’scustom_dataand never complete it.transaction.payment_failedchanges nothing and reportsevent = None: the checkout stays open and the buyer can try again.transaction.canceled(of a checkout, or of acharge_off_sessioncharge) marks the rowFAILED(unless it is already settled) and reportspayment_failed; a repeat reportsevent = 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.createdwith statuspending_approvalreportsrefund_pendingand changes nothing.- An adjustment
approved(byadjustment.updated, or already atadjustment.created) records the refund and marks the rowREFUNDED, orPARTIALLY_REFUNDEDwhile the refunds recorded so far are below the completedamount(aREFUNDEDrow never moves back), and reportsrefund_completed. An approval that arrives before the payment’stransaction.completedfails (500) so Paddle redelivers it after completion. rejected(orreversed) records nothing and reportsevent = None.- Only
adjustment.createdreportsrefund_pending(anddispute_created, below); anadjustment.updatedreportsrefund_completedwhen it approves a refund andevent = Noneotherwise.
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_dueand.canceledall save thesubscriptionsrow (created on the first one, with the transaction’samount,net_amountandpayment_emailand the subscription’scurrency): Paddle’sstatusupper case (ACTIVE,TRIALING,PAUSED,PAST_DUE,CANCELED), the first item’s price id asplan_id, itsquantity, andexpires_on=current_billing_period.ends_at(null while paused or canceled), elsenext_billed_at. An unreadable date is logged and ignored.- The event reported follows the new status, whatever the event’s name:
PAST_DUEreportssubscription_past_due,CANCELEDsubscription_cancelled, any other changesubscription_updated. A delivery that changes neither status, plan nor quantity (a repeat, or only a new billing period, which is still written) reportsevent = None. CANCELEDis final: a later event never changes the row. Otherwise events are applied in the order they arrive; thesubscriptionsmodel has no field to order them by (Paddle’supdated_atis not stored).- Subscription events find the subscription by its stored id within this
instance. The first one can arrive before
transaction.completed: then thecustom_datanames a candidate (asubscriptiontransaction of this instance, not yet settled and without a subscription id), which is bound only when it holds the Paddle transaction thatsubscription.createdreports intransaction_id(the one that created the subscription). Other subscription events carry notransaction_id; for a candidate they fail (500) and Paddle redelivers them untiltransaction.completedhas 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_subscriptioncancels immediately, or at the end of the billing period withat_period_end: True(Paddle then keeps itACTIVEwith ascheduled_change, returned asscheduled_change). A canceled Paddle subscription cannot be reinstated.update_subscriptionreplaces the subscription’s items withnew_plan_id(a Paddle price id) atquantity(default: the stored quantity, else 1), billed perproration_billing_mode:prorated_immediately(the default),prorated_next_billing_period,full_immediately,full_next_billing_periodordo_not_bill; anything else isINVALID_PRORATION_BILLING_MODE(400). A subscription with several items keeps only this one.pause_subscriptionpauses immediately, or at the end of the billing period withat_period_end: True; Paddle refuses to pause apast_duesubscription or one with a scheduled change.resume_subscriptionresumes 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_sessiontransaction is saved first (withsubscription_idinpayment_meta), thenPOST /subscriptions/{id}/chargebills a non-catalog price foramount/currency(name,description) immediately, withon_payment_failure: prevent_change. The request has nocustom_data, so the price’scustom_datacarries the transaction id, instance andkirak_ref; Paddle answers with the subscription, not the new transaction. - A success returns
PROCESSING(nogateway_payment_idyet);transaction.completedwithoriginsubscription_chargethen finds the transaction by that price tag (anoff_sessiontransaction 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 reportspayment_completed. Atransaction.canceledfor it marks itFAILED(never over a completed one) and reportspayment_failedonce, so a replay returnsFAILED. - A refusal (4xx) returns
FAILEDwithdecline_code= Paddle’s error code. A timeout, 5xx or 429 leaves the transactionPROCESSINGand returnsPROCESSING; the webhook settles it if Paddle charged. - Paddle takes no idempotency key. A retry with the same
idempotency_keyis 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
amountis Paddle’s grand total, tax included, so it can differ from theamountrequested.
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_CONFIGUREDreports nopayment_failedevent when Paddle later sendstransaction.canceledfor 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.
Webhook events
Section titled “Webhook events”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).