REFERENCE.md
---
title: CREEM API Reference
noindex: true
---
# CREEM API Reference
Complete API reference with all endpoints, request/response schemas, and field descriptions.
## Contents
- Base Configuration and Authentication
- Checkouts API: create and retrieve checkout sessions
- Products API: create, retrieve, and list products
- Customers API: retrieve, list, and generate portal links
- Subscriptions API: retrieve, update, upgrade, cancel, pause, resume
- Licenses API: activate, validate, deactivate
- Discounts API: create, retrieve, delete
- Transactions API: get and search transactions
## Base Configuration
```
Production: https://api.creem.io
Test Mode: https://test-api.creem.io
Version: v1
```
## Authentication
All requests require the `x-api-key` header:
```http
x-api-key: creem_your_api_key_here
```
API keys are found in the dashboard under Developers > API & Webhooks. Test and production use different keys.
---
## Checkouts API
### Create Checkout Session
Creates a new checkout session and returns a URL to redirect the customer.
```http
POST /v1/checkouts
```
**Request Body:**
| Field | Type | Required | Description |
| ---------------- | ------ | -------- | -------------------------------------------- |
| `product_id` | string | Yes | Product ID to purchase (e.g., `prod_abc123`) |
| `request_id` | string | No | Your tracking ID for this checkout |
| `units` | number | No | Number of units/seats (default: 1) |
| `discount_code` | string | No | Pre-fill discount code |
| `customer` | object | No | Pre-fill customer data |
| `customer.email` | string | No | Customer's email address |
| `customer.id` | string | No | Existing customer ID |
| `success_url` | string | No | Redirect URL after payment |
| `metadata` | object | No | Key-value pairs for tracking |
| `custom_fields` | array | No | Additional fields to collect (max 3) |
**Custom Fields Schema:**
```json
{
"custom_fields": [
{
"type": "text",
"key": "companyName",
"label": "Company Name",
"optional": false,
"text": {
"min_length": 1,
"max_length": 200
}
},
{
"type": "checkbox",
"key": "termsAccepted",
"label": "Accept Terms",
"optional": false,
"checkbox": {
"label": "I agree to the [terms](https://example.com/terms)"
}
}
]
}
```
**Response: CheckoutEntity**
```json
{
"id": "ch_1234567890",
"mode": "test",
"object": "checkout",
"status": "pending",
"checkout_url": "https://checkout.creem.io/ch_1234567890",
"product": "prod_abc123",
"units": 1,
"request_id": "order_123",
"success_url": "https://yoursite.com/success",
"metadata": { "userId": "user_123" }
}
```
### Retrieve Checkout
```http
GET /v1/checkouts?checkout_id={id}
```
**Query Parameters:**
| Field | Type | Required | Description |
| ------------- | ------ | -------- | ------------------- |
| `checkout_id` | string | Yes | Checkout session ID |
**Response:** Full `CheckoutEntity` with expanded `product`, `customer`, `order`, `subscription`, and `feature` objects if checkout is completed.
---
## Products API
### Create Product
```http
POST /v1/products
```
**Request Body:**
| Field | Type | Required | Description |
| --------------------------------- | ------- | ------------ | ------------------------------------------------------------------------------------ |
| `name` | string | Yes | Product name |
| `description` | string | No | Product description |
| `image_url` | string | No | Product image URL (PNG/JPG) |
| `price` | integer | Yes | Price in cents. Use `0` for free products; paid products must be at least 100 cents. |
| `currency` | string | Yes | ISO currency code (USD, EUR, etc.) |
| `billing_type` | string | Yes | `recurring` or `onetime` |
| `billing_period` | string | If recurring | `every-month`, `every-year`, etc. |
| `tax_mode` | string | No | `inclusive` or `exclusive` |
| `tax_category` | string | No | `saas`, `digital-goods-service`, `ebooks` |
| `default_success_url` | string | No | Default redirect after payment |
| `custom_fields` | array | No | Fields to collect at checkout |
| `abandoned_cart_recovery_enabled` | boolean | No | Enable cart recovery emails |
**Response: ProductEntity**
```json
{
"id": "prod_abc123",
"mode": "test",
"object": "product",
"name": "Pro Plan",
"description": "Full access to all features",
"image_url": "https://example.com/image.jpg",
"price": 2900,
"currency": "USD",
"billing_type": "recurring",
"billing_period": "every-month",
"status": "active",
"tax_mode": "exclusive",
"tax_category": "saas",
"product_url": "https://creem.io/product/prod_abc123",
"default_success_url": "https://example.com/success",
"features": [],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
```
### Retrieve Product
```http
GET /v1/products/{id}
```
### List Products
```http
GET /v1/products/search?page_number={n}&page_size={size}
```
**Query Parameters:**
| Field | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------ |
| `page_number` | number | No | Page number (default: 1) |
| `page_size` | number | No | Items per page |
**Response: ProductListEntity**
```json
{
"items": [
/* ProductEntity[] */
],
"pagination": {
"total_records": 25,
"total_pages": 3,
"current_page": 1,
"next_page": 2,
"prev_page": null
}
}
```
---
## Customers API
### Retrieve Customer
```http
GET /v1/customers?customer_id={id}
GET /v1/customers?email={email}
```
Retrieve by ID or email (provide one, not both).
**Response: CustomerEntity**
```json
{
"id": "cust_abc123",
"mode": "test",
"object": "customer",
"email": "user@example.com",
"name": "John Doe",
"country": "US",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
```
### List Customers
```http
GET /v1/customers/list?page_number={n}&page_size={size}
```
### Generate Customer Portal Link
```http
POST /v1/customers/billing
```
**Request Body:**
```json
{
"customer_id": "cust_abc123"
}
```
**Response:**
```json
{
"customer_portal_link": "https://creem.io/portal/cust_abc123?token=..."
}
```
---
## Subscriptions API
### Retrieve Subscription
```http
GET /v1/subscriptions?subscription_id={id}
```
**Response: SubscriptionEntity**
```json
{
"id": "sub_abc123",
"mode": "test",
"object": "subscription",
"status": "active",
"product": {
/* ProductEntity */
},
"customer": {
/* CustomerEntity */
},
"items": [
{
"id": "sitem_xyz789",
"object": "subscription_item",
"product_id": "prod_abc123",
"price_id": "pprice_123",
"units": 5,
"mode": "test"
}
],
"collection_method": "charge_automatically",
"last_transaction_id": "tran_xyz789",
"last_transaction_date": "2024-01-01T00:00:00Z",
"next_transaction_date": "2024-02-01T00:00:00Z",
"current_period_start_date": "2024-01-01T00:00:00Z",
"current_period_end_date": "2024-02-01T00:00:00Z",
"canceled_at": null,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
```
**Subscription Statuses:**
- `active` - Currently active and paid
- `trialing` - In trial period
- `paused` - Temporarily paused
- `canceled` - Canceled (terminal)
- `unpaid` - Payment failed
- `scheduled_cancel` - Will cancel at period end
### Update Subscription
```http
POST /v1/subscriptions/{id}
```
**Request Body:**
```json
{
"items": [
{
"id": "sitem_xyz789",
"units": 10
}
],
"update_behavior": "proration-charge-immediately"
}
```
**Update Behaviors:**
- `proration-charge-immediately` - Charge prorated amount now, new billing cycle starts
- `proration-charge` - Credit added to next invoice, same billing cycle
- `proration-none` - No proration, change at next cycle
### Upgrade Subscription
```http
POST /v1/subscriptions/{id}/upgrade
```
**Request Body:**
```json
{
"product_id": "prod_premium",
"update_behavior": "proration-charge-immediately"
}
```
### Cancel Subscription
```http
POST /v1/subscriptions/{id}/cancel
```
**Request Body:**
```json
{
"mode": "scheduled",
"onExecute": "cancel"
}
```
**Options:**
- `mode`: `immediate` (cancel now) or `scheduled` (at period end)
- `onExecute`: `cancel` or `pause` (only for scheduled mode)
### Pause Subscription
```http
POST /v1/subscriptions/{id}/pause
```
No request body required.
### Resume Subscription
```http
POST /v1/subscriptions/{id}/resume
```
No request body required.
---
## Licenses API
### Activate License
```http
POST /v1/licenses/activate
```
**Request Body:**
```json
{
"key": "ABC123-XYZ456-XYZ456-XYZ456",
"instance_name": "johns-macbook-pro"
}
```
**Response: LicenseEntity**
```json
{
"id": "lic_abc123",
"mode": "test",
"object": "license",
"status": "active",
"key": "ABC123-XYZ456-XYZ456-XYZ456",
"activation": 1,
"activation_limit": 3,
"expires_at": "2025-01-01T00:00:00Z",
"created_at": "2024-01-01T00:00:00Z",
"instance": {
"id": "inst_xyz789",
"mode": "test",
"object": "license-instance",
"name": "johns-macbook-pro",
"status": "active",
"created_at": "2024-01-01T00:00:00Z"
}
}
```
**License Statuses:**
- `active` - Valid and usable
- `inactive` - No activations yet
- `expired` - Past expiration date
- `disabled` - Manually disabled
### Validate License
```http
POST /v1/licenses/validate
```
**Request Body:**
```json
{
"key": "ABC123-XYZ456-XYZ456-XYZ456",
"instance_id": "inst_xyz789"
}
```
### Deactivate License
```http
POST /v1/licenses/deactivate
```
**Request Body:**
```json
{
"key": "ABC123-XYZ456-XYZ456-XYZ456",
"instance_id": "inst_xyz789"
}
```
---
## Discounts API
### Create Discount
```http
POST /v1/discounts
```
**Request Body:**
```json
{
"name": "Holiday Sale",
"code": "HOLIDAY2024",
"type": "percentage",
"percentage": 20,
"duration": "repeating",
"duration_in_months": 6,
"max_redemptions": 100,
"expiry_date": "2024-12-31T23:59:59Z",
"applies_to_products": ["prod_abc123", "prod_xyz456"]
}
```
**Fields:**
| Field | Type | Required | Description |
| --------------------- | ------ | --------------------- | --------------------------------------- |
| `name` | string | Yes | Display name |
| `code` | string | No | Discount code (auto-generated if empty) |
| `type` | string | Yes | `percentage` or `fixed` |
| `percentage` | number | If type=percentage | Discount percentage (e.g., 20 for 20%) |
| `amount` | number | If type=fixed | Fixed amount in cents |
| `currency` | string | If type=fixed | Currency for fixed discount |
| `duration` | string | Yes | `forever`, `once`, or `repeating` |
| `duration_in_months` | number | If duration=repeating | Months to apply |
| `max_redemptions` | number | No | Usage limit |
| `expiry_date` | string | No | ISO date when code expires |
| `applies_to_products` | array | Yes | Product IDs this applies to |
**Response: DiscountEntity**
```json
{
"id": "dis_abc123",
"mode": "test",
"object": "discount",
"status": "active",
"name": "Holiday Sale",
"code": "HOLIDAY2024",
"type": "percentage",
"percentage": 20,
"duration": "repeating",
"duration_in_months": 6,
"max_redemptions": 100,
"expiry_date": "2024-12-31T23:59:59Z",
"applies_to_products": ["prod_abc123"],
"redeem_count": 15
}
```
### Retrieve Discount
```http
GET /v1/discounts?discount_id={id}
GET /v1/discounts?discount_code={code}
```
### Delete Discount
```http
DELETE /v1/discounts/{id}/delete
```
---
## Transactions API
### Get Transaction
```http
GET /v1/transactions?transaction_id={id}
```
**Response: TransactionEntity**
```json
{
"id": "tran_abc123",
"mode": "test",
"object": "transaction",
"amount": 2900,
"amount_paid": 3509,
"discount_amount": 0,
"currency": "USD",
"type": "invoice",
"tax_country": "US",
"tax_amount": 609,
"status": "paid",
"refunded_amount": null,
"order": "ord_xyz789",
"subscription": "sub_abc123",
"customer": "cust_xyz789",
"description": "Subscription payment",
"period_start": 1704067200000,
"period_end": 1706745600000,
"created_at": 1704067200000
}
```
**Transaction Types:**
- `payment` - One-time payment
- `invoice` - Subscription payment
**Transaction Statuses:**
- `paid` - Successfully paid
- `refunded` - Fully refunded
- `partially_refunded` - Partially refunded
- `chargeback` - Disputed
### List Transactions
```http
GET /v1/transactions/search
```
**Query Parameters:**
| Field | Type | Required | Description |
| ------------- | ------ | -------- | ------------------ |
| `customer_id` | string | No | Filter by customer |
| `order_id` | string | No | Filter by order |
| `product_id` | string | No | Filter by product |
| `page_number` | number | No | Page number |
| `page_size` | number | No | Items per page |
---
## Common Entities
### OrderEntity
```json
{
"id": "ord_abc123",
"mode": "test",
"object": "order",
"customer": "cust_xyz789",
"product": "prod_abc123",
"transaction": "tran_xyz789",
"discount": "dis_abc123",
"amount": 2900,
"sub_total": 2900,
"tax_amount": 609,
"discount_amount": 0,
"amount_due": 3509,
"amount_paid": 3509,
"currency": "USD",
"status": "paid",
"type": "recurring",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
```
### PaginationEntity
```json
{
"total_records": 100,
"total_pages": 10,
"current_page": 1,
"next_page": 2,
"prev_page": null
}
```
### EnvironmentMode
Values: `test`, `prod`, `sandbox`
---
## HTTP Response Codes
| Code | Description |
| ---- | ------------------------------------------------------------------- |
| 200 | Success |
| 400 | Bad Request - Invalid parameters |
| 403 | Forbidden - Invalid or missing API key, or insufficient permissions |
| 404 | Not Found - Resource doesn't exist |
| 429 | Rate Limited |
| 500 | Server Error |
---
## Rate Limits
Contact support for specific rate limits. Implement exponential backoff for 429 responses.
## Idempotency
Use `request_id` for checkout sessions to prevent duplicate payments if retrying failed requests.
SKILL.md
---
name: creem-api
description: Integrate Creem payment infrastructure for checkouts, subscriptions, free products, licenses, and webhooks. Supports one-time payments, recurring billing, free products, and MoR compliance. Use when the user mentions Creem, or asks to add payments, billing, subscriptions, checkout, license keys, or a customer portal to their app.
noindex: true
---
# Creem API Integration Skill
Creem is a Merchant of Record (MoR) payment platform. Creem is the legal seller,
so it owns tax compliance, payment processing, chargebacks, and refunds.
## Non-obvious rules
- **Production API**: `https://api.creem.io`
- **Test API**: `https://test-api.creem.io`
- **Authentication**: `x-api-key` header. The key is a merchant credential:
it must never reach a browser, mobile app, or desktop binary. Client apps call
your backend, which calls Creem.
- **Prices**: integers in **cents** (`1000` = $10.00). Use `0` for free products.
- **Currencies**: uppercase three-letter ISO codes (`USD`, `EUR`).
- **Access is granted by webhook, not by the success redirect.** The redirect can
be forged or simply never happen if the customer closes the tab.
## Reference files
Read the file that matches the task. Each is self-contained; do not read all of
them up front.
| Need | Read |
| ------------------------------------------------------------ | -------------- |
| Exact endpoint, request body, or response field | `REFERENCE.md` |
| Webhook event payloads, signature verification, retry policy | `WEBHOOKS.md` |
| A complete integration walkthrough for a business model | `WORKFLOWS.md` |
`REFERENCE.md` covers Checkouts, Products, Customers, Subscriptions, Licenses,
Discounts, and Transactions. `WORKFLOWS.md` covers basic SaaS subscription,
one-time purchase with digital delivery, license keys for desktop apps,
seat-based team billing, freemium upgrade flows, and affiliate tracking.
## Webhook signature verification
**Every webhook handler must verify the signature before trusting the payload.**
An unverified handler lets anyone grant themselves paid access.
```typescript
import crypto from "crypto";
function verifyWebhookSignature(payload: string, signature: string, secret: string) {
const computed = crypto.createHmac("sha256", secret).update(payload).digest("hex");
// timingSafeEqual throws on a length mismatch, so check that first.
if (signature.length !== computed.length) return false;
return crypto.timingSafeEqual(Buffer.from(computed, "hex"), Buffer.from(signature, "hex"));
}
// Verify against the RAW body, before JSON.parse.
const signature = req.headers.get("creem-signature");
const rawBody = await req.text();
if (!verifyWebhookSignature(rawBody, signature!, process.env.CREEM_WEBHOOK_SECRET!)) {
return new Response("Invalid signature", { status: 401 });
}
```
Events that drive access decisions. Creem emits 13 event types in total;
`WEBHOOKS.md` documents all of them with their payloads:
| Event | Action |
| ----------------------- | ------------------------------------------------ |
| `checkout.completed` | Grant access, create the user record |
| `subscription.paid` | Extend the access period |
| `subscription.canceled` | Revoke at period end |
| `subscription.expired` | Period ended without payment; retries may follow |
| `refund.created` | Consider revoking access |
| `dispute.created` | Chargeback opened; handle the dispute |
## Test mode
Develop against `https://test-api.creem.io` with a test API key.
| Card | Behaviour |
| --------------------- | ------------------ |
| `4111 1111 1111 1111` | Success |
| `4507 9900 0000 0028` | Declined |
| `4507 9900 0000 0010` | Insufficient funds |
## Error handling
| Status | Meaning |
| ------ | ----------------------------------------------------------------------------- |
| 400 | Bad request; check parameters |
| 403 | Invalid or missing API key, or insufficient permissions (auth errors are 403) |
| 404 | Resource does not exist |
| 429 | Rate limited |
| 500 | Server error; contact support |
## Integration checklist
When implementing Creem:
1. **Environment setup**
- [ ] Store API key in environment variables
- [ ] Configure base URL for test/production
- [ ] Set up webhook endpoint
2. **Checkout flow**
- [ ] Create checkout session with product_id
- [ ] Include request_id for tracking
- [ ] Set success_url with verification
- [ ] Handle checkout.completed webhook
3. **Subscription handling**
- [ ] Handle subscription.paid for renewals
- [ ] Handle subscription.canceled for access revocation
- [ ] Implement customer portal link
- [ ] Store subscription_id for management
4. **License keys** (if applicable)
- [ ] Implement activate on first use
- [ ] Validate on each app start
- [ ] Handle deactivation for device transfer
5. **Security**
- [ ] Verify webhook signatures
- [ ] Never expose API keys client-side
- [ ] Validate success URL signatures
## Convex apps
If the project has a `convex/` directory, do NOT hand-roll checkout routes and
webhook handlers with the raw API. Use the `@creem_io/convex` component: it owns
the webhook route, syncs billing state into the Convex database, and ships
connected React/Svelte widgets.
Route by task:
| Task | Fetch |
| -------------------------------------------------------------- | -------------------------------------------------------------- |
| First-time setup, or migrating from another billing provider | https://docs.creem.io/code/sdks/convex/integration.md |
| Add or change subscription plans, cycles, trials, unit pricing | https://docs.creem.io/code/sdks/convex/subscriptions.md |
| Sell one-time products, consumables, or credit packs | https://docs.creem.io/code/sdks/convex/one-time-and-credits.md |
| Gate a feature, read billing state, add account UI | https://docs.creem.io/code/sdks/convex/entitlements.md |
| Understand the billing entity, state model, or API contract | https://docs.creem.io/code/sdks/convex/concepts.md |
| Custom auth/RBAC, webhook middleware, checkout gates, i18n | https://docs.creem.io/code/sdks/convex/advanced.md |
| Upgrade from 0.3.x, or retire another billing provider | https://docs.creem.io/code/sdks/convex/migration.md |
| Look up an exact method signature or widget prop | https://docs.creem.io/code/sdks/convex/reference.md |
The integration guide is the sequential setup script — follow it in order and
run its validation steps. The other pages are intent lookups for ongoing work.
## Other SDKs
Prefer an official SDK over raw `fetch` when one fits the stack:
| Stack | Fetch |
| --------------------------- | ---------------------------------------------- |
| Any Node or browser runtime | https://docs.creem.io/code/sdks/typescript.md |
| Next.js | https://docs.creem.io/code/sdks/nextjs.md |
| Better Auth | https://docs.creem.io/code/sdks/better-auth.md |
## Need help?
- Documentation: https://docs.creem.io
- Dashboard: https://creem.io/dashboard
- Support: support@creem.io
WEBHOOKS.md
---
title: CREEM Webhooks Reference
noindex: true
---
# CREEM Webhooks Reference
Comprehensive guide to implementing webhook handlers for CREEM events.
## Contents
- Overview and Setup
- Network and WAF Configuration
- Signature Verification
- Retry Policy
- Event Structure
- Event Types: payloads for the events listed below
- Complete Webhook Handler
- Next.js Adapter
- Best Practices
## Overview
Webhooks push real-time notifications about payments, subscriptions, and other events to your application. They are essential for:
- Granting access after payment
- Revoking access on cancellation
- Syncing subscription status
- Handling refunds and disputes
## Setup
1. Create a webhook endpoint in your application
2. Register the URL in the CREEM Dashboard (Developers > Webhooks)
3. Copy the webhook secret for signature verification
4. Test with the test environment before going live
## Network and WAF Configuration
CREEM does not provide static source IP addresses for outbound webhooks in
either production or Test Mode. If a firewall or WAF protects the webhook
endpoint, do not rely on source-IP allowlists as the authentication mechanism.
Keep the endpoint reachable over HTTPS and verify every request with the
`creem-signature` header.
Bot protection and WAF products can challenge webhook deliveries because
webhooks are automated server-to-server requests. If this happens, add a
route-level exception or skip rule for the webhook endpoint. On Cloudflare
specifically, Bot Fight Mode cannot be skipped with custom rules; disable it or
use Super Bot Fight Mode or Bot Management with a skip rule.
## Signature Verification
**CRITICAL**: Always verify signatures to prevent fraud.
The signature is sent in the `creem-signature` header as a HMAC-SHA256 hex digest.
```typescript
import crypto from "crypto";
function verifySignature(rawBody: string, signature: string, secret: string): boolean {
const computed = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
// timingSafeEqual throws on a length mismatch, so check that first.
if (signature.length !== computed.length) return false;
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(Buffer.from(computed, "hex"), Buffer.from(signature, "hex"));
}
```
## Retry Policy
If your endpoint doesn't respond with HTTP 200, CREEM retries with progressive backoff. There are **5 attempts in total** — the initial delivery plus 4 retries:
| Attempt | Sent after the previous one |
| ------- | --------------------------- |
| 1 | Initial delivery |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 6 hours |
Events are **not retried after 24 hours**, even if attempts remain. Once retries are exhausted the event is marked failed, and you can resend it manually from the dashboard.
Two things to design around:
- Retries mean your handler **must be idempotent** — the same event `id` can arrive more than once.
- The final retry can land roughly 6.5 hours after the event. Don't assume delivery within minutes, and anchor purchase time on `object.order.created_at` rather than when you received the event.
## Event Structure
All webhook events follow this structure:
```json
{
"id": "evt_unique_event_id",
"eventType": "event.type",
"created_at": 1728734325927,
"object": {
// Event-specific payload
}
}
```
---
## Event Types
### checkout.completed
Fired when a customer successfully completes a checkout. This is your primary trigger for granting access.
```json
{
"id": "evt_5WHHcZPv7VS0YUsberIuOz",
"eventType": "checkout.completed",
"created_at": 1728734325927,
"object": {
"id": "ch_4l0N34kxo16AhRKUHFUuXr",
"object": "checkout",
"request_id": "my-request-id",
"status": "completed",
"mode": "test",
"order": {
"id": "ord_4aDwWXjMLpes4Kj4XqNnUA",
"customer": "cust_1OcIK1GEuVvXZwD19tjq2z",
"product": "prod_d1AY2Sadk9YAvLI0pj97f",
"amount": 1000,
"currency": "EUR",
"status": "paid",
"type": "recurring",
"created_at": "2024-10-12T11:58:33.097Z",
"updated_at": "2024-10-12T11:58:33.097Z"
},
"product": {
"id": "prod_d1AY2Sadk9YAvLI0pj97f",
"name": "Monthly",
"description": "Monthly plan",
"price": 1000,
"currency": "EUR",
"billing_type": "recurring",
"billing_period": "every-month",
"status": "active",
"tax_mode": "exclusive",
"tax_category": "saas"
},
"customer": {
"id": "cust_1OcIK1GEuVvXZwD19tjq2z",
"object": "customer",
"email": "customer@example.com",
"name": "John Doe",
"country": "NL"
},
"subscription": {
"id": "sub_6pC2lNB6joCRQIZ1aMrTpi",
"object": "subscription",
"product": "prod_d1AY2Sadk9YAvLI0pj97f",
"customer": "cust_1OcIK1GEuVvXZwD19tjq2z",
"status": "active",
"collection_method": "charge_automatically",
"metadata": {
"custom_data": "my custom data",
"internal_customer_id": "internal_123"
}
},
"license_keys": [
{
"id": "lk_2wMk1RtYqPnZ7bVxCdEfGh",
"object": "license",
"product_id": "prod_d1AY2Sadk9YAvLI0pj97f",
"key": "ABCDE-FGHIJ-KLMNO-PQRST-UVWXY",
"status": "inactive",
"activation": 0,
"activation_limit": 1,
"expires_at": null,
"instance": null,
"created_at": "2024-10-12T11:58:33.097Z",
"mode": "test"
}
],
"custom_fields": [],
"metadata": {
"custom_data": "my custom data",
"internal_customer_id": "internal_123"
}
}
}
```
**License keys.** When the purchased product issues license keys, the checkout
object carries a `license_keys` array. This is the delivery mechanism for keys —
you do not need to call the Licenses API to find out what was issued.
The field is **omitted entirely** when the order issued no keys, so test for its
presence rather than for an empty array. Each entry is a license *object*, not a
key string:
- `id` — license id, prefixed `lk_`
- `object` — always `license`
- `product_id` — the product the key was issued for
- `key` — the key itself: five groups of five uppercase alphanumerics, e.g. `ABCDE-FGHIJ-KLMNO-PQRST-UVWXY`
- `status` — `inactive` | `active` | `expired` | `disabled`; a key stays `inactive` until its first activation
- `activation` / `activation_limit` — activations used and allowed (`activation_limit: null` means unlimited)
- `expires_at` — ISO 8601, or `null` when the key does not expire
- `instance` — the associated license instance, or `null`
- `mode` — the environment the license belongs to
One key is issued per license-key feature on the product, multiplied by the
number of units purchased — a 3-unit order of a keyed product delivers three
entries. Always treat `license_keys` as a list, never as a single key.
**Handler Example:**
```typescript
async function handleCheckoutCompleted(checkout: CheckoutObject) {
const { customer, subscription, product, metadata, order } = checkout;
// 1. Find or create user
let user = await db.users.findByEmail(customer.email);
if (!user) {
user = await db.users.create({
email: customer.email,
name: customer.name,
creemCustomerId: customer.id,
});
}
// 2. Grant access based on product
await db.subscriptions.create({
userId: user.id,
creemSubscriptionId: subscription?.id,
productId: product.id,
status: "active",
metadata: metadata,
});
// 3. Send welcome email
await sendWelcomeEmail(user.email, product.name);
}
```
---
### subscription.active
Fired when a new subscription is created and first payment collected. Use `subscription.paid` for granting access instead - this is mainly for synchronization.
```json
{
"id": "evt_6EptlmjazyGhEPiNQ5f4lz",
"eventType": "subscription.active",
"created_at": 1728734325927,
"object": {
"id": "sub_21lfZb67szyvMiXnm6SVi0",
"object": "subscription",
"status": "active",
"collection_method": "charge_automatically",
"product": {
"id": "prod_AnVJ11ujp7x953ARpJvAF",
"name": "Pro Plan",
"price": 10000,
"currency": "EUR",
"billing_type": "recurring",
"billing_period": "every-month"
},
"customer": {
"id": "cust_3biFPNt4Cz5YRDSdIqs7kc",
"email": "customer@example.com",
"name": "John Doe",
"country": "SE"
},
"created_at": "2024-09-16T19:40:41.984Z",
"updated_at": "2024-09-16T19:40:42.121Z"
}
}
```
---
### subscription.paid
Fired when a subscription payment is successfully processed. This includes initial payments and renewals.
```json
{
"id": "evt_21mO1jWmU2QHe7u2oFV7y1",
"eventType": "subscription.paid",
"created_at": 1728734327355,
"object": {
"id": "sub_6pC2lNB6joCRQIZ1aMrTpi",
"object": "subscription",
"status": "active",
"product": {
"id": "prod_d1AY2Sadk9YAvLI0pj97f",
"name": "Monthly",
"price": 1000,
"currency": "EUR",
"billing_type": "recurring",
"billing_period": "every-month"
},
"customer": {
"id": "cust_1OcIK1GEuVvXZwD19tjq2z",
"email": "customer@example.com",
"name": "John Doe",
"country": "NL"
},
"collection_method": "charge_automatically",
"last_transaction_id": "tran_5yMaWzAl3jxuGJMCOrYWwk",
"last_transaction_date": "2024-10-12T11:58:47.109Z",
"next_transaction_date": "2024-11-12T11:58:38.000Z",
"current_period_start_date": "2024-10-12T11:58:38.000Z",
"current_period_end_date": "2024-11-12T11:58:38.000Z",
"canceled_at": null,
"metadata": {
"custom_data": "my custom data"
}
}
}
```
**Handler Example:**
```typescript
async function handleSubscriptionPaid(subscription: SubscriptionObject) {
// Extend access period
await db.subscriptions.update({
where: { creemSubscriptionId: subscription.id },
data: {
status: "active",
currentPeriodEnd: new Date(subscription.current_period_end_date),
nextPaymentDate: new Date(subscription.next_transaction_date),
},
});
}
```
---
### subscription.canceled
Fired when a subscription is canceled (by customer or merchant).
```json
{
"id": "evt_2iGTc600qGW6FBzloh2Nr7",
"eventType": "subscription.canceled",
"created_at": 1728734337932,
"object": {
"id": "sub_6pC2lNB6joCRQIZ1aMrTpi",
"object": "subscription",
"status": "canceled",
"product": {
"id": "prod_d1AY2Sadk9YAvLI0pj97f",
"name": "Monthly"
},
"customer": {
"id": "cust_1OcIK1GEuVvXZwD19tjq2z",
"email": "customer@example.com"
},
"current_period_start_date": "2024-10-12T11:58:38.000Z",
"current_period_end_date": "2024-11-12T11:58:38.000Z",
"canceled_at": "2024-10-12T11:58:57.813Z",
"metadata": {}
}
}
```
**Handler Example:**
```typescript
async function handleSubscriptionCanceled(subscription: SubscriptionObject) {
// Revoke access at period end (not immediately)
await db.subscriptions.update({
where: { creemSubscriptionId: subscription.id },
data: {
status: "canceled",
canceledAt: new Date(subscription.canceled_at),
// Keep access until period ends
accessUntil: new Date(subscription.current_period_end_date),
},
});
// Send cancellation confirmation
await sendCancellationEmail(subscription.customer.email);
}
```
---
### subscription.scheduled_cancel
Fired when a subscription is scheduled to cancel at the end of the current billing period. The subscription remains active until `current_period_end_date`.
```json
{
"id": "evt_4RfTc700qGW6FBzloh3Ms8",
"eventType": "subscription.scheduled_cancel",
"created_at": 1728734337932,
"object": {
"id": "sub_6pC2lNB6joCRQIZ1aMrTpi",
"object": "subscription",
"status": "scheduled_cancel",
"product": {
"id": "prod_d1AY2Sadk9YAvLI0pj97f",
"name": "Monthly",
"price": 1000,
"billing_type": "recurring",
"billing_period": "every-month"
},
"customer": {
"id": "cust_1OcIK1GEuVvXZwD19tjq2z",
"email": "customer@example.com"
},
"current_period_start_date": "2024-10-12T11:58:38.000Z",
"current_period_end_date": "2024-11-12T11:58:38.000Z",
"canceled_at": null,
"metadata": {}
}
}
```
**Handler Example:**
```typescript
async function handleSubscriptionScheduledCancel(subscription: SubscriptionObject) {
await db.subscriptions.update({
where: { creemSubscriptionId: subscription.id },
data: {
status: "scheduled_cancel",
accessUntil: new Date(subscription.current_period_end_date),
},
});
}
```
---
### subscription.past_due
Fired when a subscription payment fails and the subscription enters a past-due state. Creem will retry payment; if a retry succeeds, the subscription can return to active.
```json
{
"id": "evt_7HkTd800rHX7GCampi4Nt9",
"eventType": "subscription.past_due",
"created_at": 1728734337932,
"object": {
"id": "sub_6pC2lNB6joCRQIZ1aMrTpi",
"object": "subscription",
"status": "past_due",
"product": {
"id": "prod_d1AY2Sadk9YAvLI0pj97f",
"name": "Monthly",
"price": 1000,
"billing_type": "recurring",
"billing_period": "every-month"
},
"customer": {
"id": "cust_1OcIK1GEuVvXZwD19tjq2z",
"email": "customer@example.com"
},
"current_period_start_date": "2024-10-12T11:58:38.000Z",
"current_period_end_date": "2024-11-12T11:58:38.000Z",
"canceled_at": null,
"metadata": {}
}
}
```
**Handler Example:**
```typescript
async function handleSubscriptionPastDue(subscription: SubscriptionObject) {
await db.subscriptions.update({
where: { creemSubscriptionId: subscription.id },
data: {
status: "past_due",
pastDueAt: new Date(),
},
});
}
```
---
### subscription.unpaid
Fired when a subscription moves to the `unpaid` status after failed payment collection. Treat it like `subscription.past_due` in payment-recovery UI, and suspend access according to your policy.
```json
{
"id": "evt_h9hBneNdvWvA8hQzIBzDx",
"eventType": "subscription.unpaid",
"created_at": 1772265400331,
"object": {
"id": "sub_3xx35QzxsnpFiJ3vRB9YKt",
"object": "subscription",
"status": "unpaid",
"product": {
"id": "prod_L8mMzoYLOOZpMpTBwGw0k",
"name": "Pro Plan",
"price": 2999,
"currency": "USD",
"billing_type": "recurring",
"billing_period": "every-month"
},
"customer": {
"id": "cust_ubD9UtnJpafXNKQ5UaV0H",
"email": "customer@example.com"
},
"collection_method": "charge_automatically",
"last_transaction_id": "tran_6uBkPewvO7KHjfvGmpin82",
"current_period_start_date": "2026-02-28T07:56:07.282Z",
"current_period_end_date": "2026-03-30T07:56:07.282Z",
"canceled_at": null,
"metadata": {}
}
}
```
---
### subscription.expired
Fired when the billing period ends without successful payment. Retries may still happen.
```json
{
"id": "evt_V5CxhipUu10BYonO2Vshb",
"eventType": "subscription.expired",
"created_at": 1734463872058,
"object": {
"id": "sub_7FgHvrOMC28tG5DEemoCli",
"object": "subscription",
"status": "active",
"product": {
"id": "prod_3ELsC3Lt97orn81SOdgQI3",
"name": "Annual Plan",
"price": 1200,
"billing_period": "every-year"
},
"customer": {
"id": "cust_3y4k2CELGsw7n9Eeeiw2hm",
"email": "customer@example.com"
},
"current_period_end_date": "2024-12-16T12:39:47.000Z"
}
}
```
**Note:** Status remains "active" during retry period. Only act on `subscription.canceled` for terminal state.
---
### refund.created
Fired when a refund is processed.
```json
{
"id": "evt_61eTsJHUgInFw2BQKhTiPV",
"eventType": "refund.created",
"created_at": 1728734351631,
"object": {
"id": "ref_3DB9NQFvk18TJwSqd0N6bd",
"object": "refund",
"status": "succeeded",
"refund_amount": 1210,
"refund_currency": "EUR",
"reason": "requested_by_customer",
"transaction": {
"id": "tran_5yMaWzAl3jxuGJMCOrYWwk",
"amount": 1000,
"amount_paid": 1210,
"status": "refunded"
},
"subscription": {
"id": "sub_6pC2lNB6joCRQIZ1aMrTpi",
"status": "canceled"
},
"customer": {
"id": "cust_1OcIK1GEuVvXZwD19tjq2z",
"email": "customer@example.com"
},
"created_at": 1728734351525
}
}
```
**Handler Example:**
```typescript
async function handleRefund(refund: RefundObject) {
// Check if this requires access revocation
if (refund.subscription?.status === "canceled") {
await db.subscriptions.update({
where: { creemSubscriptionId: refund.subscription.id },
data: {
status: "refunded",
accessUntil: new Date(), // Immediate revocation
},
});
}
// Log refund for accounting
await db.refunds.create({
transactionId: refund.transaction.id,
amount: refund.refund_amount,
currency: refund.refund_currency,
reason: refund.reason,
});
}
```
---
### dispute.created
Fired when a chargeback/dispute is opened.
```json
{
"id": "evt_6mfLDL7P0NYwYQqCrICvDH",
"eventType": "dispute.created",
"created_at": 1750941264812,
"object": {
"id": "disp_6vSsOdTANP5PhOzuDlUuXE",
"object": "dispute",
"amount": 1331,
"currency": "EUR",
"transaction": {
"id": "tran_4Dk8CxWFdceRUQgMFhCCXX",
"status": "chargeback"
},
"subscription": {
"id": "sub_5sD6zM482uwOaEoyEUDDJs",
"status": "active"
},
"customer": {
"id": "cust_OJPZd2GMxgo1MGPNXXBSN",
"email": "customer@example.com"
},
"created_at": 1750941264728
}
}
```
---
### subscription.update
Fired when a subscription is modified (seats changed, upgraded, etc.).
```json
{
"id": "evt_5pJMUuvqaqvttFVUvtpY32",
"eventType": "subscription.update",
"created_at": 1737890536421,
"object": {
"id": "sub_2qAuJgWmXhXHAuef9k4Kur",
"object": "subscription",
"status": "active",
"product": {
"id": "prod_1dP15yoyogQe2seEt1Evf3",
"name": "Monthly Sub",
"price": 1000
},
"customer": {
"id": "cust_2fQZKKUZqtNhH2oDWevQkW",
"email": "customer@example.com"
},
"items": [
{
"id": "sitem_3QWlqRbAat2eBRakAxFtt9",
"product_id": "prod_5jnudVkLGZWF4AqMFBs5t5",
"units": 1
}
],
"current_period_end_date": "2025-02-26T11:20:36.000Z"
}
}
```
---
### subscription.trialing
Fired when a subscription enters a trial period.
```json
{
"id": "evt_2ciAM8ABYtj0pVueeJPxUZ",
"eventType": "subscription.trialing",
"created_at": 1739963911073,
"object": {
"id": "sub_dxiauR8zZOwULx5QM70wJ",
"object": "subscription",
"status": "trialing",
"product": {
"id": "prod_3kpf0ZdpcfsSCQ3kDiwg9m",
"name": "Pro Plan with Trial",
"price": 1100
},
"customer": {
"id": "cust_4fpU8kYkQmI1XKBwU2qeME",
"email": "customer@example.com"
},
"current_period_start_date": "2025-02-19T11:18:25.000Z",
"current_period_end_date": "2025-02-26T11:18:25.000Z",
"items": [
{
"id": "sitem_1xbHCmIM61DHGRBCFn0W1L",
"product_id": "prod_3kpf0ZdpcfsSCQ3kDiwg9m",
"units": 1
}
]
}
}
```
---
### subscription.paused
Fired when a subscription is paused.
```json
{
"id": "evt_5veN2cn5N9Grz8u7w3yJuL",
"eventType": "subscription.paused",
"created_at": 1754041946898,
"object": {
"id": "sub_3ZT1iYMeDBpiUpRTqq4veE",
"object": "subscription",
"status": "paused",
"product": {
"id": "prod_sYwbyE1tPbsqbLu6S0bsR",
"name": "Monthly Plan",
"price": 2000
},
"customer": {
"id": "cust_4fpU8kYkQmI1XKBwU2qeME",
"email": "customer@example.com"
},
"current_period_end_date": "2025-09-01T09:51:47.000Z"
}
}
```
---
## Complete Webhook Handler
Here's a complete TypeScript webhook handler with all event types:
```typescript
import crypto from "crypto";
interface WebhookEvent {
id: string;
eventType: string;
created_at: number;
object: any;
}
export async function handleCreemWebhook(req: Request): Promise<Response> {
// 1. Get signature and raw body
const signature = req.headers.get("creem-signature");
const rawBody = await req.text();
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
// 2. Verify signature (length check first: timingSafeEqual throws on mismatch)
const secret = process.env.CREEM_WEBHOOK_SECRET!;
const computed = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
if (
signature.length !== computed.length ||
!crypto.timingSafeEqual(Buffer.from(computed, "hex"), Buffer.from(signature, "hex"))
) {
return new Response("Invalid signature", { status: 401 });
}
// 3. Parse event
const event: WebhookEvent = JSON.parse(rawBody);
try {
// 4. Handle event
switch (event.eventType) {
case "checkout.completed":
await handleCheckoutCompleted(event.object);
break;
case "subscription.active":
await handleSubscriptionActive(event.object);
break;
case "subscription.paid":
await handleSubscriptionPaid(event.object);
break;
case "subscription.canceled":
await handleSubscriptionCanceled(event.object);
break;
case "subscription.scheduled_cancel":
await handleSubscriptionScheduledCancel(event.object);
break;
case "subscription.past_due":
await handleSubscriptionPastDue(event.object);
break;
case "subscription.unpaid":
await handleSubscriptionUnpaid(event.object);
break;
case "subscription.expired":
await handleSubscriptionExpired(event.object);
break;
case "refund.created":
await handleRefundCreated(event.object);
break;
case "dispute.created":
await handleDisputeCreated(event.object);
break;
case "subscription.update":
await handleSubscriptionUpdate(event.object);
break;
case "subscription.trialing":
await handleSubscriptionTrialing(event.object);
break;
case "subscription.paused":
await handleSubscriptionPaused(event.object);
break;
default:
console.log(`Unhandled event type: ${event.eventType}`);
}
return new Response("OK", { status: 200 });
} catch (error) {
console.error("Webhook handler error:", error);
// Return 500 to trigger retry
return new Response("Internal error", { status: 500 });
}
}
```
## Next.js Adapter
If using the `@creem_io/nextjs` package:
```typescript
// app/api/webhook/creem/route.ts
import { Webhook } from "@creem_io/nextjs";
export const POST = Webhook({
webhookSecret: process.env.CREEM_WEBHOOK_SECRET!,
onCheckoutCompleted: async ({ customer, product, subscription, metadata }) => {
console.log(`${customer.email} purchased ${product.name}`);
// Grant access
},
onGrantAccess: async ({ customer, metadata }) => {
const userId = metadata?.referenceId as string;
await grantAccess(userId, customer.email);
},
onRevokeAccess: async ({ customer, metadata }) => {
const userId = metadata?.referenceId as string;
await revokeAccess(userId, customer.email);
},
});
```
## Best Practices
1. **Always verify signatures** - Never process unverified webhooks
2. **Return 200 quickly** - Process asynchronously if needed
3. **Be idempotent** - Handle duplicate deliveries gracefully
4. **Log events** - Keep records for debugging
5. **Handle all relevant events** - Don't miss critical state changes
6. **Test in sandbox** - Verify handlers before production
7. **Monitor failures** - Set up alerts for webhook failures
WORKFLOWS.md
---
title: CREEM Integration Workflows
noindex: true
---
# CREEM Integration Workflows
Step-by-step guides for common integration patterns.
## Table of Contents
1. [Basic SaaS Subscription](#1-basic-saas-subscription)
2. [One-Time Purchase with Digital Delivery](#2-one-time-purchase-with-digital-delivery)
3. [License Key System for Desktop Apps](#3-license-key-system-for-desktop-apps)
4. [Seat-Based Team Billing](#4-seat-based-team-billing)
5. [Freemium with Upgrade Flow](#5-freemium-with-upgrade-flow)
6. [Affiliate and Referral Tracking](#6-affiliate-and-referral-tracking)
---
## 1. Basic SaaS Subscription
A complete flow for a typical SaaS application with monthly/yearly plans.
### Architecture
```
User clicks "Subscribe" → Create Checkout → User pays → Webhook grants access
↓
User's subscription status ← Check status ← Webhook renews/cancels
```
### Step 1: Create Products in Dashboard
Create products in the CREEM dashboard:
- Monthly Plan: $29/month, billing_type: recurring, billing_period: every-month
- Yearly Plan: $290/year, billing_type: recurring, billing_period: every-year
### Step 2: Implement Checkout Route
```typescript
// app/api/checkout/route.ts (Next.js)
import { NextRequest, NextResponse } from "next/server";
const CREEM_API_KEY = process.env.CREEM_API_KEY!;
const BASE_URL =
process.env.NODE_ENV === "production" ? "https://api.creem.io" : "https://test-api.creem.io";
export async function POST(req: NextRequest) {
const { productId, userId, email } = await req.json();
const response = await fetch(`${BASE_URL}/v1/checkouts`, {
method: "POST",
headers: {
"x-api-key": CREEM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
product_id: productId,
request_id: `checkout_${userId}_${Date.now()}`,
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true`,
customer: { email },
metadata: {
userId,
source: "webapp",
},
}),
});
const checkout = await response.json();
if (!response.ok) {
return NextResponse.json({ error: checkout }, { status: response.status });
}
return NextResponse.json({ checkoutUrl: checkout.checkout_url });
}
```
### Step 3: Create Checkout Button Component
```tsx
// components/CheckoutButton.tsx
"use client";
import { useState } from "react";
interface CheckoutButtonProps {
productId: string;
userId: string;
email: string;
children: React.ReactNode;
}
export function CheckoutButton({ productId, userId, email, children }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
try {
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId, userId, email }),
});
const { checkoutUrl, error } = await response.json();
if (error) {
console.error("Checkout error:", error);
return;
}
// Redirect to CREEM checkout
window.location.href = checkoutUrl;
} catch (error) {
console.error("Failed to create checkout:", error);
} finally {
setLoading(false);
}
};
return (
<button onClick={handleCheckout} disabled={loading}>
{loading ? "Loading..." : children}
</button>
);
}
```
### Step 4: Handle Webhook Events
```typescript
// app/api/webhooks/creem/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { db } from "@/lib/db";
export async function POST(req: NextRequest) {
const signature = req.headers.get("creem-signature");
const rawBody = await req.text();
// Verify signature. Timing-safe comparison, with a length check first
// because timingSafeEqual throws on a length mismatch.
const computed = crypto
.createHmac("sha256", process.env.CREEM_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
if (
!signature ||
signature.length !== computed.length ||
!crypto.timingSafeEqual(Buffer.from(computed, "hex"), Buffer.from(signature, "hex"))
) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(rawBody);
switch (event.eventType) {
case "checkout.completed": {
const { customer, subscription, metadata, product } = event.object;
// Find user by metadata or email
const userId = metadata?.userId;
const user = userId
? await db.user.findUnique({ where: { id: userId } })
: await db.user.findUnique({ where: { email: customer.email } });
if (!user) {
console.error("User not found:", customer.email);
break;
}
// Create or update subscription
await db.subscription.upsert({
where: { userId: user.id },
create: {
userId: user.id,
creemSubscriptionId: subscription.id,
creemCustomerId: customer.id,
productId: product.id,
plan: product.name,
status: "active",
currentPeriodEnd: new Date(subscription.current_period_end_date),
},
update: {
creemSubscriptionId: subscription.id,
productId: product.id,
plan: product.name,
status: "active",
currentPeriodEnd: new Date(subscription.current_period_end_date),
},
});
// Update user role
await db.user.update({
where: { id: user.id },
data: { role: "pro" },
});
break;
}
case "subscription.paid": {
const { id, current_period_end_date } = event.object;
await db.subscription.update({
where: { creemSubscriptionId: id },
data: {
status: "active",
currentPeriodEnd: new Date(current_period_end_date),
},
});
break;
}
case "subscription.canceled": {
const { id, current_period_end_date } = event.object;
await db.subscription.update({
where: { creemSubscriptionId: id },
data: {
status: "canceled",
currentPeriodEnd: new Date(current_period_end_date),
},
});
break;
}
}
return NextResponse.json({ received: true });
}
```
### Step 5: Check Subscription Status
```typescript
// lib/subscription.ts
import { db } from "./db";
export async function checkSubscription(userId: string): Promise<{
isActive: boolean;
plan: string | null;
expiresAt: Date | null;
}> {
const subscription = await db.subscription.findUnique({
where: { userId },
});
if (!subscription) {
return { isActive: false, plan: null, expiresAt: null };
}
// Active if status is 'active' OR canceled but still within period
const isActive =
subscription.status === "active" ||
(subscription.status === "canceled" && subscription.currentPeriodEnd > new Date());
return {
isActive,
plan: subscription.plan,
expiresAt: subscription.currentPeriodEnd,
};
}
```
### Step 6: Create Customer Portal Link
```typescript
// app/api/billing/route.ts
export async function POST(req: NextRequest) {
const session = await getSession(req);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const subscription = await db.subscription.findUnique({
where: { userId: session.userId },
});
if (!subscription?.creemCustomerId) {
return NextResponse.json({ error: "No subscription found" }, { status: 404 });
}
const response = await fetch(`${BASE_URL}/v1/customers/billing`, {
method: "POST",
headers: {
"x-api-key": CREEM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
customer_id: subscription.creemCustomerId,
}),
});
const { customer_portal_link } = await response.json();
return NextResponse.json({ portalUrl: customer_portal_link });
}
```
---
## 2. One-Time Purchase with Digital Delivery
For selling digital products like ebooks, templates, or courses.
### Architecture
```
User purchases → Checkout completes → Webhook triggers → Generate download link
↓
Send email with access
```
### Step 1: Product Setup
Create a one-time product in the dashboard with:
- billing_type: `onetime`
- Enable "File Downloads" feature with your digital files
### Step 2: Checkout with Custom Fields
```typescript
const createCheckout = async (productId: string, customerEmail: string) => {
const response = await fetch(`${BASE_URL}/v1/checkouts`, {
method: "POST",
headers: {
"x-api-key": CREEM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
product_id: productId,
customer: { email: customerEmail },
success_url: `${APP_URL}/download?session={checkout_id}`,
custom_fields: [
{
type: "text",
key: "companyName",
label: "Company Name (for license)",
optional: true,
},
{
type: "checkbox",
key: "newsletter",
label: "Subscribe to newsletter",
optional: true,
checkbox: {
label: "Send me updates about new products",
},
},
],
}),
});
return response.json();
};
```
### Step 3: Handle Completed Purchase
```typescript
case 'checkout.completed': {
const { customer, product, feature, custom_fields } = event.object;
// Get files from features
const fileFeature = feature?.find(f => f.type === 'file');
const files = fileFeature?.file?.files || [];
// Create purchase record
await db.purchase.create({
data: {
customerEmail: customer.email,
productId: product.id,
downloadLinks: files.map(f => f.url),
customFields: custom_fields,
},
});
// Send delivery email
await sendDeliveryEmail({
to: customer.email,
productName: product.name,
downloadLinks: files,
companyName: custom_fields.find(f => f.key === 'companyName')?.text?.value,
});
// Add to newsletter if opted in
const newsletter = custom_fields.find(f => f.key === 'newsletter');
if (newsletter?.checkbox?.value) {
await addToNewsletter(customer.email);
}
break;
}
```
### Step 4: Download Page
```typescript
// app/download/page.tsx
export default async function DownloadPage({ searchParams }) {
const checkoutId = searchParams.session;
// Verify checkout is completed
const response = await fetch(
`${BASE_URL}/v1/checkouts?checkout_id=${checkoutId}`,
{
headers: { 'x-api-key': CREEM_API_KEY },
}
);
const checkout = await response.json();
if (checkout.status !== 'completed') {
return <div>Purchase not found or not completed</div>;
}
const files = checkout.feature?.find(f => f.type === 'file')?.file?.files || [];
return (
<div>
<h1>Thank you for your purchase!</h1>
<h2>Your Downloads</h2>
<ul>
{files.map((file) => (
<li key={file.id}>
<a href={file.url} download>
{file.file_name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
</a>
</li>
))}
</ul>
</div>
);
}
```
---
## 3. License Key System for Desktop Apps
For software requiring activation and device management.
### Architecture
```
User purchases
↓
Creem issues the key and sends checkout.completed (license_keys[])
↓
Your backend stores / emails the key → user enters it in the app
↓
App calls your backend (which holds the API key)
↓
Creem licenses API (activate / validate / deactivate)
```
The Creem API key is a merchant credential and must never ship inside the
desktop binary — anyone can extract it from the app bundle. The desktop app
calls your backend; only your backend calls Creem.
### How you receive the key
Creem generates the key and delivers it to you on the `checkout.completed`
webhook, in the `license_keys` array on the checkout object. You do not have to
poll or call the Licenses API to discover what was issued:
```typescript
if (event.eventType === "checkout.completed") {
for (const license of event.object.license_keys ?? []) {
await db.licenses.create({
userId: user.id,
key: license.key, // "ABCDE-FGHIJ-KLMNO-PQRST-UVWXY"
licenseId: license.id, // "lk_..." — needed for activate/validate
activationLimit: license.activation_limit,
expiresAt: license.expires_at,
});
}
await emailLicenseKeys(customer.email, event.object.license_keys ?? []);
}
```
`license_keys` is omitted entirely when the order issued no keys, so guard on
its presence. See WEBHOOKS.md for the full field list.
### Step 1: Product Setup
Create a product in the dashboard with:
- Enable "License Key" feature
- Set activation limit (e.g., 3 devices)
- Set expiration (or unlimited)
### Step 2: License Proxy on Your Backend
```typescript
// app/api/license/[action]/route.ts (Next.js) - the only place the key lives
import { NextRequest, NextResponse } from "next/server";
const BASE_URL =
process.env.NODE_ENV === "production" ? "https://api.creem.io" : "https://test-api.creem.io";
const ACTIONS = new Set(["activate", "validate", "deactivate"]);
export async function POST(req: NextRequest, { params }: { params: { action: string } }) {
if (!ACTIONS.has(params.action)) {
return NextResponse.json({ error: "Unknown action" }, { status: 404 });
}
// Forward only the license fields, never merchant credentials
const { key, instance_name, instance_id } = await req.json();
const response = await fetch(`${BASE_URL}/v1/licenses/${params.action}`, {
method: "POST",
headers: {
"x-api-key": process.env.CREEM_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ key, instance_name, instance_id }),
});
return NextResponse.json(await response.json(), { status: response.status });
}
```
Consider rate limiting this route per license key so the proxy cannot be used
to brute-force keys.
### Step 3: Desktop App Activation Flow
```typescript
// Desktop app - activation.ts
import Store from "electron-store";
interface LicenseState {
key: string;
instanceId: string;
expiresAt: string | null;
activatedAt: string;
}
const store = new Store<{ license: LicenseState }>();
// Your backend's license proxy from Step 2. No Creem API key in the app.
const LICENSE_API = "https://yourapp.com/api/license";
export async function activateLicense(licenseKey: string): Promise<boolean> {
// Generate unique instance name from machine
const instanceName = await getMachineId(); // Use machine-id package
const response = await fetch(`${LICENSE_API}/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: licenseKey,
instance_name: instanceName,
}),
});
if (!response.ok) {
const error = await response.json();
if (response.status === 403) {
throw new Error("Activation limit reached. Deactivate another device first.");
}
throw new Error(error.message || "Activation failed");
}
const license = await response.json();
// Store license locally
store.set("license", {
key: licenseKey,
instanceId: license.instance.id,
expiresAt: license.expires_at,
activatedAt: new Date().toISOString(),
});
return true;
}
export async function validateLicense(): Promise<{
valid: boolean;
status: string;
expiresAt: string | null;
}> {
const storedLicense = store.get("license");
if (!storedLicense) {
return { valid: false, status: "not_activated", expiresAt: null };
}
const response = await fetch(`${LICENSE_API}/validate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: storedLicense.key,
instance_id: storedLicense.instanceId,
}),
});
if (!response.ok) {
// Clear invalid license
store.delete("license");
return { valid: false, status: "invalid", expiresAt: null };
}
const license = await response.json();
return {
valid: license.status === "active",
status: license.status,
expiresAt: license.expires_at,
};
}
export async function deactivateLicense(): Promise<boolean> {
const storedLicense = store.get("license");
if (!storedLicense) {
return false;
}
const response = await fetch(`${LICENSE_API}/deactivate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: storedLicense.key,
instance_id: storedLicense.instanceId,
}),
});
if (response.ok) {
store.delete("license");
return true;
}
return false;
}
```
### Step 4: App Startup Check
```typescript
// main.ts (Electron)
import { app, BrowserWindow, dialog } from "electron";
import { validateLicense } from "./activation";
async function createWindow() {
// Validate license on startup
const licenseStatus = await validateLicense();
if (!licenseStatus.valid) {
// Show activation window
const activationWindow = new BrowserWindow({
width: 400,
height: 300,
modal: true,
});
activationWindow.loadFile("activation.html");
return;
}
// Check if expiring soon
if (licenseStatus.expiresAt) {
const expiresAt = new Date(licenseStatus.expiresAt);
const daysUntilExpiry = Math.ceil((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry <= 7) {
dialog.showMessageBox({
type: "warning",
title: "License Expiring",
message: `Your license expires in ${daysUntilExpiry} days. Please renew.`,
});
}
}
// Normal app startup
const mainWindow = new BrowserWindow({ width: 1200, height: 800 });
mainWindow.loadFile("index.html");
}
app.whenReady().then(createWindow);
```
---
## 4. Seat-Based Team Billing
For B2B SaaS with per-user pricing.
### Architecture
```
Admin purchases seats → Members invited → Seat count tracked
↓
Update seats via API ← Admin adds/removes members
```
### Step 1: Initial Purchase with Seats
```typescript
// Create checkout with seat count
const createTeamCheckout = async (seats: number, adminEmail: string) => {
const response = await fetch(`${BASE_URL}/v1/checkouts`, {
method: "POST",
headers: {
"x-api-key": CREEM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
product_id: "prod_team_plan", // Per-seat product
units: seats,
customer: { email: adminEmail },
success_url: `${APP_URL}/team/setup`,
metadata: {
initialSeats: seats,
},
}),
});
return response.json();
};
```
### Step 2: Track Team Members
```typescript
// Handle checkout completed - team setup
case 'checkout.completed': {
const { subscription, customer, metadata } = event.object;
const seats = event.object.units || 1;
// Create team
const team = await db.team.create({
data: {
creemSubscriptionId: subscription.id,
creemCustomerId: customer.id,
adminEmail: customer.email,
totalSeats: seats,
usedSeats: 0, // Will be 1 after admin is added
},
});
// Add admin as first member
await db.teamMember.create({
data: {
teamId: team.id,
email: customer.email,
role: 'admin',
},
});
await db.team.update({
where: { id: team.id },
data: { usedSeats: 1 },
});
break;
}
```
### Step 3: Update Seat Count
```typescript
// Update seats when team changes
export async function updateTeamSeats(teamId: string) {
const team = await db.team.findUnique({
where: { id: teamId },
include: { members: true },
});
if (!team) throw new Error("Team not found");
const currentMembers = team.members.length;
if (currentMembers === team.totalSeats) {
return; // No change needed
}
// Get subscription details first
const subResponse = await fetch(
`${BASE_URL}/v1/subscriptions?subscription_id=${team.creemSubscriptionId}`,
{
headers: { "x-api-key": CREEM_API_KEY },
},
);
const subscription = await subResponse.json();
const itemId = subscription.items[0].id;
// Update seat count in CREEM
const response = await fetch(`${BASE_URL}/v1/subscriptions/${team.creemSubscriptionId}`, {
method: "POST",
headers: {
"x-api-key": CREEM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
items: [{ id: itemId, units: currentMembers }],
update_behavior: "proration-charge-immediately",
}),
});
if (!response.ok) {
throw new Error("Failed to update seats");
}
// Update local record
await db.team.update({
where: { id: teamId },
data: { totalSeats: currentMembers },
});
}
```
### Step 4: Invite Team Members
```typescript
// API route to invite member
export async function POST(req: NextRequest) {
const { teamId, email } = await req.json();
const team = await db.team.findUnique({
where: { id: teamId },
include: { members: true },
});
if (team.members.length >= team.totalSeats) {
// Need to add more seats first
return NextResponse.json(
{
error: "No seats available. Upgrade your plan to add more members.",
requiresUpgrade: true,
},
{ status: 400 },
);
}
// Create invitation
const invitation = await db.invitation.create({
data: {
teamId,
email,
token: generateToken(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// Send invitation email
await sendInvitationEmail(email, invitation.token);
return NextResponse.json({ success: true });
}
```
---
## 5. Freemium with Upgrade Flow
Free tier with premium features unlocked via subscription.
### Step 1: Feature Gating Middleware
```typescript
// middleware/subscription.ts
import { NextRequest, NextResponse } from "next/server";
const PREMIUM_ROUTES = ["/api/export", "/api/integrations", "/api/advanced"];
const PREMIUM_LIMITS = {
free: {
projects: 3,
storage: 100 * 1024 * 1024, // 100MB
collaborators: 1,
},
pro: {
projects: -1, // unlimited
storage: 10 * 1024 * 1024 * 1024, // 10GB
collaborators: 10,
},
enterprise: {
projects: -1,
storage: -1,
collaborators: -1,
},
};
export async function subscriptionMiddleware(req: NextRequest) {
const session = await getSession(req);
if (!session) {
return NextResponse.redirect("/login");
}
const isPremiumRoute = PREMIUM_ROUTES.some((route) => req.nextUrl.pathname.startsWith(route));
if (!isPremiumRoute) {
return NextResponse.next();
}
const subscription = await getSubscription(session.userId);
if (!subscription?.isActive) {
return NextResponse.json(
{
error: "Premium subscription required",
upgrade_url: "/pricing",
},
{ status: 403 },
);
}
return NextResponse.next();
}
export function getLimits(plan: string) {
return PREMIUM_LIMITS[plan] || PREMIUM_LIMITS.free;
}
```
### Step 2: Upgrade Prompts
```tsx
// components/UpgradePrompt.tsx
"use client";
import { useState } from "react";
export function UpgradePrompt({ feature, currentPlan }: { feature: string; currentPlan: string }) {
const [loading, setLoading] = useState(false);
const handleUpgrade = async () => {
setLoading(true);
const response = await fetch("/api/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId: "prod_pro_plan" }),
});
const { checkoutUrl } = await response.json();
window.location.href = checkoutUrl;
};
return (
<div className="upgrade-prompt">
<h3>Upgrade to Pro</h3>
<p>{feature} requires a Pro subscription.</p>
<ul>
<li>Unlimited projects</li>
<li>10GB storage</li>
<li>Team collaboration</li>
<li>Priority support</li>
</ul>
<button onClick={handleUpgrade} disabled={loading}>
{loading ? "Redirecting..." : "Upgrade to Pro - $29/mo"}
</button>
</div>
);
}
```
---
## 6. Affiliate and Referral Tracking
Track conversions from marketing campaigns or affiliates.
**Two different things — pick the right one:**
- **Creem Affiliate Program (managed).** If you use Creem's built-in [Affiliate Program](https://docs.creem.io/features/affiliate-program), tracking and attribution are automatic. An affiliate link (`creem.io/affiliate?code=…`) redirects the visitor to your site and appends an opaque, signed **`creem_ref`** token. Creem attributes the sale via its own cookie (hosted checkout) or by forwarding `creem_ref` into the iframe (embedded checkout). You do **not** parse `creem_ref` or pass it to the checkout API — it identifies the click, not the affiliate, and seeing `?creem_ref=` on your site after an affiliate link is expected. See [Embedded checkout → Affiliate attribution](https://docs.creem.io/features/checkout/embedded-checkout#affiliate-attribution).
- **Custom tracking (the pattern below).** Use this only to roll your own campaign/affiliate tracking with your own `?ref=` / `?aff=` / `utm_*` params — stored in your own cookie and passed as checkout `metadata`. It's independent of the managed Affiliate Program above.
### Step 1: Pass Tracking Data
```typescript
// Checkout with tracking metadata
const createTrackedCheckout = async (
productId: string,
tracking: {
referralCode?: string;
utm_source?: string;
utm_campaign?: string;
affiliateId?: string;
},
) => {
const response = await fetch(`${BASE_URL}/v1/checkouts`, {
method: "POST",
headers: {
"x-api-key": CREEM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
product_id: productId,
metadata: {
...tracking,
timestamp: new Date().toISOString(),
},
}),
});
return response.json();
};
```
### Step 2: Track Conversions
```typescript
case 'checkout.completed': {
const { metadata, customer, order } = event.object;
// Log conversion
await db.conversion.create({
data: {
customerId: customer.id,
customerEmail: customer.email,
orderId: order.id,
amount: order.amount,
currency: order.currency,
referralCode: metadata?.referralCode,
utmSource: metadata?.utm_source,
utmCampaign: metadata?.utm_campaign,
affiliateId: metadata?.affiliateId,
},
});
// Credit affiliate
if (metadata?.affiliateId) {
await creditAffiliate(
metadata.affiliateId,
order.amount * 0.2 // 20% commission
);
}
// Credit referrer
if (metadata?.referralCode) {
const referrer = await db.user.findUnique({
where: { referralCode: metadata.referralCode },
});
if (referrer) {
await creditReferrer(referrer.id, 500); // $5 credit
}
}
break;
}
```
### Step 3: Landing Page Tracking
```tsx
// app/page.tsx
import { cookies } from "next/headers";
export default function LandingPage({ searchParams }) {
// Store tracking params in cookie
const trackingData = {
utm_source: searchParams.utm_source,
utm_campaign: searchParams.utm_campaign,
ref: searchParams.ref, // referral code
aff: searchParams.aff, // affiliate ID
};
cookies().set("tracking", JSON.stringify(trackingData), {
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return <LandingPageContent />;
}
```
```typescript
// API route reads tracking from cookie
export async function POST(req: NextRequest) {
const trackingCookie = req.cookies.get("tracking");
const tracking = trackingCookie ? JSON.parse(trackingCookie.value) : {};
const checkout = await createTrackedCheckout(productId, {
referralCode: tracking.ref,
utm_source: tracking.utm_source,
utm_campaign: tracking.utm_campaign,
affiliateId: tracking.aff,
});
return NextResponse.json({ checkoutUrl: checkout.checkout_url });
}
```
---
## Best Practices Summary
1. **Always use webhooks** for production access control, not success URL redirects
2. **Store CREEM IDs** (customer_id, subscription_id) for later API calls
3. **Use metadata** to link checkouts to your internal user/order IDs
4. **Implement idempotency** to handle duplicate webhook deliveries
5. **Test in sandbox** before going live
6. **Log everything** for debugging and customer support
7. **Handle edge cases**: expired subscriptions, failed payments, refunds
8. **Keep secrets safe**: Use environment variables, never client-side