npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@paykernel/core

v1.0.0

Published

PayKernel core — type-safe payment orchestration contracts, client, gateway registry, money types, results, and runtime abstractions for TypeScript.

Readme

@paykernel/core

Type-safe payment orchestration for TypeScript: Moyasar, PayPal, Paymob, Stripe, plus a plugin registry. Portable across Node ≥ 18, Bun, Deno, and Cloudflare Workers (see runtime.md). Server-side only for secret keys.

Production fulfillment is not this package alone. handleWebhook verifies and normalizes. Claim, lease, and durable retry live in @paykernel/webhooks plus a store from adapter-selection.md. Composition walkthrough: docs/getting-started.md.

Features

  • 🔌 Multi-Gateway Support: Moyasar, PayPal, Paymob, Stripe + third-party plugins
  • 💰 Safe money + outcomes: money(), isPaidOutcome, indeterminate post-submit results (money, operation-results)
  • 🧩 Open plugin API: createPaymentClient + GatewayAdapter registry (see plugin architecture)
  • 🪝 Lifecycle Hooks: Before, after, and error hooks on hooked operations (global + per-op where supported — not every method has a dedicated hook; see hooks matrix)
  • 🔒 Type-Safe: Full TypeScript support with strict types (names inferred from your registry)
  • 🌐 Runtime-portable: Web APIs + pure crypto; no required Express/Hono/Elysia adapter

Documentation

Package Structure

This package lives at packages/core in the monorepo. Published name remains @paykernel/core.

packages/core/             # @paykernel/core (publishable)
├── src/
│   ├── index.ts           # Main exports
│   ├── client.ts          # PaymentClient orchestrator
│   ├── errors.ts          # Custom error classes
│   ├── types/             # Type definitions
│   ├── hooks/             # Lifecycle hooks
│   └── gateways/          # Gateway implementations (moyasar, paypal, paymob, stripe)
├── dist/                  # Built output
├── docs/                  # Documentation (ships with the package)
├── package.json
├── README.md
└── tsconfig.json

Installation

bun add @paykernel/core
# or
npm install @paykernel/core
# or
pnpm add @paykernel/core

This package is ESM-only ("type": "module", exports.import only — no CommonJS require build). Use Node ≥ 18 (LTS 18/20/22 recommended) or Bun ≥ 1.0 with ESM (import / "type": "module"). Portable Web APIs + pure crypto also target Deno and Cloudflare Workers — see runtime.md. Server-side only for secret keys (do not ship secrets to browsers).

Quick Start

Preferred (plugin / adapter factories):

import { createPaymentClient, moyasarGateway } from "@paykernel/core";

const client = createPaymentClient({
  gateways: {
    moyasar: moyasarGateway({
      secretKey: process.env.MOYASAR_SECRET_KEY!,
      webhookSecret: process.env.MOYASAR_WEBHOOK_SECRET,
    }),
  },
  defaultGateway: "moyasar",
});

Migrating from 0.x: see Migrating to 1.0new PaymentClient({ moyasar, ... }) is removed, use createPaymentClient({ gateways: { moyasar: moyasarGateway(...) } }); numeric amount: 10.5money("10.50", "SAR"); successoutcome, and other 1.0 cuts.

Creating a payment

import { money, isPaidOutcome } from "@paykernel/core";

const result = await client.createPayment({
  amount: money("100.00", "SAR"),
  currency: "SAR",
  orderId: "order_123",
  callbackUrl: "https://example.com/callback",
  moyasarSource: {
    type: "token",
    token: "token_xxx",
  },
});

if (isPaidOutcome(result)) {
  // Paid-like settlement only (outcome === "succeeded" && status === "paid").
} else if (result.redirectUrl) {
  // Redirect customer for 3DS verification — do not fulfill yet
} else if (result.outcome === "indeterminate" || result.reconciliationRequired) {
  // Charge may already exist — reconcile via getPayment, do not retry create
}

Multi-Gateway Usage

