Pagamenti con Stripe in Go

Questa guida mostra come integrare pagamenti con Stripe in un backend scritto in Go, con esempi pratici e attenzione a sicurezza, affidabilità e gestione degli eventi tramite webhook. Vedremo due approcci principali:

  • Stripe Checkout: la via più rapida e “chiavi in mano” per accettare pagamenti.
  • Payment Intents: integrazione più flessibile, utile quando vuoi controllare l’esperienza di pagamento sul tuo sito o app.

Prerequisiti

  • Account Stripe e chiavi API (test e live).
  • Go 1.20+ (consigliato).
  • Una base di HTTP server in Go (net/http) e un endpoint pubblico per i webhook (in sviluppo puoi usare Stripe CLI).

Installazione SDK Stripe per Go

Stripe mantiene un SDK ufficiale per Go. Installalo con:

go get github.com/stripe/stripe-go/v76

Import tipico:

import (
  "github.com/stripe/stripe-go/v76"
)

Gestione delle chiavi e configurazione

Non hardcodare mai le chiavi. Usa variabili d’ambiente e differenzia chiaramente test e live. Un pattern comune:

package config

import (
  "log"
  "os"
)

type StripeConfig struct {
  SecretKey       string
  WebhookSecret   string
  SuccessURL      string
  CancelURL       string
  FrontendBaseURL string
}

func LoadStripeConfig() StripeConfig {
  c := StripeConfig{
    SecretKey:       os.Getenv("STRIPE_SECRET_KEY"),
    WebhookSecret:   os.Getenv("STRIPE_WEBHOOK_SECRET"),
    SuccessURL:      os.Getenv("STRIPE_SUCCESS_URL"),
    CancelURL:       os.Getenv("STRIPE_CANCEL_URL"),
    FrontendBaseURL: os.Getenv("FRONTEND_BASE_URL"),
  }
  if c.SecretKey == "" {
    log.Fatal("STRIPE_SECRET_KEY non impostata")
  }
  return c
}

Nel tuo main:

package main

import (
  "net/http"

  "github.com/stripe/stripe-go/v76"
  "yourapp/config"
)

func main() {
  cfg := config.LoadStripeConfig()
  stripe.Key = cfg.SecretKey

  mux := http.NewServeMux()
  // registra handlers...
  _ = mux

  http.ListenAndServe(":8080", mux)
}

Approccio 1: Stripe Checkout (consigliato per partire)

Stripe Checkout ti permette di creare una sessione server-side e reindirizzare l’utente a una pagina di pagamento ospitata da Stripe. È spesso la soluzione migliore per partire velocemente e ridurre il perimetro di compliance.

Creare una Checkout Session

Scenario tipico: l’utente seleziona un prodotto/abbonamento, il backend crea la sessione e restituisce l’URL di Checkout.

package handlers

import (
  "encoding/json"
  "net/http"

  "github.com/stripe/stripe-go/v76"
  "github.com/stripe/stripe-go/v76/checkout/session"
)

type CreateCheckoutRequest struct {
  // In produzione non fidarti del prezzo dal client.
  // Passa un productId/priceId e risolvi lato server.
  PriceID  string `json:"priceId"`
  Quantity int64  `json:"quantity"`
  CustomerEmail string `json:"customerEmail,omitempty"`
}

type CreateCheckoutResponse struct {
  URL string `json:"url"`
}

func CreateCheckoutSession(successURL, cancelURL string) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
      http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
      return
    }

    var req CreateCheckoutRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
      http.Error(w, "invalid json", http.StatusBadRequest)
      return
    }
    if req.PriceID == "" || req.Quantity <= 0 {
      http.Error(w, "missing priceId or invalid quantity", http.StatusBadRequest)
      return
    }

    params := &stripe.CheckoutSessionParams{
      Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
      SuccessURL: stripe.String(successURL),
      CancelURL:  stripe.String(cancelURL),
      LineItems: []*stripe.CheckoutSessionLineItemParams{
        {
          Price:    stripe.String(req.PriceID),
          Quantity: stripe.Int64(req.Quantity),
        },
      },
      // Opzionale: associa email cliente, oppure crea/usa un Customer.
      CustomerEmail: stripe.String(req.CustomerEmail),
    }

    s, err := session.New(params)
    if err != nil {
      http.Error(w, "stripe error: "+err.Error(), http.StatusBadGateway)
      return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(CreateCheckoutResponse{URL: s.URL})
  }
}

Nota importante: non lasciare che il client invii prezzi arbitrari. Invia un identificatore (ad esempio priceId di Stripe o un tuo productId) e risolvi quantità e importi lato server.

Redirect lato client

Il backend restituisce un URL; il front-end può fare window.location = url. Se usi Stripe.js, puoi anche usare redirectToCheckout, ma spesso l’URL è sufficiente.

Approccio 2: Payment Intents (più controllo, più responsabilità)

Payment Intents è il flusso raccomandato quando vuoi un checkout integrato nel tuo sito/app o devi gestire pagamenti complessi (ad esempio salvataggio di metodi di pagamento, pagamenti in due fasi, SCA/3DS, ecc.). Il backend crea un PaymentIntent e il client lo conferma usando Stripe.js o SDK mobile.

Creare un PaymentIntent in Go

Esempio per pagamenti one-shot con importo in centesimi:

package handlers

import (
  "encoding/json"
  "net/http"

  "github.com/stripe/stripe-go/v76"
  "github.com/stripe/stripe-go/v76/paymentintent"
)

type CreatePIRequest struct {
  Amount   int64  `json:"amount"`   // in centesimi
  Currency string `json:"currency"` // es: "eur"
}

type CreatePIResponse struct {
  ClientSecret string `json:"clientSecret"`
}

func CreatePaymentIntent() http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
      http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
      return
    }

    var req CreatePIRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
      http.Error(w, "invalid json", http.StatusBadRequest)
      return
    }
    if req.Amount <= 0 {
      http.Error(w, "invalid amount", http.StatusBadRequest)
      return
    }
    if req.Currency == "" {
      req.Currency = "eur"
    }

    // In produzione: calcola l'importo dal carrello lato server.
    params := &stripe.PaymentIntentParams{
      Amount:   stripe.Int64(req.Amount),
      Currency: stripe.String(req.Currency),
      AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
        Enabled: stripe.Bool(true),
      },
    }

    pi, err := paymentintent.New(params)
    if err != nil {
      http.Error(w, "stripe error: "+err.Error(), http.StatusBadGateway)
      return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(CreatePIResponse{ClientSecret: pi.ClientSecret})
  }
}

Il client userà clientSecret per confermare il pagamento. Questo pezzo è tipicamente in JavaScript (Stripe.js) o nelle SDK mobile.

Quando preferire Payment Intents

  • Vuoi un checkout completamente custom e non vuoi reindirizzare a Stripe Checkout.
  • Devi gestire logiche avanzate (ad esempio autorizzazione e cattura differita, metodi di pagamento salvati, ecc.).
  • Hai bisogno di controllare in modo fine lo stato del pagamento e i passaggi SCA.

Webhooks: la parte più importante

La conferma “vera” di un pagamento non dovrebbe basarsi sul redirect di successo o su una risposta del client. La fonte autorevole è l’evento webhook inviato da Stripe al tuo backend (ad esempio checkout.session.completed o payment_intent.succeeded).

Perché i webhook sono essenziali

  • Il cliente può chiudere la pagina o perdere la connessione dopo aver pagato.
  • I pagamenti possono richiedere azioni aggiuntive (SCA) e completarsi più tardi.
  • Devi avere una registrazione server-side idempotente e verificabile.

Verifica della firma del webhook

Stripe firma ogni webhook. Devi verificare la firma usando il WebhookSecret del tuo endpoint webhook. Esempio in Go:

package handlers

import (
  "io"
  "net/http"

  "github.com/stripe/stripe-go/v76"
  "github.com/stripe/stripe-go/v76/webhook"
)

