Skip to main content
Charging for AI generations in a way that meets MoR or PSP guidelines is hard with unmoderated prompts, failed generations and incorrect customer credits deduction. For e.g., Adobe Firefly users report failed video jobs that produce nothing yet still deduct credits, with one person losing 680 credits across 17 error-filled attempts. Users are bound to get frustrated if charges are made before the provider is sure that the model worked, or before the prompt has been screened. The fix is in the order of generation steps: moderate the prompt, charge the credits, generate, and refund automatically if the model fails. In this guide you build Aperture, a reference image and video generator that uses Creem for billing and moderation. Credits are managed by the Customer Credits API, and every prompt is validated through the Moderation API.

Demo

Aperture implements the following four-step order for every generation: Try it at aperture-creem.vercel.app. The tabs below show the possible prompt moderation results in the application:
An ordinary prompt, screened by the live Moderation API and verified to pass. It clears moderation, debits 5 credits, renders a poster into the gallery, and the wallet drops by 5.The allow path: the prompt passes moderation and the studio renders a poster while the wallet drops to 185 credits

Prerequisites

  • Node.js 22 or newer, and pnpm 11
  • A Creem account
  • A Neon account

Clone the project

Since there’s a lot to cover in this application, the better way is to clone the project, install its dependencies, and then learn the core parts around moderation and billing with Creem. Run the following commands to clone and install the project:
It installs the following important dependencies:
  • creem is the official SDK through which the Customer Credits and Moderation are managed.
  • @creem_io/better-auth is the Better Auth plugin of Creem. It syncs user.creemCustomerId on the first checkout and serves the Creem webhook.
  • better-auth handles email and password sign-in.
  • @neondatabase/serverless to query Postgres in serverless functions over HTTP requests.
Then, copy the environment variable file with the following command:
Now, let’s provision a serverless Postgres as the database.

Provision a Serverless Postgres

To set up a serverless Postgres, go to the Neon console and create a new project. Once your project is created, you will receive a connection string that you can use to connect to your Neon database. The connection string will look like this:
shouldWrap
Use this connection string as an environment variable designated as DATABASE_URL in the .env.local file.

Configure Creem credentials

1

Copy your API key

Grab your API key from the Developers section of the dashboard and set it as CREEM_API_KEY. The app picks test vs production mode from the key prefix (creem_test_ is test mode).
2

Create the three credit packs

Each pack (Starter, Pro, and Studio) maps to a one-time Creem product. With CREEM_API_KEY set, run the setup script to create all three at once:
It prints the three CREEM_PRODUCT_* lines to paste into .env.local. Re-running in the same mode returns the existing products instead of creating duplicates.Prefer the dashboard? In the products tab, create one product per pack and copy each product ID (it starts with prod_) into CREEM_PRODUCT_STARTER, CREEM_PRODUCT_PRO, and CREEM_PRODUCT_STUDIO.
3

Set the webhook signing secret

In the Developers > Webhooks section of the dashboard, create a webhook endpoint and subscribe it to checkout.completed. You don’t have your local tunnel URL yet, so use any placeholder for now such as https://example.com/api/auth/creem/webhook. Then, copy the endpoint’s signing secret into CREEM_WEBHOOK_SECRET.That secret is tied to the unique webhook and it remains the same when you repoint the URL at your tunnel from the instructions in the test webhooks locally section.
4

Generate a Better Auth secret

Better Auth signs sessions with BETTER_AUTH_SECRET. Generate a random value with the following and update in the .env.local file:
Once that’s done, execute the following command in your terminal to see the application in action on localhost:3000:
Press CTRL-C to stop the server and now let’s move to understanding the codebase.

Initialize the Creem SDK client

Every call in the app reuses a single SDK instance from src/lib/creem/client.ts:
The SDK picks the host from the server option (api.creem.io for production, test-api.creem.io for test) and adds the x-api-key header to every request. The app sets server by checking whether the API key starts with creem_test_.

Set up the database schema

Creem is the source of truth for balances, so the schema in db/schema.sql only has the Better Auth tables and a few application tables. Two of them related to billing are as follows:
  • credit_account is a lookup table. It does not store a balance. It only remembers which Creem account id (cca_…) belongs to which user, so a request can ask Creem for the live balance of the customer.
  • credit_ledger_cache is a read model. It mirrors every credit and debit. It enables fetching the transaction history page without a round trip to Creem on every page load. It also keeps an audit trail after an account is closed.
Now, apply the schema in your database with the following command:

