DEV Community

fisher yu
fisher yu

Posted on

Integrating Creem.io Payments into a Next.js App — Practical Guide & Pitfalls

While building Die0.com — a digital farewell and time capsule platform — we needed to integrate a clean, modern payment experience. We chose Creem.io as our billing engine.

This guide walks through exactly how we connected Creem to a Next.js 14 app, using Prisma, API routes, and secure Webhooks — including all the places we stumbled and recovered.


🎯 Goal & Tech Stack

Objective: Let users purchase paid features on our site.

Stack:

  • Frontend: Next.js 14, React, TypeScript
  • Backend: Next.js API Routes
  • Database: MySQL + Prisma ORM
  • Payment: Creem.io
  • Deployment: Linux + PM2

⚙️ Initial Setup

1. Install dependencies

npm install @prisma/client
npm install crypto  # Node.js built-in for signature verification
Enter fullscreen mode Exit fullscreen mode


`

2. .env configuration

env
CREEM_SECRET_KEY=creem_xxxxxxxxxxxxxx
CREEM_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxx
CREEM_PRODUCT_ID=prod_xxxxxxxxxxxxxx
DATABASE_URL=mysql://user:pass@localhost:3306/db
NEXTAUTH_URL=https://www.die0.com

💡 PM2 Note: If using PM2, remember to use pm2 restart app --update-env.


🧭 Setting Up Creem.io

  1. Create a Product
    Copy the product ID into your .env.

  2. Configure Webhook
    URL: https://www.die0.com/api/webhook/creem
    Copy the Webhook Secret to .env.

  3. Get API Key
    Copy Secret Key to .env.


💾 Prisma Models

`prisma
model ProductOrder {
id String @id @default(uuid())
orderId String @unique
checkoutId String
customerId String
productId String
userId String
amount Float?
status String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model User {
id String @id
email String @unique
featureQuota Int @default(0)
productOrders ProductOrder[]
}
`

Then:

bash
npx prisma generate
npx prisma db push


🔌 Backend API: Create Checkout Session

`ts
// app/api/create-checkout-session/route.ts
export async function POST(req: NextRequest) {
const session = { user: { id: 'user_test_id', email: 'test@example.com' } };
const { itemId } = await req.json();

const creemPayload = {
product_id: process.env.CREEM_PRODUCT_ID,
units: 1,
request_id: ${session.user.id}_${Date.now()},
customer: {
email: session.user.email,
},
success_url: https://www.die0.com/payment-status?session_id={SESSION_ID},
metadata: {
user_id: session.user.id,
itemId,
},
};

const response = await fetch('https://api.creem.io/v1/checkout_sessions', {
method: 'POST',
headers: {
Authorization: Bearer ${process.env.CREEM_SECRET_KEY},
'Content-Type': 'application/json',
},
body: JSON.stringify(creemPayload),
});

const responseBody = await response.json();
return NextResponse.json({ payment_url: responseBody.payment_url });
}
`


📡 Webhook Handler

`ts
// app/api/webhook/creem/route.ts
function verifySignature(sig: string, raw: string, secret: string): boolean {
const hmac = crypto.createHmac('sha256', secret);
hmac.update(raw, 'utf8');
return crypto.timingSafeEqual(
Buffer.from(sig),
Buffer.from(hmac.digest('hex'))
);
}

export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('Creem-Signature') || '';
const secret = process.env.CREEM_WEBHOOK_SECRET;

if (!verifySignature(signature, rawBody, secret)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}

const event = JSON.parse(rawBody);
if (event?.eventType === 'checkout.completed') {
const order = event.object.order;
const metadata = event.object.metadata;
const userId = metadata?.user_id;

await prisma.$transaction(async (tx) => {
  await tx.productOrder.create({
    data: {
      orderId: order.id,
      checkoutId: event.object.id,
      customerId: event.object.customer?.id || '',
      productId: process.env.CREEM_PRODUCT_ID!,
      userId,
      status: 'completed',
    },
  });
  await tx.user.update({
    where: { id: userId },
    data: {
      featureQuota: { increment: 10 },
    },
  });
});

return NextResponse.json({ message: 'Processed' });
Enter fullscreen mode Exit fullscreen mode

}

return NextResponse.json({ message: 'Ignored' });
}
`


🖥 Frontend Integration (Button & Call)

tsx
const handlePurchase = async (itemId: string) => {
const res = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemId }),
});
const data = await res.json();
if (data.payment_url) {
window.location.href = data.payment_url;
}
};


🧪 Debugging & Deployment Checklist

  • ✅ Creem Test Mode enabled
  • ✅ Webhook tested via ngrok
  • ✅ Prisma schema + db migration OK
  • ✅ PM2 restarted with --update-env
  • .env production values set

🔚 Wrap-up

This payment system now powers Die0’s paid feature unlocks — safely, cleanly, and reliably. If you’re building a digital product with emotional context, visit:

👉 https://www.die0.com
A place for digital farewells that actually last.


Hope this helps you avoid the pitfalls I hit. Let me know what you're building!

Top comments (0)