Creem is a Merchant-of-Record (MoR) for software. Like
Polar and Dodo Payments, Creem is the legal seller of your product: it prices,
collects, and remits sales tax / EU VAT across 190+ jurisdictions, issues the
invoice under its own entity, and owns chargebacks and disputes. You never touch
a tax table. This page covers wiring Creem into a Lunora app through
@lunora/payment; for the provider itself, see Creem's
official docs.
What Creem is, and when to choose it
Creem is product-based: you create real products (with prices) in the Creem
dashboard and reference them by product id at checkout, which is exactly the
shape @lunora/payment's priceId interface expects, so Creem slots in like
Polar and Dodo rather than like a raw PSP. Checkout is a hosted, Creem-branded page;
subscriptions are first-class objects (cancel, pause/resume, upgrade with
proration); and there is a genuine hosted billing portal customers can open
to manage their own payment methods and subscriptions.
Prop
Type
Choose Creem when you want a MoR (no tax registration, no VAT filing) with a strong EU footprint and a clean product/subscription model, and you don't need programmatic refunds or usage-based billing.
| Provider | Model | Tax / MoR | Notable |
|---|---|---|---|
| Creem | Product-based | ✅ Merchant-of-Record (EU-first) | Hosted portal, upgrade proration; refunds are dashboard-only. |
| Stripe | Price/PSP | ❌ You are the seller (you file) | Full control, manual capture/refund, richest API. |
| Polar | Product-based | ✅ Merchant-of-Record | Standard Webhooks; first-class refunds. |
| Dodo | Product-based | ✅ Merchant-of-Record | First-class refunds and usage metering. |
Switching providers in @lunora/payment is a configuration change: the provider is a stateless translator, and the store owns all state. If you outgrow
Creem's constraints (e.g. you need programmatic refunds or metered billing), swap the adapter; your ctx.payments call sites don't change.
Prerequisites
A Creem account. Sign up at creem.io and create at least
one product (and, for recurring plans, a price). You'll reference products by
their prod_… id when starting a checkout.
An API key. In the Creem dashboard open Developers (dashboard →
developers) and copy your API key.
Creem exposes a separate test-mode key and a https://test-api.creem.io
server; use test mode while you build.
A webhook signing secret. Under Developers → Webhooks, add an endpoint pointing at your app's webhook route (below) and copy the generated signing secret. Creem signs every delivery with it. See docs.creem.io/code/webhooks.
Store both as environment variables. Lunora's secrets registry declares the exact
names @lunora/payment expects:
| Env var | Purpose |
|---|---|
CREEM_API_KEY | API key for the Creem SDK. From Developers. |
CREEM_WEBHOOK_SECRET | Signing secret for the creem-signature header. From Webhooks. |
Install
Install the payment package alongside the official creem SDK. @lunora/payment
never imports creem itself (you inject the client), so the SDK stays an
ordinary dependency of your app, not a hard dependency of the package.
pnpm add @lunora/payment creemConfigure
The adapter takes the creem client by injection, typed as the structural
CreemClientLike (its checkouts, customers, and subscriptions
resources). A real Creem instance satisfies it without a cast — the shape
exists only to keep @lunora/payment free of a hard creem dependency and the
SDK's full types out of the published declarations; internally the adapter calls
the real SDK methods.
Construct the real Creem SDK client. The current creem SDK takes the API key
at construction (not per call) and selects test mode via server: "test" (or an
explicit serverURL):
import { Creem } from "creem";
const creem = new Creem({
apiKey: env.CREEM_API_KEY,
// server: "test", // → https://test-api.creem.io while developing
});Wire the adapter into ctx.payments. The adapter and its secrets come from a
config.payment(env) thunk you pass to createShardDO(). The store is built per
request from ctx.db, and the default authorizer ties referenceId to
ctx.auth.userId.
import { createCreemAdapter } from "@lunora/payment/creem";
import { Creem } from "creem";
import { createShardDO } from "./_generated/shard";
export const ShardDO = createShardDO({
payment: (env) => {
const client = new Creem({ apiKey: env.CREEM_API_KEY });
return {
adapter: createCreemAdapter({ client, webhookSecret: env.CREEM_WEBHOOK_SECRET }),
// Grant plans from active/trialing subscriptions on these product ids.
entitlements: {
plans: { pro: { features: ["export"], priceIds: ["prod_pro"] } },
},
observability: (event) => console.log("[payment]", event.type, event),
};
},
});ctx.payments is then wired by codegen onto ActionCtx wherever a lunora/
source imports @lunora/payment or reads ctx.payments.
Hosted checkout
Call the facade from an action. createCheckout reuses the reference's stored
Creem customer (minting one only on first checkout), attaches an outbound
idempotency key automatically, and returns a hosted url to redirect to. The
priceId is a Creem product id.
import { action, v } from "./_generated/server";
export const subscribe = action.input({ productId: v.string() }).action(async ({ ctx, args: { productId } }): Promise<{ url: string }> => {
const { url } = await ctx.payments.createCheckout({
referenceId: ctx.auth.userId,
priceId: productId, // Creem product id (prod_…)
mode: "subscription", // or "payment" for a one-time purchase
successUrl: "https://app.test/done",
cancelUrl: "https://app.test/cancel",
quantity: 1, // → the checkout's `units`
});
return { url };
});Creem checkout only has a success URL (there is no separate cancel URL in Creem's checkout model), so the adapter forwards successUrl and drops
cancelUrl. Keep passing cancelUrl for cross-provider portability; it's simply unused here. The framework-controlled referenceId is pinned into
checkout metadata and can never be overridden by caller-supplied metadata.
Subscriptions
All subscription methods return the provider's reported truth, which the facade persists to the store.
Cancel: immediate vs. at period end
// Cancel immediately (default): access ends now.
await ctx.payments.cancelSubscription(subscriptionId);
// Cancel at period end: the customer keeps access until the current period ends.
await ctx.payments.cancelSubscription(subscriptionId, { atPeriodEnd: true });The adapter maps this onto Creem's cancel mode: atPeriodEnd: true →
{ mode: "scheduled" }, otherwise { mode: "immediate" }. A scheduled cancel
leaves Creem's subscription in scheduled_cancel, which the adapter still treats
as active (it entitles until period end) while surfacing the pending
cancellation via cancelAtPeriodEnd: true.
Upgrade / downgrade with proration
A plan change is expressed as a priceId patch and issued as a Creem upgrade
to the new product, prorated and charged immediately:
// Adapter calls subscriptions.upgrade(id, { productId, updateBehavior: "proration-charge-immediately" })
const updated = await ctx.payments.adapter.updateSubscription(subscriptionId, { priceId: "prod_enterprise" });proration-charge-immediately means the plan takes effect at once and the prorated
difference is billed right away (Creem also supports proration-none). See
Managing subscriptions.
Resume
await ctx.payments.adapter.resumeSubscription(subscriptionId); // → subscriptions.resume(id)Billing portal
Give a customer a self-service portal to manage payment methods and subscriptions.
The facade derives the customer from the store (never a caller-supplied id, so no
IDOR) and calls Creem's generateBillingLinks:
export const portal = action.action(async ({ ctx }): Promise<{ url: string }> => {
return ctx.payments.createPortalSession(ctx.auth.userId, "https://app.test/account");
});The adapter reads the portal URL from Creem's customer_portal_link response
field. See Customer Portal.
Webhooks
Creem signs every webhook with a single creem-signature header:
hex(HMAC_SHA256(signingSecret, rawBody)), the HMAC-SHA256 of the raw request
body, hex-encoded, with no timestamp in the scheme. The adapter verifies it
with a constant-time comparison and fails closed on a missing secret or header.
Register the endpoint in Developers → Webhooks and copy the signing secret
into CREEM_WEBHOOK_SECRET.
Verify at the edge. Signature verification needs the untouched raw body, so
the endpoint runs in an httpAction and forwards the raw body + signature into the
shard, where ctx.payments and its store exist:
import { webhookResponse } from "@lunora/payment";
import { httpAction, httpRouter } from "lunorash/server";
import { processWebhook } from "./billing";
export const app = httpRouter();
app.post(
"/payment/webhook",
httpAction(async (ctx, request) => {
const body = await request.text();
const signature = request.headers.get("creem-signature") ?? "";
// `webhookResponse`, not `Response.json(...)`: only the JSON payload crosses the
// `runAction` hop, so building the response by hand collapses the deliberate 500 on an
// orphaned (out-of-order) event to a 200 and Creem never redelivers it.
return webhookResponse(await ctx.runAction(processWebhook, { body, signature }));
}),
);import { internalAction, v } from "./_generated/server";
export const processWebhook = internalAction
.input({ body: v.string(), signature: v.string() })
.action(async ({ ctx, args: { body, signature } }): Promise<{ applied: boolean; status: number }> => {
const request = new Request("https://internal/payment/webhook", {
body,
headers: { "creem-signature": signature },
method: "POST",
});
const response = await ctx.payments.handleWebhook(request);
const result = (await response.json()) as { applied?: boolean };
return { applied: result.applied ?? false, status: response.status };
});Once verified, handleWebhook normalizes the event through Lunora's state machine
and returns 200 once applied or deduplicated, so Creem stops retrying; a
duplicate is safe by construction (the event id keys an append-only log). The
one exception is an orphaned event — one whose subscription or customer row
has not been created yet, i.e. an out-of-order delivery — which returns 500 so
Creem redelivers it after the parent row lands.
Event mapping
Creem's top-level event carries id, eventType, and object. The adapter maps
each eventType to a normalized transition:
Creem eventType | Normalized action |
|---|---|
checkout.completed | payment.captured (reads the settled order amount) |
subscription.active, subscription.paid, subscription.trialing | subscription.active |
subscription.past_due, subscription.unpaid | subscription.past_due (non-entitling) |
subscription.paused | subscription.paused |
subscription.canceled, subscription.expired | subscription.canceled |
subscription.scheduled_cancel | subscription.active + cancelAtPeriodEnd: true |
subscription.update | recomputed from the object's status |
refund.created | payment.refunded (reads refund_amount/refund_currency, keys on the refund's checkout id) |
dispute.created (and any future families) | unhandled (no state transition) |
Note Creem's event name is subscription.update (no trailing "d"), distinct from Lunora's internal subscription.updated action; the adapter handles the
Creem spelling. A subscription.past_due / unpaid subscription is deliberately mapped to a non-entitling state, matching the Stripe/Polar adapters.
Limitations
Creem is a Merchant-of-Record, so it owns money movement. Several PSP-style
operations therefore throw a PROVIDER_ERROR rather than silently no-op:
- Refunds are dashboard-only. Creem exposes no SDK/API endpoint to initiate a refund, so
refundPaymentthrows. Issue refunds from the Creem dashboard; the resultingrefund.createdwebhook syncs the refunded amount back into the store. - No usage metering.
usageMeteringisfalse.trackstill records to Lunora's own durable ledger (andcheckreads it), but nothing is forwarded to Creem. - No manual capture / payment cancellation.
capturePaymentandcancelPaymentthrow, because Creem captures at checkout and owns the payment lifecycle.
Troubleshooting
WEBHOOK_SIGNATURE_INVALID / "no matching signature". The signature is computed over the raw body. If any middleware re-parses or re-serializes
JSON before you read request.text(), the bytes change and verification fails. Forward the exact raw string end-to-end, and confirm CREEM_WEBHOOK_SECRET
matches the endpoint's secret in Developers → Webhooks (test vs. live secrets differ).
Customer creation errors. customers.create returns 400 when a customer with that email already exists, so it is not idempotent by email. The adapter
recovers by retrieving the existing customer by email and adopting it only when its referenceId metadata matches; a mismatch fails closed. If you see the
create rejected for another reason, check the API key's store and mode (test vs. live).
Wrong customer returned by retrieve. The SDK's retrieve is positional: retrieve(customerId?, email?). Passing {email} directly puts the object
in the customerId slot. The adapter calls retrieve(undefined, email) for its email lookup; do the same in any direct SDK call of your own.
Seat quantities read as 1. Creem stores per-item quantity under items[].units, not a top-level units. If you sell seat-based plans, verify the
subscription's quantity after sync.
Test mode. Build against server: "test" (https://test-api.creem.io) with your test API key and a test webhook endpoint before switching to live
keys.
Customers and getOrCreateCustomer
The facade only calls getOrCreateCustomer when the store holds no Creem customer
for the referenceId, i.e. the first checkout (a caller-supplied customerId is
ignored — the customer is always derived from the store for the authorized
reference). The adapter tries customers.create first, then (on the
duplicate-email error Creem returns for an existing email) falls back to
customers.retrieve by email, an idempotent get-or-create.
Related
@lunora/payment: the provider-agnostic API,ctx.payments, entitlements, and the store schema.@lunora/scheduler: drive thereconcilesweep that re-fetches provider truth for drifted rows.- Studio: the Payments panel (under Operations) shows synced customers, subscriptions, and Creem webhook events.
- Creem docs · Webhooks · Managing subscriptions · Customer Portal