Stripe payments
On Zkrinet: add the block under Building blocks in the panel — one click, your own code is untouched and a snapshot is taken first. The installation steps below apply if you run PocketBase yourself, for example after an export.
One-time purchases and subscriptions via Stripe Checkout, Stripe's customer portal, and a webhook that keeps payments and subscriptions up to date in your database. Card details never touch your server: the customer pays on Stripe's own page.
Tested against PocketBase 0.40.4.
#Installation
- Copy
pb_hooks/andpb_migrations/to your PocketBase and restart. - Keys — choose one of two ways (the environment wins if both exist):
- In the admin panel: create one row in
zk_stripe_installningarwithhemlig_nyckel(sk_test_…/sk_live_…or a restrictedrk_…) andwebhook_hemlighet(whsec_…). The fields are hidden and can only be read by superusers. - As environment variables:
ZK_STRIPE_HEMLIG_NYCKELandZK_STRIPE_WEBHOOK_HEMLIGHET.
- In the admin panel: create one row in
- Webhook in Stripe (Developers → Webhooks → Add endpoint):
- Address:
https://din-app.se/api/zk/stripe/webhook - Events:
checkout.session.completed,checkout.session.async_payment_succeeded,checkout.session.async_payment_failed,checkout.session.expired,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted - Copy the Signing secret (
whsec_…) to step 2.
- Address:
- Prices: create products and prices in Stripe, then add each price you want to sell to
zk_stripe_priser(pris_id=price_…,namn,lage=engangorprenumeration,aktiv). Only prices in that list can be purchased. - Is your web app on a different address than PocketBase? Put its addresses in
tillatna_adresser(comma-separated, e.g.https://min-app.se, https://www.min-app.se). Otherwise only the app's own address is accepted (Settings → Application URL).
#Usage from the app
// Start a purchase (the user must be logged in)
const { url } = await pb.send("/api/zk/stripe/kassa", { method: "POST", body: {
pris: "price_123",
lyckad_url: "https://min-app.se/tack?session={CHECKOUT_SESSION_ID}",
avbruten_url: "https://min-app.se/priser",
}})
location.href = url
// Does the user have an active subscription? (the user only sees their own rows)
const aktiv = await pb.collection("zk_stripe_prenumerationer")
.getFirstListItem('status = "active" || status = "trialing"').catch(() => null)
// Customer portal: change card, cancel, see receipts
const { url: portal } = await pb.send("/api/zk/stripe/portal", { method: "POST", body: { retur_url: "https://min-app.se/konto" } })
The price list can be read without logging in: pb.collection("zk_stripe_priser").getFullList() (only active prices are visible).
Trust the webhook, not lyckad_url. The customer landing on the thank-you page proves nothing — a payment is paid only when the row in zk_stripe_betalningar has status = "betald".
#Swish & Klarna
The checkout is Stripe Checkout, so Swish and Klarna work as soon as they are turned on in Stripe. The betalmetoder setting in zk_stripe_installningar controls what the checkout sends:
betalmetoder | What happens |
|---|---|
| empty (recommended) | The checkout sends no list. Stripe shows the methods you have turned on in the Dashboard and that fit the purchase (currency, amount, one-time/subscription, the customer's country). You can turn methods on and off in Stripe without touching the app. |
card,swish,klarna | Only these methods are offered (sent as payment_method_types). Use this only if a particular checkout must be restricted. |
Why empty is recommended: Stripe itself recommends dynamic payment methods and then automatically hides what does not fit the purchase (Stripe: integration options). With a fixed list the Dashboard cannot add anything, and a method that does not fit (e.g. Swish on a price in euros) can make Stripe refuse the whole checkout, instead of just hiding the method. We have not been able to measure the latter against real Stripe, see Limitations.
This applies according to Stripe's documentation (read 7 Nov 2026):
| Swish | Klarna | |
|---|---|---|
| Currency | SEK only, customers in Sweden. 3–150,000 kr. | SEK, EUR, NOK, DKK, GBP, USD and more. Amount limits depend on country and payment option. |
| Subscription | No. "Checkout doesn't support Swish in subscription mode." | Yes (in Sweden: pay now and pay later). |
| Payment notification | Immediate ("immediate notification") | Stripe only writes "customer-initiated". Whether the notification can be delayed is not stated on the page, so the plugin assumes that it can be. You receive the full amount, and Klarna takes the risk if the customer does not pay. |
| Disputes | Do not exist. The customer must contact you. | Yes |
| Source | docs.stripe.com/payments/swish | docs.stripe.com/payments/klarna |
Swish in a subscription: if swish is in the list and the price is a subscription, the checkout filters out Swish before Stripe is called, responds with borttagna_betalmetoder: ["swish"] and writes a warning to the PocketBase log (Logs in the admin panel). If the list becomes empty (e.g. only swish), the checkout responds 400 with an error explaining why, and Stripe is never called. With an empty field Stripe handles this itself.
What you must do in Stripe:
- Settings → Payments → Payment methods: turn on Klarna and Swish.
- Swish requires approval from Stripe. The documentation has a form to request access, and some industries are blocked (e.g. alcohol, pawnshops, legal services, top-ups of digital wallets). Klarna has its own rules: no B2B purchases (Klarna rules).
- Prices must be in SEK if you want Swish to appear.
- The webhook must receive
checkout.session.async_payment_succeededandcheckout.session.async_payment_failed(they are already in the list under Installation).
Asynchronous payments: some methods give checkout.session.completed with payment_status = unpaid and the outcome only comes later. The row in zk_stripe_betalningar then goes vantar → betald (the receipt is sent only then) or vantar → misslyckad. An outcome never goes backwards, not even if Stripe sends the events in reverse order. According to Stripe, Swish gives an immediate notification, so for Swish vantar should be uncommon. For Klarna the documentation does not state whether the notification can be delayed. The plugin handles both cases, regardless of method.
#Collections
| Collection | What | Who reads |
|---|---|---|
zk_stripe_installningar | Keys (hidden), allowed addresses, receipt email on/off, payment methods | Superusers only |
zk_stripe_priser | What may be sold | Everyone (active only) |
zk_stripe_betalningar | One row per checkout: skapad → betald / vantar / misslyckad / utgangen | The user their own |
zk_stripe_prenumerationer | Stripe's status (active, past_due, canceled …), period end, whether it ends at period end | The user their own |
zk_stripe_handelser | Every received event — protects against double handling | Superusers only |
#Receipt email (optional)
If the Mejl plugin is installed and kvittomejl is turned on in the settings, the kvitto template is sent when a payment becomes paid (with name, product, amount, date and reference). Stripe can also send its own receipts — do not turn on both unless you want the customer to get two.
#Security
- The webhook signature is verified against the raw body (HMAC-SHA256), and events older than 5 minutes are rejected.
- The same event is handled only once even if Stripe sends it several times. If handling fails we respond 500 so that Stripe retries.
- Stripe does not guarantee the order of events. A paid payment never becomes unpaid because of a late event.
- The return addresses (
lyckad_url,avbruten_url,retur_url) must be on the app's address or in the allowlist, so that your checkout cannot be used to send people to a fake page. - Users cannot create or change payment rows themselves — only the webhook and the checkout write.
- Preferably use a restricted key (
rk_…) with permissions only for Checkout Sessions and Customer portal.
#Limitations
- The checkout requires a logged-in user in the
userscollection (guest purchases are not supported in this version). - One price per purchase (quantity 1–100). A cart with several different prices is not supported yet.
- Refunds and disputes are not mirrored in the database — they are handled in Stripe's dashboard.
- Stripe API version locked to
2024-06-20. There the list is calledpayment_method_types. From version2026-09-30.endiveStripe no longer accepts that parameter (it is then calledallowed_payment_method_types), so if the version is raised the checkout must be changed at the same time. - The tests run against a fake Stripe. How real Stripe responds to a fixed list that does not fit the purchase (e.g. Swish and EUR) we have not measured. Stripe's errors are passed on as
502with Stripe's own text. - The checkout does not know which currency the price has (it only stores
price_…), and therefore cannot itself filter out Swish from prices that are not in SEK. Stripe does that with an empty field. - The Swish reference (
payment_method_options[swish][reference]) cannot be set yet.
#Test
./bank.py stripe
24 tests against a fake Stripe (nothing reaches the internet): checkout, allowlists, signature (wrong key, old timestamp, missing header), duplicates, reversed order, subscription lifecycle, permissions, customer portal, receipt email, payment methods (dynamic, fixed list, Swish filtered out in subscription with log entry, invalid values) and asynchronous payments (vantar → betald/misslyckad, also reversed). The security tests are verified with mutants — every protection has been broken on purpose and caught.