Prefer outcome discrimination after createPayment / capturePayment / getPayment. Hosted checkout, customers, disputes, and payment links are outcome-only — they have no success boolean. Checkout create success is not paid; use isHostedCheckoutRedirect then fulfill from payment settlement.
success: boolean was removed in 1.0. Never branch on success. Use isPaidOutcome / outcome === "succeeded" with paid-like status (paid only).
Why not API-ok alone?
Historically gateways set success: true when the HTTP/API call completed without a transport failure — including pending, 3DS / requires_action, and authorized holds. That is not the same as “customer paid; fulfill the order.”
| Signal | Meaning |
|---|---|
success: true (removed in 1.0) |
API call OK — deprecated for fulfillment |
outcome: "succeeded" |
Operation completed in a terminal success sense (may still be auth-only — check status) |
outcome: "requires_action" |
Customer must complete 3DS, redirect, OTP, or client SDK confirm |
outcome: "declined" |
Definitive issuer/provider decline |
outcome: "failed" |
Definitive failure |
outcome: "indeterminate" |
Uncertain after submit — must reconcile; never treat as decline or paid |
Fulfill only when money is settled:
import {
isPaidOutcome,
isRequiresActionOutcome,
isIndeterminateOutcome,
mapGatewayResultToOperationResult,
} from "@paykernel/core";
const result = await client.createPayment(params);
if (isPaidOutcome(result)) {
// status paid + outcome succeeded (approved/authorized are never paid)
await fulfillOrder(result.gatewayId);
} else if (isRequiresActionOutcome(result)) {
// redirect / OTP / Stripe confirm — do not fulfill
} else if (isIndeterminateOutcome(result)) {
// do NOT mark failed; reconcile with getPayment / webhooks
// do NOT createPayment again
}isPaidOutcome is true only when outcome === "succeeded" and paid-like status (paid only). Auth holds (authorized), buyer approval (approved), pending, requires_action, declined, failed, and indeterminate all return false. reconciliationRequired: true always returns false even if outcome/status look settled.
Post-submit transport: createPayment / capturePayment / refundPayment / voidPayment no longer throw NetworkError when the mutating HTTP request may already have been accepted (timeout, connection drop, or 5xx after POST). BaseGateway returns outcome: "indeterminate" + reconciliationRequired: true. Preflight auth and GET still throw NetworkError. Caller abort before submit still throws PaymentAbortedError. Caller abort after a mutating POST maps to NetworkError with afterProviderSubmit: true.
PaymentOperationResult arms
Preferred union (via mapGatewayResultToOperationResult):
type PaymentOperationResult =
| { outcome: "succeeded"; payment: Payment }
| { outcome: "requires_action"; payment: Payment; action: PaymentAction }
| { outcome: "declined"; failure: PaymentDecline; payment?: Payment }
| { outcome: "failed"; error: PaymentErrorLike; payment?: Payment }
| {
outcome: "indeterminate";
reconciliationRequired: true;
providerRequestId?: string;
payment?: Payment;
message?: string;
};Succeeded (paid):
const op = mapGatewayResultToOperationResult(result, { gateway: "stripe" });
if (op.outcome === "succeeded" && op.payment.status === "paid") {
await fulfillOrder(op.payment.references.providerObjectId);
}
// Auth hold: outcome can be "succeeded" with status "authorized" — isPaidOutcome is false
// Partial capture: bare status "partially_captured" infers requires_action (open money).
// Successful void: outcome "succeeded" + status "cancelled" stays succeeded on
// map/infer (not coerced to failed); isPaidOutcome remains false.Requires action (3DS / redirect):
if (op.outcome === "requires_action") {
// op.action: { type: "redirect", url } | use_stripe_sdk | stcpay_otp | …
return respondWithNextAction(op.action);
}Declined:
if (op.outcome === "declined") {
// op.failure.code / message — not a transport error
return showDecline(op.failure);
}Indeterminate (must reconcile):
if (op.outcome === "indeterminate") {
// op.reconciliationRequired === true always
await scheduleReconciliation(op.payment?.references);
// Never mark order failed or paid from this arm alone
}Helpers (1.0)
| Helper | Role |
|---|---|
isPaidOutcome(result) |
outcome === "succeeded" and paid-like status (paid only; not approved / authorized) |
isRequiresActionOutcome(result) |
Customer action required |
isIndeterminateOutcome(result) |
Explicit indeterminate / must reconcile |
mapGatewayResultToOperationResult(result) |
Gateway shape → preferred union |
applyOutcomeToGatewayResult(base, outcome) |
Write outcome + references (and reconciliationRequired for indeterminate) |
inferOperationOutcome(result) |
Infer when gateway has not set outcome yet |
buildProviderReferences(input) |
Structured provider IDs |
applyOutcomeToGatewayResult writes outcome + references — no success. successFromOutcome / successFromRefundOutcome were removed in 1.0.
Throw vs outcome
Aligned with Engineering Rule 3 (uncertain outcomes must not become failure):
- Pre-submit / validation / auth config may throw (
InvalidRequestError,GatewayNotConfiguredError, …). - Transport errors before the provider may have accepted the mutation may throw (
NetworkError). - After submit is ambiguous (timeout after request may have been accepted, unknown idempotency replay): return
outcome: "indeterminate"withreconciliationRequired: true. Do not map tofailed/ decline. - Hard declines may appear as
outcome: "declined"or as thrownCardDeclinedError/InsufficientFundsError. Integrators should handle both until gateways fully migrate to outcome arms.
The testkit encodes this:
{ outcome: "timeout" }/{ outcome: "network_error" }→ throwNetworkError{ outcome: "indeterminate" }→ result withoutcome: "indeterminate",reconciliationRequired: true{ outcome: "provider_ok_client_timeout" }→ provider-side paid success retained; client throwsNetworkError
Common inputs vs provider extensions (1.0)
CommonPaymentInput is the shared create shape without provider keys:
type CommonPaymentInput = {
amount: AmountInput; // money("10.50", "SAR")
orderId?: string;
description?: string;
metadata?: PaymentMetadata;
};CreatePaymentParams is closed in 1.0: only CommonPaymentInput + currency + callbackUrl + capture / idempotencyKey / customerId / paymentMethodId / offSession. Provider fields live on per-gateway MoyasarCreatePaymentParams, StripeCreatePaymentParams, PayPalCreatePaymentParams, PaymobCreatePaymentParams via createPaymentClient registry. tokenId was removed (use moyasarSource: { type: "token", token }).
| Union | Use for |
|---|---|
PaymentDomainStatus (= PaymentStatus) |
Charge / intent lifecycle (pending, paid, …) |
AuthorizationStatus |
Auth holds |
CaptureStatus |
Capture lifecycle |
RefundDomainStatus (= RefundStatus) |
Refund objects (pending, completed, failed) |
SetupTokenStatus |
Setup / vault |
WebhookEnvelopeStatus |
WebhookEvent.status (PaymentDomainStatus | RefundDomainStatus | SetupTokenStatus) |
GatewayPaymentStatus |
GatewayPaymentResult.status — envelope plus legacy refund_* / setup_completed aliases for gateway internal mapping |
DisputeStatus |
Disputes |
import { buildProviderReferences } from "@paykernel/core";
const references = buildProviderReferences({
gateway: "paypal",
gatewayId: orderId,
status: "pending",
orderId,
captureId,
authorizationId,
providerNativeStatus: "CREATED",
});| Field | Meaning |
|---|---|
providerObjectId |
Primary provider object id (also dual-written as gatewayId) |
providerRequestId |
Provider request / correlation id when available |
internalReference |
Merchant order / internal correlation |
parentId |
Parent resource when this is a child |
relatedIds |
order / capture / authorization / refund / charge / customer |
providerNativeStatus |
Unnormalized provider string |
normalizedStatus |
SDK-normalized status |
gateway |
Gateway id |
Refund outcomes (1.0)
Refunds use RefundOperationOutcome / RefundOperationResult via mapGatewayRefundToOperationResult:
outcome |
Typical status |
|---|---|
succeeded |
completed |
pending |
pending |
failed |
failed |
indeterminate |
(ambiguous) + reconciliationRequired: true |
| Helper | Role |
|---|---|
applyOutcomeToGatewayRefundResult(base, outcome) |
Write outcome (+ reconciliationRequired when indeterminate) |
inferRefundOperationOutcome(result) |
Infer / coerce when branching on refund outcomes |
mapGatewayRefundToOperationResult(result) |
Gateway refund shape → preferred refund union |
Bare refund status (no explicit outcome, no recon flag): completed infers succeeded, pending infers pending (not indeterminate), failed infers failed. Do not treat a pending refund as settled. Ambiguous only when reconciliationRequired or an explicit indeterminate marker is set.
applyOutcomeToGatewayResult / inferOperationOutcome coerce stored outcome against status:
| Input | Inferred / stored outcome |
|---|---|
Bare payment refund_completed / refund_pending |
requires_action (open / incomplete refund snapshot — not indeterminate) |
Bare payment reversed |
failed |
outcome: "succeeded" + status: "pending" / "processing" / "approved" / "partially_captured" / "refund_completed" / "refund_pending" |
requires_action |
outcome: "succeeded" or "requires_action" + status: "failed" / "refund_failed" |
declined if decline is present, else failed |
Refund outcome: "failed" + gateway status: "completed" |
succeeded (status wins) |
Post-submit create / OTP / capture / refund / void timeouts return outcome: "indeterminate" with gatewayId taken from params when present (gatewayPaymentId, orderId, transactionUrl, idempotencyKey, …). Create without any of those ids still uses gatewayId: "unknown" because the provider has not assigned an object id — reconcile via the idempotency store / inquiry, not getPayment("unknown").
After-hook freeze
Money/identity fields restored after after-hooks include (when present): outcome, status, amount, gatewayId, capture/order/authorization/refund IDs, fees, capturedAmount, refundedAmount, clientSecret, references, decline, reconciliationRequired, providerRequestId. Restore runs between composed after-hooks as well as on the client return path, so a later handler cannot see a previous hook’s forged paid/status/amount. After-hooks cannot flip a paid result into declined or invent a paid status.
Migration checklist (0.x apps)
- Fulfillment: replace
if (result.success)withif (isPaidOutcome(result)). - 3DS / redirect: branch on
isRequiresActionOutcome(result)— do not treat as paid. - Declines: handle both thrown
CardDeclinedError/InsufficientFundsErrorandoutcome: "declined". - Timeouts / ambiguous mutations: never map to “order failed” without reconciliation; prefer
isIndeterminateOutcome/reconciliationRequired. - IDs: read
result.references?.providerObjectIdwhen available; keep readinggatewayId/orderId/captureIdfor dual-write compatibility. - Inputs: prefer
money("10.50", "SAR")and provider-typed create params. - Tests: use
@paykernel/testkitmockGateway— scripted outcomes dual-write Phase 6 fields.
See Getting started for the reconcile worker, Webhooks for paid rematch after claim, and Migrate to 1.0.