npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@flyweightdev/convex-paddle

v0.1.1

Published

A Paddle billing component for Convex.

Readme

@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