PAS7 Studio
Ілюстрація Bun.js, Stripe webhook і PostgreSQL ledger
Технології27 серп. 2026 р.·3 хв читання·Оновлено 27 серп. 2026 р.

Bun.js + Stripe Webhooks + Drizzle: платежі без дублювань

Як на Bun.js приймати Stripe webhooks, перевіряти підпис, зберігати event ledger у PostgreSQL і безпечно запускати бізнес-операції один раз.

Full-stack розробникиАвтори SaaS та ecommerceBackend-команди, що приймають платежі

Bun приймає raw request body, Stripe SDK перевіряє підпис, а PostgreSQL зберігає event ID до запуску бізнес-операції. Повторний event знаходиться в ledger і завершується без повторної видачі товару чи кредитів.

Підпис перевіряється на незміненому raw body.
Event ID має унікальний індекс і є idempotency key.
Бізнес-зміна та позначення event як processed виконуються атомарно.
Xin

Чому webhook — це не звичайний callback

Stripe може повторити доставку, змінити порядок подій або надіслати подію після тимчасової помилки сервера.

01

Stripe надсилає signed event

Подія містить payload і Stripe-Signature. Не довіряйте JSON до перевірки підпису.

02

Bun перевіряє raw body

Прочитайте request.text(), а не request.json(): зміна whitespace ламає підпис.

03

Ledger перевіряє дубль

Унікальний event.id гарантує, що одна доставка не запускає операцію двічі.

04

Транзакція змінює домен

Запишіть payment status, entitlement або order і processed event в одній транзакції.

Повторна подія зупиняється на idempotency-кроці, а бізнес job запускається тільки після перевірки.

Скріншот секції architecture

Крок 1: встановлення і секрети

01

Встановіть SDK

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

Налаштуйте secrets

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

Webhook secret для Stripe CLI та production endpoint різні. Не змішуйте їх.

03

Створіть ledger table

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(),
});

Primary key на event ID — остання лінія захисту від дублювання.

Крок 2: перевіряємо підпис на raw body

Найчастіша помилка — викликати request.json() до Stripe constructEvent. Після парсингу байти вже можуть відрізнятися від підписаного 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 перевіряє timestamp tolerance і HMAC-підпис. Не приймайте event, який не пройшов цю перевірку.

Крок 3: idempotent handler із Drizzle

Спочатку вставте event у ledger. Якщо PostgreSQL повернув conflict — це повторна доставка, тому поверніть 200, щоб Stripe не ретраїв її без потреби.

TS
import { eq } from "drizzle-orm";
import { db } from "./db/client";
import { stripeEvents } from "./db/schema";

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 };
}

Крок 4: endpoint і retry policy

Поверніть 2xx тільки коли подія успішно перевірена і оброблена або вже відома ledger-у. Для тимчасової помилки бази поверніть 500, щоб Stripe повторив доставку.

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 });
  }
}

Для важких jobs збережіть event у черзі після commit транзакції. Не робіть довгий network call усередині webhook request без timeout.

Локальна перевірка через Stripe CLI

01

Прокиньте події локально

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

Створіть тестову подію

BASH
stripe trigger checkout.session.completed
03

Перевірте дубль

Надішліть той самий event двічі. У логах має бути одна бізнес-зміна й другий результат { duplicate: true }. Для CI тестуйте підпис із test secret, а не вимикайте verification.

Production checklist

Перевіряйте signature і timestamp

Не використовуйте лише event.type або дані з frontend як доказ оплати.

Зберігайте payload за потреби

Для audit/debug можна зберігати JSON, але обмежте доступ і не логайте секрети.

Обмежуйте права webhook key

Використовуйте окремі secrets і міняйте їх за rotation policy.

Моніторте failed events

Алерти на 5xx, зростання duplicate rate та завислі записи без processedAt.

FAQ

Часті запитання

Чи можна парсити Stripe webhook через request.json()?

Не до перевірки підпису. Спочатку прочитайте raw body через request.text(), перевірте його Stripe SDK, а вже потім працюйте з event data.

Що повертати для дубльованої події?

Зазвичай 200: event уже оброблено, тому повторювати бізнес-операцію не потрібно. Для тимчасової помилки бази повертайте 500, щоб Stripe повторив доставку.

Перевірено: 27 серп. 2026 р.Актуально для: Bun 1.3+Актуально для: Stripe Node SDKАктуально для: Drizzle ORMАктуально для: PostgreSQL 16+Перевірено з: Bun runtimeПеревірено з: stripeПеревірено з: drizzle-ormПеревірено з: Stripe CLI для локальних webhook-ів

Висновок

Опишіть задачу — перші 15 хвилин консультації безкоштовні.

Пов'язані статті

Скільки коштує розробка AI асистента у 2026: RAG чатбот, база знань, CRM, Telegram та підтримка
ai-assistants

Скільки коштує розробка AI асистента у 2026: RAG чатбот, база знань, CRM, Telegram та підтримка

Практичний гід для бізнесу: від чого залежить ціна розробки AI асистента у 2026 році, що входить у RAG чатбот, інтеграції з CRM, Telegram, guardrails, оцінювання, моніторинг і супровід.

AI для розробки лендінгів: де він реально прискорює запуск, а де псує конверсію
blogs

AI для розробки лендінгів: де він реально прискорює запуск, а де псує конверсію

Дослідження про використання AI у розробці лендінгів: v0, Webflow AI, Builder.io, Framer-подібні AI builders, генерація UX, copy, SEO, персоналізація, A/B тести, ризики шаблонності, безпеки, доступності та технічного боргу.

AI SEO / GEO у 2026: ваші наступні клієнти — не люди, а агенти
growth

AI SEO / GEO у 2026: ваші наступні клієнти — не люди, а агенти

Пошук зміщується від кліків до відповідей. Боти та AI-агенти сканують, цитують, рекомендують і дедалі частіше купують. Дізнайтесь, що таке AI SEO / GEO, чому класичного SEO вже недостатньо, і як PAS7 Studio допомагає брендам перемагати у «агентному» вебі.

Найпотужніший чіп від Apple? M5 Pro і M5 Max б'ють рекорди
blogs

Найпотужніший чіп від Apple? M5 Pro і M5 Max б'ють рекорди

Аналітичний розбір Apple M5 Pro і M5 Max станом на березень 2026 року. Пояснюємо, чому ці чіпи можна вважати найпотужнішими професійними ноутбучними SoC від Apple, як вони виглядають на тлі M4 Pro, M4 Max, M1 Pro, M1 Max і що показують у порівнянні з актуальними Intel та AMD.

Професійна розробка для вашого бізнесу

Створюємо сучасні веб-рішення та боти для бізнесу. Дізнайтеся, як ми можемо допомогти вам досягти цілей.