This page maps breaking changes from 0.x to the 1.0 API now published (core 1.0.0, gateways 1.0.2/0.1.1, testkit 1.0.2). Each section has a Before (0.x) / After (1.0) snippet.
Stability policy (contributor docs on GitHub): Stability and ADR 0025.
1. Client construction
Before (0.x) — legacy constructor with provider keys:
import { PaymentClient } from "@paykernel/core";
const client = new PaymentClient({
moyasar: { secretKey: process.env.MOYASAR_SECRET_KEY! },
stripe: { secretKey: process.env.STRIPE_SECRET_KEY! },
defaultGateway: "moyasar",
});After (1.0) — createPaymentClient with gateway factories or a built registry. PaymentClient’s constructor is private; construct only via createPaymentClient.
import { createPaymentClient, createGatewayRegistry, moyasarGateway, stripeGateway } from "@paykernel/core";
const client = createPaymentClient({
gateways: {
moyasar: moyasarGateway({ secretKey: process.env.MOYASAR_SECRET_KEY! }),
stripe: stripeGateway({ secretKey: process.env.STRIPE_SECRET_KEY! }),
},
defaultGateway: "moyasar",
});
const registry = createGatewayRegistry()
.register(moyasarGateway({ secretKey: process.env.MOYASAR_SECRET_KEY! }))
.register(stripeGateway({ secretKey: process.env.STRIPE_SECRET_KEY! }))
.build();
const client2 = createPaymentClient({ registry, defaultGateway: "moyasar" });BuiltInGatewayMap remains as the default type-param fallback. MoyasarConfig has no sandbox field (removed in 1.0). Provide exactly one of registry or gateways. Mixing both, or omitting both, throws InvalidRequestError.
2. Numeric amounts → Money
Before (0.x) — number major units were accepted:
await client.createPayment({
amount: 10.5,
currency: "SAR",
callbackUrl: "https://example.com/callback",
});
const result: GatewayPaymentResult = await client.createPayment(params);
console.log(result.amount); // number | undefinedAfter (1.0) — amount / capturedAmount / refundedAmount / fee / totalRefunded / WebhookEvent.amount are Money | undefined. Pass Money via money():
import { money } from "@paykernel/core";
await client.createPayment({
amount: money("10.50", "SAR"),
currency: "SAR",
callbackUrl: "https://example.com/callback",
});
const result: GatewayPaymentResult = await client.createPayment(params);
console.log(result.amount); // Money | undefined — { amount: "10.50", currency: "SAR" }AmountInputis nowtype AmountInput = Money(notnumber | Money).- Zod amount schemas accept only the
Moneyobject arm. normalizeAmountInput(input: Money, currency)throwsMoneyAmountErroronnumber.money()may still accept a cleannumberto constructMoney(for examplemoney(10.5, "SAR")), but payment APIs do not.currencyon create params is stillstring. Ifamount.currency(normalized) disagrees withparams.currency(case-insensitive),InvalidRequestErroris thrown.- Gateways convert provider integer minor units via
fromMinorUnits→Money;moneyToMajorNumberremains exported for display only.
See Money.
3. success → outcomes
Before (0.x) — success boolean on results:
const result = await client.createPayment({ amount: money("10.50", "SAR"), currency: "SAR", callbackUrl: "..." });
if (result.success) {
// API call ok — not necessarily paid
}After (1.0) — success is removed. outcome is required (PaymentOperationOutcome / RefundOperationOutcome). Use isPaidOutcome:
import { isPaidOutcome } from "@paykernel/core";
const result = await client.createPayment({ amount: money("10.50", "SAR"), currency: "SAR", callbackUrl: "..." });
switch (result.outcome) {
case "succeeded":
if (isPaidOutcome(result)) {
// fulfill — paid-like status (`paid` only)
}
break;
case "requires_action":
// 3DS / redirect
break;
case "declined":
case "failed":
case "indeterminate":
// handle — do not createPayment again on indeterminate
break;
}
console.log(isPaidOutcome(result)); // true only for outcome === "succeeded" and paid-like statusGatewayPaymentResult.successandGatewayRefundResult.successdeleted.applyOutcomeToGatewayResult/applyOutcomeToGatewayRefundResultno longer writesuccess.successFromOutcome/successFromRefundOutcomeare removed.isGatewayPaymentResultdiscriminant is now"outcome" in result && "gatewayId" in result && "status" in result(norawResponse).- Mock / testkit scripted results set
outcomerequired.
See Outcomes.
4. Webhook events
Before (0.x) — fulfill on WebhookEvent.type (provider-native) and persist rawPayload.
After (1.0) — fulfill on event / stableType (payment.succeeded + nested payment.status === "paid"). Persist via toPersistedPaymentEventEnvelope. rawPayload stays required request-local; do not persist raw.
import { toPersistedPaymentEventEnvelope } from "@paykernel/core";
const event: WebhookEvent = await client.handleWebhook("stripe", rawBody, signature);
if (event.event && event.event.type === "payment.succeeded" && event.event.payment.status === "paid") {
// Still claim the inbox before fulfill — see /guides/webhooks
}
if (event.stableType === "payment.succeeded" && event.event?.payment.status === "paid") {
// same rematch
}
const envelope = toPersistedPaymentEventEnvelope(event.event!, {
payloadHash: event.payloadHash,
rawForHash: event.rawPayload,
});
await inbox.persist(envelope); // rawPayload not persistedWebhookEvent.typestays provider-native; do not switch fulfillment on it alone.toPersistedPaymentEventEnvelopestrips raw and secrets.
See Webhooks.
5. Statuses
Before (0.x) — mega-union PaymentStatus included refund and setup members ("refund_completed", …).
After (1.0) — PaymentStatus is an alias for PaymentDomainStatus only. Refunds use RefundDomainStatus. Envelope status is WebhookEnvelopeStatus.
import type { PaymentStatus, RefundDomainStatus, WebhookEnvelopeStatus, PaymentDomainStatus } from "@paykernel/core";
const paymentStatus: PaymentStatus = "paid"; // PaymentDomainStatus
const refundStatus: RefundDomainStatus = "completed";
const envelopeStatus: WebhookEnvelopeStatus = "completed"; // PaymentDomainStatus | RefundDomainStatus | SetupTokenStatus- Delete
"refund_completed" | "refund_pending" | "refund_failed" | "setup_completed"fromPaymentStatus. export type PaymentStatus = PaymentDomainStatus.export type WebhookEnvelopeStatus = PaymentDomainStatus | RefundDomainStatus | SetupTokenStatus.MoyasarNextActionalias is removed; usePaymentNextAction.
6. Provider fields off common params
Before (0.x) — CreatePaymentParams was a mega-interface with provider keys (stripePaymentMethodId, moyasarSource, paymobIntegrationId, tokenId, …).
After (1.0) — CreatePaymentParams is only CommonPaymentInput + currency + callbackUrl + capture? + idempotencyKey? + customerId? + paymentMethodId? + offSession? + OperationRequestOptions. Provider fields move to per-gateway types:
import type {
MoyasarCreatePaymentParams,
StripeCreatePaymentParams,
PayPalCreatePaymentParams,
PaymobCreatePaymentParams,
} from "@paykernel/core";
import { money } from "@paykernel/core";
const stripeParams: StripeCreatePaymentParams = {
amount: money("10.50", "SAR"),
currency: "SAR",
callbackUrl: "https://example.com/callback",
stripePaymentMethodId: "pm_123",
stripeCustomerId: "cus_123",
stripeSetupFutureUsage: "on_session",
};
const moyasarParams: MoyasarCreatePaymentParams = {
amount: money("10.50", "SAR"),
currency: "SAR",
callbackUrl: "https://example.com/callback",
moyasarSource: { type: "token", token: "tok_..." },
applyCoupon: true,
};
const paypalParams: PayPalCreatePaymentParams = {
amount: money("10.50", "USD"),
currency: "USD",
callbackUrl: "https://example.com/callback",
returnUrl: "https://example.com/return",
cancelUrl: "https://example.com/cancel",
paypalShippingPreference: "NO_SHIPPING",
};
const paymobParams: PaymobCreatePaymentParams = {
amount: money("10.50", "SAR"),
currency: "SAR",
callbackUrl: "https://example.com/callback",
paymobIntegrationId: "123",
paymobPaymentMethods: ["card"],
paymobIframeId: "123",
paymobBillingData: { email: "a@b.com", firstName: "A", lastName: "B", phone: "+966..." },
};tokenIdis deleted; usemoyasarSource: { type: "token", token }.
7. Idempotency
Two layers stay distinct (core must not import @paykernel/store-contracts):
Before (0.x) — IdempotencyStore.reserve and PaymobIdempotencyStore.reserve were optional.
After (1.0) — reserve is required:
import { InMemoryIdempotencyStore, type IdempotencyStore } from "@paykernel/core";
const store: IdempotencyStore = {
get: async () => undefined,
set: async () => {},
delete: async () => {},
reserve: async (key, record) => {
// atomic reserve via Redis SET NX or DB unique constraint
return undefined;
},
};
const mem = new InMemoryIdempotencyStore(); // still implements reserve (single-process only)
import type { PaymobIdempotencyStore } from "@paykernel/core";
const paymobStore: PaymobIdempotencyStore = {
get: async () => undefined,
set: async () => {},
delete: async () => {},
reserve: async (key, record) => undefined, // required
};- Tests that built a store
{ get, set, delete }withoutreserveno longer typecheck. Construction throwsInvalidRequestErrorifreserveis missing. - Do not replace gateway fencing with
@paykernel/store-contractsLeaseAwareIdempotencyStore. App inbox/recon/durable mutation idempotency: lease-aware stores from@paykernel/store-*. Gateway mutation fencing (MoyasarConfig.idempotencyStore,PaymobConfig.idempotencyStore): coreIdempotencyStorewith requiredreserve. InMemoryIdempotencyStoreis single-process only — not a multi-host production store.
Checklist
- Replace
new PaymentClient({ ... })withcreatePaymentClient({ gateways: { ... } }). - Replace
amount: 10.5withmoney("10.50", "SAR")and update result handling forMoney. - Replace
result.successchecks withresult.outcome/isPaidOutcome. - Update webhook handlers to use
event/stableTypeandtoPersistedPaymentEventEnvelope. Claim the inbox before fulfill. - Update
PaymentStatususages toPaymentDomainStatus/WebhookEnvelopeStatusas appropriate. - Move provider fields to per-gateway param types and remove
tokenId. - Ensure
IdempotencyStoreandPaymobIdempotencyStoreimplementreserve. - Remove
expectedAmountMinor/ScriptedStepfixtures. Use@paykernel/testkitmockGatewayscripted{ outcome }steps and (for conformance)fixtures.amountCases. There is nomoneyCasesexport.
See Getting started, Outcomes, and Money.