Skip to content

PayPal

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

The paypal provider 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), reading disputes (get_dispute), saving the buyer’s PayPal account during a one-time payment and charging it later off-session (charge_off_session, see “Saved methods and off-session charges” below), and webhooks. GET /payments/providers reports subscriptions, subscription_cancel, subscription_update, subscription_lifecycle, pause, dispute_read, payment_methods and off_session for it. Not supported: submit_dispute_evidence, the billing portal, saving a method without paying (setup_payment_method) and attaching a token (attach_payment_method). It needs no extra install: it calls PayPal’s REST API directly over HTTP.

Why plain HTTP (no PayPal SDK). The official paypal-server-sdk is not used: it ships under PayPal’s proprietary SDK licence (not open source; commercial redistributors must indemnify PayPal), it lacks Disputes and webhook-signature verification, its dependencies need a setuptools version that breaks Razorpay, it is synchronous only, and it keeps its own OAuth token store.

1. Get credentials. In the PayPal Developer Dashboard, create a REST app (Sandbox or Live) and copy its Client ID and Secret.

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

"paypal": {
"type": "paypal",
"environment": "sandbox",
"success_url": "https://your-domain.com/payment/success",
"cancel_url": "https://your-domain.com/payment/cancel"
}

environment is sandbox (the default) or live, and must match the dashboard the app was created in. Any other value fails when the instance is first used. success_url and cancel_url are where PayPal sends the buyer back after approving or cancelling. In the environment:

Terminal window
KIRAK_PAYMENT_PAYPAL_CLIENT_ID=...
KIRAK_PAYMENT_PAYPAL_CLIENT_SECRET=...
KIRAK_PAYMENT_PAYPAL_WEBHOOK_ID=... # from step 3

3. Configure the webhook. In the app’s settings, add a webhook with the URL https://your-domain.com/payments/webhook/paypal, subscribe it to Checkout order approved, Payment capture completed, Payment capture declined, Payment capture denied, Payment capture pending, Payment capture refunded and Payment refund pending (CHECKOUT.ORDER.APPROVED, PAYMENT.CAPTURE.COMPLETED, .DECLINED, .DENIED, .PENDING, .REFUNDED, PAYMENT.REFUND.PENDING), and copy the Webhook ID PayPal shows for it into KIRAK_PAYMENT_PAYPAL_WEBHOOK_ID. Without it every webhook fails with WEBHOOK_SECRET_NOT_CONFIGURED (500). For subscriptions also subscribe BILLING.SUBSCRIPTION.ACTIVATED, .UPDATED, .SUSPENDED, .CANCELLED, .EXPIRED, .PAYMENT.FAILED and PAYMENT.SALE.COMPLETED (without the last one no subscription payment is ever recorded), for disputes CUSTOMER.DISPUTE.CREATED, .UPDATED and .RESOLVED, and for saved methods VAULT.PAYMENT-TOKEN.CREATED and .DELETED.

Each delivery is checked by posting it back to PayPal’s verify-webhook-signature API with the five PAYPAL-* transmission headers, the webhook ID and the raw body. A missing header is rejected with MISSING_WEBHOOK_SIGNATURE (400) and a failed check 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 PAYPAL-TRANSMISSION-TIME: PayPal retries a delivery for up to 3 days and does not document whether a retry is signed again. Mock events sent from the dashboard’s webhook simulator cannot be verified this way, so they are rejected; test with events from real sandbox activity instead.

4. Use it:

# One-time payment -- creates a PayPal order; send the buyer to data["url"]
result = await kirak.payments.initiate_payment({
"provider": "paypal",
"user_id": "42", "amount": 2999, "currency": "USD", # cents
"name": "Pro Plan", "type": "one_time",
})
order_id = result["data"]["order_id"]
# On the success page: capture the approved order now instead of waiting for
# the webhook (payment_id is the PayPal order id)
await kirak.payments.verify_payment({"provider": "paypal", "payment_id": order_id})
# Refund (admin/system): payment_id is the capture id, which is the row's
# transaction_id once the payment completed; omit amount for a full refund
await kirak.payments.refund_payment({
"provider": "paypal", "payment_id": capture_id, "amount": 500,
"idempotency_key": "refund-order-77",
})
# Subscription -- subscription_plan_id is a PayPal plan id (P-...), created in
# PayPal first; the plan sets the price. Send the buyer to data["url"].
result = await kirak.payments.initiate_payment({
"provider": "paypal",
"user_id": "42", "amount": 1500, "currency": "USD",
"name": "Pro Monthly", "type": "subscription",
"subscription_plan_id": "P-5ML4271244454362WXNWU5NQ",
})
subscription_id = result["data"]["subscription_id"] # I-...

