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:
KIRAK_PAYMENT_PAYPAL_CLIENT_ID=...KIRAK_PAYMENT_PAYPAL_CLIENT_SECRET=...KIRAK_PAYMENT_PAYPAL_WEBHOOK_ID=... # from step 33. 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 refundawait 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,.CANCELLEDand.EXPIREDsave thesubscriptionsrow (created on the first event, with the transaction’scurrencyandpayment_email) with PayPal’sstatusandplan_id, andexpires_onfrombilling_info.next_billing_timewhen the event has it.BILLING.SUBSCRIPTION.PAYMENT.FAILEDsets its statusPAST_DUE(the same neutral state as Stripe and Paddle) (never overSUSPENDED); the nextPAYMENT.SALE.COMPLETEDsets it back toACTIVE. A sale does not reactivate aSUSPENDEDsubscription.- A
CANCELLEDorEXPIREDsubscription is never changed by a later event. Apart from that, and theSUSPENDEDrule above, status events are applied in the order they arrive: PayPal can deliver them out of order, and thesubscriptionsmodel 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_onto its next billing time, andamount/net_amountto the sale’s amount. PayPal’s subscription resource does not carry the plan price, so asubscriptionsrow starts with theamountandcurrencyof the initial transaction (what was passed toinitiate_payment), also when the first sale arrived before any subscription event. An unreadablenext_billing_timeis logged and leavesexpires_onunchanged. 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 newexpires_on. - Activation is not a payment: the initial transaction stays
PENDINGuntil the firstPAYMENT.SALE.COMPLETED(PayPal’s “payment made on a subscription”, matched bybilling_agreement_id). That first sale marks the initial transactionCOMPLETEDand reportspayment_completed(withevent_typePAYMENT.SALE.COMPLETED.FIRST), so the first payment is not recorded twice. Every later sale saves asubscription_renewaltransaction and reportssubscription_renewed. - The sale id becomes the row’s
transaction_id, so a redelivered sale reportsevent = None, also when two deliveries of the first sale race. Both events carryamountandcurrency, 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 noBILLING.SUBSCRIPTION.*event has bound the subscription to its row yet (throughcustom_id), a sale arriving first is acknowledged but not recorded, and PayPal does not redeliver it. - The transaction keeps the
amountpassed toinitiate_paymenteven when the sale differs (for example a setup fee); the sale’s amount is onipn_dumpand in the event. - Subscription payments cannot be refunded through
refund_paymentyet: 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_subscriptioncancels immediately. PayPal has no cancel at the end of the billing period, soat_period_end: TruereturnsNOT_SUPPORTED(501).pause_subscriptionsuspends andresume_subscriptionactivates.- All three send
reason(from params, defaultRequested by merchant; PayPal requires one, at most 128 characters). update_subscriptionrevises the subscription tonew_plan_id(andquantity). PayPal applies the change only after the buyer approves it: send them to the returnedurl. The local plan changes whenBILLING.SUBSCRIPTION.UPDATEDarrives.
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_paymentwithsave_payment_method: true(andconsent) addspayment_source.paypal.attributes.vault(store_in_vault: ON_SUCCESS,usage_type: MERCHANT) to the order and keeps the flag and consent on the transaction’spayment_meta. WhenPAYMENT.CAPTURE.COMPLETEDarrives, the order is read from PayPal and its vault token saved with the consent, before the transaction is markedCOMPLETED: 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 vaultstatusis not yetVAULTEDis savedPENDINGand activated byVAULT.PAYMENT-TOKEN.CREATED(payment_method_saved). A subscription cannot save a method:save_payment_methodwithtype: "subscription"isNOT_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_methoddeletes the token (DELETE /v3/vault/payment-tokens/{id}).set_default_payment_methodis 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 reportspayment_method_removed; a token this instance does not hold, or already revoked, reportsevent = None.charge_off_sessionsaves thePENDINGtransaction first, then creates one order (intentCAPTURE) withpayment_source.paypal.vault_idandstored_credential(payment_initiator: MERCHANT,usage: SUBSEQUENT),custom_id=<transaction id>:<kirak_ref>, andidempotency_keysent asPayPal-Request-Idin the hashed form above (operationoff_session). PayPal captures it in the same call: aCOMPLETEDcapture returns statusCOMPLETED(andgateway_payment_id= the capture id) but leaves the rowPROCESSING, with the capture id as itstransaction_idso an idempotent replay reports the samegateway_payment_id;PAYMENT.CAPTURE.COMPLETEDcompletes it and reportspayment_completedonce. A pending capture returnsPROCESSING. A declined capture, or a 422 from PayPal (for exampleINSTRUMENT_DECLINED), returnsFAILEDwithdecline_code(the processor response code, or PayPal’s issue); a laterPAYMENT.CAPTURE.DECLINEDkeeps 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) returnsREQUIRES_ACTION. Any other 4xx marks the rowFAILEDand raises. A timeout, 5xx or 429 leaves the rowPROCESSINGand returnsPROCESSING; 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.
Webhook events
Section titled “Webhook events”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.