Repository navigation
Payments
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.
| 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.
- 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.
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:
- It is listed in
PAYMENTS_ENABLED. - Every variable in its
requiredEnvis set and not empty. - 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.
-
POST /api/ordersplaces the order. Prices are computed on the server, stock is reserved, and the order ispending/unpaid. -
POST /api/payments/startwith{ order_number, email, provider }returnsredirect_url. - The buyer pays on the provider's page.
- 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).
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
401and changes nothing. -
Failed verifications are throttled. After 20 failures in 10 minutes from one address, that address gets
429withRetry-Afterbefore anything is verified. Only failures count. Loopback, private and unknown addresses are never throttled, so setTRUST_PROXY=1behind 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.
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 orders use intent=CAPTURE, so the buyer's approval moves no money. When CHECKOUT.ORDER.APPROVED arrives, AstroBaaS:
- Checks the approved amount against the order.
- 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.
- Captures with a
PayPal-Request-Id, so repeat deliveries charge once. - 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.
Provider return URLs (success_url, cancel_url, PayPal return_url, Klarna merchant_urls) are built from:
- the Site URL setting (Settings → General),
- then the
SITE_URLenvironment variable, - 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.
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 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_refundflag. The order stayscancelled.
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.
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 exampleWebhookVerificationError). The platform then answers401, not500. - Use
ctx.fetchandctx.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_methodsfilter 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.
- 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.
- Commerce: orders, stock and the payment hold
- Plugins: writing and loading a plugin
-
Security: rate limits and
TRUST_PROXY - Configuration: environment variables
- PAYMENTS.md
AstroBaaS is free software under GPL-3.0-or-later. This wiki describes the latest release; the repository and its CHANGELOG are the final word. Found a mistake here? Open an issue.
Getting started
Using the CMS
Building with it
Extending
Commerce and email
Operating
Project