How a payment flows. initiate_payment saves a PENDING transaction, then creates an order with intent CAPTURE for amount x quantity and custom_id = <transaction id>:<kirak_ref> (a random reference kept in the row’s payment_meta), and returns the order’s payer-action link (or its approve link) as url, the order id as order_id, and the transaction id. The order id is stored as the row’s transaction_id. An idempotency_key is sent as PayPal-Request-Id in the form kirak-<sha256 of "operation:user_id:idempotency_key">, where the operation is initiate (orders and subscriptions), refund or off_session: Kirak keys are per user and up to 200 characters, while PayPal’s header is per account and at most 108 characters, and one key reused for two operations must not make PayPal replay the other call. No invoice_id is sent: PayPal requires it to be unique per account, which two Kirak databases on one PayPal account (for example dev and prod) could not guarantee. If PayPal rejects the order (4xx) the row is marked FAILED; a timeout, 5xx or 429 (rate limited) leaves it PENDING and raises, since the order may exist.

Webhooks find the transaction by the order id (or, once completed, the capture id) stored on a row of this instance. custom_id is only a fallback for a row that never stored an order id, and only when it carries that row’s kirak_ref, so another Kirak database on the account, whose rows are numbered the same way, never completes this one (a digit-only custom_id from before references matches only a row without one). So when a retry of initiate_payment after a timeout gets PayPal’s original order back (same PayPal-Request-Id), the retry’s row is the one that completes.

Known limit: the PayPal-Request-Id depends on the user id and your key, not on anything unique to one database. Two Kirak databases on one PayPal account that send the same key for the same user id within PayPal’s 6-hour window get the first one’s order back. Keep your keys unique across databases, for example by prefixing them per environment.

PayPal does not take the money when the buyer approves; the order must be captured. Kirak captures it from the CHECKOUT.ORDER.APPROVED webhook and from verify_payment, whichever comes first, both with PayPal-Request-Id = kirak-capture-<order id>, so the second call gets PayPal’s stored result instead of capturing twice. After PayPal’s 6-hour request-id window a repeat answers ORDER_ALREADY_CAPTURED; Kirak then reads the order and carries on. A capture sets the row PROCESSING; only the PAYMENT.CAPTURE.COMPLETED webhook marks it COMPLETED (once, even on a redelivery) and replaces transaction_id with the capture id. A capture PayPal refuses (for example INSTRUMENT_DECLINED) sets FAILED unless the row is already settled; from the CHECKOUT.ORDER.APPROVED webhook that reports payment_failed, since no capture exists for a later PAYMENT.CAPTURE.DECLINED. A replayed capture response never moves a row the DECLINED webhook already marked FAILED back to PROCESSING. If a capture times out, is rate limited (429) or PayPal answers 5xx, the outcome is unknown and never FAILED: the webhook leaves the row as it is and returns 500 so PayPal redelivers it, and verify_payment writes the row PROCESSING and reports status: "PROCESSING" with outcome: "unknown".

verify_payment returns transaction_id, order_id, order_status, capture_id and status (the transaction’s status after the call). It answers TRANSACTION_NOT_FOUND (404) for an order this instance did not create. It is for one-time orders only; a subscription is settled by its webhooks (below).

refund_payment refunds the capture in full, or amount (minor units) of it, with idempotency_key sent as PayPal-Request-Id in the same hashed form (PayPal keeps it 45 days; the user is always the transaction’s owner, so the id is stable per transaction). It returns refund_id, status (COMPLETED, PENDING, FAILED or CANCELLED) and capture_id; the transaction is updated by the refund webhook, which marks it REFUNDED, or PARTIALLY_REFUNDED while the refunded total is below the amount charged (amount x quantity). The total is PayPal’s cumulative total_refunded_amount, or at least the refunds already recorded plus this one, so refunds arriving out of order never move a REFUNDED row back. Refund webhooks are matched by capture id only (the row’s transaction_id once the payment completed, within this instance). Known limitation: a refund arriving before completion (for example one made in PayPal’s dashboard) is acknowledged but not recorded; PayPal does not redeliver it.

Events. PAYMENT.CAPTURE.COMPLETED reports payment_completed (with amount and currency), PAYMENT.CAPTURE.DECLINED (Orders v2) and PAYMENT.CAPTURE.DENIED (the older name) report payment_failed (or event = None when the row was already settled), PAYMENT.CAPTURE.REFUNDED reports refund_completed, and PAYMENT.REFUND.PENDING reports refund_pending without changing the row. CHECKOUT.ORDER.APPROVED (the capture) and PAYMENT.CAPTURE.PENDING (stays PROCESSING) report event = None, except that a capture PayPal refused reports payment_failed with event_type CHECKOUT.ORDER.CAPTURE_REFUSED. A webhook for an order or capture this instance did not create (another instance, or other software on the same PayPal account) is acknowledged, changes nothing, and reports event = None.

