docs
Custom adapters

Custom adapters

Build a custom PaymentAdapter to connect any payment provider to PayKit's unified API.

When to build a custom adapter

PayKit ships adapters for Stripe, Razorpay, and PayPal. If your provider isn't on the list, you can implement the PaymentAdapter interface yourself and plug it in. Your application code stays the same — only the adapter changes.

The PaymentAdapter interface

Every adapter implements this contract:

import type {
  PaymentAdapter,
  HealthStatus,
  ChargeOperations,
  RefundOperations,
  CustomerOperations,
  PaymentMethodOperations,
  WebhookOperations,
  SubscriptionOperations,
  PayoutOperations,
} from '@squaredr/paykit';
import type { ProviderCapabilities } from '@squaredr/paykit';
import type { ProviderCredentials } from '@squaredr/paykit';

export interface PaymentAdapter {
  readonly name: string;
  readonly capabilities: ProviderCapabilities;

  initialize(credentials: ProviderCredentials): Promise<void>;
  healthCheck(): Promise<HealthStatus>;

  // Required
  charges: ChargeOperations;
  refunds: RefundOperations;
  customers: CustomerOperations;
  paymentMethods: PaymentMethodOperations;
  webhooks: WebhookOperations;

  // Optional
  subscriptions?: SubscriptionOperations;
  payouts?: PayoutOperations;
}

Five operation groups are required. Two — subscriptions and payouts — are optional. If your provider doesn't support them, omit them and set the corresponding capability flags to false.

Declaring capabilities

The ProviderCapabilities object tells PayKit what your adapter supports at runtime. The SDK checks these flags before calling optional operations and throws NotSupportedError when a capability is false.

const capabilities: ProviderCapabilities = {
  charges: true,
  authAndCapture: false, // set true if your provider supports auth + capture
  refunds: true,
  partialRefunds: false,
  subscriptions: false,
  savedPaymentMethods: false,
  hostedCheckout: false,
  embeddableUI: false,
  payouts: false,
  multiCurrency: true,
  directDebit: false,
  webhooks: true,
  threeDS: false,
};

All 13 flags must be set explicitly — there are no defaults.

Step-by-step: build a minimal adapter

This example builds an adapter for a fictional provider called Acme Pay. It covers charges, refunds, and webhooks — the minimum useful adapter.

1. Scaffold the class

import type {
  PaymentAdapter,
  ChargeOperations,
  RefundOperations,
  CustomerOperations,
  PaymentMethodOperations,
  WebhookOperations,
  HealthStatus,
} from '@squaredr/paykit';
import type { ProviderCapabilities } from '@squaredr/paykit';
import type { ProviderCredentials } from '@squaredr/paykit';
import { PaymentError, NotSupportedError } from '@squaredr/paykit';

export class AcmePayAdapter implements PaymentAdapter {
  readonly name = 'acme-pay';
  readonly capabilities: ProviderCapabilities = {
    charges: true,
    authAndCapture: false,
    refunds: true,
    partialRefunds: true,
    subscriptions: false,
    savedPaymentMethods: false,
    hostedCheckout: false,
    embeddableUI: false,
    payouts: false,
    multiCurrency: true,
    directDebit: false,
    webhooks: true,
    threeDS: false,
  };

  private apiKey!: string;
  private baseUrl = 'https://api.acmepay.com/v1';

  async initialize(credentials: ProviderCredentials): Promise<void> {
    const creds = credentials as Record<string, string>;
    if (!creds.apiKey) {
      throw new Error('Missing apiKey for Acme Pay adapter.');
    }
    this.apiKey = creds.apiKey;
  }

  async healthCheck(): Promise<HealthStatus> {
    const start = Date.now();
    try {
      const res = await fetch(`${this.baseUrl}/health`, {
        headers: { Authorization: `Bearer ${this.apiKey}` },
      });
      return {
        healthy: res.ok,
        latencyMs: Date.now() - start,
      };
    } catch {
      return {
        healthy: false,
        latencyMs: Date.now() - start,
        message: 'Acme Pay API unreachable',
      };
    }
  }

  // Operation groups implemented below...
  charges!: ChargeOperations;
  refunds!: RefundOperations;
  customers!: CustomerOperations;
  paymentMethods!: PaymentMethodOperations;
  webhooks!: WebhookOperations;
}

2. Implement charges

Each operation in ChargeOperations returns a UnifiedCharge — you write mapper functions that convert the provider's response format into PayKit's unified type.

