# Š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#
| Zahtjev | Verzija | Napomena |
|---|---|---|
| Next.js | 14 ili 15 | Podrazumijeva se App Router |
| Node.js | 18 ili 20 | Preporučen LTS |
| Stripe račun | Bilo koji | Trebaš webhook signing secret |
| stripe npm package | 14+ | Koristi službeni SDK |
| Baza podataka | Preporučen Postgres | Za idempotentnost i ažuriranje stanja |
| Stripe CLI | Najnoviji | Za 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:
- 1Verificiraj Stripe potpis koristeći sirovo tijelo zahtjeva.
- 2Osiguraj idempotentnost koristeći
event.idkao jedinstveni ključ. - 3Persistiraj event i minimalne podatke potrebne za audit i ponovne pokušaje.
- 4Primijeni promjene u transakciji ili enqueueaj posao za pozadinsku obradu.
- 5Vrati
200brzo 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.createdi 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:
npm i stripeDodaj environment varijable:
| Varijabla | Primjer | Gdje |
|---|---|---|
STRIPE_SECRET_KEY | sk_live_... | Samo server runtime |
STRIPE_WEBHOOK_SECRET | whsec_... | Webhook endpoint secret |
STRIPE_API_VERSION | 2024-06-20 | Opcionalno, 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.
// 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:
| Stupac | Tip | Zašto postoji |
|---|---|---|
id | text primary key | Sprema event.id i nameće jedinstvenost |
type | text | Debugging i filtriranje |
created | integer | Stripe timestamp kreiranja eventa |
livemode | boolean | Razdvajanje test i live audita |
payload | jsonb | Opcionalno, za audit i ponovnu obradu |
processed_at | timestamptz | Praćenje završetka |
processing_error | text | Praćenje grešaka |
Primjer SQL-a za Postgres:
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.
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:
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 case | Preporučeni događaji | Napomene |
|---|---|---|
| Stanje pretplate | customer.subscription.created, customer.subscription.updated, customer.subscription.deleted | Tretiraj ih kao source of truth |
| Checkout završetak | checkout.session.completed | Često se koristi za povezivanje usera s customerom |
| Invoice lifecycle | invoice.paid, invoice.payment_failed | Korisno za kontrolu pristupa i dunning |
| Disputes | charge.dispute.created | Compliance 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.
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:
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.
// 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
500za permanentne greške može stvoriti retry storm.500vrać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:
- 1Pretplate izvan redoslijeda: stariji
customer.subscription.updatedstigne nakon novijeg. - 2Race conditioni između događaja: npr.
invoice.paidstigne 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.
| Polje | Gdje | Svrha |
|---|---|---|
stripe_subscription_id | subscriptions tablica | Ključ entiteta |
last_stripe_event_created | subscriptions tablica | Zaštita od zastarjelih eventova |
Pseudo-logika:
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:
| Nuspojava | Rizik | Rješenje |
|---|---|---|
| Provisioning featurea | Duplo provisionanje | Unique constrainti ili “already provisioned” flagovi |
| Slanje emailova | Dupli emailovi | Email outbox tablica ključana s event.id |
| Slanje na Slack | Spam | De-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:
stripe loginProslijedi webhookove prema lokalnom Next.js serveru:
stripe listen --forward-to http://localhost:3000/api/stripe/webhookCLI ispiše webhook signing secret za tu sesiju. Stavi ga u .env.local kao STRIPE_WEBHOOK_SECRET.
Pokreni (trigger) česte događaje:
stripe trigger checkout.session.completedKorisniji triggeri za subscription flowove:
stripe trigger customer.subscription.created
stripe trigger invoice.paid
stripe trigger invoice.payment_failed💡 Savjet: Drži jedan terminal s
stripe listen, a drugi snpm 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#
| Tema | Preporuka | Zašto |
|---|---|---|
| Runtime | Node.js | Najpredvidljivije rukovanje sirovim bodyjem |
| Timeouti | Drži handler ispod par sekundi | Smanjuje retry |
| Logiranje | Logiraj event ID i type | Brži incident response |
| DB konekcije | Koristi pooled konekcije | Izbjegni 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:
| Simptom | Vjerojatan uzrok | Rješenje |
|---|---|---|
| Uvijek pada lokalno | Pogrešan STRIPE_WEBHOOK_SECRET | Kopiraj iz Stripe CLI-a ili Dashboard endpointa |
| Pada samo u produkciji | Drugačiji endpoint secret po okruženju | Koristi odvojene secrete za test i live |
| Pada nakon refaktora | Body je parsiran prije verifikacije | Koristi 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_SECRETpostavljen 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.idza 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.createdkako 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
200za duplikate. 500vraćaj samo za transient greške.- Ako je greška permanentna, zabilježi je i vrati
200da 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()istripe.webhooks.constructEvent. - Osiguraj idempotentnost tablicom u bazi ključanom s
event.id, pa vrati200za duplikate. - Dizajniraj za retry i isporuku izvan redoslijeda usporedbom
event.createdi 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 listenistripe 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
Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.
Više iz kategorije Web razvoj
Sve →Upravljanje pristupom značajkama u Next.js (App Router): dozvole, prava korištenja i pristup prema naplati
Naučite obrasce za upravljanje pristupom značajkama u Next.js App Routeru: ograničite UI, server actions i API-je prema ulozi, planu, pravima korištenja i statusu Stripe pretplate uz dosljedne provjere.
Supabase RLS + Next.js App Router: End-to-kraj autorizacija bez opasnih zamki
Praktičan vodič za 2026. za Supabase RLS s Next.js App Routerom — dizajn politika za multi-tenant, sigurni obrasci server-side provedbe i kontrolna lista za izbjegavanje curenja service role ključa, nesigurnog RPC-a i filtriranja na klijentu.
Rukovanje greškama u Reactu u 2026.: Error Boundaries, ponovni pokušaji i UX obrasci koji skaliraju
Praktičan, slojeviti vodič za obrasce rukovanja greškama u Reactu u 2026.: granice grešaka na razini komponente i rute, ponovni pokušaji u TanStack Query i SWR-u te UX poruke koje skaliraju kroz timove.
Trebate pomoć s projektom?
Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.
Povezani članci
Implementacija Stripe pretplata u Next.js-u: Billing Portal, webhookovi i prava pristupa
Vodič spreman za produkciju za Next.js Stripe pretplate: planovi i probni periodi, Billing Portal, provjera webhookova, idempotentna obrada, mapiranje prava pristupa i testiranje sa Stripe CLI-jem.
Next.js SaaS kontrolna lista za onboarding: računi, dozvole, emailovi i probni periodi (App Router, 2026.)
Kontrolna lista za onboarding Next.js SaaS aplikacije spremne za produkciju, koja pokriva autentikaciju, organizacije, pozivnice, RBAC, transakcijske emailove i konverziju iz probnog u plaćeni plan uz praktične obrasce, biblioteke i uobičajene zamke.
Next.js + Supabase SaaS početna arhitektura (App Router): Auth, RLS, naplata i multi-tenancy
Production-ready nacrt za Next.js App Router + Supabase SaaS početnu arhitekturu: autentikacija, Postgres podatkovni model, RLS politike, Stripe naplata i multi-tenant dizajn organizacija s konkretnim primjerima.