Skip to content

@paykernel/webhooks

Storage-agnostic webhook inbox engine — atomic claim, payload-hash conflict detection, and framework-agnostic outcomes. HTTP status lives in @paykernel/integration-http.

Updated View as Markdown

@paykernel/webhooks claims a verified delivery under a lease, then runs your handler. It does not verify PSP signatures, pick HTTP status codes, or fulfill orders. Version 0.1.1 — published on npm as @paykernel/webhooks. Install: bun add @paykernel/webhooks.

handleWebhook on @paykernel/core verifies and normalizes. The inbox engine is the next step: claim, then fulfill.

Install

Depends only on @paykernel/core. Single export map: ".".

bun add @paykernel/webhooks
# workspace / peer: @paykernel/core

Inject a durable WebhookInboxStore from a @paykernel/store-* adapter (adapter selection). Memory stores from @paykernel/testkit are test-only / NON-PRODUCTION.

This package does not export createMemoryWebhookInboxStore. Do not install @paykernel/internal-sql-store.

Pipeline

  1. Receive the raw request in your HTTP framework (Stripe/PayPal: raw body, never json() first — see webhook guide).
  2. Verify + normalize with PaymentClient.handleWebhook or gateway.verifyWebhook / parseWebhookEvent. No money side effects.
  3. Hash with resolveInboxPayloadHash (prefer gateway payloadHash; else the same object shape the gateway hashed).
  4. createWebhookInboxEngine({ store, mode }).processVerified(...) — atomic store.claim, then handler under lease.
  5. Map WebhookProcessingOutcome to HTTP in @paykernel/integration-http (mapInboxOutcome). This package never hardcodes status codes.
raw request
  → handleWebhook (verify + normalize only)
  → resolveInboxPayloadHash
  → processVerified / processWithVerifier  (claim + lease)
  → handler (fulfill only here, after rematch)
  → mapInboxOutcome  (@paykernel/integration-http)

Quickstart (claim before fulfill)

import {
  createWebhookInboxEngine,
  resolveInboxPayloadHash,
  type WebhookInboxStore,
} from "@paykernel/webhooks";
import { mapInboxOutcome } from "@paykernel/integration-http";

declare const store: WebhookInboxStore;
declare const client: {
  handleWebhook: (
    gateway: string,
    payload: unknown,
    signatureOrHeaders?: unknown,
  ) => Promise<{
    id: string;
    event?: unknown;
    payloadHash?: string;
    rawPayload?: unknown;
    gatewayPaymentId?: string;
    paymentId?: string;
  }>;
};

type Order = { orderId: string; gatewayPaymentId?: string };
declare function findOrderByGatewayPaymentId(id: string): Order | undefined;
declare function fulfillOrder(order: Order, gatewayPaymentId: string): Promise<void>;

const engine = createWebhookInboxEngine({
  store,
  mode: "inline", // or "durable_retry" — fixed at construction
  owner: "api-worker-1",
  defaultLeaseMs: 30_000,
});

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"
  );
}

async function onStripeWebhook(rawBody: string, signature: string) {
  const webhookEvent = await client.handleWebhook("stripe", rawBody, signature);

  const payloadHash = resolveInboxPayloadHash({
    eventPayloadHash: webhookEvent.payloadHash,
    payloadForHash: webhookEvent.rawPayload ?? webhookEvent.event ?? webhookEvent,
  });

  const outcome = await engine.processVerified({
    gateway: "stripe",
    providerEventId: webhookEvent.id,
    payloadHash,
    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 order = findOrderByGatewayPaymentId(gatewayPaymentId);
      if (!order) {
        throw new Error("no local order for paid webhook");
      }
      await fulfillOrder(order, gatewayPaymentId);
    },
  });

  return mapInboxOutcome(outcome); // number — not defined in this package
}

success: true is not a fulfillment signal. Do not call isPaidOutcome on a webhook event (that helper is for GatewayPaymentResult). Rematch stable type + payment.status === "paid" and bind gatewayPaymentId.

Optional sanitized dual-write envelope (never raw signatures / secrets):

import { toPersistedPaymentEventEnvelope } from "@paykernel/core";

declare const webhookEvent: { event?: unknown };
declare const payloadHash: string;
const envelope = toPersistedPaymentEventEnvelope(webhookEvent.event as never, {
  payloadHash,
});

Pass envelope on processVerified when you want a durable payloadRef. Prefer this over stuffing rawPayload / headers into the store.

Payload hash

import { resolveInboxPayloadHash } from "@paykernel/webhooks";

declare const webhookEvent: {
  payloadHash?: string;
  rawPayload?: unknown;
  event?: unknown;
};

const payloadHash = resolveInboxPayloadHash({
  eventPayloadHash: webhookEvent.payloadHash, // preferred when non-empty
  payloadForHash: webhookEvent.rawPayload ?? webhookEvent.event ?? webhookEvent,
});

resolveInboxPayloadHash (engine.ts):

  1. Trimmed eventPayloadHash when non-empty — returned as-is.
  2. Else hashWebhookPayload(payloadForHash) (same as computePayloadHash).
  3. Else throws (need eventPayloadHash or payloadForHash).

Do not mix hashWebhookPayload(rawBodyString) with an object event.payloadHash. Non-object strings are not JSON-parsed before hashing, so digests differ. Idle pending rows supersede a mismatched hash; an active lease with a different hash returns payload_conflict.

Modes

Mode is required on createWebhookInboxEngine and never switches inside process*.

Mode Behavior
inline Await handler under lease. Failure → handler_failed { retryable }. Never emits scheduled_for_retry (not_availablehandler_failed { retryable: true }).
durable_retry Await handler by default. Retryable failure → store.fail + scheduled_for_retry { reason: "handler_retry" }.
durable_retry + ackAfterClaim: true After durable claim, park and return scheduled_for_retry { reason: "parked" } without running the handler. Requires workerGuaranteed: true. Workers call processRetryable. Parking does not consume maxAttempts.
const durableEngine = createWebhookInboxEngine({
  store,
  mode: "durable_retry",
  maxAttempts: 5, // finite integer >= 1
  defaultRetryAfterMs: 5_000, // finite number >= 0
  ackAfterClaim: true,
  workerGuaranteed: true, // required to emit parked (HTTP 200 path in adapters)
});

Constructor throws if ackAfterClaim is set without mode: "durable_retry" or without workerGuaranteed. Per-call park without workerGuaranteed is retryable handler_failed, never parked. processRetryable throws on inline engines.

processRetryable claims one row at a time. Do not assume limit: 10 + leaseMs: 30_000 covers N serial handlers with leases taken up front — they are not.

Missing payloadRef on redrive never stubs { key, payloadHash }. The poll returns handler_failed { retryable: true } and leaves the row pending (does not dead-letter paid work).

Outcomes (no HTTP in this package)

type ScheduledForRetryReason = "parked" | "handler_retry" | "not_available";

type WebhookProcessingOutcome =
  | { outcome: "processed" }
  | { outcome: "duplicate_completed" }
  | { outcome: "already_processing"; retryAfterMs?: number }
  | {
      outcome: "scheduled_for_retry";
      reason: ScheduledForRetryReason;
      availableAt?: string;
      retryAfterMs?: number;
    }
  | { outcome: "handler_failed"; retryable: boolean }
  | { outcome: "payload_conflict" }
  | { outcome: "invalid_webhook"; reason?: string };

Silent ACK of failed work is forbidden. Always inspect the discriminant.

mapInboxOutcome in @paykernel/integration-http (source of HTTP numbers — not this package):

Outcome provider_redelivery (default) { kind: "durable_worker" }
processed / duplicate_completed 200 200
invalid_webhook 400 400
payload_conflict 409 409
already_processing 503 503
handler_failed retryable 500 500
handler_failed not retryable 200 200
scheduled_for_retry not_available 503 503
scheduled_for_retry parked / handler_retry 503 200

Parked is safe to ACK 200 only when a processRetryable worker is guaranteed. Do not 200 payload_conflict without a recovery plan.

processWithVerifier (verify-only)

declare const body: string;
declare const headers: Record<string, string>;

const outcome = await engine.processWithVerifier({
  raw: { body, headers },
  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,
    };
  },
  handler: async (ctx) => {
    // Fulfill only here — after claim.
  },
});

Classification:

Verify result Engine outcome
{ ok: false } or verify-false InvalidWebhookError (forgery) invalid_webhook (never claims)
Missing webhookSecret / hmacSecret / webhookId handler_failed { retryable: true } (not forgery)
Parse-stage InvalidWebhookError / InvalidRequestError handler_failed { retryable: true } (not permanent 4xx)
RateLimitError / NetworkError / unknown Error handler_failed { retryable: true }
Permanent structure GatewayApiError handler_failed { retryable: false }

Do not catch-and-map infrastructure errors to { ok: false } — that stops provider redelivery of paid events.

Event key (Paymob)

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"

Empty gateway / providerEventId, or : in the gateway id, throws (processVerified maps that to invalid_webhook).

Paymob redirect TRANSACTION_RESPONSE and processed TRANSACTION share WebhookEvent.id. Pass event (PaymentEvent) so processVerified qualifies the class from provider.eventType — do not inbox-dedupe Paymob on raw event.id or you ACK-suppress later paid. Do not fulfill on Paymob payment.processing (redirect); wait for processed TRANSACTION. See Paymob and events.

Failure paths

Situation Outcome
Handler throws retryable (inline) handler_failed { retryable: true }
Handler throws retryable (durable_retry) scheduled_for_retry { reason: "handler_retry" }
NonRetryableHandlerError (default) dead_letter + handler_failed { retryable: false }
complete loses lease after handler success handler_failed { retryable: true }not processed
Active lease, different hash payload_conflict
Idle pending, different hash supersede and reclaim
Terminal completed / dead_letter duplicate_completed / handler_failed { retryable: false }
Durable backoff (availableAt in future) scheduled_for_retry { reason: "not_available" }; inlinehandler_failed { retryable: true }

Handlers must be idempotent. Crash after side effect, before complete, re-runs the handler under a new lease.

What this package does not do

  • PSP signature algorithms (handleWebhook / gateway verify).
  • HTTP status codes (use mapInboxOutcome).
  • Auto-fulfillment, inventory, or order mutation.
  • Queue products, Redis, or DB drivers (you inject WebhookInboxStore).
  • Export a public memory store (in-package memory-store is tests-only and can drift vs SQL fencing).

Runtime exports

createWebhookInboxEngine, computePayloadHash, resolveInboxPayloadHash, deriveWebhookEventKey, parseWebhookEventKey, qualifyPaymobProviderEventId, sanitizeWebhookError, redactOpaquePayloadRefString, DEFAULT_SANITIZE_MAX_LENGTH, StoreLeaseLostError, isStoreLeaseLostError, NonRetryableHandlerError.

See also: webhook guide, HTTP integration, outcomes, stores.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close