Test webhooks locally

Creem cannot POST the checkout.completed webhook to localhost, so the buy flow needs a public URL for that endpoint. A localtunnel enables you to map a localhost port to a public and a secure https domain.
  1. Run the application in one terminal:
  2. Open a tunnel to the same port in another terminal:
  3. In the Creem dashboard, open the webhook endpoint you created earlier and update its URL to https://<subdomain>.loca.lt/api/auth/creem/webhook. The signing secret remains unchanged.
  4. Sign up, buy a pack (the 100 percent off code makes it $0), and complete the Creem checkout. Creem posts the webhook to the tunnel, the tunnel forwards it to your local endpoint, onCheckoutCompleted credits the wallet, and the balance widget updates on refresh.
Keep NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL as http://localhost:3000 so your auth cookies and the success redirect stay on localhost while you browse. You only need the tunnel for the inbound webhook.

Screen the prompt before generation

Creem requires moderation on AI generation products, so the wrapper in src/lib/creem/moderation.ts follows three rules:
  1. Screen the prompt before anything else.
  2. Block on both deny and flag.
  3. If the call errors or times out, block. Never generate on an unknown answer.
allowed is true only on an explicit allow. Every other branch, including the empty catch, returns false. So the request is blocked on any error, timeout, or an unknown response code. This ensures that generations are always moderated.

Implement the wallet layer

Every balance change goes through one module, src/lib/wallet.ts and is used by routes and the webhook. It calls the Creem API on every generation, and then it mirrors the actions into Postgres for history and auditing. The wallet layer is comprised of two files:
  • src/lib/wallet.ts: exposes wallet controls, such as creditPack, debitForGeneration, refundDebit, getBalance, listHistory, and the account controls (freeze, unfreeze, close).
  • src/lib/creem/credits-creem.ts: adapter over creemClient.customerCredits.*. It makes the Creem calls and maps the camelCase responses onto the application’s snake_case types.

Use idempotency keys on every step

Retries in applications happen for ordinary reasons:
  • a webhook fires twice
  • a user double-clicks Buy
  • a network blip makes a client resend a request that already succeeded
An idempotency key means Creem treats the retry as the same operation and does not change the balance on a duplicated request. Here is how a pack purchase is credited:
Creem applies the credit against the account, and cacheMovement mirrors the same entry into credit_ledger_cache so the history page can render it without another round trip. The debit path for a generation follows the same shape, and it also turns a Creem “not enough balance” response into a typed error for the catch block.
In the code above, isInsufficient reads the Creem error, either an HTTP 422 or an “insufficient” marker in the body, so the application can show the user a clean “you need N credits” (instead of a raw API error).

Read current and point-in-time balances

The balance read passes an optional at timestamp to Creem:
In the code above:
  • With no at, you get the current balance.
  • With an ISO timestamp, you get the balance at that moment. This is helpful for a support question like “how many credits did this customer have on the 3rd August”.

Reverse a debit

Creem has a reversal operation enabling you to skip manual re-credits. When a generation fails after you’ve charged, you can reverse the exact debit transaction:
A reversal adds a compensating entry, which shows both the charge and the refund in history. This is helpful for customer support when you need to see the usage history to answer queries.

Implement the generation route

When a generation is requested via src/app/api/generate/route.ts, it goes through the four steps shared earlier in-order. In this section, we cover each step in detail. Let’s start with how requests are validated.

Validate the request

The route uses a withUser re-usable function to allow only authenticated user to request generations. It parses the body with Zod, sets the cost from the media type (an image is 5 credits, a video is 40), and accepts an optional client-supplied idempotency key so a retry from the browser doesn’t double-charge or double-generate. It also validates the optional reference image. The imageDataUrl has to be a base64 image data URL, and the route rejects anything over 2.5 MB (as a demonstration of enforcing limits).

Replay a completed request

The route then checks whether this idempotency key already produced a finished generation. If it did, it returns that result and runs nothing else.
Before consuming credits, it checks that the user has a wallet and that the wallet is active. A frozen or closed account is turned away with a clear status. Then the following four steps run:

Step 1: moderate

If the prompt doesn’t clear, the route records a rejected generation and returns. Nothing has been charged from the user in this case.

Step 2: debit

The user is then charged (before the model runs). A separate idempotency key derived from the request key keeps the debit itself safe to retry. An insufficient balance is caught as a 402, and the generation is recorded so the generation attempt is not lost.

Step 3 and 4: generate, then return or reverse

