// Paddle Checkout Integration // Simplified - uses Paddle.js for client-side checkout package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "os" "strconv" "strings" "time" ) // PaddleConfig holds our Paddle configuration // In production these come from env vars type PaddleConfig struct { ClientToken string // For Paddle.js frontend VendorID string // Your Paddle vendor ID Environment string // "production" or "sandbox" } func getPaddleConfig() PaddleConfig { return PaddleConfig{ // Client token is PUBLIC - safe to expose in JS // This is different from the API key (which is secret) ClientToken: os.Getenv("PADDLE_CLIENT_TOKEN"), VendorID: os.Getenv("PADDLE_VENDOR_ID"), Environment: "production", } } // Price IDs - these come from your Paddle dashboard // You create these when you set up products/prices var PriceIDs = map[string]string{ "personal_yearly_usd": "pri_01knejm7ft2ska5r4qff2gm9r4", "personal_yearly_eur": "pri_01knejm7kfhfknjyjq3je56r19", "personal_yearly_gbp": "pri_01knejm7q2bkkszam05dtyrsnc", "family_yearly_usd": "pri_01knejm7xs9kqt0vn61dx6q808", "family_yearly_eur": "pri_01knejm811100z4xx2xxedwez6", "family_yearly_gbp": "pri_01knejm85v2d1fjt998xypac2v", "pro_yearly_usd": "pri_01knejm8djq4p63rmsxze7by58", "pro_yearly_eur": "pri_01knejm8gjq5staaz9s7b4k8bp", "pro_yearly_gbp": "pri_01knejm8kk3qwtk32kry5c607g", "team10_yearly_usd": "pri_01knejm8twprj5ca4zem1g4g56", "team25_yearly_usd": "pri_01knejm91d5xppcfw4w4w2tf7x", "team100_yearly_usd": "pri_01knejm98a7bxvcpgtqm9aa81b", "team250_yearly_usd": "pri_01knejm9fevyr7bse0hm7ym9mq", "team500_yearly_usd": "pri_01knejm9nz0zbvecv0fx5d1red", } // CheckoutRequest - user wants to subscribe type CheckoutRequest struct { PriceID string `json:"price_id"` // Which plan CustomerID string `json:"customer_id"` // Our customer ID (if exists) Email string `json:"email"` // Customer email VaultL0 string `json:"vault_l0"` // Which vault this pays for } // CheckoutResponse - Paddle checkout URL or token type CheckoutResponse struct { Success bool `json:"success"` CheckoutURL string `json:"checkout_url,omitempty"` // For hosted checkout TransactionID string `json:"transaction_id,omitempty"` Error string `json:"error,omitempty"` } // HandleCheckout creates a Paddle checkout for a new subscription func HandleCheckout(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req CheckoutRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondJSON(w, CheckoutResponse{Success: false, Error: "invalid json"}) return } // Validate price ID exists if req.PriceID == "" { respondJSON(w, CheckoutResponse{Success: false, Error: "price_id required"}) return } // Get or create customer var customerID string if req.CustomerID != "" { // Use existing customerID = req.CustomerID } else { // Create new customer via Paddle if req.Email == "" { respondJSON(w, CheckoutResponse{Success: false, Error: "email required for new customer"}) return } // Create in our DB + Paddle via dual-write layer // TODO: Implement using sync.go customerID = createCustomerViaPaddle(req.Email, "") } // Create transaction (invoice) in Paddle // For automatic collection (credit card): creates checkout // For manual collection (enterprise): creates invoice transactionID := createPaddleTransaction(customerID, req.PriceID) // Store the link between transaction and vault // So when webhook says "paid", we know which vault to activate storePendingSubscription(customerID, req.VaultL0, transactionID, req.PriceID) // Get checkout URL from Paddle checkoutURL := getPaddleCheckoutURL(transactionID) respondJSON(w, CheckoutResponse{ Success: true, CheckoutURL: checkoutURL, TransactionID: transactionID, }) } // HandleCheckoutSuccess - Paddle redirects here after successful payment func HandleCheckoutSuccess(w http.ResponseWriter, r *http.Request) { // Paddle redirects to: /checkout/success?transaction_id=txn_xxx&subscription_id=sub_xxx transactionID := r.URL.Query().Get("transaction_id") subscriptionID := r.URL.Query().Get("subscription_id") if transactionID == "" { http.Error(w, "Missing transaction_id", http.StatusBadRequest) return } // Look up which vault this is for vaultL0 := getVaultForTransaction(transactionID) if vaultL0 == "" { http.Error(w, "Unknown transaction", http.StatusNotFound) return } // Update our records activateVaultSubscription(vaultL0, subscriptionID, transactionID) // Redirect to vault with success message http.Redirect(w, r, "/app/?subscription=active&vault="+vaultL0, http.StatusSeeOther) } // WebhookHandler - Paddle sends us events func HandlePaddleWebhook(w http.ResponseWriter, r *http.Request) { // Read the raw body BEFORE verification — the signature is over the raw // bytes, so we can't let json.Decoder rewrite or reformat anything. body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Read error", http.StatusBadRequest) return } if !verifyWebhookSignature(r, body) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } var event map[string]interface{} if err := json.Unmarshal(body, &event); err != nil { w.WriteHeader(http.StatusBadRequest) return } // Handle empty/malformed events gracefully if event == nil { w.WriteHeader(http.StatusOK) return } eventType, _ := event["event_type"].(string) data, _ := event["data"].(map[string]interface{}) switch eventType { case "transaction.completed": // Payment succeeded if transactionID, ok := data["id"].(string); ok { activateVaultForTransaction(transactionID) } case "transaction.past_due": // Payment failed if transactionID, ok := data["id"].(string); ok { suspendVaultForTransaction(transactionID) } case "subscription.canceled": // Subscription cancelled if subscriptionID, ok := data["id"].(string); ok { cancelVaultSubscription(subscriptionID) } } w.WriteHeader(http.StatusOK) } // Helper functions (simplified - need implementation) func createCustomerViaPaddle(email, name string) string { // Use sync.go dual-write layer // POST /customers to Paddle return "ctm_" + generateID() } func createPaddleTransaction(customerID, priceID string) string { // POST /transactions to Paddle // Returns transaction ID return "txn_" + generateID() } func getPaddleCheckoutURL(transactionID string) string { // Paddle generates checkout URL // Or we build it: https://checkout.paddle.com/?transaction=txn_xxx return "https://checkout.paddle.com/?transaction=" + transactionID } func storePendingSubscription(customerID, vaultL0, transactionID, priceID string) { // Store in DB: pending subscription linking transaction to vault // When webhook arrives, we know which vault to activate } func getVaultForTransaction(transactionID string) string { // Lookup vault_l0 from pending subscriptions table return "" } func activateVaultSubscription(vaultL0, subscriptionID, transactionID string) { // Update subscriptions table: status = active // Update vaults table: subscription_id = subscriptionID } func activateVaultForTransaction(transactionID string) { // Webhook handler - activate the vault } func suspendVaultForTransaction(transactionID string) { // Webhook handler - mark subscription past_due } func cancelVaultSubscription(subscriptionID string) { // Webhook handler - mark cancelled } // verifyWebhookSignature verifies the Paddle webhook signature. // // Paddle signs webhooks with HMAC-SHA256 over ":". The // signature header looks like: // // Paddle-Signature: ts=1700000000;h1=abcdef0123456789... // // We require: // - PADDLE_WEBHOOK_SECRET env var is set (refuse if missing — no permissive default) // - the Paddle-Signature header is present and parseable // - the timestamp is within ±5 minutes of now (anti-replay) // - hmac-sha256(secret, ts + ":" + body) matches h1 in constant time // // Returns false on any failure. SECURITY.md: failures are visible and noisy, // no silent fallback. func verifyWebhookSignature(r *http.Request, body []byte) bool { secret := os.Getenv("PADDLE_WEBHOOK_SECRET") if secret == "" { log.Printf("PADDLE_WEBHOOK_SECRET not set; refusing webhook") return false } sigHeader := r.Header.Get("Paddle-Signature") if sigHeader == "" { log.Printf("webhook: missing Paddle-Signature header") return false } var ts, h1 string for _, part := range strings.Split(sigHeader, ";") { kv := strings.SplitN(part, "=", 2) if len(kv) != 2 { continue } switch kv[0] { case "ts": ts = kv[1] case "h1": h1 = kv[1] } } if ts == "" || h1 == "" { log.Printf("webhook: malformed Paddle-Signature header: %q", sigHeader) return false } // Anti-replay: timestamp within 5 minutes tsInt, err := strconv.ParseInt(ts, 10, 64) if err != nil { log.Printf("webhook: invalid ts: %v", err) return false } const maxSkew = 300 // 5 minutes now := time.Now().Unix() if now-tsInt > maxSkew || tsInt-now > maxSkew { log.Printf("webhook: timestamp out of range (ts=%d now=%d)", tsInt, now) return false } // Compute HMAC-SHA256(secret, ts + ":" + body) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts)) mac.Write([]byte{':'}) mac.Write(body) expected := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(h1)) { log.Printf("webhook: signature mismatch") return false } return true } func generateID() string { return fmt.Sprintf("%d", time.Now().UnixNano()) } // respondJSON sends JSON response func respondJSON(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) }