SendRec is open source and self-hostable. Anyone can run it on their own server with no limits. But we also run a hosted version at app.sendrec.eu, and that hosted version needs a way to pay for itself.
We needed billing. Specifically, we needed subscription billing that works for an EU-based product, handles VAT correctly across 27 member states, and doesn't require us to become payment experts. We also needed it to be completely optional — self-hosted instances should work exactly as before, with no billing code in the way.
Why Creem
For an EU-native product, the payment processor matters. Most billing platforms are US companies that treat EU compliance as an afterthought. We wanted a merchant of record — a company that legally sells the product on our behalf, handles VAT collection and remittance, manages invoicing, and deals with disputes.
Creem is based in Tallinn, Estonia. They're the merchant of record, which means SendRec never touches payment details. No PCI compliance burden, no VAT calculation logic, no invoice generation. Creem handles all of it.
The integration surface is small: create a checkout session, receive webhooks when subscription status changes, and query subscription details for the customer portal. Three API calls and a webhook handler.
The database change
Three columns on the existing users table:
ALTER TABLE users ADD COLUMN subscription_plan TEXT NOT NULL DEFAULT 'free';
ALTER TABLE users ADD COLUMN creem_subscription_id TEXT;
ALTER TABLE users ADD COLUMN creem_customer_id TEXT;
No separate subscriptions table. The user row is the single source of truth. subscription_plan determines what the user can do. The Creem IDs are stored so we can look up subscription details and link to the customer portal.
Every existing user defaults to free. When a webhook arrives confirming a subscription is active, the plan updates to pro. When the subscription is canceled or expires, it reverts to free.
The Creem client
The client wraps three API endpoints. The full implementation is about 130 lines:
type Client struct {
apiKey string
baseURL string
http *http.Client
}
func New(apiKey, baseURL string) *Client {
if baseURL == "" {
if strings.HasPrefix(apiKey, "creem_test_") {
baseURL = "https://test-api.creem.io"
} else {
baseURL = "https://api.creem.io"
}
}
return &Client{
apiKey: apiKey,
baseURL: baseURL,
http: &http.Client{Timeout: 10 * time.Second},
}
}
The constructor auto-detects test vs production endpoints from the API key prefix. Creem test keys start with creem_test_ and must hit test-api.creem.io. Production keys use api.creem.io. We learned this the hard way — a test key hitting the production endpoint returns a 403 with no helpful error message.
The baseURL parameter is there for tests. In production, you only pass the API key and the base URL is inferred.
Creating a checkout
When a user clicks "Upgrade to Pro", the frontend posts to our backend, which creates a checkout session with Creem:
func (c *Client) CreateCheckout(ctx context.Context, productID, userID, successURL string) (string, error) {
body, _ := json.Marshal(checkoutRequest{
ProductID: productID,
SuccessURL: successURL,
Metadata: map[string]string{"userId": userID},
})
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/checkouts", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", c.apiKey)
resp, err := c.http.Do(req)
// ... error handling ...
var result checkoutResponse
json.NewDecoder(resp.Body).Decode(&result)
return result.CheckoutURL, nil
}
The key detail is Metadata. We pass the SendRec user ID as metadata on the checkout. When Creem sends us a webhook later, that user ID comes back in the payload. This is how we know which user to upgrade — the webhook doesn't know about our auth system, it just echoes back whatever metadata we attached.
The handler wraps this in a thin HTTP layer:
func (h *Handlers) CreateCheckout(w http.ResponseWriter, r *http.Request) {
userID := auth.UserIDFromContext(r.Context())
var req checkoutPlanRequest
json.NewDecoder(r.Body).Decode(&req)
if req.Plan != "pro" {
httputil.WriteError(w, http.StatusBadRequest, "unsupported plan")
return
}
successURL := h.baseURL + "/settings?billing=success"
checkoutURL, err := h.creem.CreateCheckout(r.Context(), h.proProductID, userID, successURL)
// ... error handling ...
httputil.WriteJSON(w, http.StatusOK, map[string]string{"checkoutUrl": checkoutURL})
}
The frontend receives the checkout URL and redirects the browser. Creem hosts the entire payment form. After payment, Creem redirects back to /settings?billing=success.
Webhook handling
The real work happens asynchronously via webhooks. When a subscription changes state, Creem POSTs a signed JSON payload to our webhook endpoint.
Signature verification
Every webhook includes a creem-signature header containing an HMAC-SHA256 of the request body:
func (h *Handlers) verifySignature(body []byte, signature string) bool {
mac := hmac.New(sha256.New, []byte(h.webhookSecret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
We read the raw body first, verify the signature, then unmarshal. If you unmarshal first and re-marshal for verification, you'll get a different byte sequence and the signature will never match.
The hmac.Equal function does constant-time comparison, which prevents timing attacks against the signature check. This is a standard practice for webhook verification — the same approach GitHub and Stripe use.
Processing events
Four webhook events matter for subscription lifecycle:
func (h *Handlers) Webhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
signature := r.Header.Get("creem-signature")
if !h.verifySignature(body, signature) {
httputil.WriteError(w, http.StatusUnauthorized, "invalid signature")
return
}
var payload webhookPayload
json.Unmarshal(body, &payload)
userID := payload.Object.Metadata.UserID
if userID == "" {
w.WriteHeader(http.StatusOK)
return
}
switch payload.EventType {
case "subscription.active", "subscription.paid":
h.handleSubscriptionActivated(r, w, payload, userID)
case "subscription.canceled", "subscription.expired":
h.handleSubscriptionCanceled(r, w, userID)
default:
w.WriteHeader(http.StatusOK)
}
}
A few things worth noting:
- Missing metadata is not an error. Creem's dashboard sends test webhooks that don't include custom metadata. We return 200 and move on. Returning an error would cause Creem to retry indefinitely.
- The body is limited to 64 KB. We don't need to handle arbitrarily large payloads.
- Unknown events get a 200. If Creem adds new event types in the future, we don't want our endpoint returning errors for events we haven't implemented yet.
Activating a subscription
When we receive subscription.active or subscription.paid, we update the user's plan and store the Creem IDs:
func (h *Handlers) handleSubscriptionActivated(r *http.Request, w http.ResponseWriter, payload webhookPayload, userID string) {
plan := h.planFromProductID(payload.Object.Product.ID)
if plan == "" {
w.WriteHeader(http.StatusOK)
return
}
h.db.Exec(r.Context(),
"UPDATE users SET subscription_plan = $1, creem_subscription_id = $2, creem_customer_id = $3 WHERE id = $4",
plan, payload.Object.ID, payload.Object.Customer.ID, userID,
)
w.WriteHeader(http.StatusOK)
}
The planFromProductID function maps Creem product IDs to our internal plan names. Right now it's a single if statement — one product, one plan. If we add more tiers later, this is where the mapping grows.
Canceling a subscription
Cancellation is simpler — just revert to free:
func (h *Handlers) handleSubscriptionCanceled(r *http.Request, w http.ResponseWriter, userID string) {
h.db.Exec(r.Context(),
"UPDATE users SET subscription_plan = $1 WHERE id = $2",
"free", userID,
)
w.WriteHeader(http.StatusOK)
}
We don't clear the Creem IDs on cancellation. If the user re-subscribes, they might get the same subscription ID back, and having the history is useful for support.
The customer portal
Creem provides a customer portal where users can update payment methods, view invoices, and manage their subscription. We surface this as a "Manage subscription" link in Settings.
To get the portal URL, we query the subscription details:
func (c *Client) GetSubscription(ctx context.Context, subscriptionID string) (*SubscriptionInfo, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+"/v1/subscriptions?subscription_id="+subscriptionID, nil)
req.Header.Set("x-api-key", c.apiKey)
resp, err := c.http.Do(req)
// ... error handling ...
var info SubscriptionInfo
json.NewDecoder(resp.Body).Decode(&info)
return &info, nil
}
The response includes a customer.portal_url field. We pass this through to the frontend, which renders it as a link. The user clicks through to Creem's hosted portal — we never build payment management UI ourselves.
Free tier limits
Plan enforcement happens through a limits function that returns different values based on the user's subscription_plan:
func LimitsForPlan(plan string) Limits {
switch plan {
case "pro", "business":
return Limits{
MaxVideos: 0, // unlimited
MaxDuration: 0, // unlimited
MaxMonthlyVideos: 0, // unlimited
BrandingEnabled: true,
AIEnabled: true,
}
default:
return Limits{
MaxVideos: maxVideos,
MaxDuration: maxDuration,
MaxMonthlyVideos: maxMonthlyVideos,
BrandingEnabled: false,
AIEnabled: false,
}
}
}
Video creation checks these limits before allowing an upload. The frontend reads the limits and shows upgrade prompts when the user hits a ceiling.
Graceful degradation for self-hosters
The entire billing integration is optional. If CREEM_API_KEY is not set, the billing handlers are never registered:
if creemAPIKey != "" {
log.Println("Creem billing enabled")
creemClient := billing.New(creemAPIKey, "")
billingHandlers = billing.NewHandlers(db, creemClient, baseURL, creemProProductID, creemWebhookSecret)
}
When billing handlers are nil, the routes don't exist. The frontend tries to fetch /api/settings/billing and gets a 404, which it treats as "billing not configured" and hides the entire billing section. Self-hosted users never see a mention of plans or upgrades.
Self-hosters control limits through environment variables directly — MAX_VIDEOS, MAX_DURATION, etc. They can set these to 0 for unlimited, effectively giving themselves the equivalent of Pro. No billing integration needed.
What we learned about Creem's API
A few things that aren't obvious from the documentation:
Test and production are separate worlds. Test API keys (creem_test_ prefix) must hit test-api.creem.io. Production keys must hit api.creem.io. Using the wrong combination returns a 403 with no explanation. We auto-detect the endpoint from the key prefix so this can't happen again.
Products must be recurring. If you create a product as "one-time" in the Creem dashboard, it works for checkout but never sends subscription webhooks. The payment succeeds, the customer is charged, but subscription.active never fires. The product must be created as "recurring" for subscription lifecycle events to work.
The webhook payload uses eventType, not event. A small thing, but it cost us debugging time. The field name in the JSON payload is eventType, which is different from what some other payment processors use.
Subscription lookup uses subscription_id as a query parameter. Not a path parameter, not id — specifically ?subscription_id=. Getting this wrong returns a 400 that's easy to mistake for a different problem.
Test webhooks from the dashboard don't include metadata. When you click "Send test webhook" in Creem's dashboard, the payload doesn't include the custom metadata you attached during checkout. This is expected — there's no real checkout to pull metadata from. Your webhook handler needs to handle missing metadata gracefully.
Testing
Both the API client and the handlers have full test coverage using Go's standard testing tools.
Client tests mock the Creem API with httptest.NewServer:
func TestCreateCheckout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("x-api-key") != "test-key" {
t.Errorf("expected x-api-key test-key, got %s", r.Header.Get("x-api-key"))
}
var body checkoutRequest
json.NewDecoder(r.Body).Decode(&body)
if body.Metadata["userId"] != "user-abc" {
t.Errorf("expected metadata.userId user-abc, got %s", body.Metadata["userId"])
}
json.NewEncoder(w).Encode(checkoutResponse{
CheckoutURL: "https://checkout.creem.io/pay/xyz",
})
}))
defer server.Close()
client := New("test-key", server.URL)
url, err := client.CreateCheckout(context.Background(), "prod_123", "user-abc", "https://example.com/settings")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if url != "https://checkout.creem.io/pay/xyz" {
t.Errorf("expected checkout URL, got %s", url)
}
}
Handler tests use pgxmock for database assertions. The webhook test computes a real HMAC-SHA256 signature and verifies that the handler updates the database with the correct plan:
func TestWebhookSubscriptionActive(t *testing.T) {
mock, _ := pgxmock.NewPool()
mock.ExpectExec(`UPDATE users SET subscription_plan`).
WithArgs("pro", "sub_001", "cust_001", "user-456").
WillReturnResult(pgxmock.NewResult("UPDATE", 1))
payload := map[string]interface{}{
"eventType": "subscription.active",
"object": map[string]interface{}{
"id": "sub_001",
"product": map[string]interface{}{"id": "prod_pro"},
"customer": map[string]interface{}{"id": "cust_001"},
"metadata": map[string]interface{}{"userId": "user-456"},
},
}
payloadBytes, _ := json.Marshal(payload)
mac := hmac.New(sha256.New, []byte("webhook-secret"))
mac.Write(payloadBytes)
signature := hex.EncodeToString(mac.Sum(nil))
req := httptest.NewRequest(http.MethodPost, "/api/webhooks/creem", strings.NewReader(string(payloadBytes)))
req.Header.Set("creem-signature", signature)
handlers.Webhook(rec, req)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet mock expectations: %v", err)
}
}
The mock verifies that the exact SQL parameters match — the plan is "pro", the subscription ID is "sub_001", the customer ID is "cust_001", and the user ID is "user-456". If the handler sends different values to the database, the test fails.
The full picture
The checkout flow from the user's perspective:
- User clicks "Upgrade to Pro" in Settings
- Frontend POSTs to
/api/settings/billing/checkoutwith{"plan": "pro"} - Backend creates a Creem checkout session with the user ID in metadata
- Frontend redirects to Creem's hosted checkout page
- User completes payment on Creem's page
- Creem redirects back to
/settings?billing=success - Creem sends a
subscription.activewebhook to/api/webhooks/creem - Backend verifies the signature and updates
subscription_plantopro - Next time the frontend fetches billing status, the user sees "Pro"
Cancellation follows a similar async pattern — the user initiates, Creem confirms via webhook, and the plan reverts.
The entire billing integration is about 850 lines of Go (including tests) and 500 lines of frontend code. No external libraries beyond the standard library on the backend. No payment forms, no card inputs, no PCI scope. Creem handles all of that.
Try it
SendRec is open source (AGPL-3.0) and self-hostable. The billing integration is live at app.sendrec.eu — go to Settings to see the billing section. The implementation is in internal/billing/.
Top comments (0)