Introduction #
In the landscape of modern web development, handling payments is the “final boss” for many backend engineers. It requires a confluence of security, reliability, and precision. If a blog post fails to load, it’s an annoyance; if a payment fails (or worse, is double-charged), it’s a business crisis.
Go (Golang) has become the language of choice for fintech and high-concurrency systems in 2025. Its strong typing, implicit concurrency model, and robust standard library make it ideal for handling financial transactions where correctness is paramount.
Stripe remains the gold standard for developer-friendly payment APIs. However, integrating it correctly goes beyond just making a cURL request. You need to handle PaymentIntents, verify Webhooks cryptographically, and ensure Idempotency.
In this guide, we aren’t just writing a script; we are building a production-grade microservice foundation that securely processes payments. You will learn how to set up the Stripe Go SDK, handle the asynchronous nature of payment confirmations, and avoid the common pitfalls that trip up even senior developers.
Prerequisites and Environment Setup #
Before we dive into the code, ensure your development environment is ready. We are aiming for a setup that mirrors a modern 2025 production environment.
What You Need #
- Go 1.23+: We will utilize modern
net/httprouting capabilities introduced in recent versions. - Stripe Account: You need a standard account to access the Test Mode API keys.
- Stripe CLI: Essential for forwarding webhooks to your local machine during development.
- IDE: VS Code (with the Go extension) or GoLand.
Project Initialization #
Let’s create a clean workspace. We will use godotenv to manage secrets, as hardcoding API keys is a cardinal sin.
mkdir go-stripe-checkout
cd go-stripe-checkout
go mod init github.com/yourusername/go-stripe-checkout
# Install the official Stripe Go SDK and Dotenv
go get github.com/stripe/stripe-go/v81
go get github.com/joho/godotenvCreate a .env file in your root directory. Add this to your .gitignore immediately.
# .env
STRIPE_SECRET_KEY=sk_test_51...
STRIPE_WEBHOOK_SECRET=whsec_...
SERVER_ADDR=:8080Architectural Overview #
Before writing code, let’s visualize the payment flow. The modern Stripe implementation uses PaymentIntents. This is a stateful object that tracks the lifecycle of a payment.
The most critical concept here is that the payment often completes asynchronously. Your frontend might say “Success,” but your backend should trust only the Webhook from Stripe.
Step 1: Setting Up the Server and Configuration #
We will build a clean, idiomatic Go server. We’ll create a config package to load our environment variables safely.
The Configuration Structure #
Create a file config/config.go:
package config
import (
"log"
"os"
"github.com/joho/godotenv"
)
type Config struct {
StripeSecretKey string
StripeWebhookKey string
ServerAddress string
}
func LoadConfig() *Config {
// Load .env file if it exists
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, relying on environment variables")
}
cfg := &Config{
StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"),
StripeWebhookKey: os.Getenv("STRIPE_WEBHOOK_SECRET"),
ServerAddress: os.Getenv("SERVER_ADDR"),
}
if cfg.StripeSecretKey == "" || cfg.StripeWebhookKey == "" {
log.Fatal("Stripe keys are missing in environment variables")
}
return cfg
}The Main Entry Point #
Now, let’s set up main.go. We will configure the Stripe SDK globally using the secret key.
package main
import (
"log"
"net/http"
"github.com/stripe/stripe-go/v81"
"github.com/yourusername/go-stripe-checkout/config"
"github.com/yourusername/go-stripe-checkout/handlers"
)
func main() {
cfg := config.LoadConfig()
// Initialize Stripe API globally
stripe.Key = cfg.StripeSecretKey
// Initialize Handlers
paymentHandler := handlers.NewPaymentHandler(cfg)
// Router Setup (Go 1.22+ style)
mux := http.NewServeMux()
mux.HandleFunc("POST /create-payment-intent", paymentHandler.HandleCreatePaymentIntent)
mux.HandleFunc("POST /webhook", paymentHandler.HandleWebhook)
// Serve static files (for our simple frontend)
fs := http.FileServer(http.Dir("./static"))
mux.Handle("GET /", fs)
log.Printf("Server starting on %s", cfg.ServerAddress)
if err := http.ListenAndServe(cfg.ServerAddress, mux); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}Step 2: Creating the Payment Intent #
The PaymentIntent is the core of the integration. Unlike the old “Charges” API, the Intent API can handle Strong Customer Authentication (SCA) required in Europe and other complex flows.
Create handlers/payment.go.
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/paymentintent"
"github.com/yourusername/go-stripe-checkout/config"
)
type PaymentHandler struct {
Cfg *config.Config
}
func NewPaymentHandler(cfg *config.Config) *PaymentHandler {
return &PaymentHandler{Cfg: cfg}
}
type CreatePaymentRequest struct {
Amount int64 `json:"amount"` // Amount in cents!
Currency string `json:"currency"`
}
type CreatePaymentResponse struct {
ClientSecret string `json:"clientSecret"`
}
func (h *PaymentHandler) HandleCreatePaymentIntent(w http.ResponseWriter, r *http.Request) {
var req CreatePaymentRequest
// Limit request body size to prevent DoS
r.Body = http.MaxBytesReader(w, r.Body, 1048576)
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request payload", http.StatusBadRequest)
return
}
// Basic Validation
if req.Amount <= 0 {
http.Error(w, "Amount must be greater than 0", http.StatusBadRequest)
return
}
// Create a PaymentIntent params
params := &stripe.PaymentIntentParams{
Amount: stripe.Int64(req.Amount),
Currency: stripe.String(req.Currency),
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
Enabled: stripe.Bool(true),
},
}
// Idempotency: In a real app, generate a unique key per order (e.g., Request-ID header)
// params.SetIdempotencyKey("order_123_xyz")
pi, err := paymentintent.New(params)
if err != nil {
log.Printf("pi.New: %v", err)
http.Error(w, "Failed to create payment intent", http.StatusInternalServerError)
return
}
resp := CreatePaymentResponse{
ClientSecret: pi.ClientSecret,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}Key Takeaway: Currency Handling #
Notice req.Amount. Stripe (and most payment gateways) handles amounts in the smallest currency unit (e.g., cents for USD, pence for GBP).
- Correct:
$10.00->1000 - Incorrect:
$10.00->10.00(float)
Never use floats for money in Go. Integers (int64) are safe. For high-precision financial math, use a library like shopspring/decimal.
Step 3: Handling Webhooks Securely #
This is where 90% of integrations fail security audits. You must verify that the webhook actually came from Stripe and hasn’t been tampered with.
Stripe signs every webhook event using your STRIPE_WEBHOOK_SECRET. We will verify this signature before processing anything.
Add this method to handlers/payment.go (or a separate webhook.go):
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/webhook"
)
func (h *PaymentHandler) HandleWebhook(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 {
log.Printf("Error reading request body: %v\n", err)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
// Verify the signature
signatureHeader := r.Header.Get("Stripe-Signature")
event, err := webhook.ConstructEvent(payload, signatureHeader, h.Cfg.StripeWebhookKey)
if err != nil {
log.Printf("⚠️ Webhook signature verification failed. %v\n", err)
w.WriteHeader(http.StatusBadRequest) // Return 400 so Stripe knows we rejected it
return
}
// Handle the event
switch event.Type {
case "payment_intent.succeeded":
var paymentIntent stripe.PaymentIntent
err := json.Unmarshal(event.Data.Raw, &paymentIntent)
if err != nil {
log.Printf("Error parsing webhook JSON: %v\n", err)
w.WriteHeader(http.StatusBadRequest)
return
}
log.Printf("💰 Payment for %d %s succeeded!", paymentIntent.Amount, paymentIntent.Currency)
// TODO: Fulfill the order in your database here!
// fulfillOrder(paymentIntent.Metadata["order_id"])
case "payment_intent.payment_failed":
var paymentIntent stripe.PaymentIntent
json.Unmarshal(event.Data.Raw, &paymentIntent)
log.Printf("❌ Payment failed: %v\n", paymentIntent.LastPaymentError.Message)
default:
// Handle other event types
log.Printf("Unhandled event type: %s\n", event.Type)
}
w.WriteHeader(http.StatusOK)
}Why Signature Verification Matters #
Without webhook.ConstructEvent, an attacker could send a fake JSON payload to your /webhook endpoint saying “Payment Succeeded,” and your server would unlock the product without receiving money. The signature ensures integrity and authenticity.
Step 4: Testing with Stripe CLI #
Testing webhooks locally used to be a nightmare involving Ngrok tunnels. The Stripe CLI simplifies this significantly.
-
Login:
stripe login -
Listen: Forward events to your local Go server.
stripe listen --forward-to localhost:8080/webhook -
Get the Secret: The CLI will output a webhook signing secret (starting with
whsec_...). Copy this into your.envfile asSTRIPE_WEBHOOK_SECRET. -
Trigger an Event: Open a new terminal tab:
stripe trigger payment_intent.succeeded
You should see your Go server logs light up with “💰 Payment succeeded!”.
Best Practices and Common Pitfalls #
Integrating payments is not just about making it work; it’s about making it bulletproof. Here is a comparison of strategies for different aspects of the integration.
| Feature | Naive Implementation | Production-Ready Best Practice |
|---|---|---|
| Money Types | Float64 (10.99) |
Int64 (1099 cents) or Decimal Library |
| Fulfillment | Inside the HTTP Handler | Inside the Webhook Handler |
| Retries | No logic, user clicks again | Idempotency Keys to prevent double-charge |
| Config | Hardcoded Strings | Environment Variables + Secret Managers |
| Timeouts | Default HTTP Client | Custom HTTP Client with Context Timeouts |
Idempotency Keys #
In distributed systems, networks fail. If your Go server sends a request to Stripe to “Charge $50”, but the network cuts out before the response comes back, you don’t know if the charge happened.
If you retry, you might charge the user twice.
Solution: Send an Idempotency-Key header (e.g., the UUID of the user’s order). Stripe checks this key. If it has seen “Order-123” before, it won’t charge again; it will simply return the cached result of the previous attempt.
params.SetIdempotencyKey("unique-order-id-from-database")Error Handling #
Always check stripe.Error. The SDK creates structured errors.
if stripeErr, ok := err.(*stripe.Error); ok {
switch stripeErr.Code {
case stripe.ErrorCodeCardDeclined:
// Inform user specifically
case stripe.ErrorCodeExpiredCard:
// Ask for new card
}
}Conclusion #
Integrating Stripe with Go offers a powerful combination of developer experience and system performance. We have built a flow that moves beyond simple scripting into robust backend engineering:
- We set up a secure Configuration layer.
- We implemented PaymentIntents for modern checkout flows.
- We secured our application by verifying Webhook Signatures.
- We discussed critical data handling like Integer Currency and Idempotency.
As you move this to production, consider wrapping the Stripe logic in an interface. This allows you to mock the payment gateway during your unit tests, ensuring your business logic holds up without hitting the Stripe API (or your credit card) every time you run go test.
Further Reading #
Happy coding, and may your conversion rates be high and your latencies low.