Skip to content

Payments

Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

How AstroBaaS takes online payments: the built-in providers, how to switch them on, how provider webhooks are checked, where buyers are sent back to, refunds, and how to add a provider from a plugin. It is for operators setting up a shop and developers writing a gateway.

The full reference is PAYMENTS.md. Payments assume the shop is switched on; see Commerce.

Status. The protocol logic, the security controls and the whole webhook → capture → order-state chain are implemented and tested offline and in the smoke suite. Creating a session has not been tried against the providers' live sandboxes. Run each provider in its sandbox before taking real money.

Available methods

Id Kind Needs setup
stripe Hosted checkout (Stripe Checkout) Yes: env vars
paypal Hosted checkout (PayPal Orders v2, approve then capture) Yes: env vars
klarna Hosted payment page (Klarna HPP) Yes: env vars
bank-transfer Manual method No
cod Manual method (cash on delivery) No

There is no built-in "test gateway". For testing, use each provider's own sandbox (PAYPAL_ENV=sandbox, KLARNA_ENV=playground, Stripe test keys). The manual methods take no money and need no credentials. The buyer pays somewhere else, and a person marks the order paid.

GET /api/payments is public and lists the methods the install can take right now, with labels and the manual methods' instructions. Staff also see which providers are half-configured.

What the design refuses to do

  • It never touches card data. Every provider uses hosted checkout. No card number, CVV or IBAN enters the process, which keeps a self-hosted install outside PCI-DSS scope.
  • It never reads credentials from the database. Provider secrets come from environment variables only.
  • It never lets the client name the price. A session is built from the stored order and its server-computed total.
  • It never trusts a webhook body. Verification is required and there is no bypass flag.
  • A valid signature is not enough. A verified event must also match the order's currency and total before it can mark anything paid.

Configure

Nothing is on by default. List the providers in PAYMENTS_ENABLED and set each one's variables:

PAYMENTS_ENABLED=stripe,paypal,klarna

# Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

# PayPal
PAYPAL_CLIENT_ID=...
PAYPAL_CLIENT_SECRET=...
PAYPAL_WEBHOOK_ID=...
PAYPAL_ENV=sandbox          # or live

# Klarna
KLARNA_USERNAME=...
KLARNA_PASSWORD=...
KLARNA_REGION=eu            # eu | na | oc
KLARNA_ENV=playground       # or live
KLARNA_COUNTRY=DE
KLARNA_LOCALE=en-DE
Provider Required Optional (default)
Stripe STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET none
PayPal PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, PAYPAL_WEBHOOK_ID PAYPAL_ENV (sandbox)
Klarna KLARNA_USERNAME, KLARNA_PASSWORD KLARNA_REGION (eu), KLARNA_ENV (playground), KLARNA_COUNTRY, KLARNA_LOCALE

Buyers see a provider only when all three of these hold:

  1. It is listed in PAYMENTS_ENABLED.
  2. Every variable in its requiredEnv is set and not empty.
  3. Its optional validateEnv() finds nothing wrong with the values that are set.

A half-configured provider is reported as misconfigured under Admin → Settings → Payment providers. Missing variables and faulty ones are listed separately, by name only, never by value. Set the variables on the server (for example in shared/.env) and restart. See Configuration.

The buyer's path

  1. POST /api/orders places the order. Prices are computed on the server, stock is reserved, and the order is pending / unpaid.
  2. POST /api/payments/start with { order_number, email, provider } returns redirect_url.
  3. The buyer pays on the provider's page.
  4. The provider calls the webhook. It is verified, the amount is checked, and the order becomes paid + processing.

Only step 4 moves the order. The buyer landing on your success page proves nothing; anyone can visit that URL.

/api/payments/start needs the order number and the email the order was placed with. Order numbers are sequential, so the number alone would let anyone open payment pages for other people's orders. Either one wrong gives a generic 404. The route has its own rate limit (10 per minute per address by default, RATE_LIMIT_PAYMENT_START_PER_MIN).

Provider webhooks

Point each provider's webhook at:

https://your-cms.example/api/payments/webhook/<provider>

So /api/payments/webhook/stripe, /api/payments/webhook/paypal and /api/payments/webhook/klarna.

Provider How it is verified
Stripe HMAC-SHA256 signature over the timestamp and raw body. Constant-time compare, 5-minute replay window. Every v1 signature in the header is tried, so rotating the secret does not drop events.
PayPal PayPal's own verify API, then the order is read back from PayPal's API. Money is taken from that answer, not from the notification. The paypal-cert-url header must be on *.paypal.com.
Klarna Fetch-back only. The push is treated as a hint, and the order is fetched from Klarna with the shop's credentials.

Details that matter:

  • The webhook route passes the raw request bytes to the verifier untouched. Parsing and re-serialising JSON would break every signature.
  • A forged post answers 401 and changes nothing.
  • Failed verifications are throttled. After 20 failures in 10 minutes from one address, that address gets 429 with Retry-After before anything is verified. Only failures count. Loopback, private and unknown addresses are never throttled, so set TRUST_PROXY=1 behind a reverse proxy.
  • Payment webhooks do not use the anonymous 60/min budget. They have their own (600/min by default, RATE_LIMIT_WEBHOOK_PER_MIN).
  • Maintenance mode leaves payment webhooks open.

Idempotency and out-of-order delivery

Providers deliver at least once. Every applied event id is recorded on the order (the last 50), and a repeat does nothing. The event is claimed atomically, so two copies arriving together are applied once.

