ZKRINET Docs
Building blocks

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

  1. Copy pb_hooks/ and pb_migrations/ to your PocketBase and restart.
  2. Keys — choose one of two ways (the environment wins if both exist):
    • In the admin panel: create one row in zk_stripe_installningar with hemlig_nyckel (sk_test_…/sk_live_… or a restricted rk_…) and webhook_hemlighet (whsec_…). The fields are hidden and can only be read by superusers.
    • As environment variables: ZK_STRIPE_HEMLIG_NYCKEL and ZK_STRIPE_WEBHOOK_HEMLIGHET.
  3. 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.
  4. Prices: create products and prices in Stripe, then add each price you want to sell to zk_stripe_priser (pris_id = price_…, namn, lage = engang or prenumeration, aktiv). Only prices in that list can be purchased.
  5. 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:

betalmetoderWhat 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,klarnaOnly 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):

SwishKlarna
CurrencySEK only, customers in Sweden. 3–150,000 kr.SEK, EUR, NOK, DKK, GBP, USD and more. Amount limits depend on country and payment option.
SubscriptionNo. "Checkout doesn't support Swish in subscription mode."Yes (in Sweden: pay now and pay later).
Payment notificationImmediate ("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.
DisputesDo not exist. The customer must contact you.Yes
Sourcedocs.stripe.com/payments/swishdocs.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:

  1. Settings → Payments → Payment methods: turn on Klarna and Swish.
  2. 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).
  3. Prices must be in SEK if you want Swish to appear.
  4. The webhook must receive checkout.session.async_payment_succeeded and checkout.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

CollectionWhatWho reads
zk_stripe_installningarKeys (hidden), allowed addresses, receipt email on/off, payment methodsSuperusers only
zk_stripe_priserWhat may be soldEveryone (active only)
zk_stripe_betalningarOne row per checkout: skapad → betald / vantar / misslyckad / utgangenThe user their own
zk_stripe_prenumerationerStripe's status (active, past_due, canceled …), period end, whether it ends at period endThe user their own
zk_stripe_handelserEvery received event — protects against double handlingSuperusers 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

Limitations

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.