import {
  createPaymentClient,
  moyasarGateway,
  paymobGateway,
  paypalGateway,
  stripeGateway,
} from "@paykernel/core";

const client = createPaymentClient({
  gateways: {
    moyasar: moyasarGateway({ secretKey: "..." }),
    paypal: paypalGateway({ clientId: "...", clientSecret: "..." }),
    paymob: paymobGateway({
      secretKey: "...",
      publicKey: "...",
      hmacSecret: "...",
      integrationId: 123456,
      authIntegrationId: 456789, // for capture: false auth/capture flows
      region: "ksa",
      timeoutMs: 30000,
    }),
    stripe: stripeGateway({
      secretKey: "sk_...", // required — this package is server-side only
      publishableKey: "pk_...", // optional here; browser Stripe.js / Elements only
      webhookSecret: "whsec_...",
    }),
  },
  defaultGateway: "moyasar",
});

// Use default gateway
await client.createPayment({ amount: money("10.00", "SAR"), currency: "SAR", callbackUrl: "https://example.com/callback", moyasarSource: { type: "token", token: "token_xxx" } });

// Specify gateway explicitly
await client.createPayment({ amount: money("10.00", "USD"), currency: "USD", returnUrl: "https://example.com/return", cancelUrl: "https://example.com/cancel" }, "paypal");

// Stripe Checkout Example — Phase 6 outcome union (see hosted-checkout.md).
// Create success is not paid settlement. Caller idempotencyKey is required.
const stripe = client.gateway("stripe");
const result = await stripe.createCheckoutSession({
  successUrl: "https://example.com/success",
  cancelUrl: "https://example.com/cancel",
  mode: "payment",
  metadata: { paymentId: "order_123" },
  idempotencyKey: crypto.randomUUID(),
  lineItems: [
    {
      priceData: {
        currency: "USD",
        productData: {
          name: "T-Shirt",
        },
        amount: money("20.00", "USD"),
      },
      quantity: 10,
    },
  ],
});
if (result.outcome === "succeeded") {
  if (result.session.url) {
    redirect(result.session.url);
  }
  const sessionId = result.session.references.providerObjectId;
}

Multi-gateway: refund IDs and capture: false

The unified API hides provider differences, but IDs and auth flows are not interchangeable across gateways. Store the IDs each provider expects for later capture / refund / void:

| Topic | Moyasar | PayPal | Paymob | Stripe | | -------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | Refund target ID | Payment UUID from create / webhook | Capture ID (not order ID) from capturePayment() | Numeric transaction ID from webhook/dashboard (not intention pi_...) | PaymentIntent pi_... (or related charge, per Stripe docs) | | capture: false | Auth-only payment; later capturePayment / voidPayment on that payment ID | AUTHORIZE intent order → customer approves → authorizePaymentcapturePayment / void on authorization ID | Sends is_auth: true and uses authIntegrationId (or auth method override); capture/void use transaction ID | PaymentIntent with manual capture; later capturePayment / cancel (void) on pi_... |

Always use the gateway that created the payment for follow-up calls (refundPayment(..., 'paypal'), etc.). See each gateway doc for edge cases (partial refunds, currency, idempotency).

  • After-hooks cannot undo money. Hooks that run after a successful create/capture/refund/void (onAfter, afterCapture, …) may throw or return { proceed: false }, but the provider side effect already happened — the SDK logs and still returns success (no PaymentAbortedError, no reverse of the charge). Use before-hooks to abort, and reconcile in your app if an after-hook fails. Details: hooks.
  • Never fulfill in onWebhookVerified. Claim via @paykernel/webhooks first. Fulfill only on rematched payment.succeeded | capture.completed and payment.status === "paid", binding gatewayPaymentId. Homemade alreadyProcessed(event.id) in the verify hook is not a lease. Details: webhooks, getting-started.
  • Prefer Phase 7 PaymentEvent for new fulfillment. WebhookEvent.type stays provider-native; use attachPaymentEvent / event.event and switch on stable names (payment.succeeded, …) plus nested payment.status === "paid". Persist via toPersistedPaymentEventEnvelope (never store rawPayload by default). Details: webhook-events.
  • Use money("10.50", "SAR")AmountInput = Money only. Shared helpers convert decimal strings to bigint minor units (never amount * 100 float math). money(number) still constructs Money (e.g. money(10.5, "SAR")) but payment APIs reject number — pass Money only (see Migrating to 1.0). moneyToMajorNumber is display-only (float risk). See Safe Money Model.

