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 typescriptProvider 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/corein the built JavaScript; - deterministic behavior through injected
fetchandnow.
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()] });