Skip to content

Webhooks

Verify with handleWebhook, claim the inbox, then fulfill only on rematched paid events.

Updated View as Markdown

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:

  1. onWebhookReceived(gateway, payload) — fires before verification.
  2. Verify signature / authenticity. On failure: onWebhookFailed runs, then the error is rethrown (InvalidWebhookError for failed checks).
  3. Parse into a normalized WebhookEvent (only after verify succeeds). Parse failures throw InvalidRequestError only (gateway InvalidWebhookError from parse is reclassified) and do not call onWebhookFailed. Treat them as server/data-shape errors — not forged webhooks. With @paykernel/webhooks processWithVerifier, parse / InvalidRequestError maps to handler_failed { retryable: true } (~5xx) so authentic paid events redeliver; only verify-false InvalidWebhookError / { ok: false } maps to invalid_webhook (~400).
  4. Dual-write / rematch — handleWebhook attaches a v1 PaymentEvent when missing, then always rematches incomplete-money / incomplete-refund even when event.event already passes isPaymentEvent. A complete v1 payment.succeeded arm with envelope processing / partially_captured / authorized / approved / pending / failed / cancelled / reversed / refunded is rematched before hooks run. Nested event.payment.status cannot stay paid when the envelope is not. Type-only handlers must not fulfill on a rematched arm.
  5. 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_RESPONSE sets event.id to {txn}:redirect; processed TRANSACTION keeps the raw txn id — those raw ids do not collide. Still call deriveWebhookEventKey("paymob", event.id, event.provider?.eventType ?? event.type, event.status) so the engine canonicalizes to paymob:TRANSACTION_RESPONSE:{txnId} vs paymob:TRANSACTION:{txnId}:{status}. Prefer provider.eventType or known native classes (TRANSACTION / TRANSACTION_RESPONSE) — not remapped payment.succeeded. Do not inbox-dedupe on the unsigned Paymob txn id alone. Do not complete fulfillment on Paymob payment.processing — wait for processed TRANSACTION.
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 deriveWebhookEventKey throws and processVerified returns invalid_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_conflict on 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_progressalready_processing
Active lease + different hash different payload_hash_conflictpayload_conflict
Idle non-terminal + different hash different supersede — acquire with new hash
Pending + future availableAt + same hash same not_available → durable scheduled_for_retry; inlinehandler_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_…, Moyasar secret_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: falseinvalid_webhook (~400 forgery)
  • Missing webhookSecret / hmacSecret / webhookIdhandler_failed { retryable: true } (~5xx; merchant config — never forgery)
  • Parse / InvalidRequestError / parse-stage InvalidWebhookErrorhandler_failed { retryable: true } (~5xx)
  • RateLimitError / TypeError / NetworkError / unknown → handler_failed { retryable: true }
  • Permanent structure GatewayApiErrorhandler_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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close