Skip to main content

Payment Integration

Getting a payment provider live takes three steps: register the plugin at boot, create a provider instance via GraphQL, and wire the checkout flow (signPaymentProviderForCheckout → checkoutCart). Webhook routes are registered automatically by the plugin.

Built-in Payment Providers​

ProviderAdapter KeyTypePreset
Invoiceshop.unchained.invoiceINVOICEbase
Invoice Prepaidshop.unchained.invoice-prepaidINVOICEall
Stripeshop.unchained.payment.stripeGENERICall
Datatransshop.unchained.datatransGENERICall
Saferpayshop.unchained.payment.saferpayGENERICall
Payrexxshop.unchained.payment.payrexxGENERICall
PostFinance Checkoutshop.unchained.payment.postfinance-checkoutGENERICall
Apple In-App Purchaseshop.unchained.apple-iapGENERICall
Cryptopayshop.unchained.payment.cryptopayGENERICcrypto, all

Each plugin page documents its environment variables, webhook path, and provider configuration.

1. Register the Plugin​

Register plugins before startPlatform() — either via a preset or individually:

// Preset: registers all built-in plugins (includes Stripe)
import { registerAllPlugins } from '@unchainedshop/plugins/presets/all';

registerAllPlugins();
// Or cherry-pick a single plugin
import { pluginRegistry } from '@unchainedshop/core';
import { StripePlugin } from '@unchainedshop/plugins/payment/stripe';

pluginRegistry.register(StripePlugin);

A registered plugin brings its payment adapter and its webhook route — no manual HTTP wiring needed.

For Stripe, set the environment variables before boot — without STRIPE_SECRET, startPlatform() skips the plugin with a warning and neither its adapter nor its webhook route is registered:

STRIPE_SECRET=sk_test_xxx # required
STRIPE_ENDPOINT_SECRET=whsec_xxx # required for webhooks
# STRIPE_WEBHOOK_PATH defaults to /payment/stripe/webhook

2. Create a Payment Provider​

Registering an adapter only makes it available — create a provider instance to activate it (Admin UI: Settings → Payment Providers, or GraphQL):

mutation CreateStripeProvider {
createPaymentProvider(
paymentProvider: { type: GENERIC, adapterKey: "shop.unchained.payment.stripe" }
) {
_id
type
interface {
_id
label
}
}
}

type is GENERIC or INVOICE — it must match what the adapter supports (see the table above).

3. Checkout Flow​

Select the payment provider​

mutation SelectPayment($paymentProviderId: ID!) {
updateCartPaymentGeneric(paymentProviderId: $paymentProviderId) {
_id
payment {
_id
provider {
_id
interface {
label
}
}
}
}
}

For invoice-type providers use updateCartPaymentInvoice instead; updateCart(paymentProviderId: ...) also works for a plain provider switch. Query available providers with paymentProviders(type: GENERIC) or per-order via order.supportedPaymentProviders.

Sign the payment​

signPaymentProviderForCheckout returns the gateway's client token as a string — for Stripe, a PaymentIntent client_secret:

mutation SignPayment($orderPaymentId: ID) {
signPaymentProviderForCheckout(orderPaymentId: $orderPaymentId)
}

Get orderPaymentId from me { cart { payment { _id } } }. The OrderPayment type itself exposes only _id, provider, status, fee, paid, and discounts — the client secret exists solely in the mutation result.

Complete payment client-side​

Use the returned secret with the gateway SDK. For Stripe:

import { loadStripe } from '@stripe/stripe-js';

const stripe = await loadStripe('pk_test_xxx');
// clientSecret = result of signPaymentProviderForCheckout
const { error, paymentIntent } = await stripe.confirmPayment({
elements, // Stripe Elements initialized with { clientSecret }
redirect: 'if_required',
});

See the Stripe.js docs for Elements setup.

Checkout​

mutation Checkout($paymentContext: JSON) {
checkoutCart(paymentContext: $paymentContext) {
_id
status
orderNumber
payment {
status
}
}
}

For Stripe, pass paymentContext: { paymentIntentId } — the adapter retrieves the intent, verifies amount, currency and order payment, and marks the order paid if the intent succeeded. If the charge is not yet confirmed, the order stays PENDING until the webhook arrives.

4. Webhooks​

Payment plugins self-register their webhook route when you register them — there is no handler to import. For Stripe the route is POST /payment/stripe/webhook (override with STRIPE_WEBHOOK_PATH); it verifies signatures with STRIPE_ENDPOINT_SECRET and processes payment_intent.succeeded and setup_intent.succeeded.

Test locally with the Stripe CLI:

stripe listen --forward-to localhost:4010/payment/stripe/webhook

Webhook paths for the other gateways are listed on their plugin pages.

Custom Payment Adapter​

For gateways without a built-in plugin, use the registerPaymentProvider factory from @unchainedshop/core:

import { OrderPricingSheet, PaymentError, registerPaymentProvider } from '@unchainedshop/core';

registerPaymentProvider({
adapterId: 'custom-gateway', // key becomes shop.unchained.payment.custom-gateway
type: 'GENERIC',
configurationError: process.env.MY_GATEWAY_API_KEY ? null : PaymentError.INCOMPLETE_CONFIGURATION,

// Create a payment session for the front-end SDK (result of signPaymentProviderForCheckout)
sign: async (configuration, context) => {
if (!context.order) return null;
const pricing = OrderPricingSheet({
calculation: context.order.calculation,
currencyCode: context.order.currencyCode,
});
const session = await myGateway.createSession({
amount: pricing.total().amount,
currency: context.order.currencyCode,
orderId: context.order._id,
});
return session.clientToken;
},

// Called during checkoutCart; return a result = paid, false = not yet paid, throw = abort
charge: async (configuration, context) => {
const { transactionId } = context.transactionContext || {};
if (transactionId) {
const payment = await myGateway.getPayment(transactionId);
if (payment.status === 'completed') return { transactionId };
}
return false; // order stays PENDING
},

cancel: async (configuration, context) => {
await myGateway.refund(context.orderPayment?._id);
return true;
},
});

See Plugin Factories for the full option reference.

Payment Fees​

Add processing fees with a payment pricing adapter:

import { OrderPricingSheet, registerPaymentPricing } from '@unchainedshop/core';

registerPaymentPricing({
adapterId: 'card-fee',
isActivatedFor: (context) => context.provider.adapterKey === 'shop.unchained.payment.stripe',
calculate: async (sheet, context) => {
const pricing = OrderPricingSheet({
calculation: context.order?.calculation,
currencyCode: context.order?.currencyCode,
});
const total = pricing.total().amount;
sheet.addFee({ amount: Math.round(total * 0.029 + 30), isTaxable: false, isNetPrice: true }); // 2.9% + 0.30
},
});