Files
member-console/internal/server/stripe_webhook.go
Christian Galo 786657eea3 Start Stripe workflows and handle webhooks
Expose /webhooks/stripe as a public path (signature-verified)

Verify webhook signatures with ConstructEventWithOptions and
IgnoreAPIVersionMismatch=true, and log API version mismatches.
Start two Temporal workflows: stripe-webhook-processor and
stripe-outbox-poller; workflow start failures are non-fatal.
2026-04-05 21:25:26 -05:00

149 lines
4.8 KiB
Go

package server
import (
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
stripe "github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/webhook"
)
// piiKeys lists JSON keys that are stripped from Stripe payloads before storage.
var piiKeys = map[string]struct{}{
"email": {},
"name": {},
"phone": {},
"phone_number": {},
"address": {},
"shipping": {},
"billing_details": {},
"owner": {},
// "card" and "bank_account" are intentionally NOT scrubbed: in Stripe webhook
// payloads these objects contain only safe descriptor fields (brand, last4,
// exp_month, exp_year, routing_number last4) — no raw card numbers or CVCs,
// which Stripe never sends over webhooks. Scrubbing them would prevent
// payment method capture for invoice/payment projection (Phase 1d).
"customer_email": {},
"customer_name": {},
"customer_phone": {},
"customer_address": {},
"receipt_email": {},
"account_holder_name": {},
}
// ScrubPII recursively removes known PII keys from a JSON-decoded value.
func ScrubPII(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
out := make(map[string]interface{}, len(val))
for k, child := range val {
if _, isPII := piiKeys[k]; isPII {
out[k] = "[REDACTED]"
continue
}
out[k] = ScrubPII(child)
}
return out
case []interface{}:
out := make([]interface{}, len(val))
for i, child := range val {
out[i] = ScrubPII(child)
}
return out
default:
return v
}
}
// StripeWebhookHandler handles incoming Stripe webhook events.
type StripeWebhookHandler struct {
DB *sql.DB
WebhookSecret string
Logger *slog.Logger
}
// ServeHTTP verifies the Stripe signature, scrubs PII, and inserts the event
// idempotently into integration.webhook_events. It returns 200 immediately
// regardless of downstream processing state.
func (h *StripeWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Read body (Stripe SDK needs the raw bytes for signature verification)
const maxBodyBytes = 65536
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes))
if err != nil {
h.Logger.Error("failed to read webhook body", slog.Any("error", err))
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Verify signature with API version mismatch tolerance
// Stripe CLI and API may use different versions; we handle this gracefully
sigHeader := r.Header.Get("Stripe-Signature")
event, err := webhook.ConstructEventWithOptions(body, sigHeader, h.WebhookSecret, webhook.ConstructEventOptions{
IgnoreAPIVersionMismatch: true,
})
if err != nil {
h.Logger.Warn("invalid Stripe webhook signature", slog.Any("error", err))
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
// Log API version mismatches for monitoring
// The SDK has stripe.APIVersion, and event.APIVersion is the webhook's version
if event.APIVersion != stripe.APIVersion {
h.Logger.Warn("Stripe API version mismatch detected",
slog.String("event_id", event.ID),
slog.String("event_type", string(event.Type)),
slog.String("webhook_api_version", event.APIVersion),
slog.String("sdk_api_version", stripe.APIVersion),
slog.String("recommendation", "consider upgrading stripe-go SDK"))
}
// Parse payload for PII scrubbing
var raw interface{}
if err := json.Unmarshal(event.Data.Raw, &raw); err != nil {
h.Logger.Error("failed to parse webhook payload", slog.Any("error", err))
http.Error(w, "Bad payload", http.StatusBadRequest)
return
}
scrubbed := ScrubPII(raw)
scrubbedJSON, err := json.Marshal(scrubbed)
if err != nil {
h.Logger.Error("failed to marshal scrubbed payload", slog.Any("error", err))
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
// Idempotent insert — ON CONFLICT does nothing for duplicate events.
// The partitioned table requires received_at in the conflict target.
_, err = h.DB.ExecContext(r.Context(),
`INSERT INTO integration.webhook_events
(provider, provider_event_id, event_type, payload, status)
VALUES ($1, $2, $3, $4, 'received')
ON CONFLICT (provider, provider_event_id, received_at) DO NOTHING`,
"stripe", event.ID, string(event.Type), scrubbedJSON,
)
if err != nil {
h.Logger.Error("failed to insert webhook event",
slog.String("event_id", event.ID),
slog.Any("error", err))
// Still return 200 — Stripe will retry if we return 5xx,
// but the insert failure is likely transient.
// Log the error for alerting.
}
h.Logger.Info("webhook event received",
slog.String("event_id", event.ID),
slog.String("event_type", string(event.Type)))
w.WriteHeader(http.StatusOK)
}