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.
149 lines
4.8 KiB
Go
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)
|
|
}
|