Web razvoj
Next.jsStripeWebhookoviApp RouterPlaćanjaPouzdanostSaaS

Next.js + Stripe webhookovi u App Routeru: vodič za implementaciju s fokusom na pouzdanost

AO
Adrijan Omićević
·13 min čitanja

# Što ćeš izgraditi#

Ovaj vodič prikazuje produkcijski spremnu implementaciju Next.js Stripe webhookova koristeći App Router. Fokus je na pouzdanosti: stroga provjera potpisa, rad sa sirovim tijelom zahtjeva, idempotentnost, sigurni ponovni pokušaji i strategije za redoslijed događaja.

Dobit ćeš i kod koji možeš direktno kopirati, lokalno testiranje sa Stripe CLI-jem te checklistu za rješavanje čestih problema.

Ako gradiš pretplate i SaaS naplatu, nadopuni ovo s našim detaljnijim vodičem za billing: Next.js Stripe pretplate za SaaS naplatu.

# Preduvjeti#

ZahtjevVerzijaNapomena
Next.js14 ili 15Podrazumijeva se App Router
Node.js18 ili 20Preporučen LTS
Stripe računBilo kojiTrebaš webhook signing secret
stripe npm package14+Koristi službeni SDK
Baza podatakaPreporučen PostgresZa idempotentnost i ažuriranje stanja
Stripe CLINajnovijiZa lokalno testiranje webhookova

Također bi ti trebali biti poznati osnovni API routeovi i upisi u bazu.

ℹ️ Napomena: Webhookovi su sigurnosna granica. Tretiraj ih kao javni endpoint i prati best practice iz naše checkliste za sigurnost web aplikacija.

# Arhitektura: dizajn webhoka s fokusom na pouzdanost#

Webhook handler ima dva zadatka: dokazati da zahtjev dolazi od Stripea i sigurno primijeniti nuspojave.

Robusna arhitektura obično izgleda ovako:

  1. 1
    Verificiraj Stripe potpis koristeći sirovo tijelo zahtjeva.
  2. 2
    Osiguraj idempotentnost koristeći event.id kao jedinstveni ključ.
  3. 3
    Persistiraj event i minimalne podatke potrebne za audit i ponovne pokušaje.
  4. 4
    Primijeni promjene u transakciji ili enqueueaj posao za pozadinsku obradu.
  5. 5
    Vrati 200 brzo kako bi Stripe prestao pokušavati ponovno.

Zašto je to važno: Stripe će ponavljati neuspjele isporuke i po nekoliko dana, a ponovni pokušaji nisu rijetki u stvarnim sustavima. Želiš da retry bude siguran i “dosadan”.

Realnost Stripe ponovnih pokušaja i redoslijeda#

Stripe ne jamči redoslijed među različitim tipovima događaja, a ponovni pokušaji mogu uzrokovati da stariji događaji stignu nakon novijih. Ako tvoj kod pretpostavlja redoslijed, kad-tad ćeš pokvariti stanje naplate.

Dizajnerska načela koja sprječavaju korupciju podataka:

  • Koristi ograničenja u bazi za idempotentnost.
  • Radije ažuriraj stanje iz Stripe polja objekata koja predstavljaju trenutno stanje, a ne iz pretpostavljenih “delta” promjena.
  • Kada je redoslijed bitan, usporedi event.created i ignoriraj zastarjele nadogradnje.

🎯 Ključna poruka: Svaku isporuku webhoka tretiraj kao “možda dupliciranu” i “možda izvan redoslijeda”.

# Korak 1: Instaliraj Stripe i postavi environment varijable#

Instaliraj Stripe:

Bash
npm i stripe

Dodaj environment varijable:

VarijablaPrimjerGdje
STRIPE_SECRET_KEYsk_live_...Samo server runtime
STRIPE_WEBHOOK_SECRETwhsec_...Webhook endpoint secret
STRIPE_API_VERSION2024-06-20Opcionalno, ali preporučeno

