# Kinde Billing

Add Kinde billing to your Convex app. Reactive subscriptions, checkout, self-serve portal, and feature gating.

This is an upstream package. It has not been adapted or qualified for Bijection.

- Package: convex-kinde-billing
- Source: https://github.com/sholajegede/convex-kinde-billing
- npm: https://www.npmjs.com/package/convex-kinde-billing
- Directory: https://bijection.com/components/convex-kinde-billing

Read the upstream README for setup, license, and compatibility before using it.

## Upstream package documentation

Recorded 2026-10-23. This describes convex-kinde-billing; check the Bijection adaptation separately.

Kinde Billing integrates Kinde's subscription billing with Convex through webhook handling and reactive queries. It automatically syncs subscription state, plan assignments, and billing events from Kinde webhooks into your Convex database. Provides feature gating functions like hasActivePlan() and hasFeature(), plus checkout URL generation and self-serve billing portal access.

### Use cases

• **SaaS subscription management** - Gate premium features based on active subscription status and automatically sync billing state changes without manual webhook handling
• **Feature flagging by plan** - Check if users have access to specific features using plan-based permissions with reactive queries that update instantly when subscriptions change
• **Checkout flow implementation** - Generate Kinde-hosted checkout URLs with pre-selected plans and redirect users to complete payment without building custom billing forms
• **Self-service billing portal** - Provide customers with one-click access to manage their subscriptions, update payment methods, and view billing history through Kinde's hosted portal
• **Usage-based billing tracking** - Store and query metered usage records in real-time for API calls, storage, or other measurable features tied to subscription limits

### How it works

The component handles Kinde billing webhooks through a mounted HTTP handler in convex/http.ts that processes subscription events like plan assignments, payment successes, and cancellations. All billing state gets stored in Convex tables with automatic JWT verification and real-time reactivity.

Key functions include hasActivePlan() and hasFeature() for subscription gating, getActivePlan() for current subscription details, and listBillingEvents() for billing history. The getCheckoutUrl() method generates Kinde-hosted checkout links with pre-selected plans, while getPortalUrl() creates one-time billing portal URLs using the user's access token.

Setup requires mounting the webhook handler, configuring the KINDE_ISSUER_URL environment variable, and registering the webhook endpoint in Kinde's dashboard. The component automatically handles webhook timing delays and provides instant UI updates through Convex's reactive queries once billing events arrive.

### Upstream README

# convex-kinde-billing

**Add Kinde billing to your Convex app.** Reactive subscriptions, checkout, self-serve portal, and feature gating.

```ts
const kindeBilling = new KindeBilling(components.convexKindeBilling, {
  KINDE_ISSUER_URL: process.env.KINDE_ISSUER_URL!,
});

// Is this user on an active plan?
const active = await kindeBilling.hasActivePlan(ctx, { customerId: "customer_abc123" });

// Does this user have access to a specific feature?
const hasAccess = await kindeBilling.hasFeature(ctx, { customerId: "customer_abc123", featureKey: "pro" });

// Generate a checkout URL to send users to Kinde's hosted billing
const checkoutUrl = kindeBilling.getCheckoutUrl({
  clientId: process.env.KINDE_CLIENT_ID!,
  redirectUri: "https://yourapp.com/dashboard",
  planKey: "customer_pro_plan",
});

// Generate a one-time self-serve billing portal link (using user's access token)
const portalUrl = await kindeBilling.getPortalUrl(userAccessToken, {
  returnUrl: "https://yourapp.com/settings",
});
```

## What this does

Kinde fires webhook events every time a billing action occurs — plan assigned, payment succeeded, subscription cancelled. Without this component, you have to write and maintain your own webhook handler, JWT verification, database schema, and reactive queries.

This component owns all of that. Drop it in, mount the webhook, and your Convex app immediately has:

- **Reactive billing state** — subscription status, plan name, renewal date, live in Convex
- **Feature gating** — `hasFeature()` and `hasActivePlan()` to gate any feature or route
- **Checkout** — `getCheckoutUrl()` generates a Kinde-hosted checkout link with plan pre-selected
- **Self-serve portal** — `getPortalUrl()` generates a one-time billing portal URL using the user's access token.
- **Metered usage** — usage records stored and queryable in real time
- **React component** — `<ManageBillingButton>` drop-in portal button

> **Webhook timing:** After a billing action occurs in Kinde, there is a short delay — usually a few seconds — before the webhook arrives and your Convex data updates. Once the webhook arrives, Convex's real-time reactivity propagates the change to all subscribers instantly.

## Table of Contents

