Un portale self-service che manda in pensione i primi cinque ticket di supporto già nella prima settimana
Un solo portale Next.js su `app.tuodominio.com/account`, dietro la stessa auth del prodotto, con le cinque sezioni che ogni cliente SaaS chiede davvero: account, team, billing, usage, fatture. Stripe Customer Portal gestisce le carte. La usage legge dalla fonte di verità del metering. La gestione team fa cambi ruolo senza ticket. Le domande ripetute smettono di arrivare in inbox.
Il problema
Le stesse cinque domande sono il 60% del volume di supporto
Prendi un mese di ticket e taggali. La forma è la stessa in tutti i SaaS: 'cambiare la carta', 'aggiornare il nome azienda sulla fattura', 'aggiungere un teammate', 'perché mi avete addebitato questa cifra', 'scaricare le fatture dell'ultimo trimestre'. Cinque domande, 50-60% della inbox, tutte workflow che Stripe e il prodotto supportano già ma che il cliente non riesce a raggiungere senza un umano. L'engineer di supporto passa il lunedì sui rimborsi, il martedì sui cambi posto, il mercoledì a scrivere la stessa email sette volte. Costruiamo il portale che gestisce quei cinque workflow. Il cliente fa self-service. La inbox di supporto gestisce i casi davvero umani. Il team che era cresciuto per stare dietro ai ticket si riprende il proprio tempo.
Come lo affrontiamo
Sei passi da \"cinque domande al giorno\" a un portale che i clienti usano davvero
Audit dei ticket prima dello scope. Scope prima dell'auth. Auth prima delle schermate. Schermate prima del cablaggio Stripe. Cablaggio Stripe prima delle viste di usage. Viste di usage prima delle notifiche. Ogni passo dipende dal precedente; saltarne uno produce un portale che sembra completo ma non risponde a nessuna delle domande.
- 01
Audit della inbox di supporto
Tiriamo giù gli ultimi 90 giorni di ticket, li tagghiamo, e produciamo una lista ordinata dei primi 15 motivi per cui i clienti contattano il supporto. L'output è un CSV che il founder legge in un colpo solo; i primi cinque di solito coprono il 50-60% del volume, i primi quindici l'80%. Ogni tipo di ticket prende una colonna per tempo di risoluzione attuale, costo umano attuale e se una schermata di portale può assorbirlo. L'audit è il brief di tutto ciò che segue.
- 02
Definire lo scope del portale
Dall'audit, scegliamo i workflow che il portale prenderà in carico nel primo cutover (di solito: account, team, billing, usage, fatture) e i workflow che esplicitamente restano col supporto (di solito: rimborsi oltre i 30 giorni, contratti custom, aiuto sulle integrazioni). La lista vive in un documento che il founder controfirma; non costruiamo schermate per workflow che non sono nella lista.
- 03
Cablare il portale all'auth del prodotto
Il portale è la stessa app Next.js o un'app gemella che condivide la sessione auth. Niente secondo login. Il cliente clicca su 'Account' nel prodotto e atterra dentro il portale. Non costruiamo un flusso auth parallelo perché ogni flusso auth parallelo si tira dietro i suoi bug.
- 04
Costruire le cinque schermate
Account (nome, email, reset password, autenticazione a due fattori). Team (invita, rimuovi, cambia ruolo, con UI ottimistica). Billing (carta via Stripe Customer Portal, email di fatturazione, dati azienda, partita IVA). Usage (la metrica che il cliente paga, con grafici a 30 giorni e 12 mesi, dalla stessa metering data che legge il finance). Fatture (lista, download PDF, segna come pagato per pagamenti offline). Ogni schermata esce dietro a un feature flag così il rollout è incrementale.
- 05
Integrazione di Stripe Customer Portal
Embeddiamo Stripe Customer Portal per la gestione carta e la lista fatture (Stripe le mantiene meglio di quanto faremmo noi). La sessione del portale viene creata dal backend del prodotto con una return URL che riporta il cliente dentro il prodotto, non su una pagina Stripe. Il flusso sembra nativo; il cliente non si accorge che è Stripe a renderizzare il form.
- 06
Notifiche e audit
Ogni azione di portale che cambia qualcosa di cui il supporto si occuperebbe (cambio ruolo, cambio email fatturazione, cambio piano) fa partire un evento a cui il team può abbonarsi. Slack riceve la notifica, l'audit log cattura attore e timestamp, e il cliente riceve un'email così un cambio fatto da un teammate non è una sorpresa. Il portale smette di essere una scatola nera.
Cosa consegniamo
Audit della inbox di supporto
Un CSV taggato di 90 giorni di ticket, ordinato per frequenza, con una colonna che segnala quali una schermata di portale può assorbire. Il manufatto che il founder usa per decidere lo scope. Da ripetere ogni trimestre per tenere il portale onesto.
Documento di scope del portale
Un documento di una pagina che elenca ogni schermata di portale in scope e ogni workflow che resta col supporto. Firmato prima che parta il codice; affisso nel repo così il futuro scope creep deve litigare con una decisione esistente.
Schermata account
Nome, email, reset password, registrazione 2FA, lista sessioni con revoca. Sostituisce i quattro ticket più comuni sull'account. La sezione account che ogni utente SaaS si aspetta di trovare cliccando sul proprio avatar.
Gestione team
Invito via email, rimozione membro, cambio ruolo, trasferimento ownership. Le definizioni dei ruoli vivono nella stessa config dei permessi che usa il prodotto. L'email di invito porta a un flusso tipizzato che fa atterrare il nuovo membro dentro il prodotto al giorno uno.
Sezione billing
Stripe Customer Portal embeddato per le carte, email di fatturazione e ragione sociale modificabili in-product, partita IVA e codice fiscale con validazione country-specific, cronologia fatture con PDF scaricabili. La pagina che il cliente apre quando il finance gli chiede una fattura.
Vista di usage
Un grafico della metrica che il cliente paga (chiamate API, posti, GB, esecuzioni) negli ultimi 30 giorni e negli ultimi 12 mesi. Dalla pipeline di metering di cui si fida il finance. Include un CSV scaricabile per il finance del cliente e una proiezione del mese prossimo sulla base del run rate attuale.
Pagina delle API key
Una pagina dove i clienti creano, nominano, ritagliano e revocano le API key senza un ticket. Le chiavi si mostrano una volta sola alla creazione; la rotazione è un click. Sostituisce il ticket più frequente che fa perdere tempo agli engineer nei SaaS tecnici.
Configurazione di Stripe Customer Portal
Una configurazione di Stripe Customer Portal tarata sul prodotto (quali feature appaiono, quali pagine sono raggiungibili, quale email fatturazione è modificabile). Brandizzata col logo del prodotto. Riporta il cliente alla pagina billing dentro il prodotto, non a una pagina Stripe.
Webhook e handler di eventi
Handler per ogni evento Stripe innescato dal portale (`customer.updated`, `payment_method.attached`, `customer_subscription.updated`) che tengono la copia di stato del cliente nel prodotto allineata. Idempotenti; replay-safe.
Sistema di notifiche
Notifiche Slack, email e in-app per le azioni di portale di cui il team si occupa (cambio ruolo, cambio billing, cambio piano, API key creata). Abbonabili per canale; i default sono tarati su ciò che serve a un team di supporto in fase iniziale.
Audit log del portale
Ogni scrittura di portale atterra in una tabella `portal_audit` con attore, entità, prima/dopo. Mostrato nella stessa vista di audit log che usa lo staff per le proprie azioni; il team di supporto vede la timeline completa di attività del cliente quando apre un ticket.
Help integrato e feedback
Un drawer di help contestuale con tre-cinque articoli brevi per sezione e un bottone di feedback che apre un form tipizzato. Il drawer legge da un CMS; nuovi articoli di help escono senza codice. Il feedback finisce in una board Linear che il team prodotto rivede ogni settimana.
Cinque file che compongono un portale clienti che manda in pensione i ticket
I cinque file qui sotto compongono il portale: il layout che condivide auth e tiene il cliente in una sessione sola, il creatore di sessione Stripe Customer Portal, l'endpoint di gestione team con role guard, il grafico di usage che legge dalla metering data, e la pagina delle API key con token ritagliati.
Un build di portale clienti è un problema di deflection prima che un problema di UI. La inbox di supporto ci dice già quali workflow il cliente è disposto a fare da sé; il portale è il posto che glielo permette. Ogni schermata ha un compito di una riga: togli questo tipo di ticket dalla inbox. La metrica di successo non è il design della schermata, è la percentuale di ticket che smettono di arrivare.
I cinque file qui sotto sono l'ossatura che l'ingaggio lascia in piedi. Il layout che condivide auth, il creatore di sessione Stripe Customer Portal, l'endpoint di gestione team con role guard, il grafico di usage, e la pagina delle API key.
1. Il layout che condivide auth
Il portale vive su /account/* dentro l'app del prodotto. Il layout legge la sessione esistente; una visita non autenticata fa redirect al sign-in del prodotto. Non c'è una superficie auth separata da mantenere.
// app/account/layout.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth/session'
import { AccountNav } from '@/components/account/AccountNav'
export default async function AccountLayout({ children }: { children: React.ReactNode }) {
const session = await getServerSession()
if (!session) {
redirect('/auth/sign-in?next=/account')
}
return (
<div className="ds-account-shell">
<AccountNav user={session.user} />
<main className="ds-account-content">{children}</main>
</div>
)
}
2. Il creatore di sessione Stripe Customer Portal
L'endpoint crea una sessione di portale e torna una URL che il client apre. La return URL fa atterrare il cliente dentro il prodotto. La configuration ID del portale è fissata per ambiente, così un cambio in staging non può scolare in produzione.
// app/api/billing/portal-session/route.ts
import Stripe from 'stripe'
import { getServerSession } from '@/lib/auth/session'
import { getCustomer } from '@/lib/billing/customer'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(): Promise<Response> {
const session = await getServerSession()
if (!session) {
return Response.json({ success: false, error: 'unauthorized' }, { status: 401 })
}
const customer = await getCustomer(session.user.id)
if (!customer?.stripeCustomerId) {
return Response.json({ success: false, error: 'no_customer' }, { status: 400 })
}
const portal = await stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: `${process.env.PUBLIC_APP_URL}/account/billing`,
configuration: process.env.STRIPE_PORTAL_CONFIG_ID,
})
return Response.json({ success: true, data: { url: portal.url } })
}
3. L'endpoint di gestione team con role guard
Le scritture team passano da un endpoint unico che legge il ruolo dell'attore, valida l'azione contro una matrice di permessi e audita il cambio. La matrice vive in un file di config che il team può leggere; l'endpoint è piccolo.
// app/api/team/members/route.ts
import { getServerSession } from '@/lib/auth/session'
import { adminDb } from '@/lib/admin/db'
import { canPerform } from '@/lib/permissions/can'
interface ChangeRolePayload {
memberId: string
newRole: 'admin' | 'member' | 'viewer'
}
export async function PATCH(req: Request): Promise<Response> {
const session = await getServerSession()
if (!session) {
return Response.json({ success: false, error: 'unauthorized' }, { status: 401 })
}
const body = (await req.json()) as ChangeRolePayload
const actor = await loadMember(session.user.id)
if (!canPerform(actor, 'team.change_role', { targetMemberId: body.memberId })) {
return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
}
const db = await adminDb()
const before = await db.from('team_members').select('*').eq('id', body.memberId).single()
await db.from('team_members').update({ role: body.newRole }).eq('id', body.memberId)
const after = await db.from('team_members').select('*').eq('id', body.memberId).single()
await db.from('portal_audit').insert({
actor_user_id: session.user.id,
entity_type: 'team_member',
entity_id: body.memberId,
action: 'change_role',
before: before.data,
after: after.data,
})
await notifyTeam({ event: 'role_changed', memberId: body.memberId, newRole: body.newRole })
return Response.json({ success: true, data: after.data })
}
async function loadMember(userId: string) { /* ... */ return { id: userId, role: 'admin' as const } }
async function notifyTeam(_input: { event: string; memberId: string; newRole: string }) { /* ... */ }
4. Il grafico di usage
Il componente grafico legge dalla stessa fonte di metering che legge il finance. Il numero del cliente è il numero del finance; il portale non ha una copia propria che possa derivare. Due range (ultimi 30 giorni, ultimi 12 mesi) e un export CSV.
// app/account/usage/UsageChart.tsx
'use client'
import { useQuery } from '@tanstack/react-query'
import { LineChart } from '@/components/charts/LineChart'
interface UsageRange {
date: string
count: number
}
interface UsageResponse {
success: boolean
data: { last30: UsageRange[]; last12Months: UsageRange[]; forecastNextMonth: number }
}
export function UsageChart() {
const { data, isLoading } = useQuery<UsageResponse>({
queryKey: ['account', 'usage'],
queryFn: async () => (await fetch('/api/account/usage')).json() as Promise<UsageResponse>,
staleTime: 5 * 60_000,
})
if (isLoading || !data?.success) return <p>Carico l'usage…</p>
return (
<section className="ds-stack ds-stack--md">
<h2 className="ds-heading-ui">Ultimi 30 giorni</h2>
<LineChart series={data.data.last30} xKey="date" yKey="count" />
<h2 className="ds-heading-ui">Ultimi 12 mesi</h2>
<LineChart series={data.data.last12Months} xKey="date" yKey="count" />
<p className="ds-text-sm ds-text-secondary">
Proiezione del prossimo mese: {data.data.forecastNextMonth.toLocaleString('it-IT')} unità.
</p>
<a href="/api/account/usage/export.csv" className="ds-btn ds-btn--ghost">Scarica CSV</a>
</section>
)
}
5. La pagina delle API key
Il cliente crea, nomina, ritaglia e revoca le chiavi senza coinvolgimento di engineering. Le chiavi si mostrano una volta sola alla creazione; ne conserviamo un hash. La rotazione è un click, che crea una chiave nuova e fa scadere quella vecchia con un periodo di grazia.
// app/account/api-keys/page.tsx
import { adminDb } from '@/lib/admin/db'
import { getServerSession } from '@/lib/auth/session'
import { CreateKeyForm } from './CreateKeyForm'
import { KeyList } from './KeyList'
export default async function ApiKeysPage() {
const session = await getServerSession()
if (!session) return null
const db = await adminDb()
const { data: keys } = await db
.from('api_keys')
.select('id, name, scope, last_used_at, created_at, revoked_at')
.eq('owner_user_id', session.user.id)
.order('created_at', { ascending: false })
return (
<section className="ds-stack ds-stack--lg">
<header className="ds-stack ds-stack--sm">
<h1 className="ds-editorial-title">API key</h1>
<p className="ds-editorial-lede">Crea chiavi ritagliate per le integrazioni che usano questo account.</p>
</header>
<CreateKeyForm />
<KeyList keys={keys ?? []} />
</section>
)
}
6. Cosa compone questo
Il layout condiviso tiene il cliente in una sessione sola. Il creatore di sessione di portale passa il billing a Stripe senza far uscire il cliente dal prodotto. L'endpoint team sposta i cambi ruolo dalla inbox di supporto a un bottone. Il grafico di usage mostra al cliente lo stesso numero che vede il finance. La pagina delle API key chiude il ticket che fa perdere più tempo agli engineer.
La inbox di supporto torna a respirare. La coda del lunedì mattina è fatta dei casi veramente umani su cui il team voleva lavorare. Le domande ripetute smettono di arrivare perché la risposta è a un click. Il team cresciuto per gestire il volume di ticket si riprende il tempo per spedire il prodotto di cui il portale ora fa parte.
Domande frequenti
Si può fare senza integrare Stripe Customer Portal?
Sì, e a volte lo facciamo. Il Customer Portal è la strada più veloce per una superficie di billing che Stripe tiene aggiornata gratis; per la maggior parte dei SaaS in fase iniziale è la risposta giusta. I team con logica di billing custom (usage-based con impegni annuali, contratti negoziati per cliente) di solito costruiscono le schermate billing direttamente contro l'API Stripe. Facciamo la via pesante quando l'audit la giustifica.
Come evitate che il portale diventi un'altra superficie di prodotto che chiede tempo di engineering per sempre?
Due pattern. Primo, il portale usa lo stesso design system del prodotto; le schermate nuove si infilano nei componenti esistenti invece di inventare layout. Secondo, il documento di scope è la spec; non aggiungiamo schermate senza uno scope aggiornato, e l'audit trimestrale del supporto decide cosa entra e cosa esce. La maggior parte dei portali si stabilizza dopo i primi sei mesi.
E i clienti su contratto custom che non hanno una subscription Stripe?
Ricevono una sezione billing in sola lettura con i termini contrattuali, la data di rinnovo e la cronologia fatture. I bottoni di cambio che normalmente permettono di cambiare piano sono nascosti. Modelliamo questo caso esplicitamente perché ogni SaaS enterprise ce l'ha; il portale gestisce entrambi i mondi senza confondere il cliente.
Come dialoga il portale con la nostra pagina settings esistente nell'app?
Spesso la sostituisce; a volte ci vive accanto. La call di scoping decide. Il pattern che vediamo più spesso: la pagina settings legacy diventa un redirect al portale quando i flussi nuovi escono; la URL `app.x/settings` continua a funzionare, così i link nelle email esistenti non vanno in 404.
Quanto ci vuole?
Sei-otto settimane dal kickoff. L'audit della inbox prende una settimana. Scope e auth prendono una settimana. Le cinque schermate prendono tre-quattro settimane in totale. L'embedding di Stripe Customer Portal mezza settimana. Notifiche, audit log e help integrato l'ultima settimana. Buffer per un giro di feedback cliente prima del cutover.
I clienti usano davvero il portale o continuano a scrivere al supporto?
I cinque workflow che il portale gestisce calano del 60-80% nella inbox di supporto nel primo mese dal cutover. Il rimanente 20-40% di solito sono clienti che hanno scritto prima di scoprire l'esistenza del portale; un nudge in-product e un'email one-shot chiudono la maggior parte del gap. Misuriamo il tasso di deflection ogni trimestre e lo facciamo emergere sulla dashboard.
E i clienti non-inglesi?
Il portale supporta gli stessi locali del prodotto. Se il prodotto è solo in inglese, costruiamo il portale solo in inglese e segnaliamo le lingue che l'audit suggerisce di aggiungere dopo. Non costruiamo un portale in tre lingue quando il prodotto è in una; il lavoro è sprecato finché il prodotto non si allinea.
Sostituite il nostro engineer di supporto?
No. Il portale rende scalabile il lavoro dell'engineer di supporto. Invece di sette copie della stessa email a settimana, l'engineer gestisce i ticket veramente complessi che il portale non copre. Gli engineer di supporto di solito si trovano liberati: il lavoro di routine si comprime; ciò che resta è il lavoro che l'engineer voleva fare fin dall'inizio.
Definisci lo scope del tuo portale clienti
Una call di scoping, un audit della inbox di supporto nella prima settimana, uno scope fisso e un numero che teniamo. Sei-otto settimane dal kickoff a un portale che i clienti usano e a una inbox di supporto che torna a respirare.