@paykernel/example-checkout-kernel is the shared checkout kernel for every HTTP example. It is a private workspace package (private: true, version 0.1.0). It is not published. Hosts import its public helpers; they do not reimplement inbox HTTP mapping or Stripe signing.
Source: examples/checkout-kernel. There is no package README; this page follows src/index.ts, kernel.ts, handlers.ts, http-policy.ts, stripe-webhook.ts, and scenarios.ts.
Public exports
From src/index.ts (exports: { ".": "./src/index.ts" }):
import {
createCheckoutKernel,
createCheckoutHandlers,
createCheckoutFetchApp,
dispatchCheckoutRequest,
checkoutJsonResponse,
readRequestJson,
createPaymentInputFromUnknown,
gatewayPaymentIdFromUnknown,
mapInboxOutcome,
CHECKOUT_STRIPE_WEBHOOK_SECRET,
stripePaidPaymentIntentFixture,
stripeCreatedPaymentIntentFixture,
signStripeWebhook,
signedStripePaidWebhook,
signedStripeCreatedWebhook,
runCheckoutHttpScenarios,
} from "@paykernel/example-checkout-kernel";mapInboxOutcome is re-exported from @paykernel/integration-http. Status codes do not live in @paykernel/webhooks.
What the kernel composes
createCheckoutKernel builds:
| Piece | Package | Role in this example |
|---|---|---|
createPaymentClient |
@paykernel/core |
mock adapter (@paykernel/testkit mockGateway) + stripeGateway. defaultGateway: "mock". |
createWebhookInboxEngine |
@paykernel/webhooks |
mode: "inline" on stores.webhookInbox. |
createReconciliationScheduler + createPaymentReconciler |
@paykernel/reconciliation |
Due jobs; decideReconciliationPolicy only — no second createPayment. |
| Stores | @paykernel/store-sqlite/bun |
Default: createBunSqliteStoresInMemory then migrateSqliteAdapter. |
| HTTP | @paykernel/integration-http |
processWebhookHttp + mapInboxOutcome. |
Charge path is mock. Stripe is for webhook HMAC + PaymentEvent mapping. The Stripe secret in source is sk_test_example_not_live; the webhook secret is CHECKOUT_STRIPE_WEBHOOK_SECRET (whsec_test_example_checkout). Those are fixtures, not live credentials.
The client is created with no onWebhookVerified hooks. Fulfillment belongs only in the inbox handler after claim.
Store options
CreateCheckoutKernelOptions precedence: storeFactory > stores > executor > in-memory Bun SQLite.
Default path (sqlite hosts and tests that omit options):
const mem = createBunSqliteStoresInMemory({ clock });
await migrateSqliteAdapter(mem.executor);:memory: is process-local and single-host. It is not multi-host coordination. Importing a store package does not apply DDL; the kernel migrates explicitly.
Do not install @paykernel/internal-sql-store. Inbox/recon tables come from @paykernel/store-sqlite (or the store you inject).
Create payment
POST /payments → createOrderPayment:
- Amount is always the server catalog:
money("10.00", "USD"). Extra JSON fields (including clientamount) are ignored. OnlyorderIdis read from the body. - Duplicate
orderId→409 { "error": "order_exists" }. client.createPaymenton mock.NetworkErrorkeeps the order, schedules recon, returns200withoutcome: "indeterminate"andreconciliationRequired: true, and does not leakerr.message.- Other create failures delete the order and return
500 { "error": "create_failed" }. - If
result.outcome === "indeterminate"orreconciliationRequired === true, the kernel schedules recon. It does not callcreatePaymentagain. - A successful mock create still leaves the order
unpaid. Paid create does not fulfill. Fulfillment is webhook-claim or reconupdate_local_to_paid.
Webhook fulfillment
handleStripeWebhook calls processWebhookHttp with ackPolicy: { kind: "provider_redelivery" }, client as verify-only verifier, then inbox claim, then webhookHandler.
The handler returns without fulfilling unless:
- event type is
payment.succeededorcapture.completed, and payment.status === "paid", and- a
gatewayPaymentIdis present (payment.references.providerObjectId, orgatewayPaymentIdon the event).
Binding:
- Match stored
gatewayPaymentIdfirst. - Else, if the order has no stored id, bind the webhook PaymentIntent id and fulfill.
- If the stored id differs, do not fulfill (Stripe fixture metadata cannot pay a different mock charge).
- No local order → handler throws → retryable
handler_failed→ HTTP500.
payment_intent.created verifies and acks 200 and leaves the order unpaid.
Never fulfill in onWebhookVerified. handleWebhook verifies and normalizes; it does not claim, lease, or set HTTP status.
HTTP status (mapInboxOutcome)
Default policy in this kernel is provider_redelivery (packages/integration-http/src/http-policy.ts):
| Inbox outcome | Status |
|---|---|
processed |
200 |
duplicate_completed |
200 |
invalid_webhook |
400 |
payload_conflict |
409 |
already_processing |
503 |
handler_failed (retryable: true) |
500 |
handler_failed (retryable: false) |
200 |
scheduled_for_retry (not_available) |
503 |
scheduled_for_retry (parked / handler_retry) |
503 |
Route helpers
createCheckoutHandlers / dispatchCheckoutRequest / createCheckoutFetchApp:
const kernel = await createCheckoutKernel();
const app = createCheckoutFetchApp(kernel, { enableTestHooks: true }); // tests only
const res = await app.fetch(req);dispatchCheckoutRequest reads POST /webhooks/stripe with req.text() and stripe-signature / Stripe-Signature. Invalid JSON on /payments or /internal/provider-paid → 400 { "error": "invalid_json" }. Bad orderId decode → 400 { "error": "invalid_order_id" }. Unknown path → 404 { "error": "not_found" }.
Reconciliation test hook
reconcileDue (behind the flag) runs scheduler.processDue. For each job it rebuilds a ReconciliationTarget, calls reconciler.reconcile, then decideReconciliationPolicy:
update_local_to_paid+safe→ fulfill onceupdate_local_to_failed+safe→ mark faileddo_not_create_replacement→ retry with the decision reason (still no newcreatePayment)- otherwise
manual_review/retry_later
POST /internal/provider-paid with { "gatewayPaymentId": "…" } injects a paid buildProviderPaymentSnapshot for local tests. Missing id → 400; unknown id → 404.
Stripe fixtures
stripe-webhook.ts builds sanitized PaymentIntent events and signs t=<unix>,v1=<hmac-hex> with hmacSha256Hex from core. Paid fixtures include amount_received and omit string latest_charge (an unexpanded charge id demotes domain status to processing). Checkout Session cs_test_ / cs_live_ ids fail assertFixtureSafe from @paykernel/testkit. Use signStripeWebhook / signedStripePaidWebhook — do not copy HMAC helpers.
Shared HTTP scenarios
runCheckoutHttpScenarios(name, createApp) is the bun:test suite the SQLite / Workers hosts run (bun-hono-sqlite, bun-elysia-sqlite, express-sqlite, cloudflare-workers-fetch, plus checkout-kernel itself). Each it builds its own kernel, passes { enableTestHooks: true } in the host wrapper, and close()s. bun-hono-postgres does not call it — that host uses describe.skipIf(!hasPg) over a live Postgres URL.
Covered failure / honesty paths:
| Scenario | Expected |
|---|---|
| Paid mock create | Order stays unpaid, fulfillCount === 0 |
| Signed paid webhook whose PI matches stored id | 200, fulfill once |
| Paid fixture with a different PI | 200, still unpaid |
| Indeterminate create (no stored id) + paid webhook | Bind PI from webhook, then fulfill |
| Redelivery | Second 200, still one fulfill |
| Bad signature | 400, unpaid |
JSON.parse + stringify (or pretty-print) body |
400, unpaid |
| Concurrent same body | 200 or 503, exactly one fulfill |
fulfillThrows: true |
500, unpaid |
payment_intent.created |
200, unpaid |
| Indeterminate create + provider-paid + reconcile | Pays without a second createPayment |
Client-posted amount: "10.001" |
Ignored; catalog charge |
provider_ok_client_timeout |
200 + indeterminate + reconciliationRequired; no second charge |
NetworkError |
200 + indeterminate; message not in JSON |
| Two indeterminate orders | Recon does not bind the other order’s provider id |
Run
bun test examples/checkout-kernelFrom the repo root, bun test examples / bun run test:examples runs the kernel and every host.