Back to blog

Complete guide: Integrate LemonSqueezy with credits.dev for usage-based billing

Step-by-step tutorial on connecting LemonSqueezy payments to credits.dev metering. Handle subscriptions, one-time purchases, webhooks, and refunds with full code examples.

XLinkedIn

LemonSqueezy makes it easy to sell software products, and credits.dev makes it easy to track usage-based credits. This guide shows you how to integrate them so payments flow into credits automatically.

What you'll build

By the end of this tutorial, you'll have:

  • LemonSqueezy checkout for subscriptions or one-time purchases
  • Automatic credit grants when customers pay
  • Webhook handlers that sync payments to credits.dev
  • Refund handling that revokes credits
  • Plan changes that adjust credit allowances

Prerequisites

  • A LemonSqueezy account
  • A credits.dev account
  • Node.js 18+ and TypeScript
  • Basic knowledge of webhooks and API integration

Architecture overview

The integration flow:

  1. Customer purchases a product or subscription on LemonSqueezy
  2. LemonSqueezy sends a webhook to your server
  3. Your webhook handler verifies the event and calls credits.dev to grant credits
  4. Your app checks credits.dev for real-time balance before allowing actions
  5. When subscriptions renew or customers refund, LemonSqueezy webhooks trigger credit adjustments
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ │ │ │ │ │ LemonSqueezy │─────▶│ Your Server │─────▶│ credits.dev │ │ (Payment) │ │ (Webhooks) │ │ (Credits) │ │ │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────┘

LemonSqueezy handles payment processing, subscriptions, and invoicing.

Your webhook handler receives payment events and translates them into credit operations.

credits.dev tracks balances, enforces limits, and manages usage in real time.

Step 1: Set up LemonSqueezy

1.1 Create products

In your LemonSqueezy dashboard, create products for each tier:

Example products:

  • Starter Pack: $10 one-time purchase → 1,000 credits
  • Pro Plan: $50/month subscription → 10,000 credits/month
  • Enterprise Pack: $500 one-time → 100,000 credits

Screenshot placeholder: LemonSqueezy dashboard showing product creation with name "Pro Plan", price $50, and recurring billing set to monthly.