Use this before going live. Gateway-specific details live under docs/.

| Check | Why | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | PayPal webhookId | Required for verifyWebhookAsync / handleWebhook. Missing config throws InvalidRequestError (paypal.webhookId is required…). Set from the PayPal Developer Dashboard webhook. | | Moyasar / Paymob idempotencyStore (multi-worker) | In-memory store is per process only. Multi-worker, serverless, or restart-safe capture/refund/void needs a shared store (Redis/DB) with atomic reserve where available. Configure moyasar.idempotencyStore / paymob.idempotencyStore and pass idempotencyKey on mutations. | | Raw body for webhooks | Stripe and PayPal verification need the unparsed request body (string/Buffer). Do not JSON.parse first; prefer client.handleWebhook(gateway, rawBody, signatureOrHeaders). | | Fulfill on paid outcome / status, not success | success: true means API call OK (can be pending / 3DS / approved). Prefer Phase 6 isPaidOutcome(result) or outcome === 'succeeded' with status === 'paid' only (approved / authorized are not paid-like). Never treat indeterminate as paid or as a definitive decline — reconcile. See operation-results.md. | | Amounts / money model | AmountInput = Money only — pass money("10.50", "SAR"). money(number) still constructs Money but payment APIs reject number (see 1.0 migration). moneyToMajorNumber is display-only. Conversion uses bigint minor units + ISO exponents. See money.md. | | Inbox claim, then rematch + bind | handleWebhook verifies only. Claim via @paykernel/webhooks, then fulfill only on rematched payment.succeeded / capture.completed and payment.status === "paid", binding gatewayPaymentId. Never fulfill in onWebhookVerified. See getting-started. | | Secret keys server-side only | Never put secretKey / clientSecret / whsec_… / HMAC secrets in browser code. |

Keys: secret vs publishable

This package is server-side only. Configure secret keys (secretKey, PayPal clientSecret, Stripe sk_… / whsec_…, Moyasar sk_…, Paymob secretKey / hmacSecret) on the backend.

Publishable / public keys (publishableKey, Paymob publicKey) are for browser SDKs (Stripe.js, Elements, Paymob.js, etc.). They are optional on PaymentClient config and are not used for create/capture/refund/void or webhook verification in this package. Do not put secret keys in client-side code.

Stripe Webhook Note

For Stripe webhooks, you MUST pass the raw request body to handleWebhook (or verifyWebhook if you verify manually). If your framework parses JSON automatically, access the raw body buffer or string before parsing; Buffer payloads are verified using their original bytes. Prefer client.handleWebhook('stripe', rawBody, signature) — it verifies, parses, and runs webhook hooks. Stripe webhook verification fails closed when webhookSecret is not configured. Stripe webhook parsing expects snapshot events with data.object; hydrate thin events before passing them to parseWebhookEvent. Checkout, invoice, and subscription webhooks normalize gatewayPaymentId to the related PaymentIntent, SetupIntent, or Subscription when Stripe includes one.

import {
  createWebhookInboxEngine,
  resolveInboxPayloadHash,
} from "@paykernel/webhooks";

type Order = { orderId: string; gatewayPaymentId?: string };
declare const engine: ReturnType<typeof createWebhookInboxEngine>;
declare function findOrderByGatewayPaymentId(id: string): Order | undefined;
declare function findOrderById(orderId: string): Order | undefined;
declare function fulfillOrder(order: Order, gatewayPaymentId: string): Promise<void>;
declare function mapInboxOutcome(outcome: unknown): { received: true };

