Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

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:

TXT
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:

BASH
bin/plugin kahunacart webhooks
bin/plugin kahunacart webhooks --provider stripe

Over the API:

TXT
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_on to completed if 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_… or sk_live_…).

Set up Stripe

  1. Paste the secret key for your mode into the matching field.
  2. In Stripe, go to Developers → Webhooks and add https://your-site.com/shop/webhook/stripe.
  3. 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.
  4. Copy the endpoint's signing secret into the webhook field for the matching mode.
  5. 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:

BASH
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-stripe plugin 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

  1. Paste the organization access token into the field for the matching environment.
  2. In Polar, go to Settings → Webhooks and add https://your-site.com/shop/webhook/polar.
  3. Choose the Raw payload format.
  4. Subscribe to at least: order.paid, order.refunded, product.created, product.updated.
  5. Copy the endpoint's signing secret into the webhook field for the matching environment.
  6. 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

  1. Sign in at developer.paypal.com and switch to Sandbox or Live to match your mode.
  2. Go to Apps & Credentials → Create App and choose type Merchant.
  3. Copy the Client ID and Secret into the matching fields.
  4. Open the app, scroll to Webhooks, click Add Webhook, and paste https://your-site.com/shop/webhook/paypal.
  5. 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).
  6. 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.APPROVED triggers the capture call, and the PAYMENT.CAPTURE.COMPLETED event 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-Id idempotency means retrying a payment for the same pending ledger row returns the same PayPal order. A redelivered capture returning ORDER_ALREADY_CAPTURED counts 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's debug_id.
  • Full details are in the kahunacart-paypal plugin 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.