Stable, provider-agnostic payment events for fulfillment and persistence. This page is the public contract for PaymentEvent schema version 1, ported from packages/core/docs/webhook-events.md plus STABLE_PAYMENT_EVENT_TYPES in packages/core/src/types/stable-payment-event-types.ts.
Cross-links: webhooks (verify, raw body, hooks), outcomes (Payment snapshots), @paykernel/webhooks (inbox claim / lease — not in core).
handleWebhook verifies and normalizes. It does not claim, lease, or set HTTP status. HTTP status lives in @paykernel/integration-http (mapInboxOutcome).
Published as part of @paykernel/core 1.0.0.
Why
Legacy WebhookEvent.type is a free-form, provider-native string (payment_paid, payment_intent.succeeded, PAYMENT.CAPTURE.COMPLETED, TRANSACTION_RESPONSE, …). Apps should not re-implement provider mapping.
| Concept | Role |
|---|---|
| Stable names | Public fulfillment contract (payment.succeeded, …) |
PaymentEvent |
Discriminated union on type + schemaVersion: "1" |
ProviderEventMetadata |
Native eventType, ISO times, livemode, apiVersion |
PersistedPaymentEventEnvelope |
Sanitized store shape (no raw / secrets) |
Dual-write on WebhookEvent |
Additive event / stableType / provider without breaking type |
Schema versioning
import { PAYMENT_EVENT_SCHEMA_VERSION } from "@paykernel/core";
// PAYMENT_EVENT_SCHEMA_VERSION === "1"
// every PaymentEvent arm includes: schemaVersion: "1"Inventory field paymentEventSchemaVersion is "1" (public-api.inventory.json).
Compatibility rules (v1)
- Consumers must switch on
schemaVersionthentype. - Additive optional fields on an arm are OK within
schemaVersion: "1". - Changing the meaning of a stable
typestring requires a newschemaVersion. - Never silently rename a stable type once shipped (e.g. do not redefine
payment.succeededas “authorized only”). - Provider-native names live only on
provider.eventType— they are not the public stable contract.
STABLE_PAYMENT_EVENT_TYPES
Source order from packages/core/src/types/stable-payment-event-types.ts (14 names). The inventory file lists the same set alphabetically.
import {
STABLE_PAYMENT_EVENT_TYPES,
isStablePaymentEventType,
} from "@paykernel/core";| Index | Stable type |
|---|---|
| 0 | payment.created |
| 1 | payment.processing |
| 2 | payment.authorized |
| 3 | payment.succeeded |
| 4 | payment.failed |
| 5 | payment.cancelled |
| 6 | capture.completed |
| 7 | refund.pending |
| 8 | refund.completed |
| 9 | refund.failed |
| 10 | payment_method.setup_completed |
| 11 | dispute.opened |
| 12 | dispute.updated |
| 13 | dispute.closed |
isStablePaymentEventType(v) is the type guard. Do not invent additional stable names.
Unmapped / ambiguous provider events use a dedicated arm that is not in STABLE_PAYMENT_EVENT_TYPES:
{
schemaVersion: "1";
type: "provider.unmapped";
provider: ProviderEventMetadata;
payment?: Payment;
note?: string;
}Do not invent stable names for ambiguous domains (Stripe invoice / subscription schedules, Paymob redirect-only without status context).
ProviderEventMetadata
type ProviderEventMetadata = {
gateway: string;
eventId: string;
/** Provider-native event type — never silently renamed */
eventType: string;
apiVersion?: string;
livemode?: boolean;
/** ISO-8601 when the provider says the event occurred */
occurredAt: string;
/** ISO-8601 when the SDK received/parsed the event */
receivedAt: string;
requestId?: string;
};Timestamps are ISO-8601 strings, not Date.
PaymentEvent (discriminated union)
Every arm has schemaVersion: "1" and provider: ProviderEventMetadata.
type |
Required entity fields |
|---|---|
payment.created / processing / authorized / succeeded / cancelled |
payment: Payment |
payment.failed |
payment + failure: PaymentFailure |
capture.completed |
capture: Capture; optional payment |
refund.pending / refund.completed |
refund: Refund |
refund.failed |
refund; optional failure |
payment_method.setup_completed |
setup: PaymentMethodSetup |
dispute.opened / updated / closed |
dispute: Dispute |
provider.unmapped |
optional payment, optional note |
Payment reuses the Phase 6 snapshot type (status, amount?, currency?, references: ProviderReferences, …). PaymentFailure is shape-aligned with PaymentDecline (no secrets in raw).
After verify: claim, then rematch
attachPaymentEvent / webhookEventToPaymentEvent do not claim the inbox. handleWebhook verifies only. Then processVerified on @paykernel/webhooks:
import {
createWebhookInboxEngine,
resolveInboxPayloadHash,
type WebhookInboxStore,
} from "@paykernel/webhooks";
import { mapInboxOutcome } from "@paykernel/integration-http";
declare const store: WebhookInboxStore;
declare const client: import("@paykernel/core").PaymentClient;
declare const rawBody: string;
declare const signature: string | undefined;
declare function fulfillBoundToGatewayPaymentId(id: string): Promise<void>;
const engine = createWebhookInboxEngine({
store,
mode: "inline",
owner: "api-worker-1",
defaultLeaseMs: 30_000,
});
const webhookEvent = await client.handleWebhook("stripe", rawBody, signature);
// handleWebhook verifies only. Never fulfill here.
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) => {
const rec = ctx.event as { type?: string; payment?: { status?: string } };
const paid =
(rec.type === "payment.succeeded" || rec.type === "capture.completed") &&
rec.payment?.status === "paid";
if (!paid) return;
const gatewayPaymentId = webhookEvent.gatewayPaymentId;
if (typeof gatewayPaymentId !== "string" || gatewayPaymentId.length === 0) {
return;
}
await fulfillBoundToGatewayPaymentId(gatewayPaymentId);
},
});
return mapInboxOutcome(outcome); // HTTP lives here, not in @paykernel/webhooksProvider-native → stable mapping
import { mapProviderEventTypeToStable } from "@paykernel/core";
mapProviderEventTypeToStable("stripe", "payment_intent.succeeded");
// → "payment.succeeded" (then rematch on domain status — see table)
mapProviderEventTypeToStable("moyasar", "payment_paid");
// → "payment.succeeded"
mapProviderEventTypeToStable("paypal", "PAYMENT.CAPTURE.COMPLETED");
// → "capture.completed" (capture domain)
mapProviderEventTypeToStable("stripe", "invoice.paid");
// → "provider.unmapped"Tables are pure data (STRIPE_EVENT_TYPE_MAP, MOYASAR_EVENT_TYPE_MAP, PAYPAL_EVENT_TYPE_MAP, PAYMOB_TOKEN_EVENT_TYPES, STRIPE_UNMAPPED_EVENT_TYPES) plus mapProviderEventTypeToStable. Extra gateways (Tap, MyFatoorah, Hesabe) are not in these core maps.
Mapping policy highlights
Ported from packages/core/docs/webhook-events.md. Do not paraphrase upstream PSP docs.
| Gateway | Native type | Stable | Notes |
|---|---|---|---|
| Stripe | payment_intent.succeeded (full / status paid) |
payment.succeeded |
Default map when not partial |
| Stripe | payment_intent.succeeded + status partially_captured / processing / pending / approved |
payment.processing |
Catalog rematch (coerceStableSucceededToDomainStatus); not paid |
| Stripe | payment_intent.succeeded + status refunded / partially_refunded |
payment.processing |
Not payment.succeeded — no refund entity invented here |
| Stripe | payment_intent.succeeded + status failed |
payment.failed |
Rematch; type-only handlers must not fulfill |
| Stripe | payment_intent.succeeded + status cancelled / reversed |
payment.cancelled |
Rematch |
| Stripe | payment_intent.succeeded + status authorized |
payment.authorized |
Rematch; auth hold is not paid |
| Stripe | payment_intent.payment_failed |
payment.failed |
|
| Stripe | payment_intent.canceled |
payment.cancelled |
|
| Stripe | checkout.session.completed + payment_status=paid |
payment.succeeded |
Needs context |
| Stripe | charge.refunded |
refund.completed |
|
| Stripe | setup_intent.succeeded |
payment_method.setup_completed |
|
| Stripe | charge.dispute.* |
dispute.* |
|
| Stripe | invoice.* / customer.subscription.* |
unmapped | Ambiguous |
| Moyasar | payment_paid |
payment.succeeded |
|
| Moyasar | payment_failed / payment_faild |
payment.failed |
Typo normalized by gateway |
| Moyasar | payment_authorized |
payment.authorized |
|
| PayPal | PAYMENT.CAPTURE.COMPLETED |
capture.completed |
Not payment.succeeded. Rematch if domain status is partially_captured / processing / failed / cancelled / refunded |
| PayPal | PAYMENT.AUTHORIZATION.PARTIALLY_CAPTURED |
payment.processing |
Status partially_captured; not capture.completed / payment.succeeded |
| PayPal | PAYMENT.CAPTURE.REFUNDED |
refund.completed when status is refunded; otherwise refund.pending |
Type-only / refund-shaped / partially_refunded must not close a capture as fully refunded |
| PayPal | PAYMENT.REFUND.COMPLETED |
refund.completed |
Refund resource |
| PayPal | PAYMENT.CAPTURE.REVERSED |
unmapped | No stable reversed arm |
| Paymob | TOKEN |
payment_method.setup_completed |
PAYMOB_TOKEN_EVENT_TYPES is TOKEN / token |
| Paymob | TRANSACTION + success flags |
payment.succeeded / … |
Use flags / status / amounts context; processed server webhook only |
| Paymob | TRANSACTION + signed is_refunded (or HMAC is_refund alias) |
refund.pending |
Domain status refund_completed (incomplete snapshot) — not full refunded / partially_refunded. Unsigned refunded_amount_cents is stripped after HMAC |
| Paymob | TRANSACTION amount-only refund (refunded_amount_cents without signed refund flags) |
ignored for refund | Status stays non-refund; not payment.succeeded and not forged refunded |
| Paymob | TRANSACTION is_auth + full captured_amount / status paid |
payment.succeeded |
Not payment.authorized when fully settled |
| Paymob | TRANSACTION partially_captured |
payment.processing |
Aligns with isPaidOutcome (partial is not paid-like) |
| Paymob | TRANSACTION is_capture + success (no trusted captured_amount) |
payment.processing |
Fail-closes — not capture.completed / paid. Use transaction inquiry |
| Paymob | TRANSACTION_RESPONSE without status |
unmapped | Do not fulfill on redirect-only |
| Paymob | TRANSACTION_RESPONSE + success / paid / capture signals |
payment.processing |
Never payment.succeeded / capture.completed on redirect; wait for processed TRANSACTION |
PayPal capture choice: PAYMENT.CAPTURE.COMPLETED maps to capture.completed, not payment.succeeded. Apps that fulfill when money is captured should handle capture.completed and payment.status === "paid".
PayPal partial auth capture: PAYMENT.AUTHORIZATION.PARTIALLY_CAPTURED dual-writes payment.processing with domain status partially_captured. Do not fulfill remaining authorized amount on this event alone.
Paymob redirect vs processed: Native type is the distinguisher. Browser/query redirect callbacks parse as TRANSACTION_RESPONSE and dual-write payment.processing even when status is paid — so fulfill-on-payment.succeeded handlers never ship from redirect alone. Use processed TRANSACTION or transaction inquiry.
Paymob inbox key: Redirect TRANSACTION_RESPONSE sets WebhookEvent.id to {txn}:redirect; processed TRANSACTION keeps the raw txn id (paymob.gateway.ts paymobWebhookEventId). gatewayPaymentId stays the signed txn id on both. Raw event.id values do not collide, so inbox-deduping on event.id does not swallow later paid. @paykernel/webhooks deriveWebhookEventKey("paymob", id, type) strips the :redirect suffix and qualifies by class: paymob:TRANSACTION_RESPONSE:{txn} vs paymob:TRANSACTION:{txn} (processed + status → paymob:TRANSACTION:{txn}:{status}). Still do not inbox-dedupe on the unsigned Paymob txn id alone.
Stripe / Paymob partial capture: Domain status partially_captured dual-writes payment.processing, not payment.succeeded. Fulfill only with status paid or isPaidOutcome.
Direct maps (runtime constants)
MOYASAR_EVENT_TYPE_MAP:
| Native | Stable |
|---|---|
payment_paid |
payment.succeeded |
payment_failed |
payment.failed |
payment_faild |
payment.failed |
payment_authorized |
payment.authorized |
payment_abandoned |
payment.failed |
payment_voided |
payment.cancelled |
payment_refunded |
refund.completed |
payment_captured |
capture.completed |
payment_verified |
payment_method.setup_completed |
PAYPAL_EVENT_TYPE_MAP (status-gated exceptions in the policy table still apply):
| Native | Stable |
|---|---|
PAYMENT.CAPTURE.COMPLETED |
capture.completed |
PAYMENT.CAPTURE.DENIED |
payment.failed |
PAYMENT.CAPTURE.DECLINED |
payment.failed |
PAYMENT.CAPTURE.PENDING |
payment.processing |
PAYMENT.CAPTURE.REFUNDED |
refund.completed (status-gated to refund.pending unless refunded) |
PAYMENT.REFUND.PENDING |
refund.pending |
PAYMENT.REFUND.COMPLETED |
refund.completed |
PAYMENT.REFUND.FAILED |
refund.failed |
PAYMENT.AUTHORIZATION.CREATED |
payment.authorized |
PAYMENT.AUTHORIZATION.VOIDED |
payment.cancelled |
PAYMENT.AUTHORIZATION.CAPTURED |
capture.completed |
PAYMENT.AUTHORIZATION.PARTIALLY_CAPTURED |
payment.processing |
CHECKOUT.ORDER.APPROVED |
payment.processing |
CHECKOUT.PAYMENT-APPROVAL.REVERSED |
payment.cancelled |
CUSTOMER.DISPUTE.CREATED |
dispute.opened |
CUSTOMER.DISPUTE.UPDATED |
dispute.updated |
CUSTOMER.DISPUTE.RESOLVED |
dispute.closed |
STRIPE_UNMAPPED_EVENT_TYPES includes invoice.*, customer.subscription.*, and subscription_schedule.* names listed in packages/core/src/types/webhook-event-map.ts. Unknown types return provider.unmapped.
Dual-write with legacy WebhookEvent
| Field | Behavior |
|---|---|
WebhookEvent.type |
Unchanged — provider-native / gateway-normalized free-form |
WebhookEvent.stableType |
Stable name when mappable; omitted when unmapped |
WebhookEvent.event |
Full PaymentEvent |
WebhookEvent.provider |
ProviderEventMetadata |
WebhookEvent.rawPayload |
Still required request-local; deprecated for persistence |
PaymentEvent.type |
Always stable name or provider.unmapped |
import { webhookEventToPaymentEvent } from "@paykernel/core";
const event = await client.handleWebhook("moyasar", body);
// event.type === "payment_paid" // legacy free-form (unchanged)
// event.stableType === "payment.succeeded"
// event.event.type === "payment.succeeded"
// event.provider.eventType === "payment_paid"
const onlyPaymentEvent = webhookEventToPaymentEvent(event);Do not rewrite gateway tests to expect stable names on .type without an explicit migration. Dual-write is additive.
Migration from free-form type
- Prefer
event.eventorwebhookEventToPaymentEvent(event). - Switch on
schemaVersion+PaymentEvent.type. - Inbox-dedupe with
deriveWebhookEventKey(gateway, event.id, event.type)from@paykernel/webhooks(Paymob must include notification class). - For persistence, call
toPersistedPaymentEventEnvelope(never storerawPayloadby default); preferevent.payloadHashwhen present.
Raw payload retention
| Path | Raw body |
|---|---|
| Request-local handlers | WebhookEvent.rawPayload (required 0.x) |
PaymentEvent default |
No raw on nested payment |
PersistedPaymentEventEnvelope |
Strips raw / clientSecret / secrets |
| Long-term encrypted storage | App implements RawWebhookPayloadCodec + encryptRawWebhookPayload |
import {
toPersistedPaymentEventEnvelope,
webhookEventToPaymentEvent,
encryptRawWebhookPayload,
type RawWebhookPayloadCodec,
} from "@paykernel/core";
const paymentEvent = webhookEventToPaymentEvent(webhookEvent);
const envelope = toPersistedPaymentEventEnvelope(paymentEvent, {
rawForHash: webhookEvent.rawPayload,
storedAt: new Date().toISOString(),
});Core never ships a default encryptor — apps own keys via RawWebhookPayloadCodec. Never re-expose Moyasar secret_token, Stripe signatures, PayPal transmission sigs, or HMAC headers in envelopes, logs, or payloadHash inputs. hashWebhookPayload redacts known secret keys before hashing.
Helpers (public exports)
| Symbol | Purpose |
|---|---|
STABLE_PAYMENT_EVENT_TYPES / isStablePaymentEventType |
Stable name set + guard |
PAYMENT_EVENT_SCHEMA_VERSION |
"1" |
mapProviderEventTypeToStable |
Native → stable / unmapped |
webhookEventToPaymentEvent |
Legacy → PaymentEvent |
attachPaymentEvent |
Dual-write onto WebhookEvent |
buildProviderEventMetadata |
Metadata builder |
paymentFromWebhookEvent |
Phase 6 Payment from webhook fields |
hashWebhookPayload |
Redacted canonical sha256 |
toPersistedPaymentEventEnvelope |
Sanitized envelope |
encryptRawWebhookPayload |
App codec encryption helper |
assertNoSecretsInEnvelope |
Test/runtime guard |
isPaymentEvent / isPaymentSucceededEvent / isPaymentFailedEvent / isRefundCompletedEvent / isProviderUnmappedEvent |
Arm guards |
All of these are runtime exports of @paykernel/core (. only). See core API.
What this does not do
- Does not change
handleWebhookreturn type (stillWebhookEvent; dual-write is additive). - Does not implement the inbox engine. Lease-aware store contracts live in
@paykernel/store-contracts/@paykernel/testkit. The inbox engine is@paykernel/webhooks. - Does not remap gateway
parseWebhookEvent.typestrings (keeps native; stable names live onstableType/event.type). - Does not store raw provider payloads by default.
- Does not set HTTP status codes.
Failure path: unknown native type → provider.unmapped → log provider.eventType → do not fulfill. Redirect Paymob TRANSACTION_RESPONSE → payment.processing → do not fulfill. Verify failure → InvalidWebhookError (errors).