Una visita a /dashboard?checkout=success non dimostra che un pagamento sia stato completato: chiunque può digitare quell’URL. In un’integrazione di abbonamenti con Stripe, il cambio di piano deve arrivare al server tramite un webhook verificato; successivamente, l’app consulta il proprio database per decidere quali funzionalità può usare la persona.
Il flusso di questo esempio usa TypeScript, Route Handlers di Next.js, stripe-node, Prisma con Postgres e una funzione di autenticazione lato server chiamata auth(). L’SDK ufficiale di Stripe per Node.js si installa con npm e richiede una chiave segreta; Next.js consente di ricevere queste notifiche tramite Route Handlers pubblici. Il codice presuppone che nel progetto esistano già @/auth e @/lib/db.
flowchart TD A["Usuario inicia Checkout"] --> B["Servidor crea sesión"] B --> C["Stripe completa el flujo"] C --> D["Webhook verifica evento"] D --> E["Base de datos sincroniza plan"] E --> F["Servidor comprueba acceso"]
Preparare Stripe e il modello dei dati
Installa l’SDK e il pacchetto server-only per contrassegnare il modulo che contiene la chiave segreta. In Stripe, crea un prodotto con un prezzo ricorrente e salva il relativo identificatore price_... nell’ambiente del server. Il browser non sceglierà il prezzo: il Route Handler lo recupera da una variabile d’ambiente.
npm install stripe server-only
// lib/stripe.ts
import "server-only";
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
L’esempio registra nell’utente il cliente Stripe, l’abbonamento, il prezzo e il relativo stato. Una seconda tabella memorizza gli identificatori degli eventi già ricevuti, con una chiave univoca che consente di rilevare le consegne duplicate.
model User {
id String @id @default(cuid())
email String @unique
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
subscriptionStatus String?
}
model StripeEvent {
id String @id
type String
createdAt DateTime @default(now())
}
Configura nell’ambiente del server STRIPE_SECRET_KEY, STRIPE_PRICE_PRO e APP_URL. Il segreto di firma del webhook verrà aggiunto quando si configura l’endpoint. Il prezzo viene deciso dal server: non accettare dal client un importo o un identificatore del prezzo che determini quanto viene addebitato.
Creare la sessione di Checkout
Il browser deve solo chiamare questo endpoint e reindirizzare alla URL restituita. L’handler verifica la sessione autenticata, riutilizza il cliente Stripe associato all’utente oppure ne crea uno nuovo e crea una sessione in modalità abbonamento. Aggiungere l’ID dell’utente ai metadati dell’abbonamento permette di associare in seguito gli eventi all’account.
// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
export async function POST() {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
});
customerId = customer.id;
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const checkout = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
client_reference_id: user.id,
line_items: [{ price: process.env.STRIPE_PRICE_PRO!, quantity: 1 }],
subscription_data: { metadata: { userId: user.id } },
success_url: `${process.env.APP_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.APP_URL}/pricing`,
});
return NextResponse.json({ url: checkout.url });
}
Nell’interfaccia, un pulsante può inviare una richiesta POST a /api/checkout e navigare verso url. La schermata di successo serve a mostrare una conferma della navigazione, non a concedere l’accesso: l’autorizzazione dipende dallo stato aggiornato dal webhook.
Verificare gli eventi e sincronizzare l’abbonamento
Stripe firma i byte esatti del corpo della richiesta. Leggi il corpo con req.text() e passalo a constructEvent prima di considerare attendibile il contenuto. Verifica il corpo senza analizzarlo: leggerlo come JSON e serializzarlo di nuovo può modificare i byte e invalidare la firma. Questa route usa il runtime di Node.js e restituisce un errore se la firma non è valida.
// app/api/stripe/webhook/route.ts
import { Prisma } from "@prisma/client";
import Stripe from "stripe";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
import { syncSubscription } from "@/lib/billing";
export const runtime = "nodejs";
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
try {
await db.stripeEvent.create({ data: { id: event.id, type: event.type } });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return new Response("Already processed", { status: 200 });
}
throw error;
}
try {
if (event.type === "checkout.session.completed") {
const checkout = event.data.object as Stripe.Checkout.Session;
if (checkout.mode === "subscription" && checkout.subscription) {
const subscriptionId = typeof checkout.subscription === "string"
? checkout.subscription
: checkout.subscription.id;
await syncSubscription(subscriptionId);
}
}
if (
event.type === "customer.subscription.created" ||
event.type === "customer.subscription.updated" ||
event.type === "customer.subscription.deleted"
) {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscription(subscription.id);
}
} catch {
await db.stripeEvent.delete({ where: { id: event.id } }).catch(() => {});
return new Response("Handler failed", { status: 500 });
}
return new Response("ok", { status: 200 });
}
La tabella degli eventi evita di elaborare due volte lo stesso ID. Se la sincronizzazione non riesce, l’handler elimina il record di deduplicazione e restituisce un errore, così Stripe può riprovare. Per sincronizzare, recupera l’abbonamento corrente da Stripe invece di ricostruirne lo stato applicando gli eventi uno per uno: potrebbero arrivare fuori ordine.
// lib/billing.ts
import "server-only";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
export async function syncSubscription(subscriptionId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const item = subscription.items.data[0];
const customerId = typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
await db.user.update({
where: { stripeCustomerId: customerId },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: item?.price.id ?? null,
subscriptionStatus: subscription.status,
},
});
}
Questa sincronizzazione salva lo stato e il prezzo del primo elemento dell’abbonamento. Gli eventi di Checkout e dell’abbonamento mantengono aggiornati questi dati quando un abbonamento viene creato, aggiornato o eliminato. Gli eventi ripetuti non duplicano le modifiche perché la tabella usa l’ID dell’evento come chiave primaria.
Limitare le funzionalità Pro e aggiungere il portale
Centralizza la regola di accesso. In questo caso, sono considerati Pro solo gli abbonamenti con stato active o trialing e il cui prezzo corrisponde a quello configurato. La gestione di past_due non rientra in questa regola; se l’applicazione deve concedere un periodo di tolleranza, il criterio va modificato qui, non in componenti isolati.
// lib/plan.ts
const proPrices = new Set([process.env.STRIPE_PRICE_PRO!]);
type PlanUser = {
subscriptionStatus: string | null;
stripePriceId: string | null;
};
export function isPro(user: PlanUser) {
return (
(user.subscriptionStatus === "active" ||
user.subscriptionStatus === "trialing") &&
!!user.stripePriceId &&
proPrices.has(user.stripePriceId)
);
}
Esegui il controllo sul server, accanto alla risorsa protetta. Nascondere un pulsante in un componente client non blocca una richiesta diretta alla pagina o a un’API.
// app/dashboard/reports/page.tsx
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { isPro } from "@/lib/plan";
export default async function ReportsPage() {
const session = await auth();
const userId = session?.user?.id;
if (!userId) redirect("/login");
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
if (!isPro(user)) redirect("/pricing");
return <main>Reports for {user.email}</main>;
}
Applica la stessa condizione alle Server Actions e ai Route Handlers che eseguono operazioni a pagamento. Il middleware può aiutare con i reindirizzamenti, ma non deve essere l’unico controllo degli accessi: verifica i permessi nel punto in cui la risorsa viene letta o modificata.
Per consentire a ogni cliente di gestire il proprio abbonamento, crea una sessione del portale dal server. Attiva le funzionalità necessarie nelle impostazioni del Customer Portal di Stripe; le modifiche apportate dal cliente verranno sincronizzate tramite gli eventi già gestiti dal webhook.
// app/api/portal/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
export async function POST() {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
if (!user.stripeCustomerId) {
return NextResponse.json({ error: "No customer" }, { status: 400 });
}
const portal = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/dashboard/billing`,
});
return NextResponse.json({ url: portal.url });
}
Test locali ed errori comuni
Per testare i webhook in locale, accedi con Stripe CLI e inoltra gli eventi alla route. La CLI fornisce un segreto di firma dedicato: usa quel valore in locale, non quello dell’endpoint di test o di produzione. Ogni endpoint ha il proprio segreto e le variabili d’ambiente di Vercel per Preview e Production devono rimanere separate.
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# In un altro terminale
stripe trigger customer.subscription.updated
Un test completo può prevedere di terminare Checkout in modalità di test, verificare che la riga dell’utente sia cambiata, aprire il portale e annullare l’abbonamento. È inoltre utile ripetere un evento dalla Dashboard e verificare che il suo ID non venga elaborato una seconda volta. Stripe consiglia di usare il corpo non analizzato per verificare le firme; la documentazione di Next.js specifica che il corpo di una richiesta può essere letto una sola volta.
Se il webhook non arriva, controlla che l’autenticazione globale non reindirizzi quella route alla pagina di accesso e che non ci siano reindirizzamenti URL: Stripe non segue i reindirizzamenti dei webhook. Se la firma non è valida, verifica che il segreto corrisponda all’ambiente. E se l’accesso viene concesso dalla URL di successo o viene solo nascosto sul client, sposta la decisione nel webhook e nel controllo lato server.
Con questi componenti, Stripe riceve la richiesta di abbonamento, il webhook conferma e sincronizza lo stato e Next.js applica il permesso quando serve la funzionalità protetta. La schermata di successo informa l’utente; il database e il controllo lato server determinano l’accesso.
Fonti: DEV Community, Vercel Academy, GitHub, Next.js