A Convex component for integrating Paddle payments, subscriptions, and billing into your Convex application.
Inspired by the official Stripe component by Convex.
This project was created with the help of Claude Code (Opus 4.5) and reviewed by GPT 5.3-Codex, CodeRabbitAI and humans.
- 🛒 Checkout Sessions - Create one-time payment and subscription checkouts via Paddle transactions
- 📦 Subscription Management - Update, cancel, pause, resume subscriptions
- 👥 Customer Management - Automatic customer creation and linking
- 💳 Customer Portal - Let users manage their billing via Paddle's portal
- 🪑 Seat-Based Pricing - Update subscription quantities for team billing
- 🔗 User/Org Linking - Link transactions and subscriptions to users or organizations
- 🔔 Webhook Handling - Automatic sync of Paddle data to your Convex database with signature verification
- 📊 Real-time Data - Query transactions, subscriptions, adjustments in real-time
- 🔄 Idempotent Webhooks - Built-in event deduplication prevents duplicate processing
- 💰 Adjustments - Track refunds, credits, and chargebacks
npm install @flyweightdev/convex-paddleOr install directly from GitHub:
npm install github:flyweightdev/convex-paddleCreate or update convex/convex.config.ts:
import { defineApp } from "convex/server";
import paddle from "@flyweightdev/convex-paddle/convex.config.js";
const app = defineApp();
app.use(paddle);
export default app;Add these to your Convex Dashboard → Settings → Environment Variables:
| Variable | Description |
|---|---|
PADDLE_API_KEY |
Your Paddle API key (pdl_live_... or pdl_sbox_...) |
PADDLE_WEBHOOK_SECRET |
Webhook signing secret from your Paddle notification destination |
PADDLE_SANDBOX |
Set to "true" for sandbox mode (uses sandbox-api.paddle.com) |
- Go to Paddle Dashboard → Developer Tools → Notifications
- Click "New destination"
- Enter your webhook URL:
(Find your deployment name in the Convex dashboard - it's the part before
https://<your-convex-deployment>.convex.site/paddle/webhook.convex.cloudin your URL) - Select these events:
customer.createdcustomer.updatedsubscription.createdsubscription.updatedsubscription.activatedsubscription.canceledsubscription.pausedsubscription.resumedsubscription.past_duetransaction.createdtransaction.completedtransaction.updatedtransaction.paidtransaction.payment_failedadjustment.createdadjustment.updated
- Click "Save"
- Copy the Secret key and add it as
PADDLE_WEBHOOK_SECRETin Convex
Create convex/http.ts:
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { registerRoutes } from "@flyweightdev/convex-paddle";
const http = httpRouter();
// Register Paddle webhook handler at /paddle/webhook
registerRoutes(http, components.paddle, {
webhookPath: "/paddle/webhook",
});
export default http;Create convex/paddle.ts:
import { action } from "./_generated/server";
import { components } from "./_generated/api";
import { PaddleBilling } from "@flyweightdev/convex-paddle";
import { v } from "convex/values";
const paddleClient = new PaddleBilling(components.paddle, {});
// Create a checkout for a subscription
export const createSubscriptionCheckout = action({
args: { priceId: v.string() },
returns: v.object({
transactionId: v.string(),
checkoutUrl: v.union(v.string(), v.null()),
}),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
// Get or create a Paddle customer
const customer = await paddleClient.getOrCreateCustomer(ctx, {
userId: identity.subject,
email: identity.email!,
name: identity.name,
});
// Create checkout transaction
return await paddleClient.createTransaction(ctx, {
items: [{ priceId: args.priceId, quantity: 1 }],
customerId: customer.customerId,
customData: { userId: identity.subject },
});
},
});
// Create a checkout for a one-time payment
export const createPaymentCheckout = action({
args: { priceId: v.string() },
returns: v.object({
transactionId: v.string(),
checkoutUrl: v.union(v.string(), v.null()),
}),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const customer = await paddleClient.getOrCreateCustomer(ctx, {
userId: identity.subject,
email: identity.email!,
name: identity.name,
});
return await paddleClient.createTransaction(ctx, {
items: [{ priceId: args.priceId, quantity: 1 }],
customerId: customer.customerId,
customData: { userId: identity.subject },
});
},
});You can use the transactionId returned from createTransaction with Paddle.js for an inline checkout experience:
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>// Initialize Paddle.js
Paddle.Initialize({
token: "live_YOUR_CLIENT_SIDE_TOKEN", // client-side token, safe to expose
checkout: {
settings: {
displayMode: "overlay",
theme: "light",
},
},
});
// Open checkout with the transaction ID from your Convex action
const result = await createSubscriptionCheckout({ priceId: "pri_..." });
Paddle.Checkout.open({
transactionId: result.transactionId,
});Or redirect the user to result.checkoutUrl for Paddle's hosted checkout.
For development, use Paddle's sandbox environment. Set PADDLE_SANDBOX=true in your Convex dashboard environment variables, then read it in your config:
const paddleClient = new PaddleBilling(components.paddle, {
sandbox: process.env.PADDLE_SANDBOX === "true",
});And initialize Paddle.js in sandbox mode on the client:
if (import.meta.env.VITE_PADDLE_SANDBOX === "true") {
Paddle.Environment.set("sandbox");
}
Paddle.Initialize({ token: import.meta.env.VITE_PADDLE_CLIENT_TOKEN });import { PaddleBilling } from "@flyweightdev/convex-paddle";
const paddleClient = new PaddleBilling(components.paddle, {
PADDLE_API_KEY: "pdl_...", // Optional, defaults to process.env.PADDLE_API_KEY
sandbox: false, // Optional, defaults to false (production)
});| Method | Description |
|---|---|
createTransaction() |
Create a Paddle transaction for checkout |
createCustomer() |
Create a new Paddle customer |
getOrCreateCustomer() |
Get existing or create new customer |
cancelSubscription() |
Cancel a subscription |
pauseSubscription() |
Pause a subscription |
resumeSubscription() |
Resume a paused subscription |
updateSubscriptionQuantity() |
Update seat count |
createSubscriptionCharge() |
Create a one-time charge on an existing subscription |
createCustomerPortalSession() |
Generate a Customer Portal session |
Creates a Paddle transaction for checkout. Works for both one-time payments (non-recurring prices) and subscriptions (recurring prices). Paddle automatically creates a subscription when a transaction with recurring prices completes.
await paddleClient.createTransaction(ctx, {
items: [
{ priceId: "pri_...", quantity: 1 },
],
customerId: "ctm_...", // Optional
customData: { userId: "usr_123" }, // Optional, for linking back to your users
discountId: "dsc_...", // Optional
currencyCode: "USD", // Optional
});
// Returns: { transactionId: string, checkoutUrl: string | null }await paddleClient.cancelSubscription(ctx, {
paddleSubscriptionId: "sub_...",
effectiveFrom: "next_billing_period", // or "immediately"
});await paddleClient.pauseSubscription(ctx, {
paddleSubscriptionId: "sub_...",
effectiveFrom: "next_billing_period", // or "immediately"
resumeAt: "2025-12-31T00:00:00Z", // Optional auto-resume date
});await paddleClient.resumeSubscription(ctx, {
paddleSubscriptionId: "sub_...",
effectiveFrom: "immediately", // or "next_billing_period"
});await paddleClient.updateSubscriptionQuantity(ctx, {
paddleSubscriptionId: "sub_...",
priceId: "pri_...",
quantity: 10,
});const portal = await paddleClient.createCustomerPortalSession(ctx, {
customerId: "ctm_...",
subscriptionIds: ["sub_..."], // Optional
});
// Returns portal.urls with authenticated links for:
// - portal.urls.general.overview
// - portal.urls.subscriptions[0].cancel_subscription
// - portal.urls.subscriptions[0].update_subscription_payment_methodAccess data directly via the component's public queries:
import { query } from "./_generated/server";
import { components } from "./_generated/api";
// List subscriptions for a user
export const getUserSubscriptions = query({
args: {},
returns: v.any(),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
return await ctx.runQuery(
components.paddle.public.listSubscriptionsByUserId,
{ userId: identity.subject },
);
},
});
// List transactions for a user
export const getUserTransactions = query({
args: {},
returns: v.any(),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
return await ctx.runQuery(
components.paddle.public.listTransactionsByUserId,
{ userId: identity.subject },
);
},
});| Query | Arguments | Description |
|---|---|---|
getCustomer |
paddleCustomerId |
Get a customer by Paddle ID |
getCustomerByEmail |
email |
Get a customer by email |
listSubscriptions |
paddleCustomerId |
List subscriptions for a customer |
listSubscriptionsByUserId |
userId |
List subscriptions for a user |
getSubscription |
paddleSubscriptionId |
Get a subscription by ID |
getSubscriptionByOrgId |
orgId |
Get subscription for an org |
getTransaction |
paddleTransactionId |
Get a transaction by ID |
listTransactions |
paddleCustomerId |
List transactions for a customer |
listTransactionsByUserId |
userId |
List transactions for a user |
listTransactionsByOrgId |
orgId |
List transactions for an org |
listTransactionsBySubscription |
paddleSubscriptionId |
List transactions for a subscription |
listAdjustments |
paddleTransactionId |
List adjustments for a transaction |
| Mutation | Description |
|---|---|
createOrUpdateCustomer |
Create or update a customer record |
updateSubscriptionMetadata |
Update subscription userId/orgId/data |
updateSubscriptionQuantity |
Update seat count (action) |
The component automatically handles these Paddle webhook events:
| Event | Action |
|---|---|
customer.created |
Creates customer record |
customer.updated |
Updates customer record |
customer.imported |
Creates customer record |
subscription.created |
Creates subscription record |
subscription.updated |
Updates subscription record |
subscription.activated |
Marks subscription as active |
subscription.canceled |
Marks subscription as canceled |
subscription.paused |
Marks subscription as paused |
subscription.resumed |
Marks subscription as active |
subscription.past_due |
Updates subscription status |
subscription.trialing |
Updates subscription status |
subscription.imported |
Creates subscription record |
transaction.created |
Creates transaction record |
transaction.completed |
Marks transaction as completed |
transaction.updated |
Updates transaction record |
transaction.billed |
Creates transaction record |
transaction.paid |
Updates transaction status |
transaction.payment_failed |
Updates transaction status |
transaction.canceled |
Updates transaction status |
adjustment.created |
Creates adjustment record |
adjustment.updated |
Updates adjustment status |
Add custom logic to webhook events:
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { registerRoutes } from "@flyweightdev/convex-paddle";
const http = httpRouter();
registerRoutes(http, components.paddle, {
events: {
"subscription.created": async (ctx, event) => {
console.log("New subscription:", event.data.id);
// Add custom logic here (e.g., send welcome email)
},
"transaction.completed": async (ctx, event) => {
console.log("Transaction completed:", event.data.id);
// Add custom logic here (e.g., provision access)
},
},
onEvent: async (ctx, event) => {
// Called for ALL events - useful for logging/analytics
console.log("Paddle event:", event.event_type);
},
});
export default http;The component creates these tables in its own namespace (isolated from your app's tables):
| Field | Type | Description |
|---|---|---|
paddleCustomerId |
string | Paddle customer ID |
email |
string? | Customer email |
name |
string? | Customer name |
status |
string? | Customer status |
customData |
object? | Custom data |
| Field | Type | Description |
|---|---|---|
paddleSubscriptionId |
string | Paddle subscription ID |
paddleCustomerId |
string | Customer ID |
status |
string | Subscription status |
priceId |
string | Price ID |
quantity |
number? | Seat count |
scheduledChange |
object? | Scheduled change (cancel/pause) |
currentBillingPeriodStart |
string? | Period start (ISO 8601) |
currentBillingPeriodEnd |
string? | Period end (ISO 8601) |
nextBilledAt |
string? | Next billing date |
pausedAt |
string? | When paused |
canceledAt |
string? | When canceled |
userId |
string? | Linked user ID |
orgId |
string? | Linked org ID |
customData |
object? | Custom data |
| Field | Type | Description |
|---|---|---|
paddleTransactionId |
string | Paddle transaction ID |
paddleCustomerId |
string? | Customer ID |
paddleSubscriptionId |
string? | Subscription ID |
status |
string | Transaction status |
currencyCode |
string? | Currency (e.g., "USD") |
totalAmount |
string? | Total in lowest denomination |
collectionMode |
string? | "automatic" or "manual" |
billedAt |
string? | When billed |
createdAt |
string? | When created |
userId |
string? | Linked user ID |
orgId |
string? | Linked org ID |
customData |
object? | Custom data |
| Field | Type | Description |
|---|---|---|
paddleAdjustmentId |
string | Paddle adjustment ID |
paddleTransactionId |
string | Transaction ID |
paddleCustomerId |
string? | Customer ID |
paddleSubscriptionId |
string? | Subscription ID |
action |
string | "refund", "credit", "chargeback" |
reason |
string? | Adjustment reason |
status |
string | Adjustment status |
totalAmount |
string? | Total amount |
currencyCode |
string? | Currency code |
createdAt |
string? | When created |
| Field | Type | Description |
|---|---|---|
paddleEventId |
string | Paddle event ID |
eventType |
string | Event type |
occurredAt |
string | When event occurred |
processedAt |
number | When we processed it (ms) |
If you're coming from the Convex Stripe component, here are key differences:
| Concept | Stripe | Paddle |
|---|---|---|
| Checkout | Checkout Sessions | Transactions with collection_mode: "automatic" |
| Payments | Payment Intents | Transactions |
| Invoices | Invoices | Transactions (Paddle unifies these) |
| Subscriptions | Created via Checkout | Created automatically when a transaction with recurring prices completes |
| Pause | Not natively supported | First-class pause/resume support |
| Amounts | Numbers (cents) | Strings (lowest denomination) |
| Webhook signature | Stripe SDK verification | HMAC-SHA256 with Paddle-Signature header |
Check out the full example app in the example/ directory:
git clone https://github.com/flyweightdev/convex-paddle
cd convex-paddle
npm install
npm run devThe example includes:
- One-time payment checkout with Paddle.js
- Subscription checkout with Paddle.js
- Live pricing from Paddle API with USD/EUR currency toggle
- Subscription management (cancel, pause, resume)
- Seat-based team billing
- Customer portal integration
- Authentication via Clerk
.env.local (client-side, Vite):
VITE_CONVEX_URL=https://your-deployment.convex.cloud
VITE_CLERK_PUBLISHABLE_KEY=pk_test_... # Clerk publishable key
VITE_PADDLE_CLIENT_TOKEN=test_... # Paddle client-side token
VITE_PADDLE_SANDBOX=true
VITE_PADDLE_SINGLE_PRICE_ID=pri_... # Your one-time payment price ID
VITE_PADDLE_SUBSCRIPTION_PRICE_ID=pri_... # Your subscription price IDConvex Dashboard (server-side):
PADDLE_API_KEY=pdl_sbox_... # Paddle API key
PADDLE_WEBHOOK_SECRET=pdl_ntf_... # Webhook signing secret
PADDLE_SANDBOX=true # Use sandbox API
CLERK_JWT_ISSUER_DOMAIN=https://verb-noun-00.clerk.accounts.dev # Clerk JWT issuer domainThis component works with any Convex authentication provider. The example app uses Clerk with the built-in convex/react-clerk adapter.
- Install dependencies:
npm install @clerk/clerk-react-
Create a JWT template in the Clerk Dashboard:
- Navigate to JWT Templates
- Select the Convex template
- Do not rename the JWT token (it must be called
convex) - Copy the Issuer URL (your Frontend API URL, e.g.
https://verb-noun-00.clerk.accounts.dev)
-
Create
convex/auth.config.ts:
export default {
providers: [
{
domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
applicationID: "convex",
},
],
};- Set up the React provider in your app entry:
import { ClerkProvider, useAuth } from "@clerk/clerk-react";
import { ConvexReactClient } from "convex/react";
import { ConvexProviderWithClerk } from "convex/react-clerk";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
<ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
<App />
</ConvexProviderWithClerk>
</ClerkProvider>- Use
ctx.auth.getUserIdentity()in your Convex functions:
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
// identity.subject = user ID
// identity.email = user email (included in Clerk's Convex JWT template)- Configure environment variables:
- Set
CLERK_JWT_ISSUER_DOMAINin your Convex dashboard environment variables (the Issuer URL from step 2) - Set
VITE_CLERK_PUBLISHABLE_KEYin your.env.local(from Clerk Dashboard → API Keys)
- Set
Make sure you've:
- Set
PADDLE_API_KEYandPADDLE_WEBHOOK_SECRETin Convex environment variables - Configured the webhook destination in Paddle with the correct events
- Your webhook URL is correct:
https://<deployment>.convex.site/paddle/webhook
- Ensure
PADDLE_WEBHOOK_SECRETis the secret from your Paddle notification destination (not your API key) - Make sure you're using the correct environment (sandbox vs production)
- Check that the webhook URL matches exactly
Ensure your auth provider is configured:
- Create
convex/auth.config.tswith your Clerk provider config - Set
CLERK_JWT_ISSUER_DOMAINin Convex dashboard environment variables - Run
npx convex devto push the config - Verify the user is signed in before calling actions
- Check that the JWT template in Clerk is named exactly
convex
This component includes built-in idempotency via the webhook_events table. Each event_id is tracked, and duplicate events are automatically skipped. Paddle guarantees at-least-once delivery, so this is essential for correctness.
Make sure you're consistent:
- Sandbox API keys (
pdl_sbox_...) must be used withsandbox: true - Production API keys (
pdl_live_...) must be used withsandbox: false(default) - Paddle.js client tokens must match the environment (
test_...for sandbox,live_...for production)
Apache-2.0