U Next.js-u ih drži u .env.local za development i u tajnama (secrets) hosting providera za produkciju.

💡 Savjet: Eksplicitno “pinni” Stripe API verziju. Smanjuje neočekivane promjene payload-a kada Stripe promijeni zadane vrijednosti.

# Korak 2: Kreiraj webhook rutu u App Routeru#

Kreiraj app/api/stripe/webhook/route.ts.

Ključne odluke za pouzdanost:

  • Forsiraj Node.js runtime.
  • Čitaj sirovo tijelo kao bajtove.
  • Verificiraj potpis pomoću stripe.webhooks.constructEvent.
TypeScript
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
 
export const runtime = "nodejs";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: (process.env.STRIPE_API_VERSION as any) || "2024-06-20",
});
 
export async function POST(req: Request) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) return new Response("Missing webhook secret", { status: 500 });
 
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("Missing stripe-signature", { status: 400 });
 
  const rawBody = Buffer.from(await req.arrayBuffer());
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return new Response(`Webhook signature verification failed: ${message}`, { status: 400 });
  }
 
  // Acknowledge only after durable processing decisions are made.
  // We'll implement idempotency + processing next.
  return new Response("OK", { status: 200 });
}

Zašto su sirovi bajtovi bitni#

Stripe potpisuje točan payload koji šalje. Ako prvo parsiraš JSON pa ga ponovno serijaliziraš, whitespace i redoslijed ključeva se mogu promijeniti i provjera potpisa će pasti.

Korištenje req.arrayBuffer() čuva payload identičnim.

⚠️ Upozorenje: Nemoj zvati req.json() prije provjere. Jednom kad pročitaš body stream, ne možeš ga pročitati ponovno, i verifikacija će pasti.

# Korak 3: Dodaj idempotentnost s tablicom događaja#

Stripe ponavlja isporuke kod non-200 odgovora, timeouta, mrežnih grešaka i prolaznih (transient) problema. Ako tvoj handler primijeni nuspojave dvaput, možeš dodijeliti duple entitlements, poslati email dvaput ili pogrešno produžiti pretplatu.

Najjednostavniji robustan obrazac je:

  • Ubaci Stripe event ID u tablicu s jedinstvenim ograničenjem.
  • Ako insert padne zbog konflikta, odmah vrati 200.

Minimalna shema:

StupacTipZašto postoji
idtext primary keySprema event.id i nameće jedinstvenost
typetextDebugging i filtriranje
createdintegerStripe timestamp kreiranja eventa
livemodebooleanRazdvajanje test i live audita
payloadjsonbOpcionalno, za audit i ponovnu obradu
processed_attimestamptzPraćenje završetka
processing_errortextPraćenje grešaka

Primjer SQL-a za Postgres:

SQL
CREATE TABLE stripe_events (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,
  created INTEGER NOT NULL,
  livemode BOOLEAN NOT NULL,
  payload JSONB,
  processed_at TIMESTAMPTZ,
  processing_error TEXT
);

U kodu implementiraj korak “try insert”. Ispod je pseudo-DB kod koji možeš prilagoditi za Prisma, Drizzle ili raw SQL.

TypeScript
async function insertStripeEventOnce(event: Stripe.Event) {
  // Return true if inserted, false if already exists
  // Implement using your DB with a unique constraint on id
  return true;
}

Konkretan primjer s Postgres-stilom conflict handlinga:

TypeScript
async function insertStripeEventOnce(event: Stripe.Event) {
  const res = await db.query(
    `INSERT INTO stripe_events (id, type, created, livemode, payload)
     VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT (id) DO NOTHING`,
    [event.id, event.type, event.created, event.livemode, event]
  );
 
  // Some clients return rowCount; adapt for your DB driver
  return res.rowCount === 1;
}

# Korak 4: Obradi samo događaje koji su ti potrebni, sigurno#

Stripe emitira mnogo tipova događaja. Nemoj obrađivati sve. Odluči koji su događaji autoritativni za tvoje poslovno stanje i ignoriraj ostale.

Česti “must handle” događaji za SaaS billing:

Use casePreporučeni događajiNapomene
Stanje pretplatecustomer.subscription.created, customer.subscription.updated, customer.subscription.deletedTretiraj ih kao source of truth
Checkout završetakcheckout.session.completedČesto se koristi za povezivanje usera s customerom
Invoice lifecycleinvoice.paid, invoice.payment_failedKorisno za kontrolu pristupa i dunning
Disputescharge.dispute.createdCompliance i risk workflowi

Implementiraj switch za obradu uz defenzivno parsiranje#

Stripe oblici payload-a variraju po tipu događaja. Koristi type narrowing oprezno i nepoznate oblike tretiraj kao ne-fatalne.

TypeScript
function isStripeObject<T extends Stripe.Event.Data.Object>(obj: any): obj is T {
  return obj && typeof obj === "object" && typeof obj.object === "string";
}

Skeleton za obradu:

TypeScript
async function handleStripeEvent(event: Stripe.Event) {
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await onCheckoutSessionCompleted(event, session);
      break;
    }
 
    case "customer.subscription.updated":
    case "customer.subscription.created":
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription;
      await onSubscriptionChanged(event, sub);
      break;
    }
 
    case "invoice.paid": {
      const invoice = event.data.object as Stripe.Invoice;
      await onInvoicePaid(event, invoice);
      break;
    }
 
    default:
      // Ignore unneeded events, but keep them recorded for audit if you store payloads
      break;
  }
}

# Korak 5: Učini obradu trajnom (durable) i brzom#

Stripe očekuje pravovremen odgovor. Ako radiš spori posao unutar webhooka, povećavaš šanse za timeout i retry.

Obrazac “reliability-first” je:

  • Ubaci zapis eventa.
  • Napravi malu transakciju koja ažurira core stanje.
  • Vrati 200.
  • Prebaci skupe poslove u job queue ili background worker.

Ako si na Vercelu, background jobs i queueovi su posebno bitni. Vidi: Next.js background jobs, queueovi i cron na Vercelu.

Cijeli primjer rute s idempotentnošću i sigurnim praćenjem završetka#

Ovaj primjer vraća 200 za duplikate i bilježi greške obrade radi vidljivosti.

TypeScript
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
 
export const runtime = "nodejs";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: (process.env.STRIPE_API_VERSION as any) || "2024-06-20",
});
 
export async function POST(req: Request) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  const signature = req.headers.get("stripe-signature");
 
  if (!webhookSecret) return new Response("Missing STRIPE_WEBHOOK_SECRET", { status: 500 });
  if (!signature) return new Response("Missing stripe-signature", { status: 400 });
 
  const rawBody = Buffer.from(await req.arrayBuffer());
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return new Response(`Invalid signature: ${message}`, { status: 400 });
  }
 
  const inserted = await insertStripeEventOnce(event);
  if (!inserted) {
    // Duplicate delivery. Safe to acknowledge.
    return new Response("Duplicate", { status: 200 });
  }
 
  try {
    await handleStripeEvent(event);
    await markStripeEventProcessed(event.id);
    return new Response("OK", { status: 200 });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    await markStripeEventFailed(event.id, message);
 
    // Returning 500 asks Stripe to retry. Only do this for transient errors.
    return new Response("Webhook processing failed", { status: 500 });
  }
}
 
// Implement these with your DB layer
async function insertStripeEventOnce(event: Stripe.Event) {
  return true;
}
async function markStripeEventProcessed(eventId: string) {}
async function markStripeEventFailed(eventId: string, message: string) {}
async function handleStripeEvent(event: Stripe.Event) {}

⚠️ Upozorenje: Vraćanje 500 za permanentne greške može stvoriti retry storm. 500 vraćaj samo za greške za koje očekuješ da bi mogle uspjeti pri ponovnom pokušaju, npr. privremeni pad baze.

# Korak 6: Eksplicitno riješi redoslijed događaja#