Then, the model runs to generate the desired asset. If it fails, the route catches that failure and reverses the exact debit, so the user keeps their credits.
In the code above:
  • refunded is only reported after the reversal succeeds.
  • If the reversal fails and the customer is still charged, the route marks the row failed, logs a warning with the generation id, and tells the customer the team will sort it out.
  • The generation table records each step: the moderation decision, the debit transaction id, and the final status to ensure that you can audit the events in future.

Prevent duplicate charges with an advisory lock

Idempotency keys make a retry safe once the first request has finished. Two requests with the same key can also be in flight at the same moment, though, like a client that retries before the first response comes back. If both slipped past the replay check together, both would debit and both would call the model. The generation route closes that window with a Postgres advisory lock. The flow runs on a dedicated connection while holding a lock keyed on the user id and the idempotency key. A racing duplicate blocks on the lock until the first request finishes, then reaches the replay check and returns the first result instead of charging again.
In the code above:
  • Since two independent generations will use different keys, the lock in the application code only ever serializes true duplicates.
  • If the unlock query fails, it destroys the connection to Postgres so a session lock can not be reused by another request from the pool.

Sell credit packs

The pricing page lists three packs, defined in src/lib/packs.ts, and each one maps to a Creem product through the env vars you set earlier:
Clicking “Buy” calls src/app/api/checkout/route.ts, which opens a Creem checkout for that product and returns the URL. The important line is metadata.referenceId, which passes the app user id into the checkout so the webhook can credit the right wallet.

Credit the wallet from the webhook

The Creem plugin in src/lib/auth.ts verifies the webhook signature for you and serves the endpoint at /api/auth/creem/webhook. Your job is the onCheckoutCompleted hook, which runs after a confirmed payment:
In the code above:
  • ensureAccountForUser function creates the Creem credit wallet the first time a customer pays, using the customer id from the webhook. It serializes per-user creation behind a transaction-scoped advisory lock (pg_advisory_xact_lock) and re-checks the table inside the lock, so exactly one wallet is created per user.
  • The idempotency key is built from the order id, so if Creem retries the webhook, the same order does not credit the wallet twice.
  • The hook does not wrap the crediting in a try/catch. If crediting fails on a database or a network hiccup, the error throw returns a non-2xx from the webhook, so Creem retries delivery and the credits get applied on a later automatic attempt.

Protect routes with authentication checks

The application dashboard and API routes are protected by two checks:
  • src/proxy.ts that denies any request to a protected route with no session cookie:
    Note that the cookie check doesn’t prove the session is valid. It only rejects requests that carry no session cookie at all.
  • Every route re-checks the session server-side with withUser in src/lib/session.ts, which calls Better Auth’s getSession.

Freeze, unfreeze, and close accounts

Running a credits product means you eventually have to freeze a suspicious account, unfreeze it after review, or close one. Those functionalities are available in the wallet layer of the application, and are exposed through src/app/api/credits/account/route.ts:
While freeze operations are reversible, closing an account can not be reversed. Closing an account, though, preserves its ledger with the credit_ledger_cache table in the database. The transaction history itself comes from src/app/api/credits/history/route.ts where the wallet reads the live entries from Creem and populates each one with the reference and kind from the cache (database) mirror, because Creem’s /entries endpoint leaves the reference off.

Swap in a real model

In Aperture’s code, the AI-powered generator is a black box in an interface in src/lib/generator/index.ts. The default is a StubGenerator that renders a gradient poster from the prompt, to demonstrate the billing and moderation flow runs with no model credentials and at no cost. To use a real model, implement the interface and register it like following:
Then, add a case 'fal' in getGenerator() and set GENERATOR=fal as the environment variable. This allows you to use any model (or even multiple ones) without any change in the billing or moderation flow.

Deploy to Vercel

Deploy with Vercel
  1. Push the project to a Git repo and import it into Vercel.
  2. Set every environment variable from your .env.local.
  3. Set BETTER_AUTH_URL and NEXT_PUBLIC_APP_URL to your Vercel URL.
  4. Run pnpm db:migrate once against your production database.
  5. Point your Creem webhook at https://<your-app>/api/auth/creem/webhook and subscribe it to checkout.completed.

Summary

You now have a safe, metered AI image and video generator built with Next.js and Creem. Creem holds the credits and screens the prompts, and Postgres keeps a mirror for history and auditing. Every generation runs in the same order: moderate, debit, generate, then return or reverse. When you’re ready for a real model, simply swap the StubGenerator behind its interface.