import type { UnifiedCharge, CreateChargeParams, CaptureParams } from '@squaredr/paykit';
import type { ListParams, PaginatedList, RequestOptions } from '@squaredr/paykit';

// Inside the AcmePayAdapter class:

charges: ChargeOperations = {
  create: async (params: CreateChargeParams): Promise<UnifiedCharge> => {
    const res = await this.request('POST', '/charges', {
      amount: params.amount,
      currency: params.currency,
      description: params.description,
      metadata: params.metadata,
    });
    return this.mapCharge(res);
  },

  retrieve: async (id: string): Promise<UnifiedCharge> => {
    const res = await this.request('GET', `/charges/${id}`);
    return this.mapCharge(res);
  },

  capture: async (id: string, params?: CaptureParams): Promise<UnifiedCharge> => {
    throw new NotSupportedError('acme-pay', 'charges.capture');
  },

  cancel: async (id: string, options?: RequestOptions): Promise<UnifiedCharge> => {
    const res = await this.request('POST', `/charges/${id}/cancel`);
    return this.mapCharge(res);
  },

  list: async (params?: ListParams): Promise<PaginatedList<UnifiedCharge>> => {
    const res = await this.request('GET', '/charges', {
      limit: params?.limit ?? 10,
      after: params?.startingAfter,
    });
    return {
      data: res.items.map(this.mapCharge),
      hasMore: res.has_more,
    };
  },
};

3. Write mapper functions

Mappers convert provider responses to unified types. Keep them as pure functions.

private mapCharge(raw: any): UnifiedCharge {
  return {
    id: raw.id,
    providerId: raw.id,
    provider: 'acme-pay',
    amount: raw.amount,
    currency: raw.currency.toUpperCase(),
    status: this.mapChargeStatus(raw.state),
    description: raw.description ?? undefined,
    metadata: raw.metadata ?? undefined,
    createdAt: new Date(raw.created_at),
    updatedAt: new Date(raw.updated_at ?? raw.created_at),
    _raw: raw,
  };
}

private mapChargeStatus(state: string): UnifiedCharge['status'] {
  const map: Record<string, UnifiedCharge['status']> = {
    completed: 'succeeded',
    pending: 'pending',
    failed: 'failed',
    voided: 'canceled',
    refunded: 'refunded',
    partial_refund: 'partially_refunded',
  };
  return map[state] ?? 'pending';
}

4. Map provider errors to PaymentError

When the provider returns an error, throw a PaymentError with the appropriate unified error code. PayKit has 19 error codes — map the provider's codes to the closest match.

import { PaymentError } from '@squaredr/paykit';
import type { PaymentErrorCode } from '@squaredr/paykit';

const ACME_ERROR_MAP: Record<string, PaymentErrorCode> = {
  card_declined: 'card_declined',
  bad_card: 'invalid_card',
  no_funds: 'insufficient_funds',
  rate_limited: 'rate_limit',
  not_found: 'not_found',
};