Situation Outcome
Success, amount matches paid + processing
Success, amount or currency differs Rejected, audit-logged as payment.rejected
Success on an already-paid order, or after a refund Ignored
Success on an order already cancelled (hold, abandonment, staff) Reopened and stock taken again. If the stock is gone, the order stays cancelled, is flagged needs_refund, and the owner is emailed.
Session expired or failed on an unpaid order failed + cancelled, stock returned
One declined card (Stripe payment_intent.payment_failed) Counted only; the buyer can retry. Five declines flag card_testing.
Failure after a success Ignored. A paid order is never cancelled.
Refund of a paid order refunded, stock returned
Refund of an order never paid Rejected, audit-logged

PayPal: approve, then capture

PayPal orders use intent=CAPTURE, so the buyer's approval moves no money. When CHECKOUT.ORDER.APPROVED arrives, AstroBaaS:

  1. Checks the approved amount against the order.
  2. Checks the order can still ship. It reopens an order the hold cancelled, but only if the stock is still there. It never captures for an order staff cancelled or one already refunded.
  3. Captures with a PayPal-Request-Id, so repeat deliveries charge once.
  4. Takes the result from the capture response.

A PayPal outage answers the webhook 500, so PayPal delivers it again. The order becomes paid a few seconds after the buyer returns, so a storefront should poll or re-read the order.

Return URLs

Provider return URLs (success_url, cancel_url, PayPal return_url, Klarna merchant_urls) are built from:

  1. the Site URL setting (Settings → General),
  2. then the SITE_URL environment variable,
  3. and only if neither is set, the request's own origin.

The CMS serves no /checkout/success or /checkout/cancelled page; your storefront does. On a headless shop, set Site URL to the storefront's address. Buyers are sent to <site>/checkout/success?order=… and <site>/checkout/cancelled?order=….

While an online provider is enabled, GET /api/health/deep reports a payment_return_urls warning if Site URL is empty or points at the CMS itself. The same warning shows under the Site URL field and on the dashboard.

Payment hold and session expiry

An unpaid order paid through a provider is cancelled when the shop's payment hold runs out (orders_payment_hold_minutes, default 120). After that, POST /api/payments/start answers 409 payment.window_closed.

Provider Expiry
Stripe The Checkout Session's expires_at is set to the end of the hold, kept within Stripe's 30 min to 24 h window.
PayPal Orders stay payable for 3 h by default. There is no expiry parameter to send.
Klarna The session lasts 48 h; the hosted page closes 1 h before that.

A payment that arrives after the hold is handled as described in the table above.

Refunds

Refunds are admin-only:

GET  /api/orders/{id}/refund    # what is refundable, and what has been refunded
POST /api/orders/{id}/refund    # { "amount_cents": 1250 } - omit for the full remainder
  • Partial refunds are supported. A second call is needed to refund the rest.
  • Refunds are appended atomically, and the payment status is computed from the stored refunds.
  • A refund made in the provider's own dashboard is recognised from its webhook.
  • A full refund clears a needs_refund flag. The order stays cancelled.

Manual methods

bank-transfer and cod are always available. Their buyer-facing instructions come from the settings payment_instructions_bank_transfer and payment_instructions_cod (Admin → Settings). The same text appears at checkout and in the order confirmation email. Leave it empty and the email says a person will follow up.

Adding a provider from a plugin

No fork is needed. Return your provider from the payment_providers filter (PLUGIN_HOOKS.PAYMENT_PROVIDERS). The plugin bootstrap collects providers from active plugins, and they are offered, reported and held exactly like the built-in ones.

import { definePlugin, PLUGIN_HOOKS } from 'astrobaas/core';
import type { PaymentProvider } from 'astrobaas/core';

const myPsp: PaymentProvider = {
  id: 'mypsp',
  label: 'My PSP',
  requiredEnv: ['MYPSP_API_KEY', 'MYPSP_WEBHOOK_SECRET'],
  async createSession(order, ctx) { /* return { reference, redirectUrl, expiresAt? } */ },
  async verifyWebhook(rawBody, headers, ctx) { /* throw on any failure */ },
};

export default definePlugin({
  id: 'mypsp',
  name: 'My PSP',
  version: '0.1.0',
  filters: { [PLUGIN_HOOKS.PAYMENT_PROVIDERS]: (list) => [...list, myPsp] },
});

The contract:

Member Required Purpose
id, label Yes The id is stored on orders; renaming it needs a migration.
requiredEnv Yes Env var names that must be set.
createSession(order, ctx) Yes Open a hosted-checkout session.
verifyWebhook(rawBody, headers, ctx) Yes Verify and normalise an inbound webhook. Must throw on failure.
validateEnv(env) No Report faults in values that are set, by name.
refund(order, amountCents, idempotencyKey, ctx) No Without it, the admin says refunds are unavailable.
captureApproved(event, ctx) If you return approved Turn an approval into money. Must be idempotent.

Rules the platform enforces:

  • Throw an error whose class name ends in VerificationError (for example WebhookVerificationError). The platform then answers 401, not 500.
  • Use ctx.fetch and ctx.now() rather than the globals, so the provider can be tested offline.
  • Compare the paid amount with the order's total. Never trust an amount from the wire.
  • An id that already exists (for example stripe) is refused with an error, and the original stays.
  • If a method takes no credentials and calls no API, it is not a provider. Use the manual_methods filter instead.

The full contract, including captureApproved, is in PLUGIN_DEVELOPMENT.md § Payment providers from a plugin. A core provider is one file in src/lib/payments/<id>.ts plus one line in BUILT_IN_PROVIDERS in registry.ts.

Known limits

  • No partial capture. An authorised amount that does not match the order exactly is rejected.
  • Settlement currency is not reconciled. Sessions are created in the order's currency, and what the acquirer settles at is outside AstroBaaS.
  • Stripe events outside the mapped set, including disputes and chargebacks, are ignored. Watch those in Stripe.
  • Klarna tax fields are sent as zero. The amount charged is still the order's own total.

See also

Clone this wiki locally