feat(admin): Paddle client behind an interface, config, and the event idempotency record

Client is a thin REST client (net/http) rather than the vendor SDK: the surface
we need is two calls, and a hand-rolled client has no version-drift risk and no
dependency in go.sum. All Paddle wire shapes live only in http.go.

PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET are boot-required — an unverified
webhook endpoint is one anyone can issue licences through. paddle_events carries
a unique index on event_id for webhook idempotency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 10:36:26 +01:00
co-authored by Claude Opus 5
parent 3d3be7465f
commit fbd93d0ea5
8 changed files with 323 additions and 1 deletions
+54
View File
@@ -0,0 +1,54 @@
// Package paddle is the only place that talks to Paddle. Everything outside it
// depends on the Client interface and our own types, never on Paddle's wire
// shapes — so a change at Paddle is confined to http.go, and the billing package
// can be reasoned about without knowing Paddle exists.
//
// It is a thin REST client rather than the vendor SDK on purpose: the surface we
// need is two calls, and a hand-rolled client has no version-drift risk and no
// dependency to keep in go.sum.
package paddle
import "context"
// LineItem is one price at a quantity, the shape both a checkout and a
// subscription update are built from.
type LineItem struct {
PriceID string
Quantity int
}
// Client is the narrow slice of Paddle admin needs. Checkout itself happens in
// the browser via paddle-js; the server only updates an existing subscription
// and mints a portal session.
type Client interface {
// UpdateSubscriptionItems replaces a subscription's items, prorated
// immediately by Paddle. This is the one outbound mutation, used when a
// customer changes their server count or features on an existing plan.
UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
// PortalSession returns a customer-portal URL for managing billing.
PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
// Env is "sandbox" or "production", the same value catalogue price lookups
// are keyed on.
Env() string
}
var current Client
// Init constructs the client from config and stores it. Called once at boot.
func Init(apiKey, env string) (Client, error) {
c, err := newHTTPClient(apiKey, env)
if err != nil {
return nil, err
}
current = c
return c, nil
}
// Get returns the client initialised at boot. Panics if unset, which can only
// happen if a caller runs before Init — a programming error, not a runtime one.
func Get() Client {
if current == nil {
panic("paddle.Get before paddle.Init")
}
return current
}
+120
View File
@@ -0,0 +1,120 @@
package paddle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// httpClient is the only implementation of Client. It is the single place that
// knows Paddle's base URLs, auth header and request shapes — swap the whole
// vendor here without the rest of the tree noticing.
type httpClient struct {
apiKey string
env string
base string
http *http.Client
}
func newHTTPClient(apiKey, env string) (Client, error) {
if apiKey == "" {
return nil, fmt.Errorf("paddle: empty API key")
}
base := "https://sandbox-api.paddle.com"
if env == "production" {
base = "https://api.paddle.com"
}
return &httpClient{
apiKey: apiKey,
env: env,
base: base,
http: &http.Client{Timeout: 20 * time.Second},
}, nil
}
func (c *httpClient) Env() string { return c.env }
// do sends a JSON request and decodes the `data` envelope Paddle wraps every
// response in. A non-2xx is returned as an error carrying the body, so a
// configuration or auth failure is loud rather than silent.
func (c *httpClient) do(ctx context.Context, method, path string, body any, out any) error {
var buf io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("paddle: marshal %s %s: %w", method, path, err)
}
buf = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, buf)
if err != nil {
return fmt.Errorf("paddle: build %s %s: %w", method, path, err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("paddle: %s %s: %w", method, path, err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("paddle: %s %s returned %d: %s", method, path, res.StatusCode, string(raw))
}
if out == nil {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("paddle: decode %s %s: %w", method, path, err)
}
return nil
}
type updateSubscriptionRequest struct {
Items []reqItem `json:"items"`
ProrationBillingMode string `json:"proration_billing_mode"`
}
type reqItem struct {
PriceID string `json:"price_id"`
Quantity int `json:"quantity"`
}
func (c *httpClient) UpdateSubscriptionItems(ctx context.Context, subID string, items []LineItem) error {
if subID == "" {
return fmt.Errorf("paddle: empty subscription id")
}
reqItems := make([]reqItem, 0, len(items))
for _, it := range items {
reqItems = append(reqItems, reqItem{PriceID: it.PriceID, Quantity: it.Quantity})
}
return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, updateSubscriptionRequest{
Items: reqItems,
ProrationBillingMode: "prorated_immediately",
}, nil)
}
func (c *httpClient) PortalSession(ctx context.Context, customerID string) (string, error) {
if customerID == "" {
return "", fmt.Errorf("paddle: empty customer id")
}
var out struct {
Data struct {
URLs struct {
General struct {
Overview string `json:"overview"`
} `json:"general"`
} `json:"urls"`
} `json:"data"`
}
if err := c.do(ctx, http.MethodPost,
"/customers/"+customerID+"/portal-sessions", struct{}{}, &out); err != nil {
return "", err
}
return out.Data.URLs.General.Overview, nil
}
+42
View File
@@ -0,0 +1,42 @@
package paddle
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
// VerifySignature checks a raw webhook body against the Paddle-Signature header.
//
// Paddle signs an HMAC-SHA256 over "ts:body", carried as "ts=<unix>;h1=<hex>".
// It uses a constant-time compare and never logs the secret. A false return is
// always a 401 with nothing processed — an unverified body could be anyone
// claiming a subscription was paid for.
func VerifySignature(secret, header string, body []byte) bool {
if secret == "" || header == "" {
return false
}
var ts, h1 string
for _, part := range strings.Split(header, ";") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
switch k {
case "ts":
ts = v
case "h1":
h1 = v
}
}
if ts == "" || h1 == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte(":"))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(want), []byte(h1))
}