Claude Code with Paddle: Merchant of Record Billing Done Right
Why Paddle is different from Stripe in ways Claude misses
Paddle is a merchant of record. Stripe is a payment processor. The legal and operational consequences of that distinction shape every line of integration code, and Claude Code does not know this by default. Out of the box, Claude treats Paddle like Stripe with different field names. It writes tax calculation logic, manages customer creation manually, builds invoice generation flows, and stores transaction state as if Paddle were a payment rail. None of that is needed. All of it is wrong.
As the merchant of record, Paddle handles sales tax, VAT, and GST collection and remittance in every jurisdiction it operates. It issues the invoice. It is the legal seller to the end customer. Your business sells to Paddle, Paddle sells to the customer. This means several pieces of code Claude generates for a Stripe integration are duplicative or actively harmful for a Paddle integration: client-side tax calculations override Paddle's own, manual invoice generation creates compliance conflicts, and customer record management duplicates state Paddle already maintains.
This guide covers the CLAUDE.md configuration that locks Claude Code into Paddle's correct model: the Paddle Billing v2 API, the checkout overlay, customer portal sessions, webhook event handling with signature verification, and the boundary between what your application owns and what Paddle owns. If you are building the broader SaaS application around Paddle, Claude Code with Next.js covers the request lifecycle that webhooks land on. For comparison with the alternative pattern, Claude Code with Stripe shows the same patterns in a payment-processor model.
The Paddle CLAUDE.md template
The CLAUDE.md at your project root needs to declare which Paddle API version you are using, the environment variable names for the API key and webhook secret, the price ID structure, the checkout flow, and the hard rules that prevent the Stripe-flavoured mistakes Claude makes most often.
# Paddle billing rules
## Stack
- Paddle Billing v2 (not Paddle Classic)
- @paddle/paddle-node-sdk ^1.x for server operations
- @paddle/paddle-js ^1.x for the checkout overlay
- TypeScript 5.x strict, Node.js 20.x
- PADDLE_API_KEY in .env.local (server only, never exposed to client)
- PADDLE_WEBHOOK_SECRET in .env.local
- NEXT_PUBLIC_PADDLE_CLIENT_TOKEN in .env.local (client-safe, used by paddle-js)
- NEXT_PUBLIC_PADDLE_ENVIRONMENT="sandbox" or "production"
## Project structure
- src/lib/paddle/server.ts Paddle Node SDK singleton
- src/lib/paddle/client.ts Paddle JS client initialiser
- src/app/api/webhooks/paddle/route.ts webhook handler
- src/app/api/checkout/route.ts creates checkout sessions if needed
## Merchant of record (CRITICAL semantics)
- Paddle is the legal seller to the end customer
- NEVER calculate tax client-side, Paddle handles VAT, GST, sales tax automatically
- NEVER generate invoices, Paddle issues invoices from its own legal entity
- NEVER store the customer's tax_id manually unless explicitly required by your accounting
- Your application is the supplier of the product to Paddle, not to the end user
## Price IDs vs Product IDs
- Product = the thing you sell (e.g. "Claudify Skills")
- Price = a specific price point for a product (e.g. "Claudify Skills, monthly, USD")
- Checkout takes price_id values, NEVER product_id values
- ALWAYS reference prices by their pri_xxxxxxxxxx ID, never by name
## Hard rules
- NEVER expose PADDLE_API_KEY to the client (server-only operations)
- NEVER skip webhook signature verification using Paddle-Signature header
- NEVER hardcode prices in code, store IDs in environment variables
- NEVER call paddle.transactions.create() to charge a card manually, use the checkout overlay
- NEVER assume a webhook event has been processed once, handlers must be idempotent
- ALWAYS use the Paddle Node SDK on server, never raw fetch against the REST API
Three rules in this template prevent the majority of failures Claude generates without them.
The merchant of record semantics rule is the most important. Claude trained on Stripe code instinctively reaches for tax calculation libraries, customer record management, and invoice generation. With Paddle, all three duplicate state that Paddle already owns and creates compliance risk. The rule "Paddle handles VAT, GST, sales tax automatically" appearing in your CLAUDE.md prevents Claude from generating a calculateVAT() helper.
The price ID rule prevents a common runtime error. Paddle Billing v2 has a clean separation between products and prices, and the checkout API only accepts price IDs (pri_xxx). Claude will sometimes pass a product ID (pro_xxx) because the names are similar and the docs use both. Stating "Checkout takes price_id values, NEVER product_id values" in CLAUDE.md eliminates this class of bug.
The signature verification rule prevents the most serious security issue. Webhook endpoints that do not verify the Paddle-Signature header can be triggered by anyone with the URL, leading to fraudulent subscription activations and revenue loss. The rule makes signature verification non-optional.
Install and environment setup
Install both packages:
npm i @paddle/paddle-node-sdk @paddle/paddle-js
Configure environment variables. For Next.js:
# .env.local
PADDLE_API_KEY=pdl_apikey_01h...
PADDLE_WEBHOOK_SECRET=pdl_ntfset_01h...
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_xxxxxxx
NEXT_PUBLIC_PADDLE_ENVIRONMENT=sandbox
# Price IDs (one per product variant)
NEXT_PUBLIC_PADDLE_PRICE_PRO_MONTHLY=pri_01h...
NEXT_PUBLIC_PADDLE_PRICE_PRO_YEARLY=pri_01h...
Create the server-side singleton:
// src/lib/paddle/server.ts
import { Environment, Paddle } from '@paddle/paddle-node-sdk';
if (!process.env.PADDLE_API_KEY) {
throw new Error('PADDLE_API_KEY is not defined');
}
const environment =
process.env.NEXT_PUBLIC_PADDLE_ENVIRONMENT === 'production'
? Environment.production
: Environment.sandbox;
export const paddle = new Paddle(process.env.PADDLE_API_KEY, {
environment,
});
The environment check matters. Paddle's sandbox and production environments are entirely separate: sandbox keys cannot read production data, sandbox webhooks fire on sandbox events only, and the NEXT_PUBLIC_PADDLE_CLIENT_TOKEN must match the environment your server is configured for. Claude will sometimes mix sandbox and production tokens because the SDK does not enforce alignment at the type level.
The checkout overlay
Paddle Billing v2 provides a hosted checkout overlay that handles card collection, payment method selection, tax calculation, currency conversion, and invoice issuance. Your application opens the overlay with a list of price IDs and Paddle does the rest.
The client-side initialiser:
// src/lib/paddle/client.ts
import { initializePaddle, Paddle } from '@paddle/paddle-js';
let paddlePromise: Promise<Paddle | undefined> | null = null;
export function getPaddle(): Promise<Paddle | undefined> {
if (paddlePromise) return paddlePromise;
paddlePromise = initializePaddle({
environment:
process.env.NEXT_PUBLIC_PADDLE_ENVIRONMENT === 'production'
? 'production'
: 'sandbox',
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN!,
});
return paddlePromise;
}
Opening the overlay from a React component:
// src/components/CheckoutButton.tsx
'use client';
import { useCallback } from 'react';
import { getPaddle } from '@/lib/paddle/client';
interface CheckoutButtonProps {
priceId: string;
customerEmail?: string;
customerId?: string;
}
export function CheckoutButton({
priceId,
customerEmail,
customerId,
}: CheckoutButtonProps) {
const handleCheckout = useCallback(async () => {
const paddle = await getPaddle();
if (!paddle) {
console.error('Paddle failed to initialise');
return;
}
paddle.Checkout.open({
items: [{ priceId, quantity: 1 }],
customer: customerId
? { id: customerId }
: customerEmail
? { email: customerEmail }
: undefined,
settings: {
displayMode: 'overlay',
theme: 'light',
locale: 'en',
allowLogout: false,
successUrl: 'https://claudify.tech/welcome',
},
});
}, [priceId, customerEmail, customerId]);
return (
<button onClick={handleCheckout}>
Start subscription
</button>
);
}
The successUrl field is critical. Claude often omits it, expecting that the checkout completion will trigger a webhook and that the application can handle redirects there. The webhook does fire, but the user is left on the Paddle overlay until they manually close it. Setting successUrl redirects them automatically when payment completes.
Add a checkout section to CLAUDE.md:
## Checkout overlay
- ALWAYS use Paddle.Checkout.open() with items: [{ priceId, quantity }]
- ALWAYS set settings.successUrl, never rely on webhook alone for UX
- Pass customer.email or customer.id, never both (id takes precedence)
- displayMode: 'overlay' for in-app, 'inline' for embedded
- allowLogout: false in production, prevents users from accidentally creating new accounts
- For multi-item checkouts, pass multiple items in the items array
Server-side checkout creation (transactions API)
For cases where you need more control over the checkout (custom metadata, pre-filled fields, server-side validation), you can create a transaction server-side and pass its ID to the overlay. This is the pattern for authenticated SaaS where you know the user before they click "subscribe".
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { paddle } from '@/lib/paddle/server';
export async function POST(req: NextRequest) {
const { priceId, userId, userEmail } = await req.json();
const transaction = await paddle.transactions.create({
items: [{ priceId, quantity: 1 }],
customerEmail: userEmail,
customData: {
userId,
source: 'app-upgrade',
},
});
return NextResponse.json({
transactionId: transaction.id,
});
}
On the client, pass the transaction ID to the overlay:
const paddle = await getPaddle();
paddle?.Checkout.open({
transactionId: response.transactionId,
});
The customData field is the standard way to attach your application's internal IDs to a Paddle transaction. It flows through to every webhook event for that transaction, so your handler can match the Paddle subscription back to the user in your database without a separate lookup table.
Add a custom data section to CLAUDE.md:
## Custom data on transactions
- Use customData: { userId, ... } to attach application IDs to transactions
- customData flows through to all webhook events for that transaction
- Read with event.data.customData.userId in webhook handlers
- NEVER store sensitive data in customData, it is visible in Paddle dashboard
- Maximum 50 keys, 10kb total
Webhook signature verification
Paddle signs every webhook with HMAC-SHA256 and sends the signature in the Paddle-Signature header. The Paddle Node SDK ships a helper that verifies the signature and parses the event in one call.
// src/app/api/webhooks/paddle/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { paddle } from '@/lib/paddle/server';
const webhookSecret = process.env.PADDLE_WEBHOOK_SECRET;
export async function POST(req: NextRequest) {
if (!webhookSecret) {
return NextResponse.json(
{ error: 'Webhook secret not configured' },
{ status: 500 }
);
}
const signature = req.headers.get('paddle-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 400 }
);
}
const rawBody = await req.text();
let event;
try {
event = await paddle.webhooks.unmarshal(rawBody, webhookSecret, signature);
} catch (err) {
console.error('[Paddle] Invalid webhook signature:', err);
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
);
}
// Idempotency check: has this event already been processed?
const alreadyProcessed = await checkEventProcessed(event.eventId);
if (alreadyProcessed) {
return NextResponse.json({ ok: true, deduplicated: true });
}
switch (event.eventType) {
case 'subscription.created':
await handleSubscriptionCreated(event.data);
break;
case 'subscription.activated':
await activateSubscription(event.data);
break;
case 'subscription.canceled':
await cancelSubscription(event.data);
break;
case 'subscription.past_due':
await markPastDue(event.data);
break;
case 'transaction.completed':
await recordCompletedTransaction(event.data);
break;
default:
console.log('[Paddle] Unhandled event:', event.eventType);
}
await markEventProcessed(event.eventId);
return NextResponse.json({ ok: true });
}
async function checkEventProcessed(eventId: string): Promise<boolean> {
// Look up in your database
return false;
}
async function markEventProcessed(eventId: string): Promise<void> {
// Record event ID in your database
}
async function handleSubscriptionCreated(data: any) {
// Match event.data.customData.userId to your user record
// Store the Paddle subscription ID
}
async function activateSubscription(data: any) {
// Grant access to paid features
}
async function cancelSubscription(data: any) {
// Schedule access revocation at end of billing period
}
async function markPastDue(data: any) {
// Trigger dunning email
}
async function recordCompletedTransaction(data: any) {
// Append to revenue ledger
}
The idempotency check is non-negotiable. Paddle's webhook delivery is at-least-once, not exactly-once. A network timeout, a transient error in your handler, or a Paddle internal retry will cause the same event to arrive twice. Storing event.eventId and skipping duplicates prevents double-charging users with bonus credits, double-revoking access, or double-counting revenue.
Add a webhook section to CLAUDE.md:
## Webhooks
- Endpoint: src/app/api/webhooks/paddle/route.ts
- ALWAYS verify Paddle-Signature header using paddle.webhooks.unmarshal()
- ALWAYS check idempotency before processing: store event.eventId in DB
- Return 200 for valid events (even unhandled ones)
- Return 400 only for invalid signature
- Return 500 only for unrecoverable server errors (triggers Paddle retry)
- Handle: subscription.created, subscription.activated, subscription.canceled,
subscription.past_due, transaction.completed, transaction.payment_failed,
customer.created, customer.updated
- NEVER trust event payload without signature verification first
- raw body MUST be read with req.text() before parsing (signature is over the raw bytes)
Get Claudify. The Paddle-specific CLAUDE.md template lands in your repo with signature verification, idempotency, and merchant of record rules pre-configured.
Customer portal
Paddle provides a hosted customer portal where users manage their own subscriptions: update payment methods, view invoices, cancel, change plans. Your application creates a portal session for an authenticated user and redirects them.
// src/app/api/portal/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { paddle } from '@/lib/paddle/server';
export async function POST(req: NextRequest) {
const { customerId } = await req.json();
if (!customerId) {
return NextResponse.json(
{ error: 'customerId required' },
{ status: 400 }
);
}
const portalSession = await paddle.customerPortalSessions.create(
customerId,
{ subscriptionIds: [] }
);
return NextResponse.json({
url: portalSession.urls.general.overview,
});
}
From a React component:
'use client';
async function openPortal(customerId: string) {
const res = await fetch('/api/portal', {
method: 'POST',
body: JSON.stringify({ customerId }),
});
const { url } = await res.json();
window.location.href = url;
}
The customer ID (ctm_xxx) comes from your database, where you stored it during the customer.created webhook. Claude often tries to look up the customer by email at portal creation time, which is unnecessary because the customer ID is already in your records. Worse, looking up by email can return the wrong customer if a user has changed their email since signup.
Add a customer portal section to CLAUDE.md:
## Customer portal
- paddle.customerPortalSessions.create(customerId, options)
- Read customerId from your DB (stored during customer.created webhook)
- NEVER look up customers by email at portal creation time
- Redirect to portalSession.urls.general.overview by default
- For subscription-specific actions: portalSession.urls.subscriptions[subId].cancelSubscription
- Sessions expire after 24 hours, generate fresh on each request
Subscription updates
Most subscription changes (upgrades, downgrades, plan switches) can happen through the customer portal. For cases where your application needs to drive the change (admin tools, support workflows), the Paddle Node SDK provides a subscription update method.
import { paddle } from '@/lib/paddle/server';
export async function upgradeSubscription(
subscriptionId: string,
newPriceId: string
) {
const updated = await paddle.subscriptions.update(subscriptionId, {
items: [
{
priceId: newPriceId,
quantity: 1,
},
],
prorationBillingMode: 'prorated_immediately',
});
return updated;
}
The prorationBillingMode controls how the change is billed:
| Mode | Behaviour |
|---|---|
prorated_immediately |
Charge the prorated difference now |
prorated_next_billing_period |
Apply the change immediately, bill the difference on next renewal |
full_immediately |
Charge the full new price now, ignore time remaining |
full_next_billing_period |
No charge now, full new price on next renewal |
do_not_bill |
Apply change with no billing adjustment |
Claude defaults to prorated_immediately because it sounds the most correct. For most B2B SaaS upgrades, prorated_immediately is right. For B2C subscriptions where users might be sensitive to surprise charges, prorated_next_billing_period is gentler. The rule in CLAUDE.md should match your business model.
## Subscription updates
- paddle.subscriptions.update(subId, { items, prorationBillingMode })
- B2B default: prorationBillingMode = 'prorated_immediately'
- B2C default: prorationBillingMode = 'prorated_next_billing_period'
- NEVER pass items with priceId that does not exist for the product
- Subscription quantity changes also use this method
- Cancellation uses paddle.subscriptions.cancel(subId, { effectiveFrom })
Refunds and credits
Refunds are processed via the transactions API, not subscriptions. Paddle distinguishes between full refunds (return the entire transaction) and partial refunds (specify line items).
import { paddle } from '@/lib/paddle/server';
export async function refundTransaction(
transactionId: string,
reason: string
) {
const refund = await paddle.adjustments.create({
action: 'refund',
transactionId,
reason,
items: 'all',
});
return refund;
}
export async function partialRefund(
transactionId: string,
itemId: string,
amount: string,
reason: string
) {
const refund = await paddle.adjustments.create({
action: 'refund',
transactionId,
reason,
items: [
{
itemId,
amount,
type: 'partial',
},
],
});
return refund;
}
The amount is a string in minor units (e.g. "500" for $5.00), matching the format Paddle uses throughout the API. Claude often treats this as a number, which causes serialisation issues at the API boundary.
Add a refunds section to CLAUDE.md:
## Refunds
- paddle.adjustments.create({ action: 'refund', transactionId, reason, items })
- items: 'all' for full refund, array of { itemId, amount, type } for partial
- amount is a STRING in minor units ("500" for $5.00), NOT a number
- reason is required, store it in your DB for accounting
- NEVER refund without recording the adjustment ID in your DB
- Paddle processes refunds back to the original payment method, typically 5 to 10 days
Common Claude Code mistakes with Paddle
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Manual tax calculation
Claude generates: a calculateVAT() or addSalesTax() helper that adjusts prices client-side based on customer location.
Correct pattern: Pass the customer's location implicitly through the checkout, Paddle calculates and adds tax automatically. Your displayed price is the pre-tax price unless you have configured price inclusive of tax in the Paddle dashboard.
2. Product ID where price ID is expected
Claude generates: items: [{ productId: 'pro_xxx', quantity: 1 }].
Correct pattern: items: [{ priceId: 'pri_xxx', quantity: 1 }]. The checkout API does not accept product IDs.
3. Missing signature verification
Claude generates: a webhook handler that parses the JSON body and processes events without checking the Paddle-Signature header.
Correct pattern: await paddle.webhooks.unmarshal(rawBody, webhookSecret, signature) before any event processing.
4. Email-based customer lookup
Claude generates: paddle.customers.list({ email: userEmail }) at portal creation time.
Correct pattern: Store the Paddle customer ID in your database during the customer.created webhook, look it up directly when needed.
5. Inline price hardcoding
Claude generates: items: [{ priceId: 'pri_01h2j3k4l5m6n7p', quantity: 1 }] with the ID baked into the component.
Correct pattern: Read the price ID from an environment variable (process.env.NEXT_PUBLIC_PADDLE_PRICE_PRO_MONTHLY) so it can change between sandbox and production without code changes.
6. Number amount in refunds
Claude generates: amount: 500 (number) in the refund payload.
Correct pattern: amount: "500" (string), matching Paddle's API contract.
Add a common mistakes section to CLAUDE.md with these six pairs. Claude reproduces patterns from CLAUDE.md examples more reliably than from abstract rules.
Permission hooks for Paddle scripts
A Paddle integration accumulates scripts: seed scripts that create test transactions, refund processors that adjust accounting, customer portal session generators for support tools. Some are read-only, some trigger irreversible actions.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(node scripts/list-customers.js*)",
"Bash(node scripts/list-transactions.js*)",
"Bash(node scripts/preview-checkout.js*)"
],
"deny": [
"Bash(node scripts/refund-all-pending.js*)",
"Bash(node scripts/cancel-subscription.js*)",
"Bash(node scripts/create-test-transaction.js*)"
]
}
}
Refunds, cancellations, and test transactions in production all carry side effects that should require explicit confirmation. The deny list forces Claude to surface those operations as prompts rather than running them as part of an automated workflow. For broader patterns on protecting billing infrastructure, Claude Code with Stripe covers similar permission scoping for a payment-processor model.
Building billing that does not surprise you
The Paddle CLAUDE.md in this guide produces billing integrations where the merchant of record semantics are respected, tax is handled by Paddle, the checkout overlay is the primary purchase flow, webhook signatures are always verified, idempotency is enforced on event handlers, customer IDs are stored at creation time, and refunds use string amounts in minor units.
The underlying principle is the same as any API integration with Claude Code. Paddle without a CLAUDE.md produces code that looks Stripe-shaped and works for the happy path but fails in ways that are hard to diagnose: duplicate tax calculations that conflict with Paddle's own, manual invoice generation that creates compliance ambiguity, webhook handlers that fire twice on the same event, and price IDs swapped for product IDs causing checkout to silently use the wrong amount. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude generates.
For the next layer of the stack, Paddle works alongside Claude Code with Drizzle for storing subscription state in your database, and Claude Code with Sentry for monitoring webhook handler reliability over time.
Get Claudify. Drop a Paddle-aware CLAUDE.md into your project and ship merchant of record billing without the Stripe-flavoured mistakes.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify