@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/coreInject 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
- Receive the raw request in your HTTP framework (Stripe/PayPal: raw body, never
json()first — see webhook guide). - Verify + normalize with
PaymentClient.handleWebhookorgateway.verifyWebhook/parseWebhookEvent. No money side effects. - Hash with
resolveInboxPayloadHash(prefer gatewaypayloadHash; else the same object shape the gateway hashed). createWebhookInboxEngine({ store, mode }).processVerified(...)— atomicstore.claim, then handler under lease.- Map
WebhookProcessingOutcometo 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):
- Trimmed
eventPayloadHashwhen non-empty — returned as-is. - Else
hashWebhookPayload(payloadForHash)(same ascomputePayloadHash). - 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_available → handler_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" }; inline → handler_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-storeis 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.