U produkciji se najčešće pojavljuju dva problema s redoslijedom:

  1. 1
    Pretplate izvan redoslijeda: stariji customer.subscription.updated stigne nakon novijeg.
  2. 2
    Race conditioni između događaja: npr. invoice.paid stigne prije nego što je sustav povezao Stripe customer s internim userom.

Strategija A: spremi “zadnji primijenjeni Stripe event timestamp” po entitetu#

Kad ažuriraš stanje pretplate, spremi zadnji obrađeni event.created za taj subscription ID. Promjene primjenjuj samo ako je dolazni event noviji.

PoljeGdjeSvrha
stripe_subscription_idsubscriptions tablicaKljuč entiteta
last_stripe_event_createdsubscriptions tablicaZaštita od zastarjelih eventova

Pseudo-logika:

TypeScript
async function applySubscriptionUpdateSafely(event: Stripe.Event, sub: Stripe.Subscription) {
  const incoming = event.created;
 
  const current = await db.getSubscriptionByStripeId(sub.id);
  if (current && current.lastStripeEventCreated && incoming <= current.lastStripeEventCreated) {
    return; // stale, ignore
  }
 
  await db.upsertSubscriptionFromStripe(sub, incoming);
}

Strategija B: preferiraj “source of truth” objekte umjesto pretpostavki#

Umjesto “dodaj jedan mjesec pristupa”, postavi pristup na temelju Stripeovog trenutnog stanja:

  • Status pretplate
  • Current period end
  • Cancel at period end
  • Price ID i quantity

To čini retry i reordering puno manje opasnima.

# Korak 7: Idempotentnost za downstream nuspojave#

Čak i ako je sam webhook event idempotentan, nuspojave možda nisu.

Česte nuspojave:

NuspojavaRizikRješenje
Provisioning featureaDuplo provisionanjeUnique constrainti ili “already provisioned” flagovi
Slanje emailovaDupli emailoviEmail outbox tablica ključana s event.id
Slanje na SlackSpamDe-dup na temelju event.id

Praktičan obrazac je outbox tablica:

  • Piši outbox zapise u istoj DB transakciji kao i ažuriranja stanja.
  • Background worker ih isporučuje jednom.

To smanjuje kompleksnost webhooka i drži response time niskim.

# Korak 8: Lokalno testiranje sa Stripe CLI#

Stripe CLI čini testiranje webhookova predvidljivim i brzim.

Instaliraj i autentificiraj se:

Bash
stripe login

Proslijedi webhookove prema lokalnom Next.js serveru:

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

CLI ispiše webhook signing secret za tu sesiju. Stavi ga u .env.local kao STRIPE_WEBHOOK_SECRET.

Pokreni (trigger) česte događaje:

Bash
stripe trigger checkout.session.completed

Korisniji triggeri za subscription flowove:

Bash
stripe trigger customer.subscription.created
stripe trigger invoice.paid
stripe trigger invoice.payment_failed

💡 Savjet: Drži jedan terminal s stripe listen, a drugi s npm run dev. Kad verifikacija potpisa padne, CLI output i server logovi obično odmah otkriju mismatch.

# Korak 9: Produkcijska checklista konfiguracije#

Webhookovi često padaju u produkciji zbog razlika u deploymentu, a ne zbog koda.

Hosting i runtime#

TemaPreporukaZašto
RuntimeNode.jsNajpredvidljivije rukovanje sirovim bodyjem
TimeoutiDrži handler ispod par sekundiSmanjuje retry
LogiranjeLogiraj event ID i typeBrži incident response
DB konekcijeKoristi pooled konekcijeIzbjegni connection storm tijekom retrya

Osnove sigurnosti#

  • Prihvati samo POST.
  • Verificiraj Stripe potpis za svaki zahtjev.
  • Nemoj izlagati secret keyeve klijentu.
  • Razmisli o rate limitingu, ali pazi da slučajno ne blokiraš Stripe IP rangeove.

Za širi security pregled koristi: checklistu za sigurnost web aplikacija.

