Skip to content
DocspackagespaymentDocumentation

@lunora/payment — Creem

Accept payments and subscriptions through Creem — an EU-friendly, product-based Merchant-of-Record — with hosted checkout, a billing portal, and verified webhooks synced through Lunora's payment state machine.

Packages@lunora/payment@lunora/payment — Creem

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.

ProviderModelTax / MoRNotable
CreemProduct-based✅ Merchant-of-Record (EU-first)Hosted portal, upgrade proration; refunds are dashboard-only.
StripePrice/PSP❌ You are the seller (you file)Full control, manual capture/refund, richest API.
PolarProduct-based✅ Merchant-of-RecordStandard Webhooks; first-class refunds.
DodoProduct-based✅ Merchant-of-RecordFirst-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 varPurpose
CREEM_API_KEYAPI key for the Creem SDK. From Developers.
CREEM_WEBHOOK_SECRETSigning 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 creem

Configure

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:

lunora/http.ts
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 }));
    }),
);
lunora/billing.ts
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 eventTypeNormalized action
checkout.completedpayment.captured (reads the settled order amount)
subscription.active, subscription.paid, subscription.trialingsubscription.active
subscription.past_due, subscription.unpaidsubscription.past_due (non-entitling)
subscription.pausedsubscription.paused
subscription.canceled, subscription.expiredsubscription.canceled
subscription.scheduled_cancelsubscription.active + cancelAtPeriodEnd: true
subscription.updaterecomputed from the object's status
refund.createdpayment.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 refundPayment throws. Issue refunds from the Creem dashboard; the resulting refund.created webhook syncs the refunded amount back into the store.
  • No usage metering. usageMetering is false. track still records to Lunora's own durable ledger (and check reads it), but nothing is forwarded to Creem.
  • No manual capture / payment cancellation. capturePayment and cancelPayment throw, 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.