expressRawJson() plus expressWebhook adapt Node/Express to processWebhookHttp. Node-only (paymentsSdk.runtime: "node-only"), not portable. Depends only on @paykernel/integration-http among workspace packages.
Version 0.1.1 — published on npm. Peer express >= 4. Export map: "." only. Engines: Node >= 18.
bun add @paykernel/integration-express
# dependency: @paykernel/integration-http
# peer: express >= 4Usage
import express from "express";
import { expressRawJson, expressWebhook } from "@paykernel/integration-express";
const app = express();
app.post(
"/webhooks/stripe",
expressRawJson(),
expressWebhook({
gateway: "stripe",
client, // PaymentClient with no onWebhookVerified fulfillment
engine,
handler,
}),
);
app.post("/payments", express.json(), (req, res) => {
// JSON parser is fine on non-webhook routes.
});expressWebhook accepts Omit<ProcessWebhookHttpInput, "rawBody" | "headers" | "query">.
expressRawJson() returns express.raw({ type: "application/json" }). type-is matches application/json with or without ; charset=utf-8. Use it only on webhook routes.
Runnable host: Express + SQLite. That example’s SQLite is single-host. The example itself requires Bun bun:sqlite for :memory:; Node without bun:sqlite needs a store-sqlite /node or /better-sqlite3 driver.
How the body is read
expressWebhook inspects req.body in this order:
Buffer.isBuffer(body)→ forward asUint8Array(preserves the Buffer guard)typeof body === "string"→ forward the stringbody instanceof Uint8Array→ forward bytes- parsed object or array:
- object-HMAC gateways (
tap,moyasar,paymob—OBJECT_HMAC_GATEWAYSin@paykernel/integration-http) →JSON.stringify(body)and forward that string (processWebhookHttpre-parses objects; arrays stay a string and the verifier fail-closes) - string-HMAC gateways (
stripe,paypal,myfatoorah, and any other non-object-HMAC name) → fail closed400 { error: "invalid_webhook" }without calling the client (re-serialization is not byte-identical)
- object-HMAC gateways (
undefined/null→ empty string""- anything else →
String(body)
Query: string values from req.query, or the first string entry of an array. Headers: req.headers. The adapter writes result.headers with res.setHeader, then res.status(result.status).json(result.body). Unexpected throws go to next(err).
Failure paths
| Request | Status | Body |
|---|---|---|
| Stripe (or PayPal / MyFatoorah) with a parsed object body | 400 | { error: "invalid_webhook" } — client not called |
Missing stripe-signature (raw body present) |
400 | { error: "invalid_webhook" } — client not called |
| Forgery (bad HMAC) | 400 | { error: "invalid_webhook" } |
Parse / missing-config InvalidWebhookError |
500 | { outcome: "handler_failed", retryable: true } |
already_processing |
503 | { outcome: "already_processing" }; Retry-After when retryAfterMs is set |
payload_conflict |
409 | { outcome: "payload_conflict" } |
| Handler throws | 500 | { outcome: "handler_failed", retryable: true } |
Tap / Moyasar / Paymob tolerate a pre-parsed object by stringifying it. That is not safe for Stripe. Prefer expressRawJson() for every webhook route.
Default ackPolicy is provider_redelivery (scheduled_for_retry → 503). { kind: "durable_worker" } ACKs 200 only with engine.mode === "durable_retry" and workerGuaranteed === true.
Status codes come from mapInboxOutcome in @paykernel/integration-http, not from @paykernel/webhooks.
Re-exports
import {
expressRawJson,
expressWebhook,
mapInboxOutcome,
retryAfterSeconds,
processWebhookHttp,
webhookHttpResultToResponse,
createWebhookOperationContext,
getHeader,
resolveCorrelationId,
requireStringBindings,
GATEWAY_WEBHOOK_SIGNATURE,
extractWebhookSignature,
} from "@paykernel/integration-express";Types re-exported: InboxHttpAckPolicy, HeaderBag, GatewayWebhookSignatureProfile, WebhookClient, WebhookHttpResult, ProcessWebhookHttpInput.
OBJECT_HMAC_GATEWAYS is not re-exported here (the adapter imports it internally). Import the set from @paykernel/integration-http if you need it.
Example POST /internal/* routes in the Express checkout example are unauthenticated test hooks (enableTestHooks) and must not be deployed.