1.2 Get your API key

  1. Go to Settings → API in LemonSqueezy dashboard
  2. Create a new API key with read access
  3. Save it securely (you'll use it to verify webhooks)

Screenshot placeholder: LemonSqueezy API settings page with "Create API key" button highlighted.

1.3 Set up webhook endpoint

In LemonSqueezy Settings → Webhooks:

  1. Click "Add endpoint"
  2. Enter your webhook URL (e.g., https://yourdomain.com/webhooks/lemonsqueezy)
  3. Select events to subscribe to:
    • order_created — one-time purchases
    • subscription_created — new subscriptions
    • subscription_updated — plan changes
    • subscription_payment_success — recurring payments
    • subscription_cancelled — cancellations
    • order_refunded — refunds
  4. Save the signing secret (you'll use this to verify webhooks)

Screenshot placeholder: LemonSqueezy webhook configuration showing endpoint URL and selected events.

Step 2: Set up credits.dev

2.1 Create an account

Sign up at credits.dev and create your first project.

2.2 Get your API key

  1. Go to Settings → API Keys
  2. Create a new API key
  3. Save it securely

Screenshot placeholder: credits.dev dashboard showing API key creation with scopes selected.

2.3 Create plans (optional)

If you want recurring credit grants (monthly allowances), create plans in credits.dev:

  1. Go to Plans → Create Plan
  2. Name: "Pro Monthly"
  3. Amount: 10,000 credits
  4. Interval: month
  5. Accumulate: true (allow rollover) or false

You can also grant credits without plans (one-time grants).

Screenshot placeholder: credits.dev plan creation form showing "Pro Monthly" plan with 10,000 credits per month.

Step 3: Install dependencies

Install the required packages:

npm install @lemonsqueezy/lemonsqueezy.js @credits-dev/sdk crypto

Or with pnpm:

pnpm add @lemonsqueezy/lemonsqueezy.js @credits-dev/sdk crypto

Step 4: Create the webhook handler

Create a webhook endpoint that receives LemonSqueezy events and grants credits.

4.1 Verify webhook signatures

LemonSqueezy signs webhooks with HMAC. Always verify signatures to prevent fraud.

// lib/lemonsqueezy.ts import crypto from "crypto" export function verifyLemonSqueezySignature( payload: string, signature: string, secret: string ): boolean { const hmac = crypto.createHmac("sha256", secret) hmac.update(payload) const digest = hmac.digest("hex") return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest)) }

4.2 Set up environment variables

Create a .env file:

# LemonSqueezy LEMONSQUEEZY_API_KEY=your_lemonsqueezy_api_key LEMONSQUEEZY_WEBHOOK_SECRET=your_webhook_signing_secret # credits.dev CREDITSDEV_API_KEY=your_creditsdev_api_key # Optional: map LemonSqueezy product/variant IDs to credit amounts # Format: VARIANT_ID:CREDITS,VARIANT_ID:CREDITS LEMONSQUEEZY_CREDIT_MAPPING=12345:1000,67890:10000,11111:100000

4.3 Create the webhook route

Here's a complete webhook handler for Next.js (App Router):

// app/api/webhooks/lemonsqueezy/route.ts import { NextRequest, NextResponse } from "next/server" import { CreditsClient } from "@credits-dev/sdk" import { verifyLemonSqueezySignature } from "@/lib/lemonsqueezy" const creditsClient = new CreditsClient({ apiKey: process.env.CREDITSDEV_API_KEY!, }) // Parse credit mapping from env (variantId:credits) const creditMapping = new Map<string, number>( (process.env.LEMONSQUEEZY_CREDIT_MAPPING || "") .split(",") .filter(Boolean) .map((pair) => { const [variantId, credits] = pair.split(":") return [variantId, parseInt(credits, 10)] }) ) interface LemonSqueezyWebhookEvent { meta: { event_name: string custom_data?: { user_id?: string account_id?: string } } data: { id: string type: string attributes: { user_email: string user_name: string identifier: string order_number: number first_order_item: { id: number order_id: number product_id: number variant_id: number product_name: string variant_name: string price: number } status: string refunded: boolean refunded_at: string | null } relationships?: { customer?: { data?: { id: string } } } } } export async function POST(req: NextRequest) { try { // 1. Get raw body and signature const rawBody = await req.text() const signature = req.headers.get("x-signature") if (!signature) { console.error("Missing signature header") return NextResponse.json({ error: "Missing signature" }, { status: 401 }) } // 2. Verify signature const isValid = verifyLemonSqueezySignature( rawBody, signature, process.env.LEMONSQUEEZY_WEBHOOK_SECRET! ) if (!isValid) { console.error("Invalid webhook signature") return NextResponse.json({ error: "Invalid signature" }, { status: 401 }) } // 3. Parse event const event: LemonSqueezyWebhookEvent = JSON.parse(rawBody) const eventName = event.meta.event_name console.log(`[LemonSqueezy] Received event: ${eventName}`) // 4. Get customer identifier // Use custom_data if you pass user_id during checkout, otherwise use email const userId = event.meta.custom_data?.user_id || event.meta.custom_data?.account_id || event.data.attributes.user_email if (!userId) { console.error("No user identifier found in webhook") return NextResponse.json({ error: "No user identifier" }, { status: 400 }) } // 5. Handle different event types switch (eventName) { case "order_created": await handleOrderCreated(event, userId) break case "subscription_created": await handleSubscriptionCreated(event, userId) break case "subscription_payment_success": await handleSubscriptionPayment(event, userId) break case "subscription_updated": await handleSubscriptionUpdated(event, userId) break case "subscription_cancelled": await handleSubscriptionCancelled(event, userId) break case "order_refunded": await handleOrderRefunded(event, userId) break default: console.log(`[LemonSqueezy] Unhandled event: ${eventName}`) } return NextResponse.json({ success: true }) } catch (error) { console.error("[LemonSqueezy] Webhook error:", error) return NextResponse.json( { error: "Internal server error" }, { status: 500 } ) } } // ─── Event Handlers ────────────────────────────────────────────────────── async function handleOrderCreated( event: LemonSqueezyWebhookEvent, userId: string ) { const variantId = String(event.data.attributes.first_order_item.variant_id) const credits = creditMapping.get(variantId) if (!credits) { console.warn(`[LemonSqueezy] No credit mapping for variant ${variantId}`) return } console.log(`[LemonSqueezy] Granting ${credits} credits to ${userId}`) // Ensure account exists in credits.dev await ensureAccount(userId, event.data.attributes.user_email) // Grant credits const result = await creditsClient.addCredits({ externalId: userId, amount: credits, description: `Purchase: ${event.data.attributes.first_order_item.product_name}`, paymentMethod: "lemonsqueezy", metadata: { lemonsqueezy_order_id: event.data.id, order_number: event.data.attributes.order_number, variant_id: variantId, product_name: event.data.attributes.first_order_item.product_name, }, }) console.log(`[LemonSqueezy] Credits granted. New balance: ${result.balance}`) } async function handleSubscriptionCreated( event: LemonSqueezyWebhookEvent, userId: string ) { // For subscriptions, you might want to assign a plan in credits.dev // that automatically grants credits monthly console.log(`[LemonSqueezy] New subscription for ${userId}`) await ensureAccount(userId, event.data.attributes.user_email) // Option 1: Grant initial credits immediately const variantId = String(event.data.attributes.first_order_item.variant_id) const credits = creditMapping.get(variantId) if (credits) { await creditsClient.addCredits({ externalId: userId, amount: credits, description: `Subscription: ${event.data.attributes.first_order_item.product_name} (initial)`, paymentMethod: "lemonsqueezy", metadata: { lemonsqueezy_subscription_id: event.data.id, variant_id: variantId, }, }) } // Option 2: Assign a credits.dev plan for automatic monthly grants // Uncomment if you've created plans in credits.dev /* const planId = getPlanIdForVariant(variantId) if (planId) { await creditsClient.assignPlanToAccount({ externalId: userId, planId, startMode: "immediate", }) } */ } async function handleSubscriptionPayment( event: LemonSqueezyWebhookEvent, userId: string ) { // Recurring payment succeeded — grant credits for this period const variantId = String(event.data.attributes.first_order_item.variant_id) const credits = creditMapping.get(variantId) if (!credits) { console.warn(`[LemonSqueezy] No credit mapping for variant ${variantId}`) return } console.log(`[LemonSqueezy] Subscription payment: granting ${credits} credits to ${userId}`) await creditsClient.addCredits({ externalId: userId, amount: credits, description: `Subscription renewal: ${event.data.attributes.first_order_item.product_name}`, paymentMethod: "lemonsqueezy", metadata: { lemonsqueezy_subscription_id: event.data.id, order_number: event.data.attributes.order_number, variant_id: variantId, }, }) } async function handleSubscriptionUpdated( event: LemonSqueezyWebhookEvent, userId: string ) { // Handle plan upgrades/downgrades console.log(`[LemonSqueezy] Subscription updated for ${userId}`) // If the variant changed, you might want to: // 1. Cancel the old plan in credits.dev // 2. Assign the new plan // 3. Grant prorated credits // This depends on your business logic } async function handleSubscriptionCancelled( event: LemonSqueezyWebhookEvent, userId: string ) { console.log(`[LemonSqueezy] Subscription cancelled for ${userId}`) // If you assigned a credits.dev plan, cancel it here // Or just stop granting credits — existing balance remains unless you deduct } async function handleOrderRefunded( event: LemonSqueezyWebhookEvent, userId: string ) { console.log(`[LemonSqueezy] Order refunded for ${userId}`) // Find the original credit grant and revoke it // You can use metadata to find the transaction const variantId = String(event.data.attributes.first_order_item.variant_id) const credits = creditMapping.get(variantId) if (credits) { await creditsClient.deductCredits({ externalId: userId, amount: credits, description: `Refund: ${event.data.attributes.first_order_item.product_name}`, metadata: { lemonsqueezy_order_id: event.data.id, refunded_at: event.data.attributes.refunded_at, }, }) console.log(`[LemonSqueezy] Revoked ${credits} credits from ${userId}`) } } // ─── Helpers ───────────────────────────────────────────────────────────── async function ensureAccount(externalId: string, email: string) { try { // Try to get existing account await creditsClient.getAccount({ externalId }) } catch (error) { // Account doesn't exist, create it console.log(`[credits.dev] Creating account for ${externalId}`) await creditsClient.createAccount({ externalId, email, name: email.split("@")[0], }) } }

4.4 Express.js alternative

If you're using Express instead of Next.js:

// routes/webhooks.ts import express from "express" import { CreditsClient } from "@credits-dev/sdk" import { verifyLemonSqueezySignature } from "../lib/lemonsqueezy" const router = express.Router() const creditsClient = new CreditsClient({ apiKey: process.env.CREDITSDEV_API_KEY!, }) router.post( "/lemonsqueezy", express.raw({ type: "application/json" }), async (req, res) => { try { const signature = req.headers["x-signature"] as string if (!signature) { return res.status(401).json({ error: "Missing signature" }) } const rawBody = req.body.toString("utf8") const isValid = verifyLemonSqueezySignature( rawBody, signature, process.env.LEMONSQUEEZY_WEBHOOK_SECRET! ) if (!isValid) { return res.status(401).json({ error: "Invalid signature" }) } const event = JSON.parse(rawBody) const eventName = event.meta.event_name console.log(`[LemonSqueezy] Received event: ${eventName}`) // Handle events (same logic as Next.js version above) // ... res.json({ success: true }) } catch (error) { console.error("[LemonSqueezy] Webhook error:", error) res.status(500).json({ error: "Internal server error" }) } } ) export default router

Step 5: Pass user context to LemonSqueezy checkout

When creating a checkout link or overlay, pass your user's ID as custom data so webhooks can identify them.

5.1 Using LemonSqueezy overlay

// components/PricingCard.tsx "use client" import { useState } from "react" interface PricingCardProps { name: string price: string credits: number checkoutUrl: string userId: string } export function PricingCard({ name, price, credits, checkoutUrl, userId }: PricingCardProps) { const [loading, setLoading] = useState(false) const handlePurchase = async () => { setLoading(true) // Open LemonSqueezy checkout with custom data // @ts-ignore - LemonSqueezy provides this globally window.createLemonSqueezy() // @ts-ignore window.LemonSqueezy.Url.Open(checkoutUrl + `?checkout[custom][user_id]=${userId}`) setLoading(false) } return ( <div className="border rounded-lg p-6"> <h3 className="text-xl font-bold">{name}</h3> <p className="text-3xl font-bold mt-4">{price}</p> <p className="text-gray-600 mt-2">{credits.toLocaleString()} credits</p> <button onClick={handlePurchase} disabled={loading} className="mt-6 w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700" > {loading ? "Loading..." : "Purchase"} </button> </div> ) }

Load the LemonSqueezy script in your layout:

// app/layout.tsx export default function RootLayout({ children }) { return ( <html lang="en"> <head> <script src="https://app.lemonsqueezy.com/js/lemon.js" defer /> </head> <body>{children}</body> </html> ) }

5.2 Using direct checkout URLs

Append custom data to the checkout URL:

const checkoutUrl = `https://yourstore.lemonsqueezy.com/checkout/buy/your-variant-id?checkout[custom][user_id]=${userId}`

Step 6: Check balance before allowing actions

In your API routes or server actions, check credits.dev before allowing usage:

// app/api/ai/generate/route.ts import { NextRequest, NextResponse } from "next/server" import { CreditsClient } from "@credits-dev/sdk" const creditsClient = new CreditsClient({ apiKey: process.env.CREDITSDEV_API_KEY!, }) export async function POST(req: NextRequest) { const { userId, prompt } = await req.json() // 1. Check balance const balance = await creditsClient.getBalance({ externalId: userId }) if (balance.available < 10) { return NextResponse.json( { error: "Insufficient credits. Please purchase more." }, { status: 402 } ) } // 2. Perform action (e.g., call AI API) const result = await generateAIResponse(prompt) // 3. Deduct credits await creditsClient.deductCredits({ externalId: userId, amount: 10, description: "AI generation", metadata: { prompt: prompt.slice(0, 100) }, }) return NextResponse.json({ result }) }

Step 7: Display balance in your UI

Show the user's credit balance in your dashboard:

// components/BalanceWidget.tsx "use client" import { useEffect, useState } from "react" interface BalanceWidgetProps { userId: string } export function BalanceWidget({ userId }: BalanceWidgetProps) { const [balance, setBalance] = useState<number | null>(null) const [loading, setLoading] = useState(true) useEffect(() => { async function fetchBalance() { try { const res = await fetch(`/api/credits/balance?userId=${userId}`) const data = await res.json() setBalance(data.balance) } catch (error) { console.error("Failed to fetch balance:", error) } finally { setLoading(false) } } fetchBalance() // Poll every 30 seconds const interval = setInterval(fetchBalance, 30000) return () => clearInterval(interval) }, [userId]) if (loading) return <div>Loading...</div> return ( <div className="bg-white rounded-lg shadow p-6"> <h3 className="text-sm font-medium text-gray-500">Available Credits</h3> <p className="text-3xl font-bold mt-2"> {balance?.toLocaleString() ?? "—"} </p> <a href="/pricing" className="mt-4 inline-block text-blue-600 hover:underline" > Purchase more → </a> </div> ) }

API route to fetch balance:

// app/api/credits/balance/route.ts import { NextRequest, NextResponse } from "next/server" import { CreditsClient } from "@credits-dev/sdk" const creditsClient = new CreditsClient({ apiKey: process.env.CREDITSDEV_API_KEY!, }) export async function GET(req: NextRequest) { const userId = req.nextUrl.searchParams.get("userId") if (!userId) { return NextResponse.json({ error: "Missing userId" }, { status: 400 }) } try { const balance = await creditsClient.getBalance({ externalId: userId }) return NextResponse.json({ balance: balance.available, reserved: balance.reserved, }) } catch (error) { console.error("Failed to fetch balance:", error) return NextResponse.json( { error: "Failed to fetch balance" }, { status: 500 } ) } }

Step 8: Testing the integration

8.1 Use LemonSqueezy test mode

LemonSqueezy provides a test mode. Enable it in your dashboard and use test card details:

  • Card number: 4242 4242 4242 4242
  • Expiry: Any future date
  • CVC: Any 3 digits

8.2 Test webhook locally with ngrok

  1. Install ngrok: npm install -g ngrok
  2. Start your dev server: npm run dev
  3. Expose it: ngrok http 3000
  4. Update your LemonSqueezy webhook URL to the ngrok URL + /api/webhooks/lemonsqueezy

8.3 Verify events in dashboards

After a test purchase:

  1. Check LemonSqueezy dashboard → Webhooks → Recent deliveries
  2. Check credits.dev dashboard → Transactions
  3. Verify the account balance increased

Screenshot placeholder: LemonSqueezy webhook delivery log showing successful 200 response.

Screenshot placeholder: credits.dev transactions page showing new credit grant with LemonSqueezy metadata.

8.4 Test refunds

  1. In LemonSqueezy, refund a test order
  2. Verify the webhook fired
  3. Check credits.dev to ensure credits were deducted

Step 9: Production considerations

9.1 Idempotency

LemonSqueezy may retry webhooks. To prevent duplicate credit grants, track processed events:

// Store in your database interface ProcessedWebhook { id: string // event.data.id from LemonSqueezy event_name: string processed_at: Date } async function hasProcessedEvent(eventId: string): Promise<boolean> { // Query your database const existing = await db.processedWebhooks.findUnique({ where: { id: eventId }, }) return !!existing } async function markEventProcessed(eventId: string, eventName: string) { await db.processedWebhooks.create({ data: { id: eventId, event_name: eventName, processed_at: new Date() }, }) } // In your webhook handler: if (await hasProcessedEvent(event.data.id)) { console.log(`[LemonSqueezy] Event ${event.data.id} already processed`) return NextResponse.json({ success: true }) } // ... process event ... await markEventProcessed(event.data.id, eventName)

9.2 Error handling and retries

If granting credits fails, return a 500 status so LemonSqueezy retries:

try { await creditsClient.addCredits({ ... }) } catch (error) { console.error("Failed to grant credits:", error) return NextResponse.json( { error: "Failed to grant credits" }, { status: 500 } // LemonSqueezy will retry ) }

9.3 Logging and monitoring

Log all webhook events for debugging:

console.log("[LemonSqueezy]", { event: eventName, userId, variantId, credits, timestamp: new Date().toISOString(), }) // Use a service like Sentry, LogRocket, or Datadog in production

9.4 Secure your webhook endpoint

  • Always verify signatures
  • Use HTTPS in production
  • Don't expose sensitive data in responses

9.5 Handle edge cases

  • Customer already has credits: Additive by default (stacks)
  • Subscription paused: No credits granted until resumed
  • Partial refunds: LemonSqueezy doesn't support this natively; handle manually

Step 10: Advanced patterns

10.1 Trial periods with credits

Give new users a one-time credit grant:

async function ensureAccount(externalId: string, email: string) { try { await creditsClient.getAccount({ externalId }) } catch (error) { // New account — grant trial credits await creditsClient.createAccount({ externalId, email, name: email.split("@")[0], }) await creditsClient.addCredits({ externalId, amount: 500, description: "Welcome bonus", metadata: { type: "trial" }, }) console.log(`[credits.dev] Granted 500 trial credits to ${externalId}`) } }

10.2 Credit packs with bonuses

Offer "buy 1,000 credits, get 1,100" by adjusting the credit mapping:

# .env # variant_id:credits (grants 10% bonus) LEMONSQUEEZY_CREDIT_MAPPING=12345:1100,67890:11000

10.3 Usage analytics

Track which features consume the most credits:

await creditsClient.deductCredits({ externalId: userId, amount: 10, description: "AI generation", metadata: { feature: "text-to-image", model: "dall-e-3", }, }) // Later, query transactions to analyze usage by feature const transactions = await creditsClient.listTransactions({ externalId: userId, limit: 100, })

10.4 Low balance notifications

Set up a webhook in credits.dev or poll periodically:

const balance = await creditsClient.getBalance({ externalId: userId }) if (balance.available < 100) { await sendLowBalanceEmail(userId, balance.available) }

Complete example repository structure

my-app/ ├── app/ │ ├── api/ │ │ ├── webhooks/ │ │ │ └── lemonsqueezy/ │ │ │ └── route.ts # Webhook handler │ │ ├── credits/ │ │ │ └── balance/ │ │ │ └── route.ts # Balance API │ │ └── ai/ │ │ └── generate/ │ │ └── route.ts # Usage endpoint │ ├── pricing/ │ │ └── page.tsx # Pricing page │ └── dashboard/ │ └── page.tsx # User dashboard with balance ├── components/ │ ├── PricingCard.tsx # LemonSqueezy checkout │ └── BalanceWidget.tsx # Display credits ├── lib/ │ ├── lemonsqueezy.ts # Signature verification │ └── credits.ts # credits.dev client wrapper └── .env.local # Environment variables

Troubleshooting

Webhooks not firing

  • Check LemonSqueezy dashboard → Webhooks → Recent deliveries for errors
  • Verify your webhook URL is publicly accessible (use ngrok for local testing)
  • Ensure your endpoint returns 200 status

Credits not granted

  • Check your server logs for errors
  • Verify the variant ID mapping in your .env
  • Check credits.dev dashboard → Transactions for failed attempts

Signature verification fails

  • Ensure you're using the raw request body (not parsed JSON)
  • Verify the webhook secret matches LemonSqueezy settings
  • Check for trailing whitespace or encoding issues

Duplicate credit grants

  • Implement idempotency (Step 9.1)
  • Check if webhooks are being sent to multiple endpoints

Next steps

You now have a complete integration between LemonSqueezy and credits.dev!

Ideas to extend this:

  1. Add usage dashboards — show credit consumption over time
  2. Implement low balance warnings — email users when credits run low
  3. Create admin tools — manually adjust credits for support cases
  4. Add referral bonuses — grant credits when users refer friends
  5. Tier-based pricing — use credits.dev plans for monthly allowances

Resources

Get help


If you found this guide helpful, consider sharing it with other developers building usage-based products. And if you build something cool with this integration, we'd love to hear about it!