How a subscription flows. initiate_payment with type: "subscription" needs subscription_plan_id (else MISSING_SUBSCRIPTION_PLAN_ID, 400, nothing saved). It saves a PENDING transaction (transaction_type subscription, with the amount and currency passed in; PayPal takes the price from the plan), creates the subscription with custom_id = <transaction id>:<kirak_ref> (and quantity when given), and returns the approve link as url, the PayPal subscription id as subscription_id, and the transaction id. The subscription id is stored in the row’s subscription_id and transaction_id. idempotency_key is sent as PayPal-Request-Id in the same hashed form as for orders. A 4xx marks the row FAILED; a timeout, 5xx or 429 leaves it PENDING and raises.

Subscription webhooks find the initial transaction by its subscription_id within this instance; custom_id is only a fallback for a row that never stored a subscription id (the create call’s outcome was unknown), which then takes it.

  • BILLING.SUBSCRIPTION.ACTIVATED, .UPDATED, .SUSPENDED, .CANCELLED and .EXPIRED save the subscriptions row (created on the first event, with the transaction’s currency and payment_email) with PayPal’s status and plan_id, and expires_on from billing_info.next_billing_time when the event has it.
  • BILLING.SUBSCRIPTION.PAYMENT.FAILED sets its status PAST_DUE (the same neutral state as Stripe and Paddle) (never over SUSPENDED); the next PAYMENT.SALE.COMPLETED sets it back to ACTIVE. A sale does not reactivate a SUSPENDED subscription.
  • A CANCELLED or EXPIRED subscription is never changed by a later event. Apart from that, and the SUSPENDED rule above, status events are applied in the order they arrive: PayPal can deliver them out of order, and the subscriptions model has no field to order them by.
  • An event that changes neither status nor plan (a repeat, a late event for a cancelled subscription, or only a new billing time) reports event = None.
  • Each sale reads the subscription from PayPal and sets expires_on to its next billing time, and amount/net_amount to the sale’s amount. PayPal’s subscription resource does not carry the plan price, so a subscriptions row starts with the amount and currency of the initial transaction (what was passed to initiate_payment), also when the first sale arrived before any subscription event. An unreadable next_billing_time is logged and leaves expires_on unchanged. If that read times out or PayPal answers 5xx/429, the webhook fails before any write and PayPal redelivers; if PayPal refuses it (4xx), the sale is still recorded without a new expires_on.
  • Activation is not a payment: the initial transaction stays PENDING until the first PAYMENT.SALE.COMPLETED (PayPal’s “payment made on a subscription”, matched by billing_agreement_id). That first sale marks the initial transaction COMPLETED and reports payment_completed (with event_type PAYMENT.SALE.COMPLETED.FIRST), so the first payment is not recorded twice. Every later sale saves a subscription_renewal transaction and reports subscription_renewed.
  • The sale id becomes the row’s transaction_id, so a redelivered sale reports event = None, also when two deliveries of the first sale race. Both events carry amount and currency, from the sale.
  • Known limitation: a sale is matched only by a stored subscription id. If initiate_payment’s create call had an unknown outcome and no BILLING.SUBSCRIPTION.* event has bound the subscription to its row yet (through custom_id), a sale arriving first is acknowledged but not recorded, and PayPal does not redeliver it.
  • The transaction keeps the amount passed to initiate_payment even when the sale differs (for example a setup fee); the sale’s amount is on ipn_dump and in the event.
  • Subscription payments cannot be refunded through refund_payment yet: it takes a capture id, and a subscription payment is a sale. Refund them in PayPal’s dashboard.

Lifecycle. None of these write locally; the webhooks above keep subscriptions in sync.

  • cancel_subscription cancels immediately. PayPal has no cancel at the end of the billing period, so at_period_end: True returns NOT_SUPPORTED (501).
  • pause_subscription suspends and resume_subscription activates.
  • All three send reason (from params, default Requested by merchant; PayPal requires one, at most 128 characters).
  • update_subscription revises the subscription to new_plan_id (and quantity). PayPal applies the change only after the buyer approves it: send them to the returned url. The local plan changes when BILLING.SUBSCRIPTION.UPDATED arrives.

Disputes. get_dispute reads GET /v1/customer/disputes/{id} and returns dispute_id, status and the full dispute. submit_dispute_evidence is not supported (501): PayPal’s provide-evidence call is a multipart upload with a file part, which the dict-based evidence parameter cannot carry. Respond in PayPal’s Resolution Center instead.

