Compose PayKernel from the public packages. Every symbol below is exported from the package named in the import. All packages are published (core 1.0.0, others 0.1.x; Tap/MyFatoorah 1.0.2, Hesabe 0.1.1). bun add @paykernel/core works.
What to install
| You need | Packages |
|---|---|
| Create / capture / refund / verify webhooks | @paykernel/core |
| Deduped, leased webhook fulfillment | core + @paykernel/webhooks + one @paykernel/store-* |
| Recover after timeout / indeterminate create | add @paykernel/reconciliation (same store) |
Pick a gateway before createPayment |
optional @paykernel/routing |
| Metrics / spans | optional @paykernel/opentelemetry |
| App tests without live PSPs | @paykernel/testkit (dev) |
Core never depends on webhooks, stores, routing, or OpenTelemetry. You wire those at the application layer.
Which store: Adapter selection. This walkthrough uses PostgreSQL (pg) because it is the general multi-host default. Local SQLite is single-host only. Redis is optional. D1 and Durable Objects are Cloudflare-only.
1. First payment (core only)
import { createPaymentClient, money, isPaidOutcome, moyasarGateway } from "@paykernel/core";
const client = createPaymentClient({
gateways: {
moyasar: moyasarGateway({
secretKey: process.env.MOYASAR_SECRET_KEY!,
webhookSecret: process.env.MOYASAR_WEBHOOK_SECRET,
}),
},
defaultGateway: "moyasar",
});
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"` and status `paid`).
} else if (result.outcome === "indeterminate" || result.reconciliationRequired) {
// Charge may already exist. Schedule reconcile. Do not createPayment again.
} else {
// declined / pending / authorized — see /guides/outcomes
}success: true is not the fulfillment signal (the field is removed in 1.0). Prefer isPaidOutcome and the outcome discriminant. Full rules: Outcomes, Money.
2. Verify a webhook (still core only)
PaymentClient.handleWebhook verifies, normalizes, and runs hooks. It does not claim an inbox, dedupe across workers, or decide HTTP status.
const webhookEvent = await client.handleWebhook("moyasar", rawBody, signature);
// webhookEvent.event is the Phase 7 PaymentEvent when mapping succeeds.3. Production composition (inbox + durable store)
bun add @paykernel/core @paykernel/webhooks @paykernel/store-postgres @paykernel/integration-http pgMigrate explicitly. Importing a store package does not apply DDL. Run migratePostgresAdapter in ops / CI — never on import or inside the request factory.
import { createPaymentClient, stripeGateway } from "@paykernel/core";
import { createWebhookInboxEngine } from "@paykernel/webhooks";
import {
createPostgresStoresFromPg,
createPgPostgresExecutor,
migratePostgresAdapter,
} from "@paykernel/store-postgres/pg";
import { processWebhookHttp } from "@paykernel/integration-http";
import { Pool } from "pg";
type Order = { orderId: string; gatewayPaymentId?: string };
declare function findOrderByGatewayPaymentId(id: string): Order | undefined;
declare function findOrderById(orderId: string): Order | undefined;
declare function fulfillOrder(order: Order, gatewayPaymentId: string): Promise<void>;
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" };
}
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Ops / CI only — never on import or inside the request factory.
const executor = createPgPostgresExecutor(pool);
await migratePostgresAdapter(executor);
const stores = createPostgresStoresFromPg({ client: pool });
const client = createPaymentClient({
gateways: {
stripe: stripeGateway({
secretKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
}),
},
defaultGateway: "stripe",
// no onWebhookVerified fulfillment — fulfillment belongs only
// in the handler passed to processWebhookHttp after the inbox claim.
});
// `inline` does not require a processRetryable worker.
// `durable_retry` is only safe to ACK if that worker is guaranteed.
const engine = createWebhookInboxEngine({
store: stores.webhookInbox,
mode: "inline",
});
export async function onStripeWebhook(rawBody: string, signature: string) {
// Raw-body-safe: pass raw string directly; do not JSON.parse/stringify.
const result = await processWebhookHttp({
gateway: "stripe",
rawBody,
headers: { "stripe-signature": signature },
client,
engine,
handler: async (ctx) => {
if (!isPaidFulfillmentEvent(ctx.event)) return;
const event = ctx.event as {
payment?: {
references?: { providerObjectId?: string; internalReference?: string };
};
};
const gatewayPaymentId = event.payment?.references?.providerObjectId;
if (typeof gatewayPaymentId !== "string" || gatewayPaymentId.length === 0) {
return;
}
const found = findOrderForEvent(
{
gatewayPaymentId,
paymentId: event.payment?.references?.internalReference,
},
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);
},
});
return { status: result.status };
}Hash rule: prefer webhookEvent.payloadHash. If you must hash, hash the same object shape the gateway hashed (rawPayload / parsed event). Do not fall back to hashWebhookPayload(rawBody) — that mix is payload_conflict / idle supersede. resolveInboxPayloadHash throws if both inputs are missing. processWebhookHttp applies this internally.
Paymob keys: pass event: webhookEvent.event so domain status (payment.status / refund.status) qualifies paymob:TRANSACTION:{id}:{status}. A later void on the same transaction id is not already_completed. Redirect WebhookEvent.id is {txn}:redirect (processed keeps the raw txn id) so raw event.id values do not collide. Still do not inbox-dedupe on the unsigned Paymob txn id alone.
Fulfill only when the rematched stable type is still payment.succeeded / capture.completed and nested payment.status === "paid", then bind gatewayPaymentId. Do not fulfill on payment.processing, partially_captured, or Paymob TRANSACTION_RESPONSE. Never fulfill in onWebhookVerified.
Framework helpers (raw body, then verify + claim):
- Express:
expressRawJson()only on the webhook route, thenexpressWebhookfrom@paykernel/integration-express - Hono:
honoWebhookfrom@paykernel/integration-hono - Elysia:
elysiaWebhookwithparse: "none"from@paykernel/integration-elysia - Workers:
handleCloudflareWebhookfrom@paykernel/integration-cloudflare-workers
HTTP mapping lives in @paykernel/integration-http, not in @paykernel/webhooks. Default processWebhookHttp ack policy is fail-closed provider_redelivery (never 200 for scheduled_for_retry). Pass { kind: "durable_worker" } only when a processRetryable worker is guaranteed.
Inbox modes, outcomes, and crash matrix: Webhooks.
4. Indeterminate create → reconcile (do not charge again)
Timeouts, dropped connections after POST, mutating HTTP 2xx with unreadable JSON, and outcome: "indeterminate" mean the provider may already have the charge.
import {
createPaymentReconciler,
createReconciliationScheduler,
createGetPaymentLookupPort,
buildReconciliationTarget,
buildProviderPaymentSnapshot,
decideReconciliationPolicy,
type ReconciliationTarget,
} from "@paykernel/reconciliation";
import {
isMoney,
isPaymentDomainStatus,
type GatewayPaymentResult,
} from "@paykernel/core";
function publishableCurrency(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/** Provider recon snapshot from `getPayment` Money only. Incomplete money fails closed. */
function providerSnapshotFromGetPayment(got: GatewayPaymentResult) {
if (!got.gatewayId) return undefined;
if (!isPaymentDomainStatus(got.status)) return undefined;
const currency = publishableCurrency(got.currency);
const hasAmountLike =
got.amount !== undefined ||
got.capturedAmount !== undefined ||
got.refundedAmount !== undefined;
if (hasAmountLike && currency === undefined) return undefined;
const amount = isMoney(got.amount) ? got.amount : undefined;
if (amount === undefined) return undefined;
const input: Parameters<typeof buildProviderPaymentSnapshot>[0] = {
gatewayPaymentId: got.gatewayId,
status: got.status,
amount,
providerStatus: got.status,
};
if (got.capturedAmount !== undefined) {
const captured = isMoney(got.capturedAmount) ? got.capturedAmount : undefined;
if (captured === undefined) return undefined;
input.capturedAmount = captured;
}
if (got.refundedAmount !== undefined) {
const refunded = isMoney(got.refundedAmount) ? got.refundedAmount : undefined;
if (refunded === undefined) return undefined;
input.refundedAmount = refunded;
}
return buildProviderPaymentSnapshot(input);
}
const scheduler = createReconciliationScheduler({
store: stores.reconciliation,
});
const reconciler = createPaymentReconciler({
lookup: createGetPaymentLookupPort({
getPayment: async ({ gateway, gatewayPaymentId }) => {
const got = await client.getPayment({ gatewayPaymentId }, gateway);
// Never copy catalog / local trusted amount onto the provider snapshot.
// Publish currency with every major-unit field; omit incomplete money.
return providerSnapshotFromGetPayment(got);
},
}),
});
async function createOrSchedule(
params: Parameters<typeof client.createPayment>[0],
gateway = "stripe" as const,
) {
const result = await client.createPayment(params, gateway);
if (result.outcome === "indeterminate" || result.reconciliationRequired) {
await scheduler.schedule({
target: buildReconciliationTarget({
gateway,
gatewayPaymentId: result.gatewayId,
idempotencyKey: params.idempotencyKey,
expected: { status: "pending" },
}),
runAt: new Date().toISOString(),
reason: "indeterminate_create",
});
}
return result;
}
// Worker: the store row is subjectId + reason, not a serialized target.
// Keep enough app state (or a stable key) to rebuild ReconciliationTarget.
async function loadTarget(job: { record: { subjectId: string } }): Promise<ReconciliationTarget> {
return buildReconciliationTarget({
gateway: "stripe",
gatewayPaymentId: job.record.subjectId,
expected: { status: "pending" },
});
}
// Production poll loop: processDue claims immediately before each handler
// and auto-renews on leaseMs/3. claimDue is discovery / test inspection only —
// do not claimDue({ limit: N }) then serial-run handlers on that array
// (later default-30s leases expire; a peer can steal them).
await scheduler.processDue({
limit: 10,
handler: async (job) => {
const target = await loadTarget(job);
const result = await reconciler.reconcile(target);
const decision = decideReconciliationPolicy(result, target);
if (decision.action === "mark_consistent" && decision.safe) {
return { disposition: "complete" };
}
if (
(decision.action === "update_local_to_paid" ||
decision.action === "update_local_to_failed") &&
decision.safe
) {
// Apply the local paid/failed update in YOUR app first, then complete.
return { disposition: "complete" };
}
if (decision.action === "do_not_create_replacement") {
// Never createPayment again. Reschedule lookup.
return { disposition: "retry", error: new Error(decision.reason) };
}
if (decision.action === "retry_later") {
return { disposition: "retry_later", error: new Error("retry_later") };
}
return { disposition: "manual_review", note: decision.action };
},
});The store does not persist the full target. Rebuild it from subjectId / your order table. Do not complete on raw result.outcome === "consistent" — sparse pending vs provider pending is still settling (retry_later).
Invariant: lookup + decide, never a second charge.
createPayment / capture / refund that may have reached the provider return outcome: "indeterminate" (and often reconciliationRequired: true) instead of throwing. BaseGateway maps NetworkError.afterProviderSubmit (timeouts, 5xx, dropped connections after mutating POST, Moyasar mutating HTTP 200 + invalid JSON) into that result — those paths do not throw to the caller. Pre-submit / GET NetworkError still throws.
5. Optional: select a gateway first
import { createPaymentRouter, route } from "@paykernel/routing";
import { money } from "@paykernel/core";
const router = createPaymentRouter({
rules: [route({ currency: "SAR" }).to("moyasar")],
fallback: "stripe", // select-time only — not post-attempt recovery
});
const amount = money("10.00", "SAR");
const decision = router.select({
currency: amount.currency,
amount,
});
const gateway = decision.gateway;
if (gateway !== "moyasar" && gateway !== "stripe") {
throw new Error(`router selected unregistered gateway: ${gateway}`);
}
await client.createPayment(
{
amount,
currency: amount.currency,
orderId: "o1",
callbackUrl: "https://example.com/callback",
},
gateway,
);If input.currency and amount.currency are both set and differ, select throws NoRouteMatchError with reason currency_mismatch_honesty. Pass money() (or the same currency on both fields). After a timeout or indeterminate attempt, do not select another gateway. See Composition.
Failure path (inbox)
| Outcome | Meaning | Typical HTTP (you map this) |
|---|---|---|
processed |
Handler ran; complete succeeded |
200 |
duplicate_completed |
Same event already done | 200 |
scheduled_for_retry |
Read reason (parked / handler_retry / not_available) |
200 if a worker owns the row; 5xx for not_available without a worker |
handler_failed |
Handler threw; retryable says whether to redeliver |
5xx if retryable |
invalid_webhook |
Bad input / forgery class | 4xx |
payload_conflict |
Hash mismatch on an active lease | 409 |
Silent ACK of failed work is forbidden. mapInboxOutcome in @paykernel/integration-http is the published mapper. Full matrix: Webhooks.
Next
- Adapter selection
- Composition
- Outcomes
- Money
- Webhooks
- Examples — checkout kernel + Bun Hono/Elysia + Express + Cloudflare Workers + Postgres
- Gateways: Moyasar · PayPal · Paymob · Stripe