Suluk
    Preparing search index...

    Webhooks — verified inbound Stripe + idempotent dispatch

    Own the wiring (the Effect service, the Hono route, the dedup table, the provision fragment); npm the money-critical logic (SDK-free signature verification + the typed event router stay in @suluk/payments, so a fix flows to everyone).

    An Effect-TS Webhooks service over @suluk/payments' inbound Stripe surface: verify the RAW body against stripe-signature, dedup on the Stripe event id, then route the verified event to a handler.


    CANDIDATE tooling — not official OpenAPI. Suluk is a single-contributor candidate for OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable to ratify anything on the SIG's behalf. This is a shadcn registry module — code you own, wired over the @suluk/* npm packages.

    pnpm dlx shadcn@latest add MahmoodKhalil57/suluk/webhooks
    # or: npx shadcn@latest add MahmoodKhalil57/suluk/webhooks
    # pin to a ref: MahmoodKhalil57/suluk/webhooks#main

    registryDependencies pull in the base app module automatically (the shared Hono app + the Effect Db service). The module drops four files into your app and installs its npm deps.

    File Target What it is
    webhooks.service.ts src/services/webhooks.ts The Webhooks Effect service (Context.Tag + WebhooksLive layer).
    webhooks.routes.ts src/routes/webhooks.ts The Hono route — POST /webhooks/stripe.
    webhooks.schema.ts src/db/webhooks.ts The owned webhook_event Drizzle table (the dedup ledger).
    webhooks.provision.ts provision/webhooks.ts The InstanceSpec[] fragment — the migration for webhook_event.

    The Effect service (Webhooks) exposes two operations, composed as Layer.provide(WebhooksLive, DbLive(env)) (it also needs WebhookCfgLive(env) for the signing secret):

    • verify(rawBody, signatureHeader) — calls verifyStripeSignature (Web Crypto HMAC-SHA256, replay window enforced) against the RAW bytes and the STRIPE_WEBHOOK_SECRET; returns the typed StripeWebhookEvent, or null on a bad/stale signature (the caller returns 400). It parses the body only after the HMAC checks out.
    • dispatch(event) — claims the Stripe event id in the owned webhook_event table via a single INSERT … onConflictDoNothing; if the id already exists it returns { deduped: true } (Stripe is at-least-once, so a redelivery is a no-op). Otherwise it routes the event through webhookRouter and returns { deduped: false, result }.

    Handlers are a documented STUB set (defaultHandlers) — a no-op on STRIPE_EVENTS.checkoutCompleted that the app replaces with real fulfillment (e.g. a credits.grant call). It's kept here rather than importing @suluk/credits, so the webhook module stays decoupled.

    The route (webhooksRoutes()) mounts one endpoint, POST /webhooks/stripe. It reads the RAW body with c.req.text() (NOT .json() — re-serializing would change the bytes Stripe signed), reads the stripe-signature header, then verifydispatch. Returns 200 on success (so Stripe stops redelivering) and 400 on a bad/stale signature. Mount it with app.route("/webhooks", webhooksRoutes()).

    The owned table (webhookEventwebhook_event) is the at-least-once dedup ledger: one row per processed event, keyed on the Stripe event id (evt_…), with the event type and processedAt kept for auditing. The provision fragment adds its migration (0005_webhooks) to the shared app database (ref: "db") — merge it into your provision.config.ts alongside the other modules and run @suluk/provision (plan/apply).

    • npm (dependencies): @suluk/payments (the inbound Stripe surface — verification + routing), @suluk/provision (the InstanceSpec type for the fragment), effect, drizzle-orm, hono.
    • registry (registryDependencies): MahmoodKhalil57/suluk/app — the base Hono app + the Effect Db service this module injects.

    This module owns the service wiring, the route, the webhook_event dedup table, and the provision fragment — all yours to edit. The two hard, security-critical parts stay upstream in @suluk/payments so a correctness fix flows to everyone via npm, never a forked money path:

    • verifyStripeSignature — SDK-free HMAC-SHA256 over ${timestamp}.${rawBody}, with the timestamp checked against Stripe's replay window (default 300s). Never diverge this in your app.
    • webhookRouter — a typed router that dispatches on event.type (via STRIPE_EVENTS) instead of one giant switch. You supply the handler map; the routing logic is upstream.

    Fulfillment (what a handler does) is intentionally the app's job — override defaultHandlers in your copy to keep webhooks decoupled from @suluk/credits and the rest of your business logic.

    Apache-2.0