How to create a custom billing integration in Makerkit
Learn how to create a custom billing integration in Makerkit
This guide explains how to create billing integration plugins for the Makerkit SaaS platform to allow you to use a custom billing provider.
How to create a custom billing integration in Makerkit
Learn how to create a custom billing integration in Makerkit
Architecture Overview
The Makerkit billing system uses a plugin-based architecture that allows multiple billing providers to coexist. The system consists of:
Core Components
- Billing Strategy Provider Service - Abstract interface for billing operations
- Billing Webhook Handler Service - Abstract interface for webhook processing
- Registry System - Dynamic loading and management of providers
- Schema Validation - Type-safe configuration and data validation
Provider Structure
Each billing provider is implemented as a separate package under packages/{provider-name}/
with:
- Server-side services - Billing operations and webhook handling
- Client-side components - Checkout flows and UI integration
- Configuration schemas - Environment variable validation
- SDK abstractions - Provider-specific API integrations
Data Flow
Client Request → Registry → Provider Service → External API → Webhook → Handler → Database
Creating a package
You can create a new package for your billing provider by running the following command:
pnpm turbo gen package
This will create a new package in the packages directory, ready to use. You can move this anywhere in the packages
directory, but we recommend keeping it in the packages/billing
directory.
Package Structure
Once we finalize the package structure, your structure should look like this:
packages/{provider-name}/├── package.json├── tsconfig.json├── index.ts└── src/ ├── index.ts ├── components/ │ ├── index.ts │ └── {provider}-checkout.tsx ├── constants/ │ └── {provider}-events.ts ├── schema/ │ ├── {provider}-client-env.schema.ts │ └── {provider}-server-env.schema.ts └── services/ ├── {provider}-billing-strategy.service.ts ├── {provider}-webhook-handler.service.ts ├── {provider}-sdk.ts └── create-{provider}-billing-portal-session.ts
package.json Template
{ "name": "@kit/{provider-name}", "private": true, "version": "0.1.0", "exports": { ".": "./src/index.ts", "./components": "./src/components/index.ts" }, "typesVersions": { "*": { "*": ["src/*"] } }, "dependencies": { "{provider-sdk}": "^x.x.x" }, "devDependencies": { "@kit/billing": "workspace:*", "@kit/eslint-config": "workspace:*", "@kit/prettier-config": "workspace:*", "@kit/shared": "workspace:*", "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", "@types/react": "19.1.13", "next": "15.5.4", "react": "19.1.1", "zod": "^3.25.74" }}
Core Interface Implementation
BillingStrategyProviderService
This abstract class defines the contract for all billing operations:
packages/{provider}/src/services/{provider}-billing-strategy.service.ts
import { BillingStrategyProviderService } from '@kit/billing';export class YourProviderBillingStrategyService implements BillingStrategyProviderService{ private readonly namespace = 'billing.{provider}'; async createCheckoutSession(params) { // Implementation } async createBillingPortalSession(params) { // Implementation } async cancelSubscription(params) { // Implementation } async retrieveCheckoutSession(params) { // Implementation } async reportUsage(params) { // Implementation (if supported) } async queryUsage(params) { // Implementation (if supported) } async updateSubscriptionItem(params) { // Implementation } async getPlanById(planId: string) { // Implementation } async getSubscription(subscriptionId: string) { // Implementation }}
BillingWebhookHandlerService
This abstract class handles webhook events from the billing provider:
packages/{provider}/src/services/{provider}-webhook-handler.service.ts
import { BillingWebhookHandlerService } from '@kit/billing';export class YourProviderWebhookHandlerService implements BillingWebhookHandlerService{ private readonly provider = '{provider}' as const; private readonly namespace = 'billing.{provider}'; async verifyWebhookSignature(request: Request) { // Verify signature using provider's SDK // Throw error if invalid } async handleWebhookEvent(event: unknown, params) { // Route events to appropriate handlers switch (event.type) { case 'subscription.created': return this.handleSubscriptionCreated(event, params); case 'subscription.updated': return this.handleSubscriptionUpdated(event, params); // ... other events } }}
Environment Configuration
Server Environment Schema
Create schemas for server-side configuration:
packages/{provider}/src/schema/{provider}-server-env.schema.ts
// src/schema/{provider}-server-env.schema.tsimport { z } from 'zod';export const YourProviderServerEnvSchema = z.object({ apiKey: z.string({ description: '{Provider} API key for server-side operations', required_error: '{PROVIDER}_API_KEY is required', }), webhooksSecret: z.string({ description: '{Provider} webhook secret for verifying signatures', required_error: '{PROVIDER}_WEBHOOK_SECRET is required', }),});export type YourProviderServerEnv = z.infer<typeof YourProviderServerEnvSchema>;
Client Environment Schema
Create schemas for client-side configuration:
packages/{provider}/src/schema/{provider}-client-env.schema.ts
// src/schema/{provider}-client-env.schema.tsimport { z } from 'zod';export const YourProviderClientEnvSchema = z.object({ publicKey: z.string({ description: '{Provider} public key for client-side operations', required_error: 'NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY is required', }),});export type YourProviderClientEnv = z.infer<typeof YourProviderClientEnvSchema>;
Billing Strategy Service
Implementation Example
This is an abstract example
The "client" class in the example below is not a real class, it's just an example of how to implement the BillingStrategyProviderService interface. You should refer to the SDK of your billing provider to implement the actual methods.
Here's a detailed implementation pattern based on the Paddle service:
import 'server-only';import { z } from 'zod';import { BillingStrategyProviderService } from '@kit/billing';import { getLogger } from '@kit/shared/logger';import { createYourProviderClient } from './your-provider-sdk';export class YourProviderBillingStrategyService implements BillingStrategyProviderService{ private readonly namespace = 'billing.{provider}'; async createCheckoutSession( params: z.infer<typeof CreateBillingCheckoutSchema>, ) { const logger = await getLogger(); const client = await createYourProviderClient(); const ctx = { name: this.namespace, customerId: params.customerId, accountId: params.accountId, }; logger.info(ctx, 'Creating checkout session...'); try { const response = await client.checkout.create({ customer: { id: params.customerId, email: params.customerEmail, }, lineItems: params.plan.lineItems.map((item) => ({ priceId: item.id, quantity: 1, })), successUrl: params.returnUrl, metadata: { accountId: params.accountId, }, }); logger.info(ctx, 'Checkout session created successfully'); return { checkoutToken: response.id, }; } catch (error) { logger.error({ ...ctx, error }, 'Failed to create checkout session'); throw new Error('Failed to create checkout session'); } } async cancelSubscription( params: z.infer<typeof CancelSubscriptionParamsSchema>, ) { const logger = await getLogger(); const client = await createYourProviderClient(); const ctx = { name: this.namespace, subscriptionId: params.subscriptionId, }; logger.info(ctx, 'Cancelling subscription...'); try { await client.subscriptions.cancel(params.subscriptionId, { immediate: params.invoiceNow ?? true, }); logger.info(ctx, 'Subscription cancelled successfully'); return { success: true }; } catch (error) { logger.error({ ...ctx, error }, 'Failed to cancel subscription'); throw new Error('Failed to cancel subscription'); } } // Implement other required methods...}
SDK Client Wrapper
Create a reusable SDK client:
// src/services/{provider}-sdk.tsimport 'server-only';import { YourProviderServerEnvSchema } from '../schema/{provider}-server-env.schema';export async function createYourProviderClient() { // parse the environment variables const config = YourProviderServerEnvSchema.parse({ apiKey: process.env.{PROVIDER}_API_KEY, webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET, }); return new YourProviderSDK({ apiKey: config.apiKey, });}
Webhook Handler Service
Implementation Pattern
import { BillingWebhookHandlerService, PlanTypeMap } from '@kit/billing';import { getLogger } from '@kit/shared/logger';import { createYourProviderClient } from './your-provider-sdk';export class YourProviderWebhookHandlerService implements BillingWebhookHandlerService{ constructor(private readonly planTypesMap: PlanTypeMap) {} private readonly provider = '{provider}' as const; private readonly namespace = 'billing.{provider}'; async verifyWebhookSignature(request: Request) { const body = await request.clone().text(); const signature = request.headers.get('{provider}-signature'); if (!signature) { throw new Error('Missing {provider} signature'); } const { webhooksSecret } = YourProviderServerEnvSchema.parse({ apiKey: process.env.{PROVIDER}_API_KEY, webhooksSecret: process.env.{PROVIDER}_WEBHOOK_SECRET, environment: process.env.{PROVIDER}_ENVIRONMENT || 'sandbox', }); const client = await createYourProviderClient(); try { const eventData = await client.webhooks.verify(body, signature, webhooksSecret); if (!eventData) { throw new Error('Invalid signature'); } return eventData; } catch (error) { throw new Error(`Webhook signature verification failed: ${error}`); } } async handleWebhookEvent(event: unknown, params) { const logger = await getLogger(); switch (event.type) { case 'checkout.session.completed': { return this.handleCheckoutCompleted(event, params.onCheckoutSessionCompleted); } case 'customer.subscription.created': case 'customer.subscription.updated': { return this.handleSubscriptionUpdated(event, params.onSubscriptionUpdated); } case 'customer.subscription.deleted': { return this.handleSubscriptionDeleted(event, params.onSubscriptionDeleted); } default: { logger.info( { name: this.namespace, eventType: event.type, }, 'Unhandled webhook event type', ); if (params.onEvent) { await params.onEvent(event); } } } } private async handleCheckoutCompleted(event, onCheckoutSessionCompleted) { // Extract subscription/order data from event // Transform to standard format // Call onCheckoutSessionCompleted with normalized data } // Implement other event handlers...}
Client-Side Components
Checkout Component
Create a React component for the checkout flow:
'use client';import { useEffect, useState } from 'react';import { useRouter } from 'next/navigation';import { YourProviderClientEnvSchema } from '../schema/{provider}-client-env.schema';interface YourProviderCheckoutProps { onClose?: () => void; checkoutToken: string;}const config = YourProviderClientEnvSchema.parse({ publicKey: process.env.NEXT_PUBLIC_{PROVIDER}_PUBLIC_KEY, environment: process.env.NEXT_PUBLIC_{PROVIDER}_ENVIRONMENT || 'sandbox',});export function YourProviderCheckout({ onClose, checkoutToken,}: YourProviderCheckoutProps) { const router = useRouter(); const [error, setError] = useState<string | null>(null); useEffect(() => { async function initializeCheckout() { try { // Initialize provider's JavaScript SDK const { YourProviderSDK } = await import('{provider}-js-sdk'); const sdk = new YourProviderSDK({ publicKey: config.publicKey, environment: config.environment, }); // Open checkout await sdk.redirectToCheckout({ sessionId: checkoutToken, successUrl: window.location.href, cancelUrl: window.location.href, }); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Checkout failed'; setError(errorMessage); onClose?.(); } } void initializeCheckout(); }, [checkoutToken, onClose]); if (error) { throw new Error(error); } return null; // Provider handles the UI}
Registration and Integration
Register Billing Strategy
Add your provider to the billing strategy registry:
// packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts// Register {Provider} billing strategybillingStrategyRegistry.register('{provider}', async () => { const { YourProviderBillingStrategyService } = await import('@kit/{provider}'); return new YourProviderBillingStrategyService();});
Register Webhook Handler
Add your provider to the webhook handler factory:
// packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts// Register {Provider} webhook handlerbillingWebhookHandlerRegistry.register('{provider}', async () => { const { YourProviderWebhookHandlerService } = await import('@kit/{provider}'); return new YourProviderWebhookHandlerService(planTypesMap);});
Update Package Exports
Export your services from the main index file:
// packages/{provider}/src/index.tsexport { YourProviderBillingStrategyService } from './services/{provider}-billing-strategy.service';export { YourProviderWebhookHandlerService } from './services/{provider}-webhook-handler.service';export * from './components';export * from './constants/{provider}-events';export { YourProviderClientEnvSchema, type YourProviderClientEnv,} from './schema/{provider}-client-env.schema';export { YourProviderServerEnvSchema, type YourProviderServerEnv,} from './schema/{provider}-server-env.schema';
Security Best Practices
Environment Variables
- Never expose secrets in client-side code
- Use different credentials for sandbox and production
- Validate all environment variables with Zod schemas
- Store secrets securely (e.g., in environment variables or secret managers)
Webhook Security
- Always verify webhook signatures
- Use HTTPS endpoints for webhooks
- Log security events for monitoring
Data Handling
- Validate all incoming data with Zod schemas
- Sanitize user inputs
- Never log sensitive information (API keys, customer data)
- Use structured logging with appropriate log levels
Error Handling
- Don't expose internal errors to users
- Log errors with sufficient context for debugging
- Implement proper error boundaries in React components
- Handle rate limiting and API errors gracefully
Example Implementation
For a complete reference implementation, see the Stripe integration at packages/billing/stripe/
. Key files to study:
src/services/stripe-billing-strategy.service.ts
- Complete billing strategy implementationsrc/services/stripe-webhook-handler.service.ts
- Webhook handling patternssrc/components/stripe-embedded-checkout.tsx
- Client-side checkout componentsrc/schema/
- Environment configuration schemas
Also take a look at the Lemon Squeezy integration at packages/billing/lemon-squeezy/
or the Paddle integration at packages/plugins/paddle/
(in the plugins repository)
Conclusion
NB: different providers have different APIs, so the implementation will be different for each provider.
Following this guide, you should be able to create a robust billing integration that:
- Implements all required interfaces correctly
- Handles errors gracefully and securely
- Provides a good user experience
- Follows established patterns and best practices
- Integrates seamlessly with the existing billing system
Remember to:
- Test thoroughly with the provider's sandbox environment
- Follow security best practices throughout development
- Document any provider-specific requirements or limitations
- Consider edge cases and error scenarios
- Validate your implementation against the existing test suite