PayKernel is composed at the application layer. @paykernel/core never depends on webhooks, stores, routing, or OpenTelemetry. Published versions: core 1.0.0, gateways 1.0.2/0.1.1, testkit 1.0.2, others 0.1.x.
@paykernel/core verify, money, createPayment, PaymentEvent
↑
@paykernel/webhooks inbox engine + WebhookInboxStore types
@paykernel/reconciliation lookup + decideReconciliationPolicy + scheduler
@paykernel/routing select-only gateway choice
@paykernel/opentelemetry metrics / spans (optional; folder is packages/observability)
↑
@paykernel/store-* durable adapters (migrate explicitly)
@paykernel/integration-* HTTP mapping + framework raw-body helpersPick a store with Adapter selection. This page uses PostgreSQL as the general multi-host default. Full copy-paste walkthrough: Getting started.
Production stack
| Layer | Package | Role |
|---|---|---|
| Client | @paykernel/core |
createPaymentClient + gateway factories (moyasarGateway, stripeGateway, paypalGateway, paymobGateway). Extra gateways: @paykernel/gateway-tap, @paykernel/gateway-myfatoorah, @paykernel/gateway-hesabe (not BuiltInGatewayName). |
| Inbox | @paykernel/webhooks |
createWebhookInboxEngine({ store, mode }) — inline or durable_retry |
| HTTP | @paykernel/integration-http |
processWebhookHttp, mapInboxOutcome |
| Store | @paykernel/store-postgres (or another adapter) |
webhookInbox + reconciliation + idempotency. Migrate explicitly. |
| Reconcile | @paykernel/reconciliation |
createPaymentReconciler + createReconciliationScheduler + decideReconciliationPolicy |
| Route | @paykernel/routing (optional) |
createPaymentRouter + route(...).to(...) — select-time only |
| Telemetry | @paykernel/opentelemetry (optional) |
Metrics / spans. Core has no @opentelemetry/* dependency. Subpath: @paykernel/opentelemetry/otel. |
Do not install @paykernel/internal-sql-store. Relational adapters share schemas via @paykernel/sql-foundation.
Inbox + durable store
Migrate explicitly. Importing a store package does not apply DDL. Run migrate 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"
);
}
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 });
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",
});
const engine = createWebhookInboxEngine({
store: stores.webhookInbox,
mode: "inline", // does not require a processRetryable worker
});
export async function onStripeWebhook(rawBody: string, signature: string) {
const result = await processWebhookHttp({
gateway: "stripe",
rawBody, // do not JSON.parse/stringify
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 };
}inlinedoes not require aprocessRetryableworker.durable_retryis only safe to ACK if that worker is guaranteed (workerGuaranteed: true+ackPolicy: { kind: "durable_worker" }).- Default
processWebhookHttpack policy is fail-closedprovider_redelivery(never 200 forscheduled_for_retry). - Framework helpers:
honoWebhook,elysiaWebhook(parse: "none"),expressWebhook+expressRawJson(webhook route only),handleCloudflareWebhook.
Fulfill only when the rematched stable type is still payment.succeeded / capture.completed and nested payment.status === "paid", then bind gatewayPaymentId. Metadata orderId must not fulfill an order whose stored PI differs. See Webhooks.
Indeterminate create → reconcile
Timeouts, dropped connections after POST, and outcome: "indeterminate" mean the provider may already have the charge. Invariant: lookup + decide, never a second charge.
import {
createPaymentReconciler,
createReconciliationScheduler,
createGetPaymentLookupPort,
buildReconciliationTarget,
buildProviderPaymentSnapshot,
decideReconciliationPolicy,
type ReconciliationTarget,
} from "@paykernel/reconciliation";
import {
isMoney,
isPaymentDomainStatus,
type GatewayPaymentResult,
} from "@paykernel/core";
declare const params: Parameters<typeof client.createPayment>[0];
declare const gateway: "stripe";
function publishableCurrency(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
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);
}
async function loadTarget(job: { record: { subjectId: string } }): Promise<ReconciliationTarget> {
return buildReconciliationTarget({
gateway: "stripe",
gatewayPaymentId: job.record.subjectId,
expected: { status: "pending" },
});
}
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.
return providerSnapshotFromGetPayment(got);
},
}),
});
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",
});
}
// Production poll loop: processDue claims immediately before each handler
// and auto-renews on leaseMs/3. claimDue is discovery / test inspection only —
// do not serial-run handlers on a claimDue array.
await scheduler.processDue({
limit: 10,
handler: async (job) => {
const target = await loadTarget(job); // rebuild from subjectId — store does not persist the full target
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") {
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 };
},
});Do not complete on raw result.outcome === "consistent" — sparse pending vs provider pending is still settling (retry_later). Full worker: Getting started.
Routing (select ≠ execute)
@paykernel/routing chooses a gateway with explicit ordered rules. The package does not execute payments, and it does not automatically switch gateways after an unsafe or indeterminate attempt.
| Surface | Role |
|---|---|
| Rules | route(match).to(gateway) — currency, country, method, amount range, tenant, capabilities, merchant preference |
| Select | createPaymentRouter + pure select(input) → RoutingDecision with gateway always set on success |
| Select-time fallback | Optional fallback when no rule matches (not post-attempt recovery) |
| Post-attempt eligibility | isSafeFallbackEligible / evaluateFallback / classifySubmissionState — default-deny except not submitted / definitive pre-submit failure |
| Telemetry | decisionToTelemetryAttributes(decision) — non-sensitive gateway + match metadata |
import {
createPaymentRouter,
route,
decisionToTelemetryAttributes,
classifySubmissionState,
isSafeFallbackEligible,
evaluateFallback,
trySelectFallbackGateway,
} from "@paykernel/routing";
import { createOperationContext, money } from "@paykernel/core";
const router = createPaymentRouter({
rules: [
route({ currency: "SAR", paymentMethod: "mada" }).to("moyasar"),
route({ currency: "USD" }).to("stripe"),
],
fallback: "stripe", // select-time only
});
const amount = money("10.00", "SAR");
const decision = router.select({
currency: amount.currency,
paymentMethod: "mada",
amount,
});
const ctx = createOperationContext({
operationId: crypto.randomUUID(),
gateway: decision.gateway,
operationType: "payment.create",
});
decisionToTelemetryAttributes(decision);
void ctx;
const selected = decision.gateway;
if (selected !== "moyasar" && selected !== "stripe") {
throw new Error(`router selected unregistered gateway: ${selected}`);
}
await client.createPayment(
{
amount,
currency: amount.currency,
orderId: "o1",
callbackUrl: "https://example.com/callback",
},
selected,
);If input.currency and amount.currency are both set and differ, select throws NoRouteMatchError with reason currency_mismatch_honesty. Amount ranges use core toMinorUnits (bigint) — never float.
Design rules
- Select ≠ execute — pure, sync
select. The router never callscreatePayment/ capture / refund / network I/O. - Deterministic first-match — same rules + input → same decision (rule array order is significant).
- Select-time
fallbackonly — default gateway when no rule matches; never post-attempt recovery. - Post-attempt default-deny — only
not_submitted/pre_submission_failureare auto-eligible. - Never after indeterminate / timeout / connection_reset / uncertain 5xx / submitted without a loud expert override.
- Expert override is opt-in —
{ confirmUnsafeFallback: true, reason }only; never defaulted; baretruerejected. - No secret telemetry — gateway + match metadata only. Attributes exclude
tenantConfigdumps, secrets, PII, health/cost maps.
Post-attempt fallback (restricted)
const state = classifySubmissionState({ errorKind: "timeout" });
// state === "timeout" → NOT safe
if (isSafeFallbackEligible(state)) {
// only not_submitted | pre_submission_failure
}
const eligibility = evaluateFallback({
submissionState: state,
// expertOverride: { confirmUnsafeFallback: true, reason: "ops confirmed no charge" },
});
// eligibility.allowed === false for timeout without override — do not createPayment on another gateway
if (eligibility.allowed) {
const routeInput = { currency: amount.currency, amount };
const next = trySelectFallbackGateway(router, routeInput, eligibility, {
attemptedGateways: [decision.gateway],
});
const nextGateway = next.gateway;
if (nextGateway !== "moyasar" && nextGateway !== "stripe") {
throw new Error(`fallback selected unregistered gateway: ${nextGateway}`);
}
await client.createPayment(
{
amount,
currency: amount.currency,
orderId: "o1",
callbackUrl: "https://example.com/callback",
},
nextGateway,
);
}trySelectFallbackGateway re-validates isSafeFallbackEligible(submissionState) and does not trust a forged { allowed: true } without expertOverride: true.
SubmissionState |
Auto-fallback eligible? |
|---|---|
not_submitted |
Yes |
pre_submission_failure |
Yes |
submitted |
No |
indeterminate |
No |
timeout |
No |
connection_reset |
No |
provider_5xx_uncertain |
No |
AbortError / abort codes classify as indeterminate by default (not fallback-eligible). Use aborted_before_submit only when cancel is known pre-submit.
Non-goals: automatically retrying or routing an indeterminate payment to another gateway; router-owned mutation retries; a hard dependency on @paykernel/opentelemetry.
Failure path (inbox)
| Outcome | Meaning | Typical HTTP (mapInboxOutcome) |
|---|---|---|
processed |
Handler ran; complete succeeded |
200 |
duplicate_completed |
Same event already done | 200 |
scheduled_for_retry |
Read reason (parked / handler_retry / not_available) |
200 only with a guaranteed worker; 503 under default provider_redelivery |
handler_failed |
Handler threw; retryable says whether to redeliver |
5xx if retryable |
invalid_webhook |
Bad input / forgery class | 400 |
payload_conflict |
Hash mismatch on an active lease | 409 |
Silent ACK of failed work is forbidden. See Webhooks.
Related
- Getting started — full inbox + Postgres + reconcile samples (
isPaidFulfillmentEvent,findOrderForEvent) - Runtime — injectable fetch / clock / crypto
- Best practices
- Examples