Skip to main content

Overview

Openfront supports multiple payment providers through a flexible adapter system. You can integrate Stripe, PayPal, manual payment methods (like Cash on Delivery), or build custom payment adapters.

Payment Adapter Interface

All payment providers in Openfront implement a standard adapter interface with these core functions:
  • createPaymentFunction - Initialize a payment intent or order
  • capturePaymentFunction - Capture an authorized payment
  • refundPaymentFunction - Process refunds
  • getPaymentStatusFunction - Check payment status
  • generatePaymentLinkFunction - Generate payment dashboard links
  • handleWebhookFunction - Verify and process webhook events
The adapter pattern is defined in features/integrations/payment/index.ts:
features/integrations/payment/index.ts
export const paymentProviderAdapters = {
  stripe: () => import("./stripe"),
  paypal: () => import("./paypal"),
  manual: () => import("./manual"),
};

Stripe Configuration

Environment Variables

Add these variables to your .env file:
.env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Implementation Details

The Stripe adapter (features/integrations/payment/stripe.ts) uses the official Stripe SDK:
export async function createPaymentFunction({ cart, amount, currency }) {
  const stripe = getStripeClient();

  const paymentIntent = await stripe.paymentIntents.create({
    amount,
    currency: currency.toLowerCase(),
    automatic_payment_methods: {
      enabled: true,
    },
  });

  return {
    clientSecret: paymentIntent.client_secret,
    paymentIntentId: paymentIntent.id,
  };
}

Webhook Verification

Stripe webhooks are verified using signature validation:
features/integrations/payment/stripe.ts
export async function handleWebhookFunction({ event, headers }) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  const stripe = getStripeClient();

  try {
    const stripeEvent = stripe.webhooks.constructEvent(
      JSON.stringify(event),
      headers['stripe-signature'],
      webhookSecret
    );

    return {
      isValid: true,
      event: stripeEvent,
      type: stripeEvent.type,
      resource: stripeEvent.data.object,
    };
  } catch (err) {
    throw new Error(`Webhook signature verification failed: ${err.message}`);
  }
}

PayPal Configuration

Environment Variables

.env
NEXT_PUBLIC_PAYPAL_CLIENT_ID=your_client_id
PAYPAL_CLIENT_SECRET=your_client_secret
NEXT_PUBLIC_PAYPAL_SANDBOX=true  # Set to false for production
PAYPAL_WEBHOOK_ID=your_webhook_id

Implementation Details

The PayPal adapter (features/integrations/payment/paypal.ts) handles currency formatting, including zero-decimal currencies:
export async function createPaymentFunction({ cart, amount, currency }) {
  const accessToken = await getPayPalAccessToken();
  const baseUrl = getPayPalBaseUrl();

  const response = await fetch(
    `${baseUrl}/v2/checkout/orders`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${accessToken}`,
      },
      body: JSON.stringify({
        intent: "AUTHORIZE",
        purchase_units: [
          {
            amount: {
              currency_code: currency.toUpperCase(),
              value: formatPayPalAmount(amount, currency),
            },
          },
        ],
      }),
    }
  );

  const order = await response.json();
  return {
    orderId: order.id,
    status: order.status,
  };
}

Currency Handling

PayPal supports zero-decimal currencies (JPY, KRW, etc.) which require special formatting:
features/integrations/payment/paypal.ts
const NO_DIVISION_CURRENCIES = [
  "JPY", "KRW", "VND", "CLP", "PYG", "XAF", "XOF",
  "BIF", "DJF", "GNF", "KMF", "MGA", "RWF", "XPF",
  "HTG", "VUV", "XAG", "XDR", "XAU"
];

const formatPayPalAmount = (amount: number, currency: string): string => {
  const upperCurrency = currency.toUpperCase();
  const isNoDivision = NO_DIVISION_CURRENCIES.includes(upperCurrency);

  if (isNoDivision) {
    return amount.toString();
  }
  return (amount / 100).toFixed(2);
};

Manual Payment Provider

The manual payment provider is used for Cash on Delivery, bank transfers, and other offline payment methods:
features/integrations/payment/manual.ts
export async function createPaymentFunction({ cart, amount, currency }) {
  return {
    status: 'pending',
    data: {
      status: 'pending',
      amount,
      currency: currency.toLowerCase(),
    }
  };
}

export async function capturePaymentFunction({ paymentId, amount }) {
  return {
    status: 'captured',
    amount,
    data: {
      status: 'captured',
      amount,
      captured_at: new Date().toISOString(),
    }
  };
}

Building Custom Payment Adapters

To create a custom payment provider:
  1. Create a new file in features/integrations/payment/
  2. Implement all required adapter functions
  3. Register your adapter in the paymentProviderAdapters object

Example Custom Adapter

features/integrations/payment/custom.ts
export async function createPaymentFunction({ cart, amount, currency }) {
  // Call your payment gateway API
  const response = await fetch('https://api.yourgateway.com/payments', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.CUSTOM_GATEWAY_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount,
      currency,
      metadata: {
        cartId: cart.id,
      },
    }),
  });

  const payment = await response.json();
  
  return {
    paymentId: payment.id,
    status: payment.status,
    clientSecret: payment.client_secret,
  };
}

export async function capturePaymentFunction({ paymentId, amount }) {
  // Implement capture logic
}

export async function refundPaymentFunction({ paymentId, amount }) {
  // Implement refund logic
}

export async function getPaymentStatusFunction({ paymentId }) {
  // Implement status check
}

export async function generatePaymentLinkFunction({ paymentId }) {
  return `https://dashboard.yourgateway.com/payments/${paymentId}`;
}

export async function handleWebhookFunction({ event, headers }) {
  // Implement webhook verification
  const signature = headers['x-gateway-signature'];
  const isValid = verifySignature(event, signature);
  
  if (!isValid) {
    throw new Error('Invalid webhook signature');
  }
  
  return {
    isValid: true,
    event,
    type: event.type,
    resource: event.data,
  };
}

Register Custom Adapter

features/integrations/payment/index.ts
export const paymentProviderAdapters = {
  stripe: () => import("./stripe"),
  paypal: () => import("./paypal"),
  manual: () => import("./manual"),
  custom: () => import("./custom"), // Add your custom adapter
};

Using Payment Providers in Your Application

Payment providers are managed through the KeystoneJS admin panel. The PaymentProvider model stores configuration:
type PaymentProvider {
  id: ID!
  name: String!
  provider: String!  # stripe, paypal, manual, custom
  isActive: Boolean!
  regions: [Region!]!
  apiKey: String
  webhookSecret: String
}
Load and use a payment adapter:
import { paymentProviderAdapters } from '@/features/integrations/payment';

const adapter = await paymentProviderAdapters[provider.provider]();
const result = await adapter.createPaymentFunction({
  cart,
  amount: cart.total,
  currency: cart.currency.code,
});

Best Practices

  • Store API keys in environment variables, never in code
  • Always verify webhook signatures
  • Use HTTPS for all payment communications
  • Implement rate limiting on webhook endpoints
  • Implement proper error handling for network failures
  • Log payment errors for debugging
  • Provide clear error messages to users
  • Implement retry logic for transient failures
  • Use sandbox/test modes during development
  • Test with various currencies and amounts
  • Verify webhook delivery and retry logic
  • Test payment capture and refund flows
  • Handle zero-decimal currencies correctly
  • Convert amounts to smallest currency unit (cents)
  • Store currency codes with transactions
  • Test currency conversion edge cases

Build docs developers (and LLMs) love