- [Install](#install)
- [Quick Start](#quick-start)
- [Setup](#setup)
- [Usage](#usage)
- [Checkout](#checkout)
- [Self-Serve Portal](#self-serve-portal)
- [React Components](#react-components)
- [API Reference](#api-reference)
- [Type Reference](#type-reference)
- [Webhook Events](#webhook-events)
- [Database Schema](#database-schema)
- [Customer IDs](#customer-ids)
- [Using with kinde-sync](#using-with-kinde-sync)
- [Testing](#testing)
- [Limitations](#limitations)
- [Troubleshooting](#troubleshooting)
- [Contributing](#contributing)
- [Changelog](#changelog)

## Install

```bash
npm install convex-kinde-billing
```

**Requirements:** Convex v1.33.1 or later, Node.js 18+

## Quick Start

Five steps to add Kinde billing to your Convex app.

### 1. Add the component

In `convex/convex.config.ts`:

```ts
import { defineApp } from "convex/server";
import convexKindeBilling from "convex-kinde-billing/convex.config";

const app = defineApp();
app.use(convexKindeBilling);

export default app;
```

### 2. Set environment variables

```bash
npx convex env set KINDE_ISSUER_URL https://yourdomain.kinde.com
```

### 3. Mount the webhook handler

In `convex/http.ts`:

```ts
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { KindeBilling } from "convex-kinde-billing";

const kindeBilling = new KindeBilling(components.convexKindeBilling, {
  KINDE_ISSUER_URL: process.env.KINDE_ISSUER_URL!,
});

const http = httpRouter();

http.route({
  path: "/webhooks/kinde/billing",
  method: "POST",
  handler: kindeBilling.webhookHandler,
});

export default http;
```

### 4. Register the webhook in Kinde

1. In Kinde → **Webhooks → Add endpoint**
2. Set the URL: `https://your-deployment.convex.site/webhooks/kinde/billing`
3. Select all 8 billing events (listed in [Webhook Events](#webhook-events) below)
4. Save

Your Convex site URL is in the Convex dashboard under **Settings → URL & Deploy Key** — it ends in `.convex.site`.

### 5. Initialize the client

In `convex/billing.ts`:

```ts
import { components } from "./_generated/api";
import { KindeBilling } from "convex-kinde-billing";

export const kindeBilling = new KindeBilling(components.convexKindeBilling, {
  KINDE_ISSUER_URL: process.env.KINDE_ISSUER_URL!,
});
```

Import `kindeBilling` from this file in any Convex function that needs billing.

## Setup

**`convex/billing.ts`** — your central billing module:

```ts
import { components } from "./_generated/api";
import { KindeBilling } from "convex-kinde-billing";
import { query } from "./_generated/server";
import { v } from "convex/values";

export const kindeBilling = new KindeBilling(components.convexKindeBilling, {
  KINDE_ISSUER_URL: process.env.KINDE_ISSUER_URL!,
});

export const checkAccess = query({
  args: { customerId: v.string() },
  handler: async (ctx, { customerId }) =>
    kindeBilling.hasActivePlan(ctx, { customerId }),
});
```

**`convex/http.ts`** — webhook entry point:

```ts
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { KindeBilling } from "convex-kinde-billing";

const kindeBilling = new KindeBilling(components.convexKindeBilling, {
  KINDE_ISSUER_URL: process.env.KINDE_ISSUER_URL!,
});

const http = httpRouter();

http.route({
  path: "/webhooks/kinde/billing",
  method: "POST",
  handler: kindeBilling.webhookHandler,
});

export default http;
```

## Usage

### Check if a customer has an active plan

```ts
export const checkAccess = query({
  args: { customerId: v.string() },
  handler: async (ctx, args) => {
    return await kindeBilling.hasActivePlan(ctx, { customerId: args.customerId });
  },
});
// Returns: true | false
// Returns false (never throws) when customerId doesn't exist yet
```

### Check if a customer has a specific feature

```ts
export const checkFeature = query({
  args: { customerId: v.string(), featureKey: v.string() },
  handler: async (ctx, args) => {
    return await kindeBilling.hasFeature(ctx, args);
  },
});
// Returns: true if customer is active and planId or planName contains featureKey
```

### Get the customer's current plan

```ts
export const getPlan = query({
  args: { customerId: v.string() },
  handler: async (ctx, args) => {
    return await kindeBilling.getActivePlan(ctx, { customerId: args.customerId });
  },
});
// Returns: { planId, planName, status, currentPeriodEnd } | null
```

### Get the full subscription record

```ts
export const getSubscription = query({
  args: { customerId: v.string() },
  handler: async (ctx, args) => {
    return await kindeBilling.getSubscription(ctx, { customerId: args.customerId });
  },
});
// Returns: Subscription | null
```

### List billing events for a customer

```ts
export const getBillingHistory = query({
  args: { customerId: v.string() },
  handler: async (ctx, args) => {
    return await kindeBilling.listBillingEvents(ctx, {
      customerId: args.customerId,
      limit: 20,
    });
  },
});
// Returns: BillingEvent[] ordered newest first
```

### Query metered usage records

```ts
export const getApiUsage = query({
  args: { customerId: v.string() },
  handler: async (ctx, args) => {
    return await kindeBilling.getUsage(ctx, {
      customerId: args.customerId,
      meterId: "api_calls",
      limit: 100,
    });
  },
});
// Returns: UsageRecord[] ordered newest first
```

### Gate a feature by plan status

```ts
export const generateReport = action({
  args: { userId: v.string() },
  handler: async (ctx, { userId }) => {
    const active = await kindeBilling.hasActivePlan(ctx, { customerId: userId });
    if (!active) throw new Error("Upgrade required to generate reports.");
    // ... generate report
  },
});
```

### Gate by plan name

```ts
export const accessAdvancedAnalytics = query({
  args: { customerId: v.string() },
  handler: async (ctx, { customerId }) => {
    const plan = await kindeBilling.getActivePlan(ctx, { customerId });
    if (!plan || plan.planName !== "Pro") {
      return { allowed: false, reason: "Pro plan required" };
    }
    return { allowed: true };
  },
});
```

## Checkout

Kinde's checkout is hosted — you redirect users to a Kinde URL and they complete payment there. `getCheckoutUrl()` builds that URL with the plan pre-selected.

```ts
const checkoutUrl = kindeBilling.getCheckoutUrl({
  clientId: process.env.KINDE_CLIENT_ID!,
  redirectUri: "https://yourapp.com/dashboard",
  planKey: "customer_pro_plan",
});

window.location.href = checkoutUrl;
```

Show a pricing table instead of pre-selecting a plan:

```ts
const checkoutUrl = kindeBilling.getCheckoutUrl({
  clientId: process.env.KINDE_CLIENT_ID!,
  redirectUri: "https://yourapp.com/dashboard",
  pricingTableKey: "main_pricing_table",
});
```

For B2B org sign-up:

```ts
const checkoutUrl = kindeBilling.getCheckoutUrl({
  clientId: process.env.KINDE_CLIENT_ID!,
  redirectUri: "https://yourapp.com/dashboard",
  planKey: "customer_pro_plan",
  isCreateOrg: true,
});
```

After checkout, Kinde fires `customer.plan_assigned` and `customer.agreement_created` webhooks — your Convex billing state updates automatically.

## Self-Serve Portal

`getPortalUrl()` calls Kinde's Account API using the logged-in user's access token.

### Using Kinde's React SDK

```tsx
import { PortalLink } from "@kinde-oss/kinde-auth-react";

<PortalLink>Manage Billing</PortalLink>
```

### Without Kinde's React SDK

```ts
// Get the user's Kinde access token from your auth flow, then:
const portalUrl = await kindeBilling.getPortalUrl(userAccessToken, {
  returnUrl: "https://yourapp.com/settings",
});
window.location.href = portalUrl;
```

For B2B org billing portal:

```ts
const portalUrl = await kindeBilling.getPortalUrl(userAccessToken, {
  returnUrl: "https://yourapp.com/settings",
  subNav: "organization_billing",
});
window.location.href = portalUrl;
```

## React Components

### `<PortalLink>` — Kinde Auth SDK

If you use `@kinde-oss/kinde-auth-react`, use Kinde's built-in component. No backend call needed:

```tsx
import { PortalLink } from "@kinde-oss/kinde-auth-react";

<PortalLink>Manage Billing</PortalLink>
```

### `ManageBillingButton`

Drop-in button that calls `getPortalUrl` using the user's access token and redirects to the portal.

```tsx
import { ManageBillingButton } from "convex-kinde-billing/react";

function SettingsPage({ userAccessToken }: { userAccessToken: string }) {
  return (
    <ManageBillingButton
      kindeBilling={kindeBilling}
      userAccessToken={userAccessToken}
      returnUrl={window.location.href}
    >
      Manage Billing
    </ManageBillingButton>
  );
}
```

Props:

| Prop | Type | Required | Description |
|||||
| `kindeBilling` | `KindeBilling` | ✓ | Your `KindeBilling` instance |
| `userAccessToken` | `string` | ✓ | The logged-in user's Kinde access token |
| `returnUrl` | `string` | | URL to return to after the portal |
| `children` | `ReactNode` | | Button label (default: `"Manage Billing"`) |
| `className` | `string` | | CSS class name |

## API Reference

### `KindeBilling` class

```ts
import { KindeBilling } from "convex-kinde-billing";

const kindeBilling = new KindeBilling(components.convexKindeBilling, {
  KINDE_ISSUER_URL: process.env.KINDE_ISSUER_URL!,
});
```

#### Constructor options

| Option | Type | Required | Description |
|||||
| `KINDE_ISSUER_URL` | `string` | ✓ | Your Kinde issuer URL e.g. `https://yourdomain.kinde.com` |

#### Methods

| Method | Type | Args | Returns | Description |
||||||
| `webhookHandler` | HTTP action | — | — | Mount in `convex/http.ts` to receive Kinde billing webhooks |
| `getCheckoutUrl` | sync | `{ clientId, redirectUri, planKey?, pricingTableKey?, isCreateOrg? }` | `string` | Build a Kinde-hosted checkout URL |
| `getPortalUrl` | async | `(userAcce

[README truncated for prompt length]