Skip to content

flyweightdev/convex-paddle

Repository files navigation

@flyweightdev/convex-paddle

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.

Features

  • 🛒 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

Quick Start

1. Install the Component

npm install @flyweightdev/convex-paddle

Or install directly from GitHub:

npm install github:flyweightdev/convex-paddle

2. Add to Your Convex App

Create 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;

3. Set Up Environment Variables

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)

4. Configure Paddle Webhooks

  1. Go to Paddle Dashboard → Developer Tools → Notifications
  2. Click "New destination"
  3. Enter your webhook URL:
    https://<your-convex-deployment>.convex.site/paddle/webhook
    
    (Find your deployment name in the Convex dashboard - it's the part before .convex.cloud in your URL)
  4. Select these events:
    • customer.created
    • customer.updated
    • subscription.created
    • subscription.updated
    • subscription.activated
    • subscription.canceled
    • subscription.paused
    • subscription.resumed
    • subscription.past_due
    • transaction.created
    • transaction.completed
    • transaction.updated
    • transaction.paid
    • transaction.payment_failed
    • adjustment.created
    • adjustment.updated
  5. Click "Save"
  6. Copy the Secret key and add it as PADDLE_WEBHOOK_SECRET in Convex

5. Register Webhook Routes

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;

6. Use the Component

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 },
    });
  },
});

Using with Paddle.js (Client-Side)

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.

Sandbox Mode

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 });

API Reference

PaddleBilling Client

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)
});

Methods

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

createTransaction

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 }

cancelSubscription

await paddleClient.cancelSubscription(ctx, {
  paddleSubscriptionId: "sub_...",
  effectiveFrom: "next_billing_period", // or "immediately"
});

pauseSubscription

await paddleClient.pauseSubscription(ctx, {
  paddleSubscriptionId: "sub_...",
  effectiveFrom: "next_billing_period", // or "immediately"
  resumeAt: "2025-12-31T00:00:00Z",    // Optional auto-resume date
});

resumeSubscription

await paddleClient.resumeSubscription(ctx, {
  paddleSubscriptionId: "sub_...",
  effectiveFrom: "immediately",         // or "next_billing_period"
});

updateSubscriptionQuantity

await paddleClient.updateSubscriptionQuantity(ctx, {
  paddleSubscriptionId: "sub_...",
  priceId: "pri_...",
  quantity: 10,
});

createCustomerPortalSession

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_method

Component Queries

Access 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 },
    );
  },
});

Available Public Queries

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

Available Public Mutations

Mutation Description
createOrUpdateCustomer Create or update a customer record
updateSubscriptionMetadata Update subscription userId/orgId/data
updateSubscriptionQuantity Update seat count (action)

Webhook Events

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

Custom Webhook Handlers

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;

Database Schema

The component creates these tables in its own namespace (isolated from your app's tables):

customers

Field Type Description
paddleCustomerId string Paddle customer ID
email string? Customer email
name string? Customer name
status string? Customer status
customData object? Custom data

subscriptions

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

transactions

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

adjustments

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

webhook_events

Field Type Description
paddleEventId string Paddle event ID
eventType string Event type
occurredAt string When event occurred
processedAt number When we processed it (ms)

Differences from Stripe

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

Example App

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 dev

The 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

Example Environment Variables

.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 ID

Convex 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 domain

Authentication

This component works with any Convex authentication provider. The example app uses Clerk with the built-in convex/react-clerk adapter.

Setting up Clerk

  1. Install dependencies:
npm install @clerk/clerk-react
  1. 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)
  2. Create convex/auth.config.ts:

export default {
  providers: [
    {
      domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
      applicationID: "convex",
    },
  ],
};
  1. 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>
  1. 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)
  1. Configure environment variables:
    • Set CLERK_JWT_ISSUER_DOMAIN in your Convex dashboard environment variables (the Issuer URL from step 2)
    • Set VITE_CLERK_PUBLISHABLE_KEY in your .env.local (from Clerk Dashboard → API Keys)

Troubleshooting

Tables are empty after checkout

Make sure you've:

  1. Set PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET in Convex environment variables
  2. Configured the webhook destination in Paddle with the correct events
  3. Your webhook URL is correct: https://<deployment>.convex.site/paddle/webhook

Webhook signature verification failing

  1. Ensure PADDLE_WEBHOOK_SECRET is the secret from your Paddle notification destination (not your API key)
  2. Make sure you're using the correct environment (sandbox vs production)
  3. Check that the webhook URL matches exactly

"Not authenticated" errors

Ensure your auth provider is configured:

  1. Create convex/auth.config.ts with your Clerk provider config
  2. Set CLERK_JWT_ISSUER_DOMAIN in Convex dashboard environment variables
  3. Run npx convex dev to push the config
  4. Verify the user is signed in before calling actions
  5. Check that the JWT template in Clerk is named exactly convex

Duplicate webhook events

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.

Sandbox vs Production

Make sure you're consistent:

  • Sandbox API keys (pdl_sbox_...) must be used with sandbox: true
  • Production API keys (pdl_live_...) must be used with sandbox: false (default)
  • Paddle.js client tokens must match the environment (test_... for sandbox, live_... for production)

License

Apache-2.0

About

A Convex component for integrating Paddle payments, subscriptions, and billing into your Convex application.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages