Skip to content

Custom gateways

Implement PaymentGateway via BaseGateway, wrap it in a GatewayAdapter, and register it with createPaymentClient — no core edits.

Updated View as Markdown

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 registered

Map 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 outcome with applyOutcomeToGatewayResult / applyOutcomeToGatewayRefundResult. The helper writes outcome + references (and reconciliationRequired for indeterminate). It does not write success (success was removed in 1.0).
  • Post-submit timeouts should be indeterminate (applyIndeterminatePaymentOutcome), not a fake decline. Callers must not createPayment again.
  • Fulfillment is isPaidOutcome / status === "paid" — not success: true.
  • Never fulfill in onWebhookVerified. After inbox claim, fulfill only on rematched payment.succeeded | capture.completed and payment.status === "paid", bound to gatewayPaymentId.
  • onWebhookReceived sees 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

Navigation

Type to search…

↑↓ navigate↵ selectEsc close