# Rješavanje problema: česte zamke i popravci#

Provjera potpisa povremeno pada#

Tipični uzroci:

SimptomVjerojatan uzrokRješenje
Uvijek pada lokalnoPogrešan STRIPE_WEBHOOK_SECRETKopiraj iz Stripe CLI-a ili Dashboard endpointa
Pada samo u produkcijiDrugačiji endpoint secret po okruženjuKoristi odvojene secrete za test i live
Pada nakon refaktoraBody je parsiran prije verifikacijeKoristi req.arrayBuffer() i prvo verificiraj

Također provjeri prosljeđuješ li na ispravan path. U App Routeru, putanja datoteke mapira se direktno na URL rute.

Webhook radi lokalno, ali ne na Vercelu#

Česti razlozi:

  • Ruta se izvršava na Edge runtimeu i raw body handling je drugačiji.
  • Nedostaju environment varijable u produkciji.
  • Ograničenja konekcija prema bazi se probiju tijekom retry stormova.

Rješenja:

  • Osiguraj export const runtime = "nodejs";
  • Provjeri da je STRIPE_WEBHOOK_SECRET postavljen u deployment okruženju.
  • Dodaj idempotentnost i brze odgovore kako se retry ne bi multiplicirao u load.

Dupli grantovi ili dupli emailovi#

Ako vidiš duplicirane nuspojave, nešto od ovoga nedostaje:

  • Enforcement jedinstvenosti event ID-a
  • Idempotency ključevi za nuspojave

Rješenje:

  • Enforcaj jedinstvenost na stripe_events.id.
  • Dodaj outbox tablice ključane s event.id za notifikacije.

Pogrešno stanje pretplate u tvojoj bazi#

Ako se subscription tablica “prebacuje” između stanja, to je obično kombinacija redoslijeda događaja i “delta” logike.

Rješenje:

  • Prati i uspoređuj event.created kako bi ignorirao zastarjele updateove.
  • Ažuriraj stanje iz Stripe subscription polja kao source of truth.

Stripe nastavlja retryati satima#

Stripe retrya kada endpoint vraća non-200 ili time-outa.

Rješenje:

  • Vrati 200 za duplikate.
  • 500 vraćaj samo za transient greške.
  • Ako je greška permanentna, zabilježi je i vrati 200 da zaustaviš retry, pa alertaj tim.

ℹ️ Napomena: “Permanentna greška” je nešto poput “user not found for customer ID” što se neće popraviti samo od sebe na retry. Zabilježi za ručnu sanaciju umjesto da Stripe neumorno pogađa tvoj endpoint.

# Ključne poruke#

  • Verificiraj Stripe potpise koristeći sirove bajtove tijela zahtjeva preko req.arrayBuffer() i stripe.webhooks.constructEvent.
  • Osiguraj idempotentnost tablicom u bazi ključanom s event.id, pa vrati 200 za duplikate.
  • Dizajniraj za retry i isporuku izvan redoslijeda usporedbom event.created i korištenjem Stripe objekata kao source of truth.
  • Drži webhook handler brz i durable, a spore nuspojave prebaci u background jobs ili outbox obrazac.
  • Koristi Stripe CLI za lokalno testiranje s stripe listen i stripe trigger, te provjeri environment-specific webhook secrete.

# Zaključak#

Pouzdana implementacija Next.js Stripe webhookova manje je stvar “obrade eventa”, a više garantiranja ispravnosti pod retryjem, duplikacijama i promijenjenim redoslijedom. Ako implementiraš verifikaciju potpisa nad sirovim bodyjem, idempotentnost potpomognutu bazom i guardove za redoslijed, tvoj billing sustav postaje dramatično teže “slomiti”.

Ako želiš da Samioda implementira ili auditira tvoje Next.js Stripe webhookove, subscription flowove i pozadinsku obradu, javi se putem naše web stranice i pomoći ćemo ti isporučiti produkcijski billing pipeline.

FAQ

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.