PaymentClient.handleWebhook(gateway, payload, signatureOrHeaders?, headers?) verifies the payload, normalizes it into a WebhookEvent, and runs webhook hooks. It does not claim an inbox, lease, or set HTTP status.
HTTP status codes live in @paykernel/integration-http (mapInboxOutcome, processWebhookHttp), not in @paykernel/webhooks.
Per-gateway verification
Each gateway authenticates webhooks differently. Pass the right body shape and signature material or verification will fail closed. Do not paraphrase upstream PSP docs — link out from the gateway pages and document only PayKernel’s mapping.
Stripe — raw body + stripe-signature
Stripe signs the exact raw request body bytes. Pass the unparsed body (string or Buffer) plus the stripe-signature header. A parsed JSON object will never verify.
// Bun / Fetch-style handlers: read the body as text before JSON.parse
app.post("/webhooks/stripe", async ({ request }) => {
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature") ?? undefined;
const event = await client.handleWebhook("stripe", rawBody, signature);
// Verify only — claim + rematch before fulfillment.
return { received: true };
});Requires stripe.webhookSecret (whsec_...). Missing secret → verification fails. Details: Stripe.
PayPal — raw body + transmission headers
PayPal verifies via their API using several transmission headers. Pass the raw request body (string / Buffer / Uint8Array) plus a headers object. Parsed objects are accepted but may fail verification. handleWebhook uses async verification automatically for PayPal (sync verifyWebhook throws).
app.post("/webhooks/paypal", async (req) => {
const rawBody = req.rawBody ?? req.body;
const event = await client.handleWebhook("paypal", rawBody, {
"paypal-transmission-id": req.headers["paypal-transmission-id"],
"paypal-transmission-time": req.headers["paypal-transmission-time"],
"paypal-transmission-sig": req.headers["paypal-transmission-sig"],
"paypal-cert-url": req.headers["paypal-cert-url"],
"paypal-auth-algo": req.headers["paypal-auth-algo"],
});
return { received: true };
});Requires paypal.webhookId. paypal-transmission-time must be parseable. Unparseable or far-future timestamps are rejected before calling PayPal. Aged transmissions are soft-accepted: the SDK warns and still calls PayPal signature verify. There is no hard 15-minute replay reject — merchants must dedupe by event.id. paypal-cert-url must be HTTPS on *.paypal.com. Transient PayPal API failures during verification throw — return 5xx for those, 4xx only for genuinely invalid webhooks. Details: PayPal.
Moyasar — secret_token in the payload
Moyasar embeds a secret_token field in the JSON body. No signature header is required; pass the parsed body only. Requires moyasar.webhookSecret. Compared against payload.secret_token with a constant-time check. Details: Moyasar.
app.post("/webhooks/moyasar", async (req) => {
const event = await client.handleWebhook("moyasar", req.body);
return { received: true };
});Paymob — HMAC
Pass the body and the HMAC string (query param, body field, or both — the gateway also reads payload.hmac when present). Requires paymob.hmacSecret in production. Details: Paymob.
app.post("/webhooks/paymob", async (req) => {
const hmac = req.query.hmac ?? req.body.hmac;
const event = await client.handleWebhook("paymob", req.body, hmac);
return { received: true };
});Raw body required (Stripe and HMAC-style)
| Gateway | Body to pass | Why |
|---|---|---|
| Stripe | Raw string or Buffer required |
Stripe HMAC is over timestamp.rawBody. Parsed/re-serialized JSON never matches. verifyWebhook returns false if given an object. |
| Paymob | Parsed object is fine for HMAC fields | HMAC is over selected payload fields, not the raw HTTP body. Still do not mutate fields before verifying. |
| PayPal | Prefer raw string / Buffer / Uint8Array |
Verification is an API postback. Raw payloads are embedded as webhook_event without re-serialization. |
| Moyasar | Parsed JSON body | Checks secret_token on the object. |
In frameworks that auto-parse JSON, register a raw-body parser for the webhook route only. Do not JSON.stringify a parsed object and expect Stripe verification to succeed.
Hook ordering
handleWebhook runs verify and parse as separate stages:
onWebhookReceived(gateway, payload)— fires before verification.- Verify signature / authenticity. On failure:
onWebhookFailedruns, then the error is rethrown (InvalidWebhookErrorfor failed checks). - Parse into a normalized
WebhookEvent(only after verify succeeds). Parse failures throwInvalidRequestErroronly (gatewayInvalidWebhookErrorfrom parse is reclassified) and do not callonWebhookFailed. Treat them as server/data-shape errors — not forged webhooks. With@paykernel/webhooksprocessWithVerifier, parse /InvalidRequestErrormaps tohandler_failed { retryable: true }(~5xx) so authentic paid events redeliver; only verify-falseInvalidWebhookError/{ ok: false }maps toinvalid_webhook(~400). - Dual-write / rematch —
handleWebhookattaches a v1PaymentEventwhen missing, then always rematches incomplete-money / incomplete-refund even whenevent.eventalready passesisPaymentEvent. A complete v1payment.succeededarm with envelopeprocessing/partially_captured/authorized/approved/pending/failed/cancelled/reversed/ refunded is rematched before hooks run. Nestedevent.payment.statuscannot staypaidwhen the envelope is not. Type-only handlers must not fulfill on a rematched arm. onWebhookVerified(event)— fires after verify + parse succeed.
| Hook | Throw behavior | Why |
|---|---|---|
onWebhookReceived |
Log and continue to verify | Untrusted path must never block authenticity checks |
onWebhookFailed |
Secondary: logged if it throws; the primary verification error is always rethrown | Metrics/alerts must not replace InvalidWebhookError |
onWebhookVerified |
Rethrow (after logging) | HTTP handler should return 5xx so the provider retries delivery |
onWebhookVerified is fail-fast — if the first composed handler throws, the second is not run. Use it for metrics only:
hooks: {
onWebhookVerified: async (event) => {
await metrics.increment("webhook.verified", { gateway: event.gateway });
},
}The payload given to onWebhookReceived is unverified and untrusted. onWebhookFailed is authenticity failures only (bad signature, missing secret, provider rejected the transmission) — not malformed-but-authenticated payloads.
Deduplicate before fulfillment
Merchants must claim via @paykernel/webhooks (or an equivalent inbox) before fulfilling orders, capturing inventory, or sending goods.
- For Stripe / PayPal / Moyasar the provider event id is
event.id. - Paymob: redirect
TRANSACTION_RESPONSEsetsevent.idto{txn}:redirect; processedTRANSACTIONkeeps the raw txn id — those raw ids do not collide. Still callderiveWebhookEventKey("paymob", event.id, event.provider?.eventType ?? event.type, event.status)so the engine canonicalizes topaymob:TRANSACTION_RESPONSE:{txnId}vspaymob:TRANSACTION:{txnId}:{status}. Preferprovider.eventTypeor known native classes (TRANSACTION/TRANSACTION_RESPONSE) — not remappedpayment.succeeded. Do not inbox-dedupe on the unsigned Paymob txn id alone. Do not complete fulfillment on Paymobpayment.processing— wait for processedTRANSACTION.
import {
createWebhookInboxEngine,
resolveInboxPayloadHash,
type WebhookInboxStore,
} from "@paykernel/webhooks";
declare const store: WebhookInboxStore;
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 engine = createWebhookInboxEngine({ store, mode: "inline" });
const webhookEvent = await client.handleWebhook("stripe", rawBody, signature);
const outcome = await engine.processVerified({
gateway: "stripe",
providerEventId: webhookEvent.id,
// Prefer event.payloadHash. Never hash rawBody as a fallback for an object hash.
payloadHash: resolveInboxPayloadHash({
eventPayloadHash: webhookEvent.payloadHash,
payloadForHash: webhookEvent.rawPayload ?? webhookEvent,
}),
event: webhookEvent.event ?? webhookEvent,
handler: async (ctx) => {
if (!isPaidFulfillmentEvent(ctx.event)) return;
const gatewayPaymentId = webhookEvent.gatewayPaymentId;
if (typeof gatewayPaymentId !== "string" || gatewayPaymentId.length === 0) {
return;
}
const found = findOrderForEvent(webhookEvent, 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);
},
});
// Map outcome → HTTP in your framework — never silent-ACK failures.Do not call isPaidOutcome(event) on a WebhookEvent — that helper is for GatewayPaymentResult / PaymentOperationResult.
Production HTTP path: processWebhookHttp from @paykernel/integration-http (see Getting started).
Stable PaymentEvent (preferred for new handlers)
WebhookEvent.type remains the provider-native free-form string (payment_paid, payment_intent.succeeded, …). Prefer the dual-write fields:
import {
STABLE_PAYMENT_EVENT_TYPES,
isStablePaymentEventType,
toPersistedPaymentEventEnvelope,
webhookEventToPaymentEvent,
} from "@paykernel/core";
const event = await client.handleWebhook("moyasar", body);
// event.type === "payment_paid" // legacy free-form
// event.stableType === "payment.succeeded"
// event.event is PaymentEvent (schemaVersion: "1")
// event.provider.eventType === "payment_paid"
const envelope = toPersistedPaymentEventEnvelope(event.event!, {
payloadHash: event.payloadHash,
rawForHash: event.rawPayload,
});PAYMENT_EVENT_SCHEMA_VERSION is '1'. Consumers must switch on schemaVersion then type. Additive optional fields on an arm are OK within v1. Changing the meaning of a stable type string requires a new schemaVersion. Never silently rename a stable type. Provider-native names live only on provider.eventType.
Stable names (STABLE_PAYMENT_EVENT_TYPES):
payment.created · payment.processing · payment.authorized · payment.succeeded · payment.failed · payment.cancelled · capture.completed · refund.pending · refund.completed · refund.failed · payment_method.setup_completed · dispute.opened · dispute.updated · dispute.closed
Unmapped / ambiguous provider events use { schemaVersion: "1"; type: "provider.unmapped"; ... }. Do not invent stable names for ambiguous domains (Stripe invoice / subscription schedules, Paymob redirect-only without status context).
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 |
Timestamps on ProviderEventMetadata are ISO-8601 strings, not Date.
Provider-native → stable mapping
import { mapProviderEventTypeToStable } from "@paykernel/core";
mapProviderEventTypeToStable("stripe", "payment_intent.succeeded");
// → "payment.succeeded"
mapProviderEventTypeToStable("moyasar", "payment_paid");
// → "payment.succeeded"
mapProviderEventTypeToStable("paypal", "PAYMENT.CAPTURE.COMPLETED");
// → "capture.completed"
mapProviderEventTypeToStable("stripe", "invoice.paid");
// → "provider.unmapped"| 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; not paid |
| Stripe | payment_intent.succeeded + status refunded / partially_refunded |
payment.processing |
Not payment.succeeded |
| Stripe | payment_intent.succeeded + status failed |
payment.failed |
Type-only handlers must not fulfill |
| Stripe | payment_intent.succeeded + status cancelled / reversed |
payment.cancelled |
Rematch |
| Stripe | payment_intent.succeeded + status authorized |
payment.authorized |
Auth hold is not paid |
| Stripe | payment_intent.payment_failed |
payment.failed |
|
| Stripe | checkout.session.completed + payment_status=paid |
payment.succeeded |
Needs context |
| Stripe | charge.refunded |
refund.completed |
|
| 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 / paid |
| PayPal | PAYMENT.CAPTURE.REVERSED |
unmapped | No stable reversed arm |
| Paymob | TOKEN |
payment_method.setup_completed |
|
| Paymob | TRANSACTION + success flags |
payment.succeeded / … |
Processed server webhook only |
| Paymob | TRANSACTION partially_captured |
payment.processing |
Partial is not paid-like |
| Paymob | TRANSACTION is_capture + success (no trusted captured_amount) |
payment.processing |
Not capture.completed / paid |
| 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.
Paymob redirect vs processed: 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.
Stripe / Paymob partial capture: Domain status partially_captured dual-writes payment.processing, not payment.succeeded. Fulfill only with status paid or isPaidOutcome on payment results.
Dual-write with legacy WebhookEvent
| Field | Behavior |
|---|---|
WebhookEvent.type |
Unchanged — provider-native 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 |
Do not persist rawPayload by default. Use toPersistedPaymentEventEnvelope (strips raw / clientSecret / secrets). hashWebhookPayload redacts known secret keys before hashing. Core never ships a default encryptor — apps own keys via RawWebhookPayloadCodec.
Inbox engine (@paykernel/webhooks)
Storage-agnostic claim / dedupe / retry / audit for verified webhook deliveries. The engine is gateway-agnostic: verification and normalization are injected; HTTP status codes are never hardcoded.
| # | Step | Who |
|---|---|---|
| 1 | Receive raw request | Your HTTP framework |
| 2 | Verify signature / authenticity | PaymentClient.handleWebhook / gateway.verifyWebhook / injected verifyAndNormalize |
| 3 | Normalize event | Same path; dual-write PaymentEvent preferred |
| 4 | Calculate payload hash | Prefer gateway event.payloadHash; else resolveInboxPayloadHash (same object shape as gateway) |
| 5 | Derive event key | deriveWebhookEventKey(gateway, providerEventId) → gateway:providerEventId |
| 6 | Atomically claim | store.claim only (engine never get-then-set) |
| 7 | Conflict / non-acquired | Map claim kinds → WebhookProcessingOutcome (no handler) |
| 8 | Run application handler | Under lease; ctx.renew available |
| 9 | Complete | store.complete with current lease token → processed |
| 10 | Fail / retry | Sanitized store.fail → mode-specific outcome |
Mode is required on createWebhookInboxEngine and is fixed for the life of the engine.
| Mode | Behavior |
|---|---|
inline |
Await handler under lease. Retryable throw → handler_failed { retryable: true }. Never emits scheduled_for_retry. |
durable_retry |
Await handler by default. Retryable throw → scheduled_for_retry { reason: "handler_retry" }. |
durable_retry + ackAfterClaim: true |
After successful claim, park as scheduled_for_retry { reason: "parked" } without running the handler. Requires workerGuaranteed: true. Workers call processRetryable. |
processRetryable is only valid on durable_retry engines (throws if mode === "inline").
Event key and payload hash
import { deriveWebhookEventKey, parseWebhookEventKey } from "@paykernel/webhooks";
deriveWebhookEventKey("stripe", "evt_123"); // "stripe:evt_123"
deriveWebhookEventKey("paymob", "123456789", "TRANSACTION_RESPONSE");
// "paymob:TRANSACTION_RESPONSE:123456789"
deriveWebhookEventKey("paymob", "123456789", "TRANSACTION", "paid");
// "paymob:TRANSACTION:123456789:paid"- Both parts must be non-empty after trim; otherwise
deriveWebhookEventKeythrows andprocessVerifiedreturnsinvalid_webhook. - Gateway must not contain
:(colon is the key separator). hashWebhookPayload(rawBodyString) !== hashWebhookPayload(parsedObject)even when the string is JSON of that object. Mixing them →payload_conflicton redelivery.
| Existing row | New hash | Claim result |
|---|---|---|
completed / dead_letter / failed |
any | terminal (already_completed / duplicate_failed) — no re-run |
| Active lease + same hash | same | in_progress → already_processing |
| Active lease + different hash | different | payload_hash_conflict → payload_conflict |
| Idle non-terminal + different hash | different | supersede — acquire with new hash |
Pending + future availableAt + same hash |
same | not_available → durable scheduled_for_retry; inline → handler_failed { retryable: true } |
Forbidden to store (by default)
- Raw provider payloads (unredacted)
- Signature headers (
stripe-signature, PayPal transmission sigs, HMAC strings) - Authorization headers, Bearer tokens, API keys
- Webhook secrets (
whsec_…, Moyasarsecret_token, …) - Unsanitized exception messages that may embed secrets
Handlers MUST be idempotent. Crash after side effect and before complete → lease expires → reclaim → handler runs again.
WebhookProcessingOutcome and HTTP mapping
The engine never hardcodes Express/Hono status codes. Map with mapInboxOutcome from @paykernel/integration-http.
| Outcome | Meaning | mapInboxOutcome (default provider_redelivery) |
|---|---|---|
processed |
Handler ran; inbox completed | 200 |
duplicate_completed |
Already terminal success; handler not re-run | 200 |
already_processing |
Another worker holds lease | 503 |
scheduled_for_retry reason: "parked" |
Durable park with workerGuaranteed: true |
200 only if ackPolicy: { kind: "durable_worker" }; else 503 |
scheduled_for_retry reason: "handler_retry" |
Retryable handler fail recorded | 200 with durable worker; 503 under provider_redelivery |
scheduled_for_retry reason: "not_available" |
Claim backoff; no handler ran | 503 |
handler_failed retryable: true |
Handler failed or verify infra/parse | 500 |
handler_failed retryable: false |
Dead letter / non-retryable | 200 |
payload_conflict |
Same key, different hash while lease active | 409 |
invalid_webhook |
Bad input / verify-false MAC mismatch | 400 |
Store claim kind mapping:
| Claim kind | Outcome |
|---|---|
acquired |
Continue pipeline |
already_completed |
duplicate_completed |
in_progress |
already_processing |
payload_hash_conflict |
payload_conflict |
duplicate_failed |
handler_failed { retryable: false } |
not_available |
durable_retry: scheduled_for_retry { reason: "not_available" }. inline: handler_failed { retryable: true } |
Injected verifier (verify-only)
declare function fulfillPaidOrder(event: unknown): Promise<void>;
const outcome = await engine.processWithVerifier({
raw: { body, headers },
// VERIFY ONLY — no fulfill / onWebhookVerified money work.
verifyAndNormalize: async (raw) => {
const event = await client.handleWebhook(
"stripe",
raw.body,
raw.headers["stripe-signature"],
);
return {
ok: true,
gateway: "stripe",
providerEventId: event.id,
payloadHash: resolveInboxPayloadHash({
eventPayloadHash: event.payloadHash,
payloadForHash: event.rawPayload ?? event.event ?? event,
}),
event: event.event ?? event,
};
},
handler: async (ctx) => {
await fulfillPaidOrder(ctx.event);
},
});Classification:
InvalidWebhookError(verify-false only) /ok: false→invalid_webhook(~400 forgery)- Missing
webhookSecret/hmacSecret/webhookId→handler_failed { retryable: true }(~5xx; merchant config — never forgery) - Parse /
InvalidRequestError/ parse-stageInvalidWebhookError→handler_failed { retryable: true }(~5xx) RateLimitError/TypeError/NetworkError/ unknown →handler_failed { retryable: true }- Permanent structure
GatewayApiError→handler_failed { retryable: false }
Never map infrastructure/parse throws to ok: false (that stops redelivery).
Public inbox exports
| Export | Role |
|---|---|
createWebhookInboxEngine |
Factory; fixed mode |
computePayloadHash |
Core hashWebhookPayload wrapper |
resolveInboxPayloadHash |
Prefer gateway hash; else hash the same object shape |
deriveWebhookEventKey / parseWebhookEventKey |
Key helpers |
sanitizeWebhookError |
Default lastError sanitizer |
StoreLeaseLostError / isStoreLeaseLostError |
Portable fencing errors |
NonRetryableHandlerError |
Dead-letter / non-retryable throws |
WebhookInboxStore |
Domain store contract |
WebhookProcessingMode / WebhookProcessingOutcome |
Modes + outcomes |
Memory stores are not exported from @paykernel/webhooks. Tests: createMemoryWebhookInboxStore from @paykernel/testkit (NON-PRODUCTION). Production: @paykernel/store-*.
Core must not depend on @paykernel/webhooks. Apps that need inbox behavior add the webhooks package as a separate dependency.
See Getting started for Postgres composition and Outcomes for isPaidOutcome. The stable event catalog is STABLE_PAYMENT_EVENT_TYPES on this page.