DEV Community

Alex Neamtu
Alex Neamtu

Posted on • Originally published at sendrec.eu

How We Added Billing to SendRec with Creem

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;
Enter fullscreen mode Exit fullscreen mode

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},
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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})
}
Enter fullscreen mode Exit fullscreen mode

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))
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. User clicks "Upgrade to Pro" in Settings
  2. Frontend POSTs to /api/settings/billing/checkout with {"plan": "pro"}
  3. Backend creates a Creem checkout session with the user ID in metadata
  4. Frontend redirects to Creem's hosted checkout page
  5. User completes payment on Creem's page
  6. Creem redirects back to /settings?billing=success
  7. Creem sends a subscription.active webhook to /api/webhooks/creem
  8. Backend verifies the signature and updates subscription_plan to pro
  9. 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)