Payments SDK
Guides

Provider authoring

Build an independently published provider without editing the SDK.

A third party can publish @somebody/payments-provider without changing @payments-sdk/payments. The provider implements the structural contracts from @payments-sdk/core, and the runtime derives its typed methods and capability namespaces from the configured provider object.

Package boundary

Use the core package as a development-time contract:

npm install @payments-sdk/payments
npm install --save-dev @payments-sdk/core typescript

Provider source should use type-only imports from core. The published provider JavaScript must not contain a runtime import of @payments-sdk/core.

A small provider

This fake provider creates a redirect and supports verification. The literal 'acme-pay' ID is what gives consumers a narrowed provider key:

import type {
  CheckoutHandler,
  PaymentProvider,
  VerifyHandler,
} from '@payments-sdk/core';

interface AcmeCheckoutOptions {
  readonly returnUrl: string;
}

interface AcmeVerifyOptions {
  readonly merchantReference?: string;
}

interface AcmeCheckoutData {
  readonly paymentId: string;
}

interface AcmeVerifyData {
  readonly gatewayStatus: string;
}

const checkout: CheckoutHandler<
  'acme-pay',
  AcmeCheckoutOptions,
  AcmeCheckoutData
> = async ({ input, fetch }) => {
  const response = await fetch('https://gateway.example/payments', {
    method: 'POST',
    body: JSON.stringify({
      amount: input.intent.amount,
      reference: input.intent.reference,
      returnUrl: input.providerOptions.returnUrl,
    }),
  });

  if (!response.ok) throw new Error('Acme Pay request failed');
  const body = (await response.json()) as {
    paymentId: string;
    paymentUrl: string;
  };

  return {
    provider: 'acme-pay',
    status: 'pending',
    action: { type: 'redirect', url: body.paymentUrl },
    providerData: { paymentId: body.paymentId },
  };
};

const verify: VerifyHandler<
  'acme-pay',
  AcmeVerifyOptions,
  AcmeVerifyData
> = async ({ input, fetch }) => {
  const response = await fetch(
    `https://gateway.example/payments/${input.reference}`,
  );
  if (!response.ok) throw new Error('Acme Pay lookup failed');

  const body = (await response.json()) as { status: string };
  const status = body.status === 'paid' ? 'succeeded' : 'unknown';

  return {
    provider: 'acme-pay',
    status,
    providerData: { gatewayStatus: body.status },
  };
};

export function acmePay(): PaymentProvider<'acme-pay'> {
  return {
    id: 'acme-pay',
    checkout,
    verify,
  } satisfies PaymentProvider<'acme-pay'>;
}

The example intentionally maps only an explicitly recognized gateway status to succeeded. Unknown or ambiguous states must remain non-successful.

Capabilities are optional

Add only capabilities the gateway really supports:

return {
  id: 'acme-pay',
  checkout,
  verify,
  returns: parseReturn,
  // Add webhooks only when the gateway signs and sends server events.
} satisfies PaymentProvider<'acme-pay'>;

The consumer's type surface follows those properties. A provider without returns does not add payments.returns.acmePay, and a provider without verify cannot be passed to payments.verify().

Inbound parsing

Returns and webhooks receive an InboundRequest and runtime context. Keep the raw URL and body available because gateways may send malformed query strings or signatures over the original wire representation:

import type { ReturnsHandler } from '@payments-sdk/core';

const parseReturn: ReturnsHandler<
  'acme-pay',
  { gatewayStatus: string }
> = async (request) => {
  const url = request instanceof Request ? request.url : request.url;
  const query = new URL(url).searchParams;

  return {
    provider: 'acme-pay',
    status: 'pending',
    reference: query.get('reference') ?? undefined,
    providerData: { gatewayStatus: query.get('status') ?? 'missing' },
  };
};

Validate signatures before exposing a webhook event as trusted input. A browser return is normally an unauthoritative claim and should be followed by provider verification.

Testing and publishing

Provider tests should cover:

  • request URLs, methods, headers, and signed canonical fields;
  • successful, failed, pending, and unknown gateway statuses;
  • malformed callbacks and missing required fields;
  • no runtime import of @payments-sdk/core in the built JavaScript;
  • deterministic behavior through injected fetch and now.

Keep credentials out of errors, serialized provider data, and test fixtures. Build the provider as ESM and publish it independently. Consumers then add it to the runtime with no change to the @payments-sdk/payments package:

import { acmePay } from '@somebody/payments-provider';
import { createPayments } from '@payments-sdk/payments';

const payments = createPayments({ providers: [acmePay()] });

On this page