Payment providers
This page explains how to set up each first-party payment provider and how to monitor webhook deliveries. It is for the store administrator who configures payments.
How providers work
The base plugin ships the offline method. Every other provider is a separate plugin that registers itself at runtime. Every registered provider that is enabled and configured appears at checkout, all at once, and the customer picks one.
Four providers are first-party:
| Offline | Stripe | Polar | PayPal | |
|---|---|---|---|---|
| Slug | offline |
stripe |
polar |
paypal |
| Plugin | built in | kahunacart-stripe |
kahunacart-polar |
kahunacart-paypal |
| Flow | complete on the spot | hosted redirect | hosted redirect | hosted redirect |
| Hosted checkout | — | yes | yes | yes |
| Refunds via API | — | yes | yes | yes |
| Partial refunds | — | yes | yes | yes |
| Merchant of record | — | — | yes | — |
| Catalog sync | — | — | yes (products + orders) | — |
| Mode values | — | test / live |
sandbox / production |
sandbox / live |
| Webhook auth | — | HMAC signing secret | Standard Webhooks secret | Webhook ID + API call-out |
| Label configurable | yes | no | yes | no |
The three add-on plugins are at version 0.1.0, marked premium: true and testing: true. Each requires Grav 2.0 or later, KahunaCart >=0.1.0, and PHP 8.3 with ext-curl. None vendors a provider SDK.
The webhook URL
Every provider's webhook lives at the same address:
https://your-site.com{route}/webhook/{slug}
With the default /shop route, that gives /shop/webhook/stripe, /shop/webhook/polar and /shop/webhook/paypal.
The endpoint is public, and each provider verifies its own signature before anything touches order state. A request that fails verification gets a 400 and changes nothing. An unhandled event type gets a 200, so the provider stops retrying it.
Webhooks are authoritative. The customer returning to your site is advisory.
Important
If you change the store route, every webhook URL changes with it. Update the endpoint at each provider.
The webhook delivery log
Every inbound delivery is recorded as one row, whatever became of it.
Read the delivery log
From the CLI:
bin/plugin kahunacart webhooks
bin/plugin kahunacart webhooks --provider stripe
Over the API:
GET /kahunacart/webhooks?provider=stripe&page=1&per_page=25
GET /kahunacart/webhooks/summary
Both require the kahunacart.settings permission, not kahunacart.reports, because a row can carry customer email addresses and provider references.
What is stored
| Column | Always | Notes |
|---|---|---|
provider, received_at, status_code |
yes | What was addressed, when, and what this store answered. |
action |
when there was one | The provider's translated event. Null if nothing could be translated. |
reference, order_id |
when resolvable | Resolved from the event, never guessed. |
error |
on failure | The exception class and message, or the class of refusal. |
payload_bytes |
yes | How many bytes arrived. |
payload_excerpt |
verified deliveries only | The first 2 KB of the raw body. |
Note
The excerpt is kept only for a delivery the provider verified. A refused delivery keeps the slug, the byte count and the class of failure, and nothing of the body.
Retention
Deliveries are deleted after payments.webhook_log_days days. The default is 30; 0 keeps everything.
Failure streaks are reported, never acted on
Both the CLI and /kahunacart/webhooks/summary show a current failure streak per provider: how many deliveries in a row have been refused or failed since that provider's last good one.
Nothing is disabled on the strength of it. No provider is switched off, no endpoint stops accepting, no config is rewritten. A rotated webhook secret is the usual cause, and auto-disabling would turn that into a silent outage.
Important
Check the streak yourself. bin/plugin kahunacart webhooks prints it and names the two settings that are almost always at fault.
Offline
The offline method is built into the base plugin and enabled by default. Use it for bank transfers, cheques and cash on delivery. It cannot refund, cannot redirect, and has no webhook.
The order is placed with payment status pending, and the customer sees your instructions on their receipt. Mark it paid in the admin when the money arrives. The sidebar badge counts exactly these orders.
Offline config keys
| Key | Default | What it does |
|---|---|---|
offline.enabled |
true |
Shows the offline method at checkout. |
offline.label |
Bank Transfer |
The checkout label. |
offline.instructions |
empty | Markdown shown on the confirmation page. |
Offline notes and limits
- Set
downloads.grant_ontocompletedif you sell digital goods this way. Otherwise the customer waits for the transfer to clear before receiving their files. - Refunds are recorded by hand. There is nothing to call.
Stripe
Stripe is the kahunacart-stripe plugin. Configure it at Plugins → KahunaCart Stripe or in user/config/plugins/kahunacart-stripe.yaml.
The customer is redirected to Stripe's checkout page and returns while the confirmation arrives by webhook. Card details never touch your server.
Note
Phase 1 ships the hosted Checkout flow only. On-site Elements is not available yet.
What you need for Stripe
- A Stripe account with access to Developers → Webhooks.
- The secret key for the mode you are configuring (
sk_test_…orsk_live_…).
Set up Stripe
- Paste the secret key for your mode into the matching field.
- In Stripe, go to Developers → Webhooks and add
https://your-site.com/shop/webhook/stripe. - Subscribe to at least:
checkout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed,payment_intent.payment_failed,refund.created,refund.updated,refund.failed,charge.refunded. - Copy the endpoint's signing secret into the webhook field for the matching mode.
- Click Save.
Stripe now appears at checkout as Credit / Debit Card (Stripe). Signatures are HMAC-SHA256 over the raw body with a 300-second tolerance, compared in constant time.
Important
Without refund.created, refund.updated and refund.failed subscribed, a refund that settles asynchronously stays pending in the ledger forever. A webhook endpoint carries its own Stripe API version, independent of your keys, and only endpoints on 2024-10-28.acacia or later receive these three events.
Stripe config keys
| Key | Default | What it does |
|---|---|---|
enabled |
true |
Turns the provider on. |
mode |
test |
test or live. Selects the key pair. |
statement_label |
empty | Short label on the card statement. Empty uses the Stripe account default. |
test_secret_key |
empty | sk_test_… |
test_webhook_secret |
empty | whsec_… |
live_secret_key |
empty | sk_live_… |
live_webhook_secret |
empty | whsec_… |
Stripe registers only when enabled is on and the active mode's secret key is non-empty. Without the webhook secret, every incoming webhook is rejected.
Test Stripe payments
Keep mode set to test and pay with a Stripe test card such as 4242 4242 4242 4242, any future expiry and CVC. For webhooks on a local site, use the Stripe CLI:
stripe listen --forward-to https://your-site.test/shop/webhook/stripe
stripe trigger checkout.session.completed
stripe listen prints its own whsec_…. While forwarding, paste that one into the test webhook field, not the dashboard's.
Stripe notes and limits
- The checkout label is not configurable.
- The Stripe Checkout session is a single line item named "Order payment" for the whole order total, not an itemized cart. Your KahunaCart order is the itemized record.
- Full details are in the
kahunacart-stripeplugin README.
Polar
Polar is the kahunacart-polar plugin. Configure it at Plugins → KahunaCart Polar or in user/config/plugins/kahunacart-polar.yaml.
Polar is a merchant of record with two-way catalog sync. The customer is redirected to Polar's hosted checkout.
What you need for Polar
- A Polar organization. Sandbox and production are separate accounts with their own organizations, products and tokens.
- An organization access token scoped
checkouts:write,products:read,products:write,refunds:write. - Your organization UUID, unless the token is already scoped to one organization.
Set up Polar
- Paste the organization access token into the field for the matching environment.
- In Polar, go to Settings → Webhooks and add
https://your-site.com/shop/webhook/polar. - Choose the Raw payload format.
- Subscribe to at least:
order.paid,order.refunded,product.created,product.updated. - Copy the endpoint's signing secret into the webhook field for the matching environment.
- Click Save.
Polar now appears at checkout. Signatures follow the Standard Webhooks spec (webhook-id, webhook-timestamp, webhook-signature) with a ±300-second window.
Polar config keys
| Key | Default | What it does |
|---|---|---|
enabled |
true |
Turns the provider on. |
mode |
sandbox |
sandbox (talks to sandbox-api.polar.sh) or production. |
label |
empty | Checkout label. Empty uses Polar (cards, global tax handled). |
allow_discount_codes |
false |
Lets customers enter a Polar-side discount code. Off by default: a Polar discount changes the amount charged after KahunaCart has priced the order. |
sandbox_access_token |
empty | Organization access token. |
sandbox_webhook_secret |
empty | Endpoint signing secret. |
sandbox_organization_id |
empty | UUID. Required for catalog sync unless the token is scoped to one organization. |
sandbox_checkout_product_id |
empty | UUID of the carrier product. Empty means the plugin manages it. |
production_access_token |
empty | As above, for production. |
production_webhook_secret |
empty | As above, for production. |
production_organization_id |
empty | As above, for production. |
production_checkout_product_id |
empty | As above, for production. |
Polar registers only when enabled is on and the active mode's access token is non-empty. Mode resolves to production only on the exact string production; anything else is sandbox.
Note
The slug polar is fixed. It is the webhook route segment and the key sync mappings are stored under, so changing it would orphan every mapping and every registered endpoint.
Test Polar payments
Keep mode set to sandbox and use a sandbox organization with its own token. Pay with a Stripe test card. Point a sandbox webhook endpoint at your local site through an HTTPS tunnel, then paste that endpoint's secret into the sandbox webhook field.
Tax with Polar as merchant of record
Polar sells to your customer on your behalf and remits sales tax, VAT and GST itself, so the amount KahunaCart hands it is the pre-tax total. You configure nothing: Polar declares the MerchantOfRecord capability, and KahunaCart recalculates the order without local tax before handing it over. A store selling through both Polar and Stripe taxes the Stripe path normally and the Polar path not at all.
Two things to expect:
- The cart page still shows the local tax estimate. Suppression happens when the customer submits checkout with Polar selected.
- Inclusive prices still contain the tax you built into them. Price net for Polar.
See Tax → Merchant-of-record providers.
The Polar carrier product
Polar's checkout API always references a catalog product, so the plugin sends a single product, the carrier, at an ad-hoc price equal to the order total. Leave {mode}_checkout_product_id empty and the plugin finds or creates a private product called KahunaCart Order with a placeholder $1.00 price that is never charged.
Note
Polar's order records show the carrier product rather than the items bought. Your KahunaCart order remains the itemized record. See Catalog sync for the details.
Polar refunds
order.refunded carries running totals, not per-refund amounts, so a second partial refund arrives with the sum of both. The webhook reports the cumulative figure as such, with its net and tax components, and Polar refunds the tax it collected alongside the net amount. The ledger dedupes on provider, reference and action. A refund can come back pending and settle later.
Polar catalog sync
Polar is the only first-party provider that syncs its catalog. See Catalog sync for the default-variant limitation and the rest. Full details are in the kahunacart-polar plugin README.
PayPal
PayPal is the kahunacart-paypal plugin. Configure it at Plugins → KahunaCart PayPal or in user/config/plugins/kahunacart-paypal.yaml.
The customer is redirected to PayPal's approval page, and the order is captured and completed from the webhook.
What you need for PayPal
- An account at developer.paypal.com.
- A Merchant-type app in the matching environment, giving a client ID and secret.
- The webhook ID generated when you add the endpoint.
Set up PayPal
- Sign in at developer.paypal.com and switch to Sandbox or Live to match your mode.
- Go to Apps & Credentials → Create App and choose type Merchant.
- Copy the Client ID and Secret into the matching fields.
- Open the app, scroll to Webhooks, click Add Webhook, and paste
https://your-site.com/shop/webhook/paypal. - Subscribe to these four event types:
CHECKOUT.ORDER.APPROVED(triggers the capture call),PAYMENT.CAPTURE.COMPLETED(completes the order),PAYMENT.CAPTURE.DENIED(marks the payment failed),PAYMENT.CAPTURE.REFUNDED(records a refund in the ledger). - Save, then copy the generated Webhook ID, which looks like
1JE43649LP004314D, into the webhook field for the matching mode.
PayPal now appears at checkout as PayPal. Sandbox and live webhooks are separate objects with separate IDs.
PAYMENT.CAPTURE.DECLINED, PAYMENT.CAPTURE.REVERSED and CHECKOUT.ORDER.DECLINED are handled too if your account can subscribe to them, and all three also mark the payment failed.
Tip
CHECKOUT.ORDER.DECLINED is missing from PayPal's published event-name list but does fire on alternative payment methods such as iDEAL and BLIK. Add it if you accept those.
PayPal config keys
| Key | Default | What it does |
|---|---|---|
enabled |
true |
Turns the provider on. |
mode |
sandbox |
sandbox or live. |
brand_name |
empty | Store name on PayPal's approval page. Empty uses the account business name. |
shipping_preference |
GET_FROM_FILE |
GET_FROM_FILE (use the buyer's PayPal address), NO_SHIPPING (digital-only stores), or SET_PROVIDED_ADDRESS. |
sandbox_client_id |
empty | Client ID for sandbox. |
sandbox_client_secret |
empty | Client secret for sandbox. |
sandbox_webhook_id |
empty | The webhook ID, not a secret. It is not sensitive. |
live_client_id |
empty | Client ID for live. |
live_client_secret |
empty | Client secret for live. |
live_webhook_id |
empty | The webhook ID for live. |
PayPal registers only when enabled is on and both the active mode's client ID and secret are set. The checkout label is not configurable.
Tip
If you sell digital goods only, set shipping_preference to NO_SHIPPING so the buyer is never asked for an address.
Test PayPal payments
Keep mode set to sandbox, create a sandbox buyer account under Testing Tools → Sandbox Accounts, and pay with it. PayPal has no CLI tunnel, so a webhook has to reach a public URL. You have two options:
- Simulate. Testing Tools → Webhooks Simulator posts a sample event to your endpoint. Simulated events carry valid transmission headers but do not verify against a real webhook ID, so use them to check routing, not to complete an order.
- Tunnel. Expose your local site through an HTTPS tunnel and register that URL as a sandbox webhook. This gives the full flow, capture and completion included.
How PayPal verification works
PayPal signs webhooks with a certificate chain, and the only server-side check it supports is asking PayPal itself, so every accepted webhook costs one API call.
A request is rejected without calling out when it misses any of the five Paypal-Transmission-* / Paypal-Auth-Algo / Paypal-Cert-Url headers, carries a cert URL that is not on a PayPal host, or arrives while no webhook ID is configured. Anything PayPal does not answer SUCCESS gets a 400.
PayPal notes and limits
- Capture happens from the webhook.
CHECKOUT.ORDER.APPROVEDtriggers the capture call, and thePAYMENT.CAPTURE.COMPLETEDevent that follows completes the order. The flow works whether or not the customer's browser returns. - A failed capture is answered with a 200, so it is not logged as a signature failure.
- The PayPal order is one purchase unit with the total amount, not itemized.
PayPal-Request-Ididempotency means retrying a payment for the same pending ledger row returns the same PayPal order. A redelivered capture returningORDER_ALREADY_CAPTUREDcounts as success.- HUF and TWD have an exponent of 2 in ISO 4217, but PayPal transacts both in whole units and rejects decimals. The plugin follows PayPal.
- Credential failures always report the generic
PayPal rejected the API credentials.Other errors append PayPal'sdebug_id. - Full details are in the
kahunacart-paypalplugin README.
Choose a provider
- Least setup and widest card coverage → Stripe.
- Digital goods worldwide without VAT/GST registration → Polar, which handles tax as merchant of record.
- Customers who expect a PayPal button → PayPal. Note the extra API call per webhook.
- Bank transfers, cheques, or cash on delivery → offline.
Offering more than one is normal, Polar next to a non-MoR provider included. Each path is priced for the provider that is about to charge it.
Write your own provider
See the provider contract for the specification, the provider developer guide for a walkthrough, and admin sections for adding your own admin screen.
Run bin/plugin kahunacart status to list every registered provider with its declared capabilities.