CUSTOMER.DISPUTE.CREATED reports dispute_created; .UPDATED and .RESOLVED report dispute_updated. The result carries dispute_id, dispute_status, transaction_id, user_id, and amount/currency from dispute_amount. The dispute is matched to this instance’s transaction by disputed_transactions[].seller_transaction_id (a capture id or a subscription sale id). The transaction row is not changed: many PayPal disputes are inquiries that move no money, and a non-settled DISPUTED status would let a late completion event rewrite the row.

Saved methods and off-session charges. PayPal keeps the buyer’s account in its Vault; Kirak stores only the vault token id (as gateway_method_id, method_type and brand paypal, the PayPal customer id, and the payer’s email_address in meta).

  • Account prerequisite. Vaulting must be enabled on the PayPal business account: reference-transaction approval (ask your PayPal account manager) and “Save PayPal and Venmo payment methods” turned on for the app, in sandbox and live. PayPal’s guide lists 34 countries; its Payment Method Tokens API reference still says US only. Kirak does not check this: on an account without it, PayPal completes the payment without vaulting and no method is saved (logged), and an off-session charge is refused by PayPal.
  • Saving during a payment. initiate_payment with save_payment_method: true (and consent) adds payment_source.paypal.attributes.vault (store_in_vault: ON_SUCCESS, usage_type: MERCHANT) to the order and keeps the flag and consent on the transaction’s payment_meta. When PAYMENT.CAPTURE.COMPLETED arrives, the order is read from PayPal and its vault token saved with the consent, before the transaction is marked COMPLETED: if that read times out or PayPal answers 5xx/429, the webhook fails and PayPal redelivers it. A refused read (4xx) or an order PayPal did not vault is logged and the payment still completes. A redelivery for a transaction already completed saves nothing, so it cannot bring back a method removed since. A token whose vault status is not yet VAULTED is saved PENDING and activated by VAULT.PAYMENT-TOKEN.CREATED (payment_method_saved). A subscription cannot save a method: save_payment_method with type: "subscription" is NOT_SUPPORTED (501).
  • Not supported: saving without paying and attaching a token (501). PayPal’s setup tokens need a server call after the buyer approves, and PayPal sends no webhook for that approval.
  • detach_payment_method deletes the token (DELETE /v3/vault/payment-tokens/{id}). set_default_payment_method is local only (PayPal has no default token). VAULT.PAYMENT-TOKEN.DELETED (for example the buyer removed the merchant in their PayPal account) revokes the method and reports payment_method_removed; a token this instance does not hold, or already revoked, reports event = None.
  • charge_off_session saves the PENDING transaction first, then creates one order (intent CAPTURE) with payment_source.paypal.vault_id and stored_credential (payment_initiator: MERCHANT, usage: SUBSEQUENT), custom_id = <transaction id>:<kirak_ref>, and idempotency_key sent as PayPal-Request-Id in the hashed form above (operation off_session). PayPal captures it in the same call: a COMPLETED capture returns status COMPLETED (and gateway_payment_id = the capture id) but leaves the row PROCESSING, with the capture id as its transaction_id so an idempotent replay reports the same gateway_payment_id; PAYMENT.CAPTURE.COMPLETED completes it and reports payment_completed once. A pending capture returns PROCESSING. A declined capture, or a 422 from PayPal (for example INSTRUMENT_DECLINED), returns FAILED with decline_code (the processor response code, or PayPal’s issue); a later PAYMENT.CAPTURE.DECLINED keeps that code unless it carries a processor code of its own, so a replay still returns it. An order that needs the payer (PAYER_ACTION_REQUIRED, as a status or a 422 issue) returns REQUIRES_ACTION. Any other 4xx marks the row FAILED and raises. A timeout, 5xx or 429 leaves the row PROCESSING and returns PROCESSING; the webhook settles it.

Currencies. PayPal takes 25 currencies: AUD BRL CAD CNY CZK DKK EUR HKD HUF ILS JPY MYR MXN TWD NZD NOK PHP PLN GBP RUB SGD SEK CHF THB USD (BRL, CNY and MYR only for in-country accounts). Amounts stay in ISO minor units in Kirak. HUF and TWD have 2 decimals in ISO 4217 but none at PayPal, so their amount must be a whole number of forints/dollars (a multiple of 100 minor units); anything else fails with AMOUNT_NOT_REPRESENTABLE (400) before any row is saved.

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

PayPal reports payment_completed (a capture, including an off-session charge, or the first payment of a subscription), payment_failed, refund_completed, refund_pending, subscription_renewed, subscription_updated, subscription_past_due, subscription_cancelled, dispute_created, dispute_updated, payment_method_saved (VAULT.PAYMENT-TOKEN.CREATED activating a pending method) and payment_method_removed (VAULT.PAYMENT-TOKEN.DELETED); the PayPal events behind each are listed under Events above.