function isPaidFulfillmentEvent(event: unknown): boolean {
  if (event === null || typeof event !== "object") return false;
  const rec = event as { type?: unknown; payment?: { status?: unknown } };
  return (
    (rec.type === "payment.succeeded" || rec.type === "capture.completed") &&
    rec.payment?.status === "paid"
  );
}

/** Bind webhook PI first. Metadata orderId must not fulfill a different stored PI. */
function findOrderForEvent(
  webhookEvent: { gatewayPaymentId?: string; paymentId?: string },
  _event: unknown,
): { kind: "ok"; order: Order } | { kind: "mismatch" } | { kind: "missing" } {
  const webhookPi =
    typeof webhookEvent.gatewayPaymentId === "string" &&
    webhookEvent.gatewayPaymentId.length > 0
      ? webhookEvent.gatewayPaymentId
      : undefined;
  if (webhookPi === undefined) return { kind: "missing" };
  const byGw = findOrderByGatewayPaymentId(webhookPi);
  if (byGw) return { kind: "ok", order: byGw };
  const candidate = webhookEvent.paymentId
    ? findOrderById(webhookEvent.paymentId)
    : undefined;
  if (!candidate) return { kind: "missing" };
  if (candidate.gatewayPaymentId === undefined) {
    candidate.gatewayPaymentId = webhookPi;
    return { kind: "ok", order: candidate };
  }
  if (candidate.gatewayPaymentId === webhookPi) {
    return { kind: "ok", order: candidate };
  }
  return { kind: "mismatch" };
}

// Example using Elysia / Fetch-style handlers
app.post("/webhook/stripe", async ({ request }) => {
  const signature = request.headers.get("stripe-signature") ?? undefined;
  const rawBody = await request.text(); // raw body — do not JSON.parse first

  const webhookEvent = await client.handleWebhook("stripe", rawBody, signature);
  // handleWebhook verifies only. Never fulfill here / never if (result.success)
  // / never on event.status === "paid" alone (Paymob redirect can lie).
  const outcome = await engine.processVerified({
    gateway: "stripe",
    providerEventId: webhookEvent.id,
    payloadHash: resolveInboxPayloadHash({
      eventPayloadHash: webhookEvent.payloadHash,
      payloadForHash: webhookEvent.rawPayload ?? webhookEvent,
    }),
    event: webhookEvent.event ?? webhookEvent,
    handler: async (ctx) => {
      if (!isPaidFulfillmentEvent(ctx.event)) return;
      const gatewayPaymentId = webhookEvent.gatewayPaymentId;
      if (typeof gatewayPaymentId !== "string" || gatewayPaymentId.length === 0) {
        return;
      }
      const found = findOrderForEvent(webhookEvent, ctx.event);
      if (found.kind === "mismatch") return;
      if (found.kind === "missing") {
        throw new Error("no local order for paid webhook");
      }
      await fulfillOrder(found.order, gatewayPaymentId);
    },
  });
  // Map inbox outcome → HTTP. Never silent-ACK handler_failed retryable.
  return mapInboxOutcome(outcome);
});

Error Handling

import {
  PaymentError,
  PaymentAbortedError,
  GatewayNotConfiguredError,
  InvalidWebhookError,
  GatewayApiError,
  CardDeclinedError,
  InsufficientFundsError,
  RateLimitError,
} from '@paykernel/core';

try {
  await client.createPayment({ ... });
} catch (error) {
  if (error instanceof PaymentAbortedError) {
    // Aborted by a hook
    console.log('Aborted:', error.message);
  } else if (error instanceof GatewayApiError) {
    // Gateway API returned an error
    console.log('Gateway error:', error.rawError);
  } else if (error instanceof PaymentError) {
    // Other payment error
    console.log('Error code:', error.code);
  }
}

License

MIT