private async request(method: string, path: string, body?: unknown) {
  const res = await fetch(`${this.baseUrl}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${this.apiKey}`,
      'Content-Type': 'application/json',
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  if (!res.ok) {
    const error = await res.json();
    throw new PaymentError({
      code: ACME_ERROR_MAP[error.code] ?? 'processing_error',
      message: error.message,
      provider: 'acme-pay',
      providerCode: error.code,
      providerMessage: error.message,
      isRetryable: error.code === 'rate_limited',
      httpStatus: res.status,
      _raw: error,
    });
  }

  return res.json();
}

5. Handle webhooks

Webhook operations have two methods: verify (returns a boolean) and parse (returns a UnifiedWebhookEvent).

import type { UnifiedWebhookEvent, WebhookEventType } from '@squaredr/paykit';
import { createHmac } from 'node:crypto';

webhooks: WebhookOperations = {
  verify: (
    payload: string | Buffer,
    headers: Record<string, string>,
    secret: string,
  ): boolean => {
    const signature = headers['x-acme-signature'];
    if (!signature) return false;
    const expected = createHmac('sha256', secret)
      .update(typeof payload === 'string' ? payload : payload.toString())
      .digest('hex');
    return signature === expected;
  },

  parse: (
    payload: string | Buffer,
    headers: Record<string, string>,
    secret: string,
  ): UnifiedWebhookEvent => {
    if (!this.webhooks.verify(payload, headers, secret)) {
      throw new Error('Invalid webhook signature');
    }
    const body = JSON.parse(typeof payload === 'string' ? payload : payload.toString());

    const EVENT_MAP: Record<string, WebhookEventType> = {
      'payment.completed': 'charge.succeeded',
      'payment.failed': 'charge.failed',
      'refund.completed': 'refund.completed',
    };

    return {
      id: body.event_id,
      provider: 'acme-pay',
      type: EVENT_MAP[body.type] ?? ('charge.succeeded' as WebhookEventType),
      providerType: body.type,
      data: body.data,
      createdAt: new Date(body.timestamp),
      _raw: body,
    };
  },
};

6. Implement remaining required operations

Customers, refunds, and payment methods follow the same pattern — call the provider API, map the response to the unified type. If your provider doesn't support an operation (e.g. stored payment methods), throw NotSupportedError:

import { NotSupportedError } from '@squaredr/paykit';

paymentMethods: PaymentMethodOperations = {
  create: async () => { throw new NotSupportedError('acme-pay', 'paymentMethods.create'); },
  retrieve: async () => { throw new NotSupportedError('acme-pay', 'paymentMethods.retrieve'); },
  attach: async () => { throw new NotSupportedError('acme-pay', 'paymentMethods.attach'); },
  detach: async () => { throw new NotSupportedError('acme-pay', 'paymentMethods.detach'); },
  list: async () => { throw new NotSupportedError('acme-pay', 'paymentMethods.list'); },
};

Set the corresponding capability to false so the PayKit client knows not to route these calls to your adapter.

Registering your adapter

Use registerAdapter() to make your adapter discoverable by name:

import { registerAdapter } from '@squaredr/paykit';
import { AcmePayAdapter } from './acme-pay-adapter';

registerAdapter('acme-pay', AcmePayAdapter);

Once registered, you can reference it by name in multi-provider configs:

import { PayKit } from '@squaredr/paykit';

const paykit = new PayKit({
  providers: {
    stripe: { secretKey: 'sk_test_xxx' },
    'acme-pay': { apiKey: 'ak_xxx' },
  },
  routing: {
    default: 'stripe',
    rules: [{ currency: 'XYZ', provider: 'acme-pay' }],
  },
});

Or pass the adapter instance directly:

const paykit = new PayKit({
  adapter: new AcmePayAdapter(),
});
await paykit.initialize({ apiKey: 'ak_xxx' });

Testing your adapter

Use PayKit's unified types to write provider-agnostic tests:

import { describe, it, expect, beforeAll } from 'vitest';
import { AcmePayAdapter } from './acme-pay-adapter';
import type { PaymentAdapter } from '@squaredr/paykit';

describe('AcmePayAdapter', () => {
  let adapter: PaymentAdapter;

  beforeAll(async () => {
    adapter = new AcmePayAdapter();
    await adapter.initialize({ apiKey: process.env.ACME_API_KEY! });
  });

  it('creates a charge', async () => {
    const charge = await adapter.charges.create({
      amount: 5000,
      currency: 'USD',
      description: 'Test charge',
    });

    expect(charge.provider).toBe('acme-pay');
    expect(charge.amount).toBe(5000);
    expect(charge.currency).toBe('USD');
    expect(charge.status).toBe('succeeded');
    expect(charge.createdAt).toBeInstanceOf(Date);
  });

  it('reports correct capabilities', () => {
    expect(adapter.capabilities.charges).toBe(true);
    expect(adapter.capabilities.subscriptions).toBe(false);
  });

  it('health check returns valid status', async () => {
    const health = await adapter.healthCheck();
    expect(typeof health.healthy).toBe('boolean');
    expect(typeof health.latencyMs).toBe('number');
  });
});

Look at the MockAdapter in packages/core/src/testing/mock-adapter.ts for a complete in-memory reference implementation.

Reference adapters

Study the included adapters for real-world patterns:

AdapterSourceKey patterns
Stripepackages/core/src/adapters/stripe/Full capabilities, mapper-per-resource pattern, error type mapping
Razorpaypackages/core/src/adapters/razorpay/Order-based flow, HMAC webhook verification
PayPalpackages/core/src/adapters/paypal/OAuth token management, REST API mapping

Checklist

Before shipping your adapter:

  • All 5 required operation groups implemented (charges, refunds, customers, paymentMethods, webhooks)
  • capabilities object has all 13 flags set explicitly
  • initialize() validates credentials and throws clear errors
  • healthCheck() returns latency and handles failures gracefully
  • Provider errors mapped to PaymentError with correct unified error codes
  • Webhook verify() and parse() use cryptographic signature validation
  • Unsupported operations throw NotSupportedError
  • Tests cover create, retrieve, and list for each operation group
  • registerAdapter() called if you want name-based discovery