func StripeWebhook(webhookSecret string) http.HandlerFunc {
  return func(w http.ResponseWriter, r *http.Request) {
    const maxBodyBytes = int64(65536)
    r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)

    payload, err := io.ReadAll(r.Body)
    if err != nil {
      http.Error(w, "read error", http.StatusBadRequest)
      return
    }

    sigHeader := r.Header.Get("Stripe-Signature")
    event, err := webhook.ConstructEvent(payload, sigHeader, webhookSecret)
    if err != nil {
      http.Error(w, "signature verification failed", http.StatusBadRequest)
      return
    }

    // Gestione evento
    switch event.Type {
    case "checkout.session.completed":
      // Parse dell'oggetto contenuto nell'evento
      // (vedi sezione seguente)
    case "payment_intent.succeeded":
      // idem
    default:
      // eventi non gestiti
    }

    // Rispondi 200 rapidamente, dopo aver messo in coda/registrato il lavoro.
    w.WriteHeader(http.StatusOK)
  }
}

Deserializzare gli oggetti Stripe dall’evento

Stripe invia un payload generico. In Stripe Go, puoi usare stripe.Event e poi deserializzare event.Data.Raw nel tipo desiderato.

package handlers

import (
  "encoding/json"
  "log"

  "github.com/stripe/stripe-go/v76"
)

func handleCheckoutCompleted(event stripe.Event) {
  var s stripe.CheckoutSession
  if err := json.Unmarshal(event.Data.Raw, &s); err != nil {
    log.Printf("unmarshal checkout session: %v", err)
    return
  }

  // Esempio: usa s.ID come riferimento; puoi leggere s.PaymentStatus, s.AmountTotal, ecc.
  // In produzione: aggiorna il tuo DB, attiva l'ordine/abbonamento, ecc.
  log.Printf("checkout completed session=%s payment_status=%s", s.ID, s.PaymentStatus)
}

Per un PaymentIntent:

func handlePaymentIntentSucceeded(event stripe.Event) {
  var pi stripe.PaymentIntent
  if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
    log.Printf("unmarshal payment intent: %v", err)
    return
  }

  // Usa pi.ID e pi.Metadata per correlare con il tuo ordine.
  log.Printf("payment intent succeeded id=%s amount=%d", pi.ID, pi.Amount)
}

Correlare pagamenti e ordini con metadata

Un modo robusto per collegare un pagamento a un ordine nel tuo database è usare Metadata. Per esempio, quando crei un Checkout Session o un PaymentIntent, inserisci order_id.

params := &stripe.CheckoutSessionParams{
  // ...
  PaymentIntentData: &stripe.CheckoutSessionPaymentIntentDataParams{
    Metadata: map[string]string{
      "order_id": "ORD-12345",
      "user_id":  "USR-999",
    },
  },
}

Nel webhook, leggi la metadata dal PaymentIntent (o dalla sessione, a seconda del flusso) e aggiorna lo stato dell’ordine in modo deterministico.

Idempotenza: evitare doppie creazioni e doppie attivazioni

Nel mondo reale, richieste e webhook possono essere ritentati. Stripe ritenta i webhook se il tuo endpoint non risponde 2xx. Anche il client può ritentare una chiamata per creare un pagamento. Serve idempotenza su due livelli:

  • Idempotency-Key per le chiamate Stripe: impedisce che la stessa operazione crei più oggetti su Stripe.
  • Idempotenza applicativa nel tuo DB: impedisce di attivare due volte un ordine quando arrivano più eventi equivalenti.

Usare Idempotency-Key con Stripe Go

Stripe Go permette di impostare l’idempotency key tramite Params (es. stripe.Params). Un approccio comune è creare una chiave deterministica basata su orderId + tipo operazione.

import "github.com/stripe/stripe-go/v76"

// Esempio: impostare idempotency key in params
params := &stripe.PaymentIntentParams{
  Amount: stripe.Int64(1999),
  Currency: stripe.String("eur"),
}

params.Params.IdempotencyKey = "pi_create_ORD-12345"

pi, err := paymentintent.New(params)

Idempotenza nel webhook

Nel tuo database, salva l’event.id o l’identificatore dell’oggetto (es. payment_intent) come “processato”. Se arriva di nuovo, ignora in modo sicuro.

