Third-party gateways are first-class: implement PaymentGateway (usually via BaseGateway), wrap it in a GatewayAdapter, and pass the adapter to createPaymentClient. No core source edits.
BuiltInGatewayName stays closed: "moyasar" | "paypal" | "paymob" | "stripe". Custom and extra adapters use open GatewayId strings inferred from your registry (TMap).
Preferred path: createPaymentClient
import {
applyOutcomeToGatewayResult,
BaseGateway,
createPaymentClient,
defineGatewayCapabilities,
money,
type GatewayAdapter,
type GatewayContext,
type CreatePaymentParams,
type CaptureParams,
type RefundParams,
type GatewayPaymentResult,
type GatewayRefundResult,
type WebhookEvent,
} from "@paykernel/core";
class ExampleGateway extends BaseGateway {
readonly name = "example" as const;
constructor(
private readonly apiKey: string,
hooks: ConstructorParameters<typeof BaseGateway>[1],
logger?: ConstructorParameters<typeof BaseGateway>[2],
) {
super(
{ apiKey },
hooks,
logger,
defineGatewayCapabilities({
payments: true,
refunds: true,
// unspecified keys default to false (fail-closed)
}),
);
}
async createPayment(params: CreatePaymentParams): Promise<GatewayPaymentResult> {
return this.executeWithHooks("createPayment", params, async (p) => {
return applyOutcomeToGatewayResult(
{
gatewayId: "ex_123",
status: "paid",
redirectUrl: undefined,
rawResponse: {},
amount: p.amount, // Money — payment APIs reject number
currency: p.currency,
gateway: "example",
},
"succeeded",
);
});
}
async capturePayment(params: CaptureParams): Promise<GatewayPaymentResult> {
return this.executeWithHooks("capturePayment", params, async () => {
throw new Error("Not implemented");
});
}
async refundPayment(params: RefundParams): Promise<GatewayRefundResult> {
return this.executeWithHooks("refundPayment", params, async () => {
throw new Error("Not implemented");
});
}
verifyWebhook(
_payload: unknown,
signature?: string,
_headers?: Record<string, string>,
): boolean {
return signature === "valid";
}
parseWebhookEvent(payload: unknown): WebhookEvent {
return {
id: "evt_1",
type: "payment_paid",
gateway: "example",
paymentId: undefined,
gatewayPaymentId: "ex_123",
status: "paid",
timestamp: new Date(),
rawPayload: payload,
};
}
}
/** Close over secrets. Never put them on context or manifest. */
function exampleGateway(config: { apiKey: string }): GatewayAdapter<"example", ExampleGateway> {
const closed = { ...config };
return {
name: "example",
manifest: {
name: "example",
displayName: "Example Provider",
version: "1.0.0",
capabilities: defineGatewayCapabilities({
payments: true,
refunds: true,
}),
},
create(context: GatewayContext) {
return new ExampleGateway(closed.apiKey, context.hooks, context.logger);
},
};
}
const client = createPaymentClient({
gateways: {
example: exampleGateway({ apiKey: process.env.EXAMPLE_API_KEY! }),
},
defaultGateway: "example",
hooks: {
beforeCreatePayment: (ctx) => {
return { proceed: true };
},
onWebhookVerified: async (event) => {
// Authenticity only — metrics/logging. Never fulfill here.
void event.status;
},
},
});
await client.createPayment({
amount: money("10.00", "USD"),
currency: "USD",
callbackUrl: "https://merchant.example/callback",
});
await client.handleWebhook("example", rawBody, signatureHeader);verifyWebhook must return boolean true for success. A Promise is awaited; any other truthy value is not treated as verified. Implement verifyWebhookAsync when verification needs I/O (PayPal-style postback).
Mix with built-ins
import {
createPaymentClient,
stripeGateway,
moyasarGateway,
} from "@paykernel/core";
const payments = createPaymentClient({
gateways: {
stripe: stripeGateway({ secretKey: process.env.STRIPE_SECRET_KEY! }),
moyasar: moyasarGateway({ secretKey: process.env.MOYASAR_SECRET_KEY! }),
example: exampleGateway({ apiKey: process.env.EXAMPLE_API_KEY! }),
},
defaultGateway: "moyasar",
});
payments.gateway("stripe"); // StripeGateway
payments.gateway("example"); // ExampleGateway
// payments.gateway("adyen"); // compile-time error — not registeredMap keys must equal adapter.name or construction throws InvalidRequestError.
Extra first-party packages (not BuiltInGatewayName)
Tap, MyFatoorah, and Hesabe use the same adapter shape from their own packages. They are not members of BuiltInGatewayName.
import { createPaymentClient } from "@paykernel/core";
import { tapGateway } from "@paykernel/gateway-tap";
const tapPayments = createPaymentClient({
gateways: {
tap: tapGateway({ secretKey: process.env.TAP_SECRET_KEY! }),
},
defaultGateway: "tap",
});import { myfatoorahGateway } from "@paykernel/gateway-myfatoorah";
const myfatoorahPayments = createPaymentClient({
gateways: {
myfatoorah: myfatoorahGateway({
apiToken: process.env.MYFATOORAH_API_TOKEN!,
country: "KWT",
webhookSecret: process.env.MYFATOORAH_WEBHOOK_SECRET,
}),
},
defaultGateway: "myfatoorah",
});import { createPaymentClient, InMemoryIdempotencyStore } from "@paykernel/core";
import { hesabeGateway } from "@paykernel/gateway-hesabe";
const hesabePayments = createPaymentClient({
gateways: {
hesabe: hesabeGateway({
merchantCode: process.env.HESABE_MERCHANT_CODE!,
accessCode: process.env.HESABE_ACCESS_CODE!,
encryptionKey: process.env.HESABE_ENCRYPTION_KEY!, // 32 UTF-8 bytes
ivKey: process.env.HESABE_IV_KEY!, // 16 UTF-8 bytes
username: process.env.HESABE_USERNAME!,
password: process.env.HESABE_PASSWORD!,
idempotencyStore: new InMemoryIdempotencyStore(), // single-process only — use a durable store
}),
},
defaultGateway: "hesabe",
});See Tap, MyFatoorah, and Hesabe.
Registry builder
import { createGatewayRegistry, createPaymentClient } from "@paykernel/core";
const registry = createGatewayRegistry()
.register(stripeGateway({ secretKey: "..." }))
.register(exampleGateway({ apiKey: "..." }))
.build(); // immutable
const client = createPaymentClient({
registry,
defaultGateway: "stripe",
});| Builder | Behavior |
|---|---|
register(adapter) |
Fails if the name is already present (InvalidRequestError) |
replace(adapter) |
Overwrite or insert |
registerDynamic(adapter) |
Same runtime as register; erases static map precision |
build() |
Freezes adapters, names, manifests |
Provide exactly one of registry or gateways. Mixing both, or omitting both, throws InvalidRequestError. defaultGateway must be registered. Two or more gateways without defaultGateway and without a named gateway argument on the op throws InvalidRequestError. A one-gateway map may omit defaultGateway.
There is no live unregisterGateway. Build a new client to change the set.
createDynamicGatewayRegistry() starts a string-keyed builder when names are only known at runtime (explicit loss of static inference).
What you get without editing core
| Concern | How it works |
|---|---|
| Payments | client.createPayment / capture / refund / void / get |
| Webhooks | client.handleWebhook(name, …) → verify → parse → hooks. Still verify only — claim via @paykernel/webhooks. |
| Hooks | Shared HooksManager via GatewayContext.hooks |
| Logging | Redacting logger on client + context |
| Errors | Throw PaymentError subclasses; they propagate unchanged |
| Runtime | GatewayContext extends PaymentRuntime (fetch, crypto, clock, randomUUID). Use this.fetch, not bare fetch. |
| Capabilities | gateway.supports("partialRefunds") / gateway.capabilities. Unspecified keys are false. |
Never put API keys, webhook secrets, DB handles, or request objects on GatewayContext or GatewayManifest. Pass optional runtime on createPaymentClient; the client builds context via createDefaultGatewayContext.
Naming
| Type | Meaning |
|---|---|
BuiltInGatewayName |
"moyasar" | "paypal" | "paymob" | "stripe" |
GatewayName |
0.x alias of BuiltInGatewayName (closed) — legacy config |
GatewayId |
Open string for hooks, webhooks, and plugin contracts |
Registry / TMap |
Inferred names from your adapters |
0.x new PaymentClient({ moyasar, … }) was removed at 1.0. The constructor is private and throws InvalidRequestError. It does not construct the four built-ins. Use createPaymentClient. 1.0 migration.
Outcomes and webhooks for plugins
- Dual-write Phase 6
outcomewithapplyOutcomeToGatewayResult/applyOutcomeToGatewayRefundResult. The helper writesoutcome+references(andreconciliationRequiredfor indeterminate). It does not writesuccess(successwas removed in 1.0). - Post-submit timeouts should be indeterminate (
applyIndeterminatePaymentOutcome), not a fake decline. Callers must notcreatePaymentagain. - Fulfillment is
isPaidOutcome/status === "paid"— notsuccess: true. - Never fulfill in
onWebhookVerified. After inbox claim, fulfill only on rematchedpayment.succeeded|capture.completedandpayment.status === "paid", bound togatewayPaymentId. onWebhookReceivedsees unverified payloads (logging/metrics only).
Conformance (@paykernel/testkit)
Core does not depend on testkit. Validate adapters offline / with fixture-driven doubles — never live provider credentials in CI:
import { defineGatewayCapabilities } from "@paykernel/core";
import { runGatewayConformanceSuite, mockGateway } from "@paykernel/testkit";
const capabilities = defineGatewayCapabilities({
payments: true,
immediateCapture: true,
refunds: true,
partialRefunds: true,
});
await runGatewayConformanceSuite({
name: "example",
createGateway: () => mockGateway({ name: "example", capabilities }),
capabilities,
});In-memory stores in testkit are NON-PRODUCTION. Lease-aware contracts: @paykernel/store-contracts.
Related: core · plugin runtime · capabilities · gateways