PAS7 Studio
Illustration of Bun.js, Stripe webhook, and PostgreSQL ledger
Technology27 Aug 2026·3 min read·Updated 27 Aug 2026

Bun.js + Stripe Webhooks + Drizzle: payments without duplicates

Handle Stripe webhooks on Bun.js with signature verification, a PostgreSQL event ledger, idempotency, and transaction-safe business operations.

Full-stack developersSaaS and ecommerce teamsBackend payment teams

Bun reads the raw request body, the Stripe SDK verifies its signature, and PostgreSQL stores the event ID before business work starts. A duplicate event is found in the ledger and becomes a no-op.

Verify signatures against the unchanged raw body.
Use a unique event ID as the idempotency key.
Commit the business change and processed marker atomically.
Xin

Why a webhook is not a normal callback

Stripe may retry delivery, change event order, or deliver after a temporary server failure.

01

Stripe sends a signed event

The payload includes Stripe-Signature. Do not trust JSON before verification.

02

Bun verifies the raw body

Read request.text(), not request.json(): parsing can change the signed bytes.

03

The ledger catches duplicates

A unique event.id prevents one delivery from running business work twice.

04

A transaction changes the domain

Update payment status or entitlement and mark the event processed atomically.

Duplicate delivery stops at idempotency; the business job runs only after verification.

Section architecture screenshot

Step 1: install and store secrets

01

Install the SDK

BASH
bun add stripe drizzle-orm postgres
bun add -d drizzle-kit
02

Configure secrets

ENV
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
DATABASE_URL=postgres://app:app@localhost:5432/payments

The Stripe CLI secret and production endpoint secret are different. Keep them separate.

03

Create the event ledger

TS
export const stripeEvents = pgTable("stripe_events", {
  id: text("id").primaryKey(),
  type: text("type").notNull(),
  processedAt: timestamp("processed_at", { withTimezone: true }),
  receivedAt: timestamp("received_at", { withTimezone: true }).defaultNow().notNull(),
});

The primary key is the final duplicate-delivery guard.

Step 2: verify the raw body

The common mistake is calling request.json() before constructEvent. The bytes may no longer match the signed payload.

TS
import Stripe from "stripe";
const stripe = new Stripe(Bun.env.STRIPE_SECRET_KEY!);
export async function verifyStripeRequest(request: Request) {
  const signature = request.headers.get("stripe-signature");
  if (!signature) throw new Response("Missing signature", { status: 400 });
  const rawBody = await request.text();
  return stripe.webhooks.constructEvent(rawBody, signature, Bun.env.STRIPE_WEBHOOK_SECRET!);
}

constructEvent verifies the HMAC signature and timestamp tolerance. Reject anything that fails it.

Step 3: an idempotent Drizzle handler

Insert the event first. A conflict means Stripe delivered it again, so return 200 without repeating fulfillment.

TS
export async function processEvent(event: Stripe.Event) {
  const inserted = await db.insert(stripeEvents).values({ id: event.id, type: event.type }).onConflictDoNothing().returning();
  if (inserted.length === 0) return { duplicate: true };
  await db.transaction(async (tx) => {
    if (event.type === "checkout.session.completed") {
      const session = event.data.object as Stripe.Checkout.Session;
      // tx.update(orders).set({ status: "paid" }).where(eq(orders.id, session.client_reference_id!));
    }
    await tx.update(stripeEvents).set({ processedAt: new Date() }).where(eq(stripeEvents.id, event.id));
  });
  return { duplicate: false };
}

Step 4: endpoint and retry policy

Return 2xx only after the event is verified and processed, or already present in the ledger. Return 500 for a temporary database failure so Stripe retries.

TS
export async function POST(request: Request) {
  try {
    const event = await verifyStripeRequest(request);
    await processEvent(event);
    return new Response("ok", { status: 200 });
  } catch (error) {
    console.error("stripe webhook failed", error);
    return new Response("retry", { status: 500 });
  }
}

For slow jobs, enqueue after the database commit. Do not make an unbounded network call inside the webhook request.

Test locally with Stripe CLI

01

Forward events

BASH
stripe listen --forward-to http://localhost:3000/api/stripe/webhook
02

Trigger an event

BASH
stripe trigger checkout.session.completed
03

Verify duplicates

Send the same event twice. You should see one business change and a second { duplicate: true } result. Never disable signature verification in CI.

Production checklist

Verify signature and timestamp

Frontend data or event type alone is not proof of payment.

Protect payload data

Store JSON only when needed for audit; restrict access and redact secrets from logs.

Rotate webhook secrets

Use separate secrets per environment and follow a rotation policy.

Monitor failed events

Alert on 5xx responses, rising duplicate rates, and unprocessed ledger rows.

FAQ

FAQ

Can I parse a Stripe webhook with request.json()?

Not before signature verification. Read request.text(), verify it with the Stripe SDK, and only then use event data.

What should a duplicate event return?

Usually 200: it was already processed. Return 500 for a temporary database failure so Stripe retries.

Reviewed: 27 Aug 2026Applies to: Bun 1.3+Applies to: Stripe Node SDKApplies to: Drizzle ORMApplies to: PostgreSQL 16+Tested with: Bun runtimeTested with: stripeTested with: drizzle-ormTested with: Stripe CLI

Conclusion

Describe the task — first 15 minutes of consultation are free.

Related Articles

AI Assistant Development Cost in 2026: RAG Chatbots, CRM Integrations, Guardrails, and Support
ai-assistants

AI Assistant Development Cost in 2026: RAG Chatbots, CRM Integrations, Guardrails, and Support

A practical buyer guide to AI assistant development cost in 2026: prototypes, RAG chatbots, knowledge-base assistants, CRM and website integrations, guardrails, evaluations, monitoring, and support.

AI for landing page development: where it speeds up launches and where it hurts conversion
blogs

AI for landing page development: where it speeds up launches and where it hurts conversion

A practical research piece on using AI for landing page development: v0, Webflow AI, Builder.io, Framer-like builders, UX generation, copy, SEO, personalization, A/B testing, template risk, accessibility, security and technical debt.

AI SEO / GEO in 2026: Your Next Customers Aren’t Humans — They’re Agents
growth

AI SEO / GEO in 2026: Your Next Customers Aren’t Humans — They’re Agents

Search is shifting from clicks to answers. Bots and AI agents crawl, cite, recommend, and increasingly buy. Learn what AI SEO / GEO means, why classic SEO is no longer enough, and how PAS7 Studio helps brands win visibility in the agentic web.

The most powerful Apple chip yet? M5 Pro and M5 Max are breaking records
blogs

The most powerful Apple chip yet? M5 Pro and M5 Max are breaking records

A data-backed March 2026 analysis of Apple M5 Pro and M5 Max. We break down why these chips can credibly be called Apple's most powerful pro laptop silicon, how they compare with M4 Pro, M4 Max, M1 Pro, M1 Max, and how they stack up against Intel and AMD laptop rivals.

Professional development for your business

We create modern web solutions and bots for businesses. Learn how we can help you achieve your goals.