Sicurezza e best practice

  • Mai fidarti del client per importi e valuta: calcola lato server dal carrello/prezzi in DB o dai PriceID Stripe.
  • Verifica firma webhook sempre, e limita la dimensione del body.
  • Usa HTTPS in produzione e proteggi le variabili d’ambiente (secret manager).
  • Log puliti: non loggare segreti o payload completi con dati sensibili.
  • Least privilege: se possibile separa ruoli/chiavi (ad esempio chiavi ristrette o ambienti distinti).

Testing in locale con Stripe CLI

Stripe CLI ti aiuta a inoltrare webhook al tuo computer e a generare eventi di test. Un flusso tipico:

stripe login
stripe listen --forward-to localhost:8080/webhooks/stripe

Il comando listen mostrerà un webhook signing secret dedicato per il listener, da usare come STRIPE_WEBHOOK_SECRET in locale.

Per triggerare un evento di test:

stripe trigger checkout.session.completed

Struttura consigliata del progetto

  • config/: caricamento configurazioni (Stripe, DB, ecc.).
  • handlers/: HTTP handlers (create session, webhook, ecc.).
  • payments/: logica di dominio per ordini, idempotenza, mapping eventi.
  • store/: accesso al database (repository pattern).

Esempio di wiring nel router

Un esempio minimale di registrazione endpoint:

package main

import (
  "net/http"

  "github.com/stripe/stripe-go/v76"
  "yourapp/config"
  "yourapp/handlers"
)

func main() {
  cfg := config.LoadStripeConfig()
  stripe.Key = cfg.SecretKey

  mux := http.NewServeMux()

  mux.Handle("/api/checkout/session", handlers.CreateCheckoutSession(cfg.SuccessURL, cfg.CancelURL))
  mux.Handle("/webhooks/stripe", handlers.StripeWebhook(cfg.WebhookSecret))

  http.ListenAndServe(":8080", mux)
}

Gestione degli stati: cosa salvare nel database

Per un flusso robusto, salva almeno:

  • Order ID (tuo identificatore).
  • Stripe object IDs (es. checkout_session_id, payment_intent_id).
  • Stato ordine (pending, paid, failed, refunded, ecc.).
  • Eventi processati (es. tabella stripe_events con event_id univoco).
  • Importo e valuta “congelati” al momento dell’acquisto.

Rimborsi e post-pagamento

I rimborsi possono essere avviati lato dashboard Stripe o tramite API. In genere, per un backend serve:

  • Verificare che l’ordine sia rimborsabile secondo le tue regole.
  • Creare un refund su Stripe associato al PaymentIntent o Charge.
  • Aggiornare lo stato dell’ordine tramite webhook (es. charge.refunded o eventi correlati).
import (
  "github.com/stripe/stripe-go/v76"
  "github.com/stripe/stripe-go/v76/refund"
)

func RefundPaymentIntent(paymentIntentID string) (*stripe.Refund, error) {
  params := &stripe.RefundParams{
    PaymentIntent: stripe.String(paymentIntentID),
  }
  return refund.New(params)
}

Errori comuni e come evitarli

  • Usare il redirect di successo come conferma: usa sempre i webhook come fonte di verità.
  • Importi dal client: calcola lato server e/o usa PriceID.
  • Webhook non idempotenti: gestisci i retry, salva event.id.
  • Firma webhook ignorata: verifica sempre la firma.
  • Timeout troppo lunghi nel webhook: rispondi rapidamente e sposta lavorazioni pesanti su una coda.

Checklist per andare in produzione

  • Chiavi live impostate correttamente e segreti conservati in modo sicuro.
  • Endpoint webhook pubblico con HTTPS e firma verificata.
  • Log e alerting sugli errori webhook e su pagamenti falliti.
  • Idempotenza su creazione pagamenti e su attivazione ordini.
  • Testing end-to-end con carte di test e scenari SCA.

Con questi elementi hai una base solida per accettare pagamenti con Stripe in Go, mantenendo un flusso affidabile e sicuro. Da qui puoi estendere verso abbonamenti, salvataggio metodi di pagamento, fatturazione e integrazioni più avanzate, mantenendo sempre il webhook come “single source of truth” per lo stato delle transazioni.

Torna su