# Keyvane: complete documentation

Source: https://keyvane.dev · Generated 2026-11-05

Keyvane gives independent developers license keys, plans, feature access and usage limits in one API, kept in sync with their Stripe subscriptions.

---

# Pricing

- **Developer**: $0. For trying Keyvane and for open-source projects. Includes: 1 product; Basic license management; Limited active licenses; Core SDK & API integration; Community support.
- **Indie**: $19 /month. For independent developers earning their first revenue. Includes: Multiple products; Higher license limits; Stripe subscription sync; Feature access control & usage limits; Basic analytics.
- **Startup**: $59 /month. For growing products with more customers and a team. Includes: High license volume; Team members; Advanced usage tracking; Audit log; Priority support.
- **Business**: Custom. For advanced security and contractual needs. Includes: Custom limits; Contract-based support; Security & access options; Custom integrations.

No commission on revenue. During the public beta every feature is available on every plan and limits are not enforced.

---

# Frequently asked questions

## What is Keyvane?

Keyvane is an open-core licensing and entitlement platform for independent developers and small SaaS teams. It turns Stripe subscription events into license keys, plan-based feature access and usage limits, and answers one API call: what may this customer do right now?

## Is Keyvane a payment processor?

No. Keyvane never touches card data or money and is not a Merchant of Record. Stripe keeps charging your customers, sending invoices and handling taxes. Keyvane manages licenses, access and usage rights on top of it.

## Do I have to give Keyvane my Stripe API key?

No. Keyvane only needs the signing secret of one webhook endpoint you create in Stripe, and it verifies every event with it.

## What happens if a webhook is missed, duplicated or arrives out of order?

Every event is stored and applied in a transaction. Duplicates are acknowledged without being applied twice, older snapshots cannot roll access back, and failures return an error so Stripe retries. You can replay any failed event from the dashboard.

## Can I use Keyvane for desktop apps and CLIs?

Yes. License keys with device activation limits work today with a publishable key that is safe to embed in your app. Signed offline license files are on the roadmap.

## Which payment providers are supported?

Stripe subscriptions, through Checkout, Payment Links or the Billing API. More providers are planned. Your app asks Keyvane for entitlements instead of asking the provider, so adding one will not change your access code.

## Is Keyvane open source?

The SDK, examples and documentation are MIT-licensed. The platform is source-available under the Functional Source License (FSL-1.1-MIT): you can read, modify and self-host it for your own products, and every release becomes MIT two years after it ships.

## What if Keyvane is unreachable?

Your app decides. The SDK raises typed errors, so you can keep free features working and pause paid ones, or the reverse. Short-lived caching of entitlements absorbs brief network blips.

---

<!-- https://keyvane.dev/docs -->

# Keyvane documentation

Keyvane is the **monetization access layer** for independent software. It sits between your payment provider and your product and answers one question reliably: *what is this customer allowed to do right now?*

- **Plans and entitlements.** Free, Pro, Business… each with on/off features and usage limits.
- **License keys.** Expiry, suspension, revocation and per-device activation limits for desktop apps, CLIs and APIs.
- **Usage limits.** Atomic, idempotent metering with daily, monthly, yearly or lifetime resets.
- **Stripe subscription sync.** Purchases, upgrades, failed payments and cancellations update access automatically.
- **Customer access view and audit log.** See every customer's plan, usage and history in one place.

```text
 ┌──────────────┐  signed webhooks   ┌──────────────┐   SDK / REST API   ┌──────────────┐
 │    Stripe    │ ─────────────────▶ │  Keyvane   │ ◀───────────────── │   Your app   │
 │ payments,    │                    │ plans, keys, │   "can user_42     │ web, API,    │
 │ invoices,    │                    │ entitlements,│    export?"        │ desktop, CLI │
 │ taxes        │                    │ usage, audit │                    │              │
 └──────────────┘                    └──────────────┘                    └──────────────┘
```

Keyvane is **not** a payment processor, Merchant of Record or tax engine. Your payment provider keeps doing that job. Keyvane turns its commercial events into product access.

## Start here

1. [Quickstart](getting-started.md): run Keyvane, define plans and make your first entitlement check in about 10 minutes.
2. [Core concepts](concepts.md): projects, products, plans, features, customers, licenses and how access is decided.
3. [Stripe integration](stripe.md): connect a webhook, map prices to plans and tell Keyvane who paid.
4. [License keys & devices](license-keys.md): sell desktop software and CLIs with keys and activation limits.

## Guides by stack

- [Next.js](nextjs.md): server components and route handlers
- [Express & Node APIs](express.md): middleware for plan checks and usage limits
- [Electron & desktop apps](electron.md): activation, device limits and offline grace
- Article: [Seven Stripe webhook edge cases that silently break access](stripe-webhook-edge-cases.md)

## Reference

- [JavaScript SDK](sdk.md) (`@keyvane/sdk`, MIT)
- [Python SDK](python.md) (`keyvane`, MIT)
- [REST API](api-reference.md) for any language, with an [OpenAPI spec](https://keyvane.dev/openapi.json)
- [AI coding agents](agents.md): `llms.txt`, Markdown docs and a `SKILL.md` for Claude Code, Cursor and others
- [Self-hosting](self-hosting.md) with one Node.js process and SQLite
- [Security & reliability](security.md)
- [Pricing & licensing](pricing-and-licensing.md): what is open source and what is commercial
- [Roadmap](roadmap.md)

## Examples in this repository

| Example | What it shows |
|---|---|
| [`examples/pdf-analyzer`](../examples/pdf-analyzer) | A web app with Free / Pro / Business plans, a metered feature, a Pro-only feature and Stripe upgrades |
| [`examples/license-cli`](../examples/license-cli) | A CLI licensed with keys, device activations and an offline grace period |

---

<!-- https://keyvane.dev/docs/getting-started -->

# Quickstart

This guide takes you from zero to a working integration. You will:

1. Run Keyvane (or use your hosted instance).
2. Define a product with Free, Pro and Business plans.
3. Check a customer's entitlements and record usage from your code.
4. Simulate a Stripe purchase and watch access change.

You need **Node.js 22.18 or newer**.

## 1. Run Keyvane

```bash
git clone https://github.com/Ravenium22/Keyvane.git
cd Keyvane
npm install
npm run seed:demo   # creates a demo account, project, API keys and example .env files
npm run dev         # http://localhost:8787
```

Open <http://localhost:8787/login> and sign in with the credentials that `seed:demo` printed (`demo@keyvane.local` / `keyvane-demo`).

> Using a hosted Keyvane instance instead? Sign up, create a project and tick **“Add the example product”**. Everything below works the same with your instance's URL instead of `http://localhost:8787`.

## 2. Look at the product

Open **Products & plans → AI PDF Analyzer**. The demo product mirrors a typical indie product:

| Plan | Price | `pdf_analysis` (metered, monthly) | `export` | `priority_processing` |
|---|---:|---:|:---:|:---:|
| Free (default) | $0 | 10 | — | — |
| Pro | $12 / month | 500 | ✓ | — |
| Business | $39 / month | 3,000 | ✓ | ✓ |

- **Plans** are what you sell. The **default plan** is what customers get without a license.
- **Features** are what your code checks. *On/off* features are switches. *Metered* features have a limit that resets every day, month or year (or never).
- The **matrix** at the bottom decides which plan unlocks what. Change a cell, save, and it applies to every customer immediately.

## 3. Create an API key

Go to **API keys → Create a key**, choose **Secret**, and copy the key (it is shown once). Use it only on your server:

```bash
export KEYVANE_URL=http://localhost:8787
export KEYVANE_SECRET_KEY=kv_sk_...
```

Every project also has a **publishable key** (`kv_pk_…`). It can only validate and activate license keys, so it is safe to ship inside desktop apps. See [License keys](license-keys.md).

## 4. Check entitlements from your code

```bash
npm install @keyvane/sdk
```

```ts
import { Keyvane } from '@keyvane/sdk';

const keyvane = new Keyvane(); // reads KEYVANE_URL and KEYVANE_SECRET_KEY

// Register customers when they sign up, using YOUR user ID.
await keyvane.customers.upsert({ externalId: user.id, email: user.email });

// What may this user do right now?
const access = await keyvane.entitlements.get({ customer: user.id });

access.plan?.key;                  // 'free'
access.can('export');              // false: Pro only
access.remaining('pdf_analysis');  // 10

// Consume usage. The limit check and the increment are one atomic step.
const usage = await keyvane.usage.record({
  customer: user.id,
  feature: 'pdf_analysis',
  idempotencyKey: job.id, // retries never count twice
});

if (!usage.allowed) {
  // 'limit_exceeded' or 'not_included': show an upgrade prompt
  return res.status(402).json({ error: usage.message });
}
```

Prefer plain HTTP? The same calls with `curl`:

```bash
curl "$KEYVANE_URL/v1/entitlements?customer=user_42" \
  -H "Authorization: Bearer $KEYVANE_SECRET_KEY"

curl "$KEYVANE_URL/v1/usage" \
  -H "Authorization: Bearer $KEYVANE_SECRET_KEY" \
  -d customer=user_42 -d feature=pdf_analysis
```

## 5. Simulate a purchase

You can try the whole subscription lifecycle without a Stripe account. `seed:demo` configured a webhook secret for the demo project, and the simulator sends correctly signed Stripe events to your server:

```bash
npm run stripe:simulate -- subscribe --customer user_42 --plan pro
```

Ask for the entitlements again: `user_42` is now on **Pro** with 500 analyses and export enabled. Then try:

```bash
npm run stripe:simulate -- upgrade   --customer user_42 --plan business
npm run stripe:simulate -- past-due  --customer user_42   # grace period keeps access
npm run stripe:simulate -- recover   --customer user_42
npm run stripe:simulate -- cancel    --customer user_42   # back to Free
```

Every event shows up under **Stripe → Events** and in the customer's history.

When you are ready for the real thing, follow the [Stripe integration guide](stripe.md).

## 6. Run the example app

```bash
npm run dev --workspace pdf-analyzer-example   # http://localhost:3000
```

Sign in as any username, analyze the sample PDF, hit the Free limit, then simulate an upgrade:

```bash
npm run stripe:simulate -- subscribe --customer user_<username> --plan pro
```

Reload the app: the limit is now 500 and **Export to Markdown** is unlocked.

## Next steps

- Understand how access is decided: [Core concepts](concepts.md)
- Connect real payments: [Stripe integration](stripe.md)
- Ship a desktop app or CLI: [License keys & devices](license-keys.md)
- Deploy Keyvane: [Self-hosting](self-hosting.md)

---

<!-- https://keyvane.dev/docs/concepts -->

# Core concepts

Keyvane has a small number of building blocks. Once you know how they fit together, every API response and dashboard screen will make sense.

```text
Project ─┬─ API keys (secret / publishable)
         ├─ Stripe connection (webhook secret)
         ├─ Products ─┬─ Plans ────┐
         │            └─ Features ─┴─ Entitlement matrix (plan × feature)
         ├─ Customers ─── Licenses ─── Subscriptions (from Stripe)
         └─ Audit log, usage counters, daily stats
```

## Projects

A **project** is an isolated workspace with its own API keys, Stripe connection, products, customers and licenses. Most developers use one project per company plus a second one for testing (e.g. *Acme* and *Acme (test)*) connected to Stripe's test mode.

## Products

A **product** is something you sell: a SaaS app, an API, a desktop tool. Each product has a stable **key** such as `pdf-analyzer` that you use in code. When a project has exactly one product you can omit `product` in API calls.

Product settings that affect access:

| Setting | Default | Meaning |
|---|---|---|
| Default plan | Free (if created from the template) | What customers get when they have no valid license. With no default plan, unknown customers get nothing. |
| Cancellation policy | `immediate` | When Stripe ends a subscription, access ends. `period_end` keeps access until the end of the period the customer paid for. |
| Grace period | 7 days | How long access continues after a failed payment while Stripe retries. |

## Plans

**Plans** (Free, Pro, Business…) are what customers buy. A plan has:

- a **key** (`pro`) used in code,
- a **rank**: when a customer holds several valid licenses, the highest-ranked plan wins,
- an optional display **price** (Keyvane does not charge it; your payment provider does),
- optional **Stripe price IDs**: subscriptions to these prices grant this plan,
- an optional **device limit** applied to new licenses on this plan.

## Features

**Features** are what your code checks. There are two types:

- **On/off** (`boolean`), e.g. `export`. `access.can('export')` is true or false.
- **Metered** (`metered`), e.g. `pdf_analysis`, has a limit per plan and a **reset period**: `day`, `month`, `year` or `never` (lifetime). Periods are calendar periods in **UTC**. A monthly feature resets at 00:00 UTC on the 1st.

## The entitlement matrix

For every plan × feature pair you decide whether the plan includes the feature and, for metered features, the limit (empty = unlimited). This matrix is the single source of truth: change it and every customer on that plan is affected immediately, with no deploy needed.

## Customers

A **customer** is one of your users. Keyvane identifies customers by **your own user ID** (`external_id`), so you never store Keyvane IDs in your database:

```ts
await keyvane.entitlements.get({ customer: user.id });
```

Customers are created when you call `customers.upsert`, when you record usage for a new ID, when you issue a license to them, or when they buy through Stripe. Reading entitlements for an unknown ID does **not** create a customer; it returns the default plan.

## Licenses

A **license** grants a customer (or an anonymous key holder) a plan of a product. Every license has a human-friendly **key** such as `KV-7K2QD-9XWMN-4RT8H-PZ3VB`.

| Source | Created by |
|---|---|
| `stripe` | A Stripe subscription (one license per subscription and product, updated as the subscription changes) |
| `dashboard` | You, in the dashboard (beta testers, lifetime deals, support credits) |
| `api` | Your server, via `POST /v1/licenses` |

You can **suspend** (reversible), **reinstate**, **revoke** (permanent), set an **expiry date** and a **device limit**. Admin actions always win over the subscription state: a suspended license stays suspended even if its subscription renews.

## How access is decided

When your app asks for a customer's entitlements, Keyvane:

1. collects the customer's licenses for the product,
2. evaluates each one *at the current moment* (see the table below),
3. picks the valid license with the **highest-ranked plan** (ties go to the newest),
4. falls back to the product's **default plan** if no license is valid,
5. combines that plan's row of the matrix with the customer's usage in the current period.

A license is valid unless:

| Condition | Status | Code |
|---|---|---|
| Revoked by you | `revoked` | `revoked` |
| Suspended by you | `suspended` | `suspended` |
| Past its expiry date | `expired` | `expired` |
| Subscription `past_due` and the grace period is over | `past_due` | `payment_overdue` |
| Subscription `unpaid` | `past_due` | `payment_overdue` |
| Subscription `canceled` and its access window ended | `canceled` | `subscription_canceled` |
| Subscription scheduled to cancel and the date passed | `canceled` | `subscription_canceled` |
| Subscription `incomplete`, `incomplete_expired` or `paused` | `inactive` | `subscription_inactive` |

Because the decision is computed on every request from stored facts and the current time, expirations, grace periods and period ends take effect exactly on time, with no cron jobs.

## Usage metering

`usage.record` checks the remaining allowance and increments the counter in **one atomic step**, so concurrent requests can never push a customer over the limit. A denied attempt is not counted.

- **Idempotency keys** make retries safe: the same key is never counted twice, and replays return `replayed: true`.
- Usage is counted **per customer, per feature, per period**, so it survives plan changes. A customer who used 10 analyses on Free and upgrades to Pro has 490 left this month.
- For license-key products, usage is counted against the license's customer (or the license itself if it has no customer).

## API keys

| Key | Prefix | Where to use it | Can do |
|---|---|---|---|
| Secret | `kv_sk_` | Your server only | Everything in the API |
| Publishable | `kv_pk_` | Desktop apps, CLIs, browsers | Validate, activate and deactivate license keys |

Secret keys are stored as SHA-256 hashes and shown once. Revoke a key and it stops working immediately.

## Audit log

Every change to licenses, plans, customers, API keys and every applied Stripe event is recorded with **who** did it: you in the dashboard, an API key, Stripe or Keyvane itself. Open **Activity**, or a customer's or license's **History**.

---

<!-- https://keyvane.dev/docs/stripe -->

# Stripe integration

Keyvane keeps access in sync with your Stripe subscriptions. Stripe keeps charging cards, sending invoices and handling taxes; Keyvane listens to its webhooks and turns them into licenses and entitlements.

**Keyvane never needs your Stripe API keys.** It only needs the signing secret of one webhook endpoint, and it verifies every event with it.

## Setup (5 minutes)

### 1. Create a webhook endpoint in Stripe

In the Stripe Dashboard, open **Developers → Webhooks → Add endpoint**. Start in **test mode**. Use your project's endpoint URL, which is shown on the **Stripe** page of the Keyvane dashboard:

```text
https://keyvane.dev/v1/webhooks/stripe/<your project id>
```

Select these events:

```text
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
customer.subscription.paused
customer.subscription.resumed
customer.updated
invoice.paid
invoice.payment_failed
```

### 2. Paste the signing secret into Keyvane

On the endpoint page in Stripe, click **Reveal signing secret** (`whsec_…`) and paste it on Keyvane's **Stripe** page. It is stored encrypted.

### 3. Map Stripe prices to plans

Open **Products & plans**, edit each paid plan and add its **Stripe price IDs** (`price_…`). Add both the monthly and the yearly price if you have them. A price can belong to only one plan.

When a subscription contains a mapped price, its customer gets that plan. If a subscription contains prices of several plans, the highest-ranked plan wins.

### 4. Tell Keyvane which of your users paid

Keyvane identifies customers by **your** user ID. Pass it to Stripe in one of these ways:

**Payment Links** (no code): append `client_reference_id` to the link you show a signed-in user.

```text
https://buy.stripe.com/test_abc123?client_reference_id=user_42&prefilled_email=alex%40example.com
```

**Checkout Sessions** (server-side):

```ts
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: 'price_pro_monthly', quantity: 1 }],
  client_reference_id: user.id,
  subscription_data: { metadata: { keyvane_customer_id: user.id } },
  success_url: 'https://yourapp.com/billing?success=1',
  cancel_url: 'https://yourapp.com/billing',
});
```

**Subscriptions created with the API**: set `metadata.keyvane_customer_id` on the subscription.

If you pass nothing, Keyvane still creates a license, linked to a customer that is identified only by the Stripe customer ID. That works for license-key products, but entitlement checks by your user ID will not find it.

## Testing

### Without a Stripe account: the simulator

```bash
npm run stripe:simulate -- subscribe --customer user_42 --plan pro
npm run stripe:simulate -- past-due  --customer user_42
npm run stripe:simulate -- cancel    --customer user_42
```

The simulator sends correctly signed, realistic events to your running server. See `npm run stripe:simulate` for all actions.

### With Stripe test mode

1. Create a product and price in Stripe test mode, map the price ID to a plan in Keyvane.
2. Create a Payment Link for that price.
3. Open the link with `?client_reference_id=user_42` and pay with card `4242 4242 4242 4242`.
4. Within seconds, `GET /v1/entitlements?customer=user_42` returns the paid plan.

For local development, the Stripe CLI can forward events to your machine:

```bash
stripe listen --forward-to localhost:8787/v1/webhooks/stripe/<project id>
```

The CLI prints its own signing secret (`whsec_…`). Paste that one into Keyvane while you test locally.

> `stripe trigger customer.subscription.created` creates a subscription with a throwaway price that is not mapped to any plan. Keyvane records the event as **ignored** and tells you which price is missing. Map it and click **Reprocess**.

## How Stripe states become access

| Stripe subscription status | Access |
|---|---|
| `active`, `trialing` | ✅ The mapped plan |
| `active` with `cancel_at_period_end` | ✅ Until the period ends, then ❌ (even if the final event never arrives) |
| `past_due` | ✅ During the product's **grace period** (default 7 days from the first failure), then ❌ |
| `unpaid` | ❌ |
| `canceled` | ❌ immediately (policy `immediate`), or ✅ until the paid period ends (policy `period_end`). Subscriptions canceled after failed payments always lose access immediately. |
| `incomplete`, `incomplete_expired`, `paused` | ❌ |

When access ends, the customer falls back to the product's default plan (for example, Free). Upgrades and downgrades change the plan of the existing license, and usage already counted this period is kept.

## Reliability guarantees

Mistakes in subscription handling cost real money or lock out paying customers, so the sync is built defensively:

- **Verified.** Every request is checked against the `Stripe-Signature` header (HMAC-SHA256) with a 5-minute timestamp tolerance. Unsigned or forged requests are rejected with `400` and never stored.
- **Stored.** Every verified event is saved with its payload and outcome under **Stripe → Events**.
- **Idempotent.** Stripe can deliver an event more than once. Duplicates are acknowledged with `200` and not applied again.
- **Atomic.** Each event is applied in a database transaction: all of its effects land, or none do.
- **Order-safe.** Stripe does not guarantee delivery order. Keyvane applies a subscription snapshot only if it is newer than the last one it applied, breaks same-second ties by lifecycle order (`incomplete` → `active` → `canceled`), and never revives an ended subscription.
- **Retried.** If processing fails, Keyvane responds `500` and Stripe retries with backoff for up to three days. You can also **Reprocess** any failed or ignored event from the dashboard once you have fixed the cause (for example, a missing price mapping).
- **Time-based without cron.** Grace periods, scheduled cancellations and expirations are evaluated at request time, so they take effect even if no further webhook arrives.

## Existing subscribers

Keyvane does not import past subscriptions. Existing subscribers are picked up the next time Stripe sends an event for their subscription (at the latest at renewal). To link them sooner, add `keyvane_customer_id` to each subscription's metadata in Stripe. That sends `customer.subscription.updated`, which links and syncs the subscription right away.

## Limitations in this version

- Only subscriptions are handled. One-time payments (`mode: payment`) are acknowledged but ignored; issue licenses for them with the API or dashboard for now.
- One Stripe account per project. Use separate projects for test mode and live mode.
- Stripe is the only payment provider today. Others are on the [roadmap](roadmap.md). Your app talks to Keyvane's entitlements rather than to the provider, so adding one will not change your code.

---

<!-- https://keyvane.dev/docs/license-keys -->

# License keys & devices

Use license keys when your software runs somewhere you do not control: desktop apps, CLIs, plugins, self-hosted software or APIs where customers authenticate with a key. For web apps where users sign in, checking [entitlements by user ID](concepts.md#how-access-is-decided) is usually simpler.

## Key format

```text
KV-7K2QD-9XWMN-4RT8H-PZ3VB
```

That is 20 random characters, or 100 bits of entropy, from an alphabet without `I`, `L`, `O` and `U`. Input is normalized: lowercase, extra spaces and the look-alikes `O`→`0`, `I`/`L`→`1` are accepted.

## Issuing keys

- **Automatically from Stripe:** every subscription creates a license (see [Stripe integration](stripe.md)).
- **In the dashboard:** **Licenses → New license**, or **Grant a license** on a customer page. Typical uses are beta testers, lifetime deals, partners and support credits.
- **From your server:**

```ts
const license = await keyvane.licenses.create({
  plan: 'pro',
  customer: user.id,      // optional; omit for a standalone key
  durationDays: 365,      // or expiresAt: '2027-12-31'
  maxActivations: 3,      // devices; defaults to the plan's device limit
  note: 'AppSumo #1234',  // internal, never shown to publishable keys
});
console.log(license.key);
```

## Validating in your app

Ship a **publishable key** (`kv_pk_…`) inside the app. It can only validate, activate and deactivate keys, so extracting it from your binary does not help anyone.

```ts
import { Keyvane } from '@keyvane/sdk';

const keyvane = new Keyvane({
  apiKey: 'kv_pk_…',
  baseUrl: 'https://keyvane.dev',
});

const result = await keyvane.licenses.validate({ key: licenseKey, fingerprint: machineId });

if (result.valid) {
  result.entitlements?.can('export'); // what the license unlocks
} else {
  console.log(result.code, result.message); // e.g. 'expired', 'This license expired on 2026-12-01.'
}
```

| Code | Meaning |
|---|---|
| `valid` | Good to go |
| `not_found` | No such key in this project |
| `expired` | Past `expires_at` |
| `suspended` / `revoked` | Disabled by you |
| `subscription_canceled` / `payment_overdue` / `subscription_inactive` | The Stripe subscription behind the license no longer grants access |
| `product_mismatch` | The key belongs to another product (pass `product` to enforce this) |
| `not_activated` | You passed a `fingerprint` that is not activated for this key |
| `activation_limit_reached` | `activate` was called on a full license |

## Device activations

Set a **device limit** on the plan (default for new licenses) or per license. Then, on first launch:

```ts
const activation = await keyvane.licenses.activate({
  key: licenseKey,
  fingerprint: machineId,           // stable per device, see below
  name: `${os.hostname()} (macOS)`, // shown in your dashboard
});
if (activation.code === 'activation_limit_reached') {
  // ask the user to deactivate another device
}
```

- `activate` is idempotent: activating the same fingerprint again just updates *last seen*.
- `validate` with a `fingerprint` also checks that this device is activated.
- `deactivate` frees a seat, e.g. when the user signs out or moves to a new machine. You can also remove devices on the license page in the dashboard.

**Fingerprints.** Use something stable and privacy-friendly: hash the hostname, platform, architecture and CPU model rather than sending raw hardware IDs. The [license CLI example](../examples/license-cli/src/cli.ts) shows one approach.

## Working offline

Validation is an online check. For desktop apps, cache the last successful result and allow a grace period when Keyvane is unreachable:

```ts
try {
  const result = await keyvane.licenses.validate({ key, fingerprint });
  saveCache({ validatedAt: Date.now(), valid: result.valid });
} catch (err) {
  if (err instanceof KeyvaneConnectionError && cache && Date.now() - cache.validatedAt < 7 * DAY) {
    // keep working offline for up to a week
  } else throw err;
}
```

Signed, fully offline license files are on the [roadmap](roadmap.md).

## Revoking, suspending, extending

| Action | Effect | Reversible |
|---|---|---|
| Suspend | Access stops immediately | Yes, with **Reinstate** |
| Revoke | Access stops permanently | No. Issue a new key instead. |
| Change expiry | Extend or shorten access | Yes |
| Change plan | Upgrade or downgrade a manual license | Yes (Stripe licenses follow the subscription) |

All of these are available in the dashboard and through the API (`/v1/licenses/{id}/suspend`, `/reinstate`, `/revoke` and `PATCH /v1/licenses/{id}`), and every change is recorded in the audit log.

---

<!-- https://keyvane.dev/docs/nextjs -->

# Next.js

Add plans, feature gates and usage limits to a Next.js App Router app. Keyvane is called from the server only: server components, route handlers and server actions. The secret key never reaches the browser.

**You need:** Next.js 14 or newer, Node.js 18+, a Keyvane project with a product, and a secret key.

## 1. Install and configure

```bash
npm install @keyvane/sdk
```

```bash
# .env.local
KEYVANE_URL=https://keyvane.dev
KEYVANE_SECRET_KEY=kv_sk_...
```

```ts
// lib/keyvane.ts
import 'server-only';
import { Keyvane } from '@keyvane/sdk';

export const keyvane = new Keyvane({ cacheTtlMs: 5_000 });
```

## 2. Register customers at sign-up

Use your own user ID. Keyvane never needs to be stored in your database.

```ts
await keyvane.customers.upsert({ externalId: user.id, email: user.email, name: user.name });
```

## 3. Show the plan and lock features in a server component

```tsx
// app/dashboard/page.tsx
import { keyvane } from '@/lib/keyvane';
import { getSession } from '@/lib/auth';

export const dynamic = 'force-dynamic'; // entitlements must never come from a build-time cache

export default async function Dashboard() {
  const { user } = await getSession();
  const access = await keyvane.entitlements.get({ customer: user.id });

  return (
    <main>
      <p>Plan: {access.plan?.name ?? 'Free'}</p>
      <p>{access.remaining('pdf_analysis') ?? 'Unlimited'} analyses left this month</p>
      {access.can('export') ? <ExportButton /> : <UpgradeLink feature="Export" />}
    </main>
  );
}
```

## 4. Consume usage in a route handler

```ts
// app/api/analyze/route.ts
import { NextResponse } from 'next/server';
import { keyvane } from '@/lib/keyvane';
import { getSession } from '@/lib/auth';

export async function POST(request: Request) {
  const { user } = await getSession();
  const jobId = request.headers.get('x-job-id') ?? crypto.randomUUID();

  const usage = await keyvane.usage.record({
    customer: user.id,
    feature: 'pdf_analysis',
    idempotencyKey: jobId, // retries never count twice
  });
  if (!usage.allowed) {
    return NextResponse.json({ error: usage.message, code: usage.code }, { status: 402 });
  }

  const result = await analyze(await request.formData());
  return NextResponse.json(result);
}
```

The limit check and the increment happen in one atomic step, so two concurrent requests can never both take the last analysis.

## 5. Upgrades through Stripe

Link a signed-in user to a Stripe Payment Link with their ID, so Keyvane knows who paid:

```tsx
<a href={`https://buy.stripe.com/test_abc123?client_reference_id=${user.id}`}>Upgrade to Pro</a>
```

Then follow the [Stripe integration guide](stripe.md) to connect the webhook and map the price to your Pro plan. Access updates within seconds of the purchase.

## Handling outages

```ts
import { KeyvaneConnectionError } from '@keyvane/sdk';

try {
  access = await keyvane.entitlements.get({ customer: user.id });
} catch (err) {
  if (err instanceof KeyvaneConnectionError) return <FreeTier />; // keep free features working
  throw err;
}
```

Next: [Core concepts](concepts.md) · [SDK reference](sdk.md)

---

<!-- https://keyvane.dev/docs/express -->

# Express & Node APIs

Protect API routes by plan and enforce usage limits with two small middlewares. The same pattern works in Fastify, Hono, Koa or any Node.js framework.

**You need:** Node.js 18+, a Keyvane project with a product, and a secret key in `KEYVANE_SECRET_KEY` (plus `KEYVANE_URL`).

```bash
npm install @keyvane/sdk
```

## Middleware

```ts
// keyvane.ts
import type { NextFunction, Request, Response } from 'express';
import { Keyvane, KeyvaneConnectionError } from '@keyvane/sdk';

export const keyvane = new Keyvane({ cacheTtlMs: 5_000 });

/** Allow the request only if the customer's plan includes `feature`. */
export function requireFeature(feature: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      const access = await keyvane.entitlements.get({ customer: req.user.id });
      if (!access.can(feature)) {
        return res.status(403).json({ error: `${feature} is not included in your plan`, plan: access.plan?.key });
      }
      next();
    } catch (err) {
      if (err instanceof KeyvaneConnectionError) return res.status(503).json({ error: 'Plan check unavailable, try again' });
      next(err);
    }
  };
}

/** Consume `quantity` units of a metered feature before running the handler. */
export function meter(feature: string, quantity = 1) {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      const usage = await keyvane.usage.record({
        customer: req.user.id,
        feature,
        quantity,
        idempotencyKey: req.get('Idempotency-Key') ?? undefined,
      });
      if (!usage.allowed) {
        return res.status(429).json({ error: usage.message, code: usage.code, remaining: 0 });
      }
      res.setHeader('X-Usage-Remaining', String(usage.feature.type === 'metered' ? (usage.feature.remaining ?? 'unlimited') : 'n/a'));
      next();
    } catch (err) {
      next(err);
    }
  };
}
```

## Routes

```ts
app.post('/v1/analyses', meter('pdf_analysis'), createAnalysis);
app.get('/v1/exports/:id', requireFeature('export'), downloadExport);
```

## API products sold with license keys

If your customers call **your** API with a key you issued, use Keyvane license keys as the API keys and meter against them:

```ts
const usage = await keyvane.usage.record({ licenseKey: req.get('X-Api-Key')!, feature: 'requests' });
if (!usage.allowed) return res.status(usage.code === 'license_invalid' ? 401 : 429).json({ error: usage.message });
```

Next: [License keys](license-keys.md) · [REST API](api-reference.md)

---

<!-- https://keyvane.dev/docs/electron -->

# Electron & desktop apps

Sell a desktop app with license keys: activate the key on first launch, limit how many devices can use it, check it on startup, and keep working offline for a few days.

**You need:** Electron 28+, a Keyvane project with a product, and the project's **publishable key** (`kv_pk_…`). Never ship a secret key in an app.

## Where the code runs

Call Keyvane from the **main process** only and expose a narrow API to the renderer over IPC. Store the key encrypted with Electron's `safeStorage`.

```bash
npm install @keyvane/sdk
```

```ts
// main/license.ts
import { app, ipcMain, safeStorage } from 'electron';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { arch, cpus, hostname, platform } from 'node:os';
import { join } from 'node:path';
import { Keyvane, KeyvaneConnectionError } from '@keyvane/sdk';

const keyvane = new Keyvane({ apiKey: 'kv_pk_...', baseUrl: 'https://keyvane.dev', timeoutMs: 5_000 });
const file = join(app.getPath('userData'), 'license.bin');
const OFFLINE_DAYS = 7;

const fingerprint = () => createHash('sha256').update([hostname(), platform(), arch(), cpus()[0]?.model].join('|')).digest('hex').slice(0, 32);

type Saved = { key: string; validatedAt?: number; features?: Record<string, boolean> };
const load = (): Saved | null => (existsSync(file) ? JSON.parse(safeStorage.decryptString(readFileSync(file))) : null);
const save = (s: Saved) => writeFileSync(file, safeStorage.encryptString(JSON.stringify(s)));

ipcMain.handle('license:activate', async (_e, key: string) => {
  const result = await keyvane.licenses.activate({ key, fingerprint: fingerprint(), name: `${hostname()} (${platform()})` });
  if (!result.valid) return { ok: false, code: result.code, message: result.message };
  save({ key: result.license!.key, validatedAt: Date.now(), features: { export: result.entitlements?.can('export') ?? false } });
  return { ok: true, plan: result.license?.plan?.name };
});

ipcMain.handle('license:status', async () => {
  const saved = load();
  if (!saved) return { ok: false, code: 'no_license' };
  try {
    const result = await keyvane.licenses.validate({ key: saved.key, fingerprint: fingerprint() });
    if (result.valid) save({ ...saved, validatedAt: Date.now(), features: { export: result.entitlements?.can('export') ?? false } });
    return { ok: result.valid, code: result.code, message: result.message, features: result.valid ? load()?.features : {} };
  } catch (err) {
    const fresh = saved.validatedAt && Date.now() - saved.validatedAt < OFFLINE_DAYS * 86_400_000;
    if (err instanceof KeyvaneConnectionError && fresh) return { ok: true, offline: true, features: saved.features };
    throw err;
  }
});

ipcMain.handle('license:deactivate', async () => {
  const saved = load();
  if (saved) await keyvane.licenses.deactivate({ key: saved.key, fingerprint: fingerprint() });
});
```

## What your customers experience

| Situation | Result |
|---|---|
| First launch | Enter the key once; the device is activated |
| Key used on too many devices | `activation_limit_reached`: offer to deactivate another device |
| Subscription canceled or key revoked | `valid: false` on the next check, with a reason you can show |
| Offline | Works for up to 7 days after the last successful check |

You can see and remove each device on the license page of the Keyvane dashboard. The [license CLI example](../examples/license-cli) implements the same flow without Electron.

Next: [License keys & devices](license-keys.md)

---

<!-- https://keyvane.dev/docs/stripe-webhook-edge-cases -->

# Seven Stripe webhook edge cases that silently break access

Most subscription bugs are not crashes. A customer pays and still sees the Free plan, or cancels and keeps Pro for months. These seven cases cause most of them. Each section says what happens, why, and how to handle it, whether you build it yourself or use Keyvane.

## 1. Events arrive out of order

Stripe [does not guarantee](https://docs.stripe.com/webhooks) that events arrive in the order they were created. An older `customer.subscription.updated` (status `active`) can land after `customer.subscription.deleted`, and a naive handler resurrects a canceled subscription.

**Handle it:** store the `created` timestamp of the last event you applied per subscription, and ignore older snapshots. Treat `canceled` and `incomplete_expired` as terminal: never move a subscription out of them.

**In Keyvane:** both rules are built in, and same-second ties are resolved by lifecycle order (`incomplete` → `active` → `canceled`).

## 2. The same event is delivered twice

Stripe can deliver an event more than once, for example after a timeout on your side. Handlers that increment counters, send emails or create records do it twice.

**Handle it:** record every `event.id` and acknowledge duplicates with `200` without applying them again. Do the work and the record in one database transaction.

**In Keyvane:** every event is stored. Duplicates return `{"duplicate": true}` and are never re-applied.

## 3. The subscription arrives before you know who paid

With Checkout and Payment Links, `customer.subscription.created` often arrives before `checkout.session.completed`, the only event that carries your `client_reference_id`. If you create the access record on the first event, you do not yet know which user it belongs to.

**Handle it:** create a customer keyed by the Stripe customer ID, and link it to your user when the checkout event arrives, in either order. If both records exist, merge them.

**In Keyvane:** customers are linked and merged automatically in any order. Usage already counted for the user is preserved.

## 4. "Cancel at period end" is not a cancellation yet

When a customer cancels in the Stripe portal, the subscription stays `active` with `cancel_at_period_end: true` until the period ends. Revoking access on that `updated` event takes away time they paid for. Waiting for `deleted` is right, but if that final event never reaches you, access never ends.

**Handle it:** keep access while `active`, and also stop it once `cancel_at` (or the period end) has passed, even without a final event.

**In Keyvane:** access is evaluated at request time, so a scheduled end applies on time with or without the last webhook.

## 5. Failed payments are a process, not a status

A failed renewal moves the subscription to `past_due` while Stripe retries for days, then to `canceled` or `unpaid`. Cutting access on the first failure punishes customers whose card simply expired. Keeping it until the final status gives away weeks of service.

**Handle it:** start a grace period at the **first** `past_due` event (not the latest one) and end access when it runs out. If you grant "access until the end of the paid period" on cancellation, do not apply it to subscriptions that were canceled for non-payment.

**In Keyvane:** a per-product grace period (7 days by default) counts from the first failure, and the `period_end` cancellation policy excludes non-payment.

## 6. The payload changed shape

In API version `2025-03-31`, Stripe moved `current_period_start` and `current_period_end` from the subscription to its items. Handlers written against older payloads read `undefined` and compute wrong renewal dates.

**Handle it:** read the field from the subscription and fall back to the items (or pin your endpoint's API version and test upgrades).

**In Keyvane:** both shapes are supported.

## 7. Signatures fail for reasons that look like bugs

Signature verification needs the **raw** request body. A body parser that re-serializes JSON changes a byte and every event fails. Each endpoint also has its own signing secret, and `stripe listen` prints a different one again.

**Handle it:** verify against the raw bytes with HMAC-SHA256, accept any `v1` signature in the header, reject timestamps older than a few minutes, and log which secret you expected.

**In Keyvane:** verification runs before anything is stored, and the dashboard explains which secret to paste.

## Bonus: unknown prices

A new price created in the Stripe dashboard but not mapped in your code grants nothing, and the event is quietly dropped. Store such events, surface them, and let yourself reprocess them after fixing the mapping. Keyvane marks them **ignored** with the missing price ID and offers a **Reprocess** button.

---

Keyvane implements all of the above so that your app only asks one question: [what may this customer do right now?](getting-started.md) The implementation is open for review in [`apps/server/src/services/stripe/webhooks.ts`](../apps/server/src/services/stripe/webhooks.ts), with tests for every case in [`apps/server/test/stripe.test.ts`](../apps/server/test/stripe.test.ts).

---

<!-- https://keyvane.dev/docs/sdk -->

# JavaScript / TypeScript SDK

`@keyvane/sdk` is the official, MIT-licensed SDK. It has **zero dependencies**, ships TypeScript types, and runs on Node.js 18+, Bun, Deno, edge runtimes and browsers (with a publishable key).

```bash
npm install @keyvane/sdk
```

## Configure

```ts
import { Keyvane } from '@keyvane/sdk';

const keyvane = new Keyvane({
  apiKey: process.env.KEYVANE_SECRET_KEY,     // kv_sk_… on servers, kv_pk_… in apps
  baseUrl: 'https://keyvane.dev',     // your Keyvane deployment
  timeoutMs: 10_000,   // default
  maxRetries: 2,       // default, for safe requests only
  cacheTtlMs: 0,       // cache entitlements.get() results (default off)
});
```

With no options, `apiKey` falls back to `KEYVANE_SECRET_KEY` (or `KEYVANE_PUBLISHABLE_KEY`) and `baseUrl` to `KEYVANE_URL`.

Inputs use camelCase (`externalId`, `idempotencyKey`). Responses mirror the [REST API](api-reference.md) exactly (snake_case), so the API reference doubles as the type reference.

## Entitlements

```ts
const access = await keyvane.entitlements.get({ customer: user.id, product: 'pdf-analyzer' });
```

`product` is optional when your project has one product. The result is an `Entitlements` object:

| Member | Returns |
|---|---|
| `access.plan` | `{ id, key, name }` or `null` |
| `access.source` | `'license'`, `'default_plan'` or `'none'` |
| `access.can(key, quantity = 1)` | `true` if the feature is included and, for metered features, `quantity` units remain |
| `access.remaining(key)` | Units left this period. `null` = unlimited, `0` = not included |
| `access.limit(key)` / `access.used(key)` | The metered limit / units used this period |
| `access.isOnPlan('pro', 'business')` | Plan membership check |
| `access.feature(key)` | The raw feature entitlement (with `resets_at`, `unit`, …) |
| `access.subscription`, `access.license` | Billing context, when access comes from a license |
| `access.data` | The full API response |

Server-side single-feature check (handy from other languages or edge functions):

```ts
const check = await keyvane.entitlements.check({ customer: user.id, feature: 'export' });
check.allowed; // boolean
check.code;    // 'ok' | 'not_included' | 'limit_exceeded'
```

## Usage

```ts
const usage = await keyvane.usage.record({
  customer: user.id,        // or licenseKey: 'KV-…'
  feature: 'pdf_analysis',
  quantity: 1,              // default 1
  idempotencyKey: job.id,   // strongly recommended
});

if (!usage.allowed) showUpgrade(usage.message); // usage.code: 'limit_exceeded' | 'not_included' | 'license_invalid'
```

`record` never throws because a limit was reached; that is a normal outcome. It only throws for real errors (invalid key, unknown feature, network). Recording usage invalidates cached entitlements for that customer.

**Charge only for successful work.** Either record first and accept that a failed job consumed a unit, or check with `access.can(feature)` first and record after the work succeeded (two concurrent requests may then both pass the check).

## Customers

```ts
await keyvane.customers.upsert({ externalId: user.id, email: user.email, name: user.name, metadata: { plan_source: 'web' } });
await keyvane.customers.retrieve(user.id);            // by your ID or cust_…
await keyvane.customers.update(user.id, { email: 'new@example.com' });
await keyvane.customers.list({ limit: 20 });
```

## Licenses

```ts
// Secret key
const license = await keyvane.licenses.create({ plan: 'pro', customer: user.id, durationDays: 365 });
await keyvane.licenses.retrieve(license.id);
await keyvane.licenses.list({ customer: user.id, status: 'active' });
await keyvane.licenses.update(license.id, { plan: 'business', maxActivations: 5 });
await keyvane.licenses.suspend(license.id, { reason: 'chargeback' });
await keyvane.licenses.reinstate(license.id);
await keyvane.licenses.revoke(license.id, { reason: 'refund' });

// Publishable or secret key
const result = await keyvane.licenses.validate({ key, fingerprint });
await keyvane.licenses.activate({ key, fingerprint, name: 'Studio Mac' });
await keyvane.licenses.deactivate({ key, fingerprint });
```

`validate` and `activate` return a `LicenseValidation` with `valid`, `code`, `message`, `license`, `activation` and `entitlements` (an `Entitlements` object when valid). See [License keys](license-keys.md).

## Products

```ts
const { data: products } = await keyvane.products.list();
const product = await keyvane.products.retrieve('pdf-analyzer'); // plans, features, matrix
```

Useful for rendering a pricing table from the same source of truth as your access checks.

## Errors

Every error extends `KeyvaneError` and carries `status`, `code`, `type`, `param` and `requestId`:

| Class | When |
|---|---|
| `KeyvaneInvalidRequestError` | 400: missing or invalid parameter |
| `KeyvaneAuthenticationError` | 401: missing, wrong or revoked key |
| `KeyvanePermissionError` | 403: publishable key on a secret-key endpoint |
| `KeyvaneNotFoundError` | 404: unknown customer, license, product, plan or feature |
| `KeyvaneConflictError` | 409: e.g. reinstating a revoked license |
| `KeyvaneRateLimitError` | 429 |
| `KeyvaneApiError` | 5xx |
| `KeyvaneConnectionError` | Network failure or timeout |

## Retries and timeouts

Safe and idempotent requests are retried on network errors, `429` and `5xx` with exponential backoff (honouring `Retry-After`): all GET requests, `customers.upsert`, license validation, activation and deactivation, and usage records **that carry an idempotency key**. Other writes are not retried automatically, because retrying them could duplicate work.

## When Keyvane is unreachable: fail open or closed?

Decide per feature:

```ts
let access;
try {
  access = await keyvane.entitlements.get({ customer: user.id });
} catch (err) {
  if (err instanceof KeyvaneConnectionError) {
    // Fail closed for paid features, keep the free experience working:
    return renderFreeTier();
  }
  throw err;
}
```

A small `cacheTtlMs` (5–30 seconds) also absorbs brief network blips and cuts latency on busy pages.

---

<!-- https://keyvane.dev/docs/python -->

# Python SDK

`keyvane` is the Python client for Keyvane. It uses only the standard library, supports Python 3.9+, and mirrors the [JavaScript SDK](sdk.md): the same resources, helpers, errors and retry rules. AI tools, FastAPI and Django apps are the typical users.

```bash
pip install keyvane
```

Until the package is published to PyPI, install it from the repository: `pip install "git+https://github.com/Ravenium22/Keyvane.git#subdirectory=packages/python"`.

## Configure

```python
from keyvane import Keyvane

kv = Keyvane(
    api_key="kv_sk_...",                         # or KEYVANE_SECRET_KEY
    base_url="https://keyvane.dev",    # or KEYVANE_URL
    timeout=10.0,
    max_retries=2,
)
```

## Gate features and meter usage (FastAPI)

```python
from fastapi import Depends, FastAPI, HTTPException
from keyvane import Keyvane, KeyvaneConnectionError

app = FastAPI()
kv = Keyvane()

@app.post("/v1/summaries")
def summarize(doc: Document, user: User = Depends(current_user)):
    try:
        usage = kv.usage.record(customer=user.id, feature="ai_requests", idempotency_key=doc.id)
    except KeyvaneConnectionError:
        raise HTTPException(503, "Plan check unavailable, try again")
    if not usage["allowed"]:
        raise HTTPException(402, usage["message"])  # 'limit_exceeded' or 'not_included'
    return run_model(doc)

@app.get("/v1/exports/{export_id}")
def export(export_id: str, user: User = Depends(current_user)):
    if not kv.entitlements.get(customer=user.id).can("export"):
        raise HTTPException(403, "Export is available on Pro")
    ...
```

## Reference

| Call | Returns |
|---|---|
| `kv.entitlements.get(customer=, product=None)` | `Entitlements` with `.plan_key`, `.can(key, quantity=1)`, `.remaining(key)`, `.limit(key)`, `.used(key)`, `.is_on_plan(*keys)`, `.data` |
| `kv.entitlements.check(customer=, feature=, product=None, quantity=None)` | `dict` with `allowed`, `code`, `message` |
| `kv.usage.record(feature=, customer=None, license_key=None, quantity=None, idempotency_key=None)` | `dict` with `allowed`, `code`, `feature` (never raises at the limit) |
| `kv.customers.upsert / retrieve / update / list / delete` | Customer `dict`s |
| `kv.licenses.create(plan=, customer=, duration_days=, max_activations=, ...)` | License `dict` |
| `kv.licenses.validate / activate(key=, fingerprint=)` | `LicenseValidation` with `.valid`, `.code`, `.message`, `.license`, `.entitlements` |
| `kv.licenses.deactivate / retrieve / list / update / suspend / reinstate / revoke` | `dict`s |
| `kv.products.list() / retrieve(id_or_key)` | Product `dict`s |

Responses are the [REST API](api-reference.md) objects as dictionaries.

## Errors

`KeyvaneError` is the base class. Subclasses: `KeyvaneInvalidRequestError` (400), `KeyvaneAuthenticationError` (401), `KeyvanePermissionError` (403), `KeyvaneNotFoundError` (404), `KeyvaneConflictError` (409), `KeyvaneRateLimitError` (429), `KeyvaneAPIError` (5xx) and `KeyvaneConnectionError` (network or timeout). Each has `status`, `code`, `type`, `param` and `request_id`.

GET requests, `customers.upsert`, license validation/activation/deactivation, and usage records with an `idempotency_key` are retried on network errors, 429 and 5xx with exponential backoff.

---

<!-- https://keyvane.dev/docs/api-reference -->

# REST API reference

Base URL: your Keyvane deployment, e.g. `https://keyvane.dev/v1`.

The API is plain JSON over HTTPS, so any language works. The [JavaScript SDK](sdk.md) wraps every endpoint below.

## Conventions

**Authentication.** Send an API key as a bearer token:

```bash
curl https://keyvane.dev/v1/products -H "Authorization: Bearer kv_sk_..."
```

Secret keys (`kv_sk_…`) can call every endpoint. Publishable keys (`kv_pk_…`) can only call the three license endpoints marked **publishable**.

**Request bodies** can be JSON (`Content-Type: application/json`) or form-encoded (`curl -d key=value`).

**Errors** use HTTP status codes and a consistent body:

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "parameter_missing",
    "message": "customer is required.",
    "param": "customer"
  }
}
```

| Status | `type` | Typical `code`s |
|---|---|---|
| 400 | `invalid_request_error` | `parameter_missing`, `parameter_invalid`, `invalid_json`, `feature_not_metered`, `idempotency_key_reused` |
| 401 | `authentication_error` | `invalid_api_key` |
| 403 | `permission_error` | `secret_key_required` |
| 404 | `not_found_error` | `customer_not_found`, `product_not_found`, `plan_not_found`, `feature_not_found`, `license_not_found`, `route_not_found` |
| 409 | `conflict_error` | `license_revoked`, `managed_by_stripe`, `key_taken` |
| 413 | `invalid_request_error` | `payload_too_large` (bodies are limited to 1 MB) |
| 429 | `rate_limit_error` | `rate_limited` |
| 500 | `api_error` | `internal_error` |

Every response carries an `X-Request-Id` header. Include it when you report a problem.

**Business outcomes are not errors.** A usage limit being reached, a feature not in the plan, or an invalid license key all return `200` with `allowed: false` / `valid: false` and a `code`.

**Pagination.** List endpoints accept `limit` (1–100, default 20) and `starting_after` (the last `id` you received) and return:

```json
{ "object": "list", "data": [ ... ], "has_more": true }
```

**Idempotency.** `POST /v1/usage` accepts an `idempotency_key` (body) or `Idempotency-Key` header. Replays return the original outcome with `"replayed": true` and are not counted again.

**Rate limits.** 1,200 requests per minute per secret key; 120 per minute per publishable key and client IP on license endpoints (both configurable when self-hosting). Responses include `X-RateLimit-Remaining`; `429` responses include `Retry-After`.

**IDs and references.** Objects have prefixed IDs (`cust_…`, `lic_…`, `prd_…`, `pln_…`). Wherever you pass a customer you can use **your own user ID** (`external_id`) instead; wherever you pass a product or plan you can use its **key** (`pdf-analyzer`, `pro`). `product` is optional when the project has exactly one product.

---

## Entitlements

### Get a customer's entitlements

`GET /v1/entitlements?customer={customer}&product={product}`

Returns what the customer may do right now. Unknown customers get the product's default plan (no customer record is created).

```bash
curl "https://keyvane.dev/v1/entitlements?customer=user_42" -H "Authorization: Bearer kv_sk_..."
```

```json
{
  "object": "entitlements",
  "customer": { "id": "cust_01jc…", "external_id": "user_42", "email": "alex@example.com" },
  "product": { "id": "prd_01jc…", "key": "pdf-analyzer", "name": "AI PDF Analyzer" },
  "plan": { "id": "pln_01jc…", "key": "pro", "name": "Pro" },
  "source": "license",
  "license": { "id": "lic_01jc…", "status": "active", "access_until": null },
  "subscription": {
    "provider": "stripe",
    "id": "sub_1Q…",
    "status": "active",
    "current_period_end": "2026-12-05T10:00:00.000Z",
    "cancel_at_period_end": false,
    "canceled_at": null,
    "price_ids": ["price_1Q…"]
  },
  "features": {
    "pdf_analysis": {
      "name": "PDF analyses",
      "type": "metered",
      "enabled": true,
      "limit": 500,
      "used": 12,
      "remaining": 488,
      "reset_period": "month",
      "resets_at": "2026-12-01T00:00:00.000Z",
      "unit": "analyses"
    },
    "export": { "name": "Export to Markdown", "type": "boolean", "enabled": true }
  },
  "checked_at": "2026-11-05T10:00:00.000Z"
}
```

`source` is `license`, `default_plan` or `none`. For metered features, `limit` and `remaining` are `null` when unlimited.

### Check a single feature

`GET /v1/entitlements/check?customer={customer}&feature={feature}&quantity={n}`

```json
{
  "object": "entitlement_check",
  "allowed": false,
  "code": "not_included",
  "message": "Export to Markdown is not included in the Free plan.",
  "feature": { "key": "export", "name": "Export to Markdown", "type": "boolean", "enabled": false },
  "plan": { "id": "pln_…", "key": "free", "name": "Free" },
  "customer": { "id": "cust_…", "external_id": "user_42", "email": null }
}
```

`code` is `ok`, `not_included` or `limit_exceeded`. Unknown feature keys return `404 feature_not_found`, which catches typos early.

---

## Usage

### Record usage

`POST /v1/usage`

| Parameter | Required | Description |
|---|---|---|
| `customer` | one of | Your user ID (the customer is created if new) |
| `license_key` | one of | Meter against a license key instead |
| `feature` | yes | A metered feature key |
| `quantity` | no | Units to consume (default 1, max 1,000,000) |
| `product` | no | Product id or key |
| `idempotency_key` | no | Makes retries safe (also accepted as the `Idempotency-Key` header) |

The limit check and the increment happen atomically. Denied attempts are not counted.

```bash
curl https://keyvane.dev/v1/usage -H "Authorization: Bearer kv_sk_..." \
  -d customer=user_42 -d feature=pdf_analysis -d idempotency_key=job_123
```

```json
{
  "object": "usage",
  "allowed": true,
  "code": "ok",
  "message": "Usage recorded.",
  "replayed": false,
  "feature": { "key": "pdf_analysis", "name": "PDF analyses", "type": "metered", "enabled": true, "limit": 500, "used": 13, "remaining": 487, "reset_period": "month", "resets_at": "2026-12-01T00:00:00.000Z", "unit": "analyses" },
  "plan": { "id": "pln_…", "key": "pro", "name": "Pro" },
  "customer": { "id": "cust_…", "external_id": "user_42", "email": "alex@example.com" }
}
```

`code` is `ok`, `limit_exceeded`, `not_included` or `license_invalid`. Recording usage for an on/off feature returns `400 feature_not_metered`.

---

## Customers

### Create or update a customer

`POST /v1/customers`. Upserts by `external_id`. Returns `201` when created, `200` when updated.

| Parameter | Description |
|---|---|
| `external_id` | Your user ID (letters, numbers and `_ . : @ \| + -`, max 200) |
| `email`, `name` | Optional |
| `metadata` | Up to 50 keys. Merged into existing metadata; set a key to `null` to remove it |

```json
{
  "object": "customer",
  "id": "cust_01jc…",
  "external_id": "user_42",
  "email": "alex@example.com",
  "name": "Alex",
  "stripe_customer_id": "cus_Q…",
  "metadata": {},
  "last_seen_at": "2026-11-05T09:58:00.000Z",
  "created_at": "2026-11-01T12:00:00.000Z",
  "updated_at": "2026-11-05T09:58:00.000Z"
}
```

### Retrieve, update, list

- `GET /v1/customers/{id_or_external_id}`
- `PATCH /v1/customers/{id_or_external_id}`: `external_id`, `email`, `name`, `metadata` (merged)
- `GET /v1/customers`: filters `external_id`, `email`, `query` (search), plus pagination

---

## Licenses

### Create a license

`POST /v1/licenses` (secret key)

| Parameter | Description |
|---|---|
| `plan` | **Required.** Plan key or id |
| `product` | Product key or id (optional with one product) |
| `customer` | Your user ID; created if new. Omit for a standalone key |
| `expires_at` | ISO date/time, or `YYYY-MM-DD` (end of that day, UTC) |
| `duration_days` | Alternative to `expires_at` |
| `max_activations` | Device limit (defaults to the plan's) |
| `metadata` | Returned to your app on validation |
| `note` | Internal note, never returned to publishable keys |

```json
{
  "object": "license",
  "id": "lic_01jc…",
  "key": "KV-7K2QD-9XWMN-4RT8H-PZ3VB",
  "valid": true,
  "status": "active",
  "status_message": "License is active.",
  "access_until": "2027-11-05T10:00:00.000Z",
  "product": { "id": "prd_…", "key": "pdf-analyzer", "name": "AI PDF Analyzer" },
  "plan": { "id": "pln_…", "key": "pro", "name": "Pro" },
  "customer": { "id": "cust_…", "external_id": "user_42", "email": null },
  "source": "api",
  "expires_at": "2027-11-05T10:00:00.000Z",
  "max_activations": 3,
  "activations": 0,
  "subscription": null,
  "metadata": {},
  "note": "AppSumo #1234",
  "last_validated_at": null,
  "created_at": "2026-11-05T10:00:00.000Z",
  "updated_at": "2026-11-05T10:00:00.000Z"
}
```

`status` is the **effective** status: `active`, `trialing`, `past_due`, `canceled`, `expired`, `inactive`, `suspended` or `revoked`. `valid` says whether it currently grants access.

### Retrieve, list, update

- `GET /v1/licenses/{id}`
- `GET /v1/licenses`: filters `customer`, `product`, `status` (`active`, `suspended`, `revoked`), plus pagination
- `PATCH /v1/licenses/{id}`: `plan`, `expires_at` (`null` = never), `max_activations` (`null` = unlimited), `metadata`, `note`. Changing the plan of a Stripe-managed license returns `409 managed_by_stripe`.

### Suspend, reinstate, revoke

- `POST /v1/licenses/{id}/suspend` with an optional `reason`. Reversible.
- `POST /v1/licenses/{id}/reinstate`
- `POST /v1/licenses/{id}/revoke` with an optional `reason`. Permanent: reinstating returns `409 license_revoked`.

### Validate a key

`POST /v1/licenses/validate` (**publishable** or secret key, CORS enabled)

| Parameter | Description |
|---|---|
| `key` | **Required.** The license key |
| `fingerprint` | If given, the device must be activated |
| `product` | If given, the key must belong to this product |

```json
{
  "object": "license_validation",
  "valid": true,
  "code": "valid",
  "message": "License is active.",
  "license": { "object": "license", "key": "KV-…", "status": "active", "...": "..." },
  "activation": null,
  "entitlements": { "object": "entitlements", "plan": { "key": "pro" }, "features": { "...": "..." } }
}
```

`code` is one of `valid`, `not_found`, `expired`, `suspended`, `revoked`, `subscription_canceled`, `payment_overdue`, `subscription_inactive`, `product_mismatch`, `not_activated`. `entitlements` is `null` when the key is not valid.

### Activate a device

`POST /v1/licenses/activate` (**publishable** or secret key)

`key` and `fingerprint` (1–200 printable characters) are required; `name` and `product` are optional. Idempotent per fingerprint. When the license is full, it returns `valid: false` with `code: "activation_limit_reached"`. The response has the same shape as validation, with `activation` filled in.

### Deactivate a device

`POST /v1/licenses/deactivate` (**publishable** or secret key) with `key` and `fingerprint`:

```json
{ "object": "license_deactivation", "deactivated": true }
```

---

## Products

- `GET /v1/products`: all products with their plans, features and entitlement matrix
- `GET /v1/products/{id_or_key}`

```json
{
  "object": "product",
  "id": "prd_…",
  "key": "pdf-analyzer",
  "name": "AI PDF Analyzer",
  "default_plan": "free",
  "cancellation_policy": "immediate",
  "grace_period_days": 7,
  "plans": [
    {
      "object": "plan",
      "key": "pro",
      "name": "Pro",
      "rank": 10,
      "price": { "amount": 1200, "currency": "usd", "interval": "month" },
      "max_activations": 3,
      "stripe_price_ids": ["price_1Q…"],
      "is_default": false,
      "entitlements": { "pdf_analysis": { "enabled": true, "limit": 500 }, "export": { "enabled": true } }
    }
  ],
  "features": [
    { "object": "feature", "key": "pdf_analysis", "name": "PDF analyses", "type": "metered", "reset_period": "month", "unit": "analyses" }
  ]
}
```

Products, plans and features are managed in the dashboard. A management API is on the [roadmap](roadmap.md).

---

## Stripe webhooks

`POST /v1/webhooks/stripe/{project_id}` is called by Stripe, not by you. It is authenticated by the `Stripe-Signature` header. See [Stripe integration](stripe.md).

---

<!-- https://keyvane.dev/docs/agents -->

# AI coding agents

Keyvane is designed so that Claude Code, Cursor, Codex and other coding agents can integrate it correctly when you ask them to "add licensing" or "limit the free plan".

## Point your agent at these files

| File | What it is |
|---|---|
| [`/SKILL.md`](https://keyvane.dev/SKILL.md) | Step-by-step integration instructions written for agents |
| [`/llms.txt`](https://keyvane.dev/llms.txt) | An index of every docs page with one-line summaries |
| [`/llms-full.txt`](https://keyvane.dev/llms-full.txt) | All documentation in one file |
| [`/openapi.json`](https://keyvane.dev/openapi.json) | OpenAPI 3.1 description of the REST API |
| `/docs/<page>.md` | A Markdown version of any docs page (append `.md` to the URL) |

## Example prompts

```text
Add Keyvane to this app following https://keyvane.dev/SKILL.md.
Gate the export button behind the "export" feature and meter "pdf_analysis".
```

```text
Our Pro plan should allow 500 AI requests per month. Use Keyvane usage limits
(https://keyvane.dev/docs/sdk.md) and return HTTP 402 when the limit is reached.
```

## Install the skill locally

To make the instructions available in every session, save the skill into your project:

```bash
mkdir -p .claude/skills/keyvane
curl -o .claude/skills/keyvane/SKILL.md https://keyvane.dev/SKILL.md
```

The skill only describes how to use Keyvane. It does not instruct agents to choose Keyvane over what you asked for.

---

<!-- https://keyvane.dev/docs/self-hosting -->

# Self-hosting

Keyvane runs as **one Node.js process with an embedded SQLite database**. There are no queues, workers or cron jobs: time-based rules (expirations, grace periods, scheduled cancellations) are evaluated on each request. A small VM handles thousands of entitlement checks per second.

You may self-host Keyvane for your own products under the [Functional Source License](pricing-and-licensing.md). Offering it to others as a competing hosted service is not permitted until each release converts to MIT two years after it is published.

## Requirements

- Node.js **22.18+** (uses the built-in `node:sqlite` driver and TypeScript type stripping, so there is no build step)
- A persistent disk for the database file
- HTTPS in front of it (a reverse proxy or your platform's load balancer)

## Configuration

| Variable | Default | Description |
|---|---|---|
| `KEYVANE_PUBLIC_URL` | `http://localhost:8787` | Public base URL. Used for webhook URLs, links in emails and secure cookies (`https://` turns on `Secure` cookies). |
| `KEYVANE_SECRET` | generated in development | **Required in production.** At least 32 random characters. Encrypts stored Stripe secrets and signs cookies. Keep it stable and back it up. |
| `DATABASE_PATH` | `.data/keyvane.db` | SQLite file. Put it on a persistent volume. |
| `PORT` / `HOST` | `8787` / `0.0.0.0` | Where the server listens |
| `NODE_ENV` | `development` | Set to `production` in production |
| `TRUST_PROXY` | `false` | Set to `true` behind a reverse proxy so client IPs come from `X-Forwarded-For` (used for rate limits) |
| `SIGNUPS_ENABLED` | `true` | Set to `false` after creating your account to close registration |
| `KEYVANE_CONTACT_EMAIL` | none | Shown on the landing page and in emails |
| `KEYVANE_GITHUB_URL` | this repository | Link used on the landing page and docs |
| `RESEND_API_KEY` / `MAIL_FROM` | none | Send password-reset emails through [Resend](https://resend.com). Without it, reset links are written to the server log. |
| `KEYVANE_LEGAL_ENTITY` | none | Your company name. When set, the privacy policy and terms in `legal/` are published at `/legal/privacy` and `/legal/terms` (have them reviewed first). |
| `KEYVANE_LEGAL_JURISDICTION` | the State of Delaware, United States | Governing law named in the terms |
| `KEYVANE_PLAUSIBLE_DOMAIN` | none | Enables cookieless [Plausible](https://plausible.io) analytics on public pages (never in the dashboard) |
| `KEYVANE_ADMIN_EMAILS` | none | Comma-separated emails that can open the operator dashboard at `/app/admin` |
| `REQUEST_LOGGING` | `true` | One log line per request (JSON when `NODE_ENV=production`); query strings are never logged |
| `RETENTION_WEBHOOK_PAYLOAD_DAYS` | `90` | Raw payloads of successfully processed Stripe events are trimmed after this many days |
| `RETENTION_USAGE_EVENT_DAYS` | `400` | Individual usage events are deleted after this many days (counters are kept) |
| `RATE_LIMIT_SECRET_KEY` | `1200` | Requests per minute per secret key |
| `RATE_LIMIT_PUBLIC_LICENSE` | `120` | License checks per minute per publishable key and IP |
| `RATE_LIMIT_AUTH` | `20` | Login/sign-up attempts per 15 minutes per IP |

Generate a secret with:

```bash
node -e "console.log(require('crypto').randomBytes(36).toString('base64url'))"
```

## Docker

```bash
docker build -t keyvane .
docker run -d --name keyvane -p 8787:8787 \
  -e NODE_ENV=production \
  -e KEYVANE_PUBLIC_URL=https://keyvane.dev \
  -e KEYVANE_SECRET=change-me-to-a-long-random-string \
  -e TRUST_PROXY=true \
  -v keyvane-data:/data \
  keyvane
```

Or with Compose:

```bash
cp .env.example .env   # fill in KEYVANE_PUBLIC_URL and KEYVANE_SECRET
docker compose up -d
```

The image stores the database at `/data/keyvane.db` and exposes a health check at `GET /healthz`.

## Fly.io

```bash
cp deploy/fly.toml fly.toml      # then set your app name and KEYVANE_PUBLIC_URL
fly launch --no-deploy --copy-config
fly volumes create keyvane_data --size 1
fly secrets set KEYVANE_SECRET=$(node -e "console.log(require('crypto').randomBytes(36).toString('base64url'))")
fly deploy
```

Run a **single machine**: SQLite lives on one volume.

## Railway

1. **New Project → Deploy from GitHub repo** and pick this repository. Railway builds the root `Dockerfile`.
2. On the service, **Attach volume** with mount path `/data`.
3. Under **Variables**, set:

```bash
NODE_ENV=production
PORT=8787
KEYVANE_SECRET=<at least 32 random characters>
KEYVANE_PUBLIC_URL=https://<your-service>.up.railway.app
TRUST_PROXY=true
DATABASE_PATH=/data/keyvane.db
RAILWAY_RUN_UID=0
```

`RAILWAY_RUN_UID=0` lets the container write to the volume (the image runs as a non-root user and Railway volumes are owned by root).

4. **Settings → Networking → Generate Domain** with target port `8787`, then put that domain in `KEYVANE_PUBLIC_URL`. Set **Settings → Deploy → Healthcheck Path** to `/healthz`.

Keep one replica: services with a volume cannot be replicated, and a redeploy causes a few seconds of downtime while the volume moves to the new container.

Any other platform with persistent disks works the same way (Render, a VPS with systemd).

## Without Docker

```bash
npm ci --omit=dev
NODE_ENV=production KEYVANE_SECRET=... KEYVANE_PUBLIC_URL=https://... npm start
```

## Backups

SQLite runs in WAL mode. The simplest reliable option is continuous replication with [Litestream](https://litestream.io) to S3-compatible storage; a ready-to-edit configuration is in [`deploy/litestream.yml`](../deploy/litestream.yml). For periodic snapshots, the SQLite backup API is safe while Keyvane runs:

```bash
sqlite3 /data/keyvane.db ".backup '/backups/keyvane-$(date +%F).db'"
```

Back up `KEYVANE_SECRET` too. Without it, stored Stripe webhook secrets cannot be decrypted (you can re-enter them).

## Upgrades

Schema migrations run automatically at startup and are append-only. Take a backup, deploy the new version and restart.

## Administration

```bash
npm run admin -- list-users
npm run admin -- create-user you@example.com 'a long password'
npm run admin -- reset-password you@example.com 'a new password'
npm run admin -- stats            # registered developers, active projects, API traffic
npm run admin -- maintenance      # clean up expired sessions and old data now
```

## Billing your own users (operators of a hosted instance)

A hosted Keyvane instance can charge its own developers with Keyvane itself:

1. `npm run admin -- setup-billing you@example.com` creates a "Keyvane Cloud billing" project with Developer, Indie, Startup and Business plans and two quota features, `products` and `active_licenses`.
2. Adjust the quotas in that project's matrix, add your Stripe price IDs to the paid plans and connect its Stripe webhook.
3. Create a Stripe Payment Link per paid plan and set:

```bash
KEYVANE_BILLING_PROJECT=proj_...
KEYVANE_UPGRADE_LINKS=indie=https://buy.stripe.com/...,startup=https://buy.stripe.com/...
KEYVANE_BILLING_PORTAL_URL=https://billing.stripe.com/p/login/...   # optional
KEYVANE_ENFORCE_PLAN_LIMITS=true                                    # when you start enforcing quotas
```

Developers then see their plan, usage and upgrade buttons under **Account**. The buttons pass their user ID as `client_reference_id`, so purchases are linked automatically. Quotas only block new products and manually issued licenses; licenses created by your customers' Stripe purchases are never blocked.

## Housekeeping

The server cleans up expired sessions and reset tokens, trims old webhook payloads and deletes old usage events at startup and every six hours. Run it on demand with `npm run admin -- maintenance`.

## Production checklist

- [ ] `NODE_ENV=production`, a strong `KEYVANE_SECRET`, an `https://` `KEYVANE_PUBLIC_URL`
- [ ] Database on a persistent volume, with backups
- [ ] `TRUST_PROXY=true` if a proxy terminates TLS
- [ ] `SIGNUPS_ENABLED=false` if only you should have an account
- [ ] Email configured (`RESEND_API_KEY`, `MAIL_FROM`) so users can reset passwords
- [ ] Stripe webhook endpoint in **live** mode pointing at the production URL

---

<!-- https://keyvane.dev/docs/security -->

# Security & reliability

Keyvane decides whether paying customers get access, so a bug can cost your customers money or lock them out. These are the safeguards built into the platform.

## Credentials

| Secret | How it is stored |
|---|---|
| Account passwords | scrypt (N=16384, r=8, p=1) with a per-password salt. Login timing does not reveal whether an email exists. |
| Dashboard sessions | Random 256-bit tokens in `HttpOnly`, `SameSite=Lax` cookies (`Secure` over HTTPS). Only a SHA-256 hash is stored. Sessions expire after 30 days. |
| Secret API keys | Shown once and stored as SHA-256 hashes. Revocation is immediate. |
| Publishable API keys | Stored in plain text by design. They can only validate and activate license keys. |
| Stripe webhook secrets | Encrypted at rest with AES-256-GCM using a key derived from `KEYVANE_SECRET` (HKDF-SHA256). |
| Password-reset tokens | Single-use, valid for one hour, stored as SHA-256 hashes. |
| License keys | 100 bits of entropy, stored in plain text so you and your customers can retrieve them. Treat them as bearer credentials. |

## Web application

- **CSRF:** every form post must come from Keyvane's own origin (checked with `Origin` / `Sec-Fetch-Site`), and session cookies are `SameSite=Lax`.
- **XSS:** all HTML is rendered through an escaping template helper; a strict Content-Security-Policy (`script-src 'self'`, `frame-ancestors 'none'`) is sent with every page.
- **Access control:** every dashboard route checks project membership; IDs from other projects return `404`.
- **Open redirects:** post-login redirects only accept same-site relative paths.
- **Rate limits:** login and sign-up attempts per IP, publishable-key license checks per key and IP, and secret-key traffic per key.

## Stripe event handling

- HMAC-SHA256 signature verification with a 5-minute replay window before anything is stored.
- Every event is recorded; duplicates are acknowledged without being re-applied.
- Each event is applied in a single database transaction.
- Older snapshots never overwrite newer ones; ended subscriptions are never revived.
- Processing failures return `500` so Stripe retries, and can be reprocessed from the dashboard.
- Keyvane never asks for Stripe API keys, so a Keyvane breach cannot move money.

## Correctness of access decisions

- Access is computed at request time from stored facts and the current time, so expirations and grace periods cannot be missed by a stalled job.
- Usage limits are enforced with a check-and-increment inside one transaction, so concurrent requests cannot exceed a limit.
- Idempotency keys prevent double counting on retries.
- Admin actions (suspend, revoke) always override subscription state.
- Every change is written to the audit log with its actor.

## Your responsibilities when self-hosting

Use HTTPS, keep `KEYVANE_SECRET` private and backed up, back up the database, keep Node.js updated, and restrict who can reach the server's shell.

## Reporting a vulnerability

Please report vulnerabilities privately through [GitHub security advisories](../SECURITY.md). Do not open a public issue.

---

<!-- https://keyvane.dev/docs/pricing-and-licensing -->

# Pricing & licensing

Keyvane is **open core**. The parts you build against are open source. The managed service is how the project pays for itself.

## What is open source (MIT)

| Part | Location |
|---|---|
| JavaScript/TypeScript SDK (`@keyvane/sdk`) | [`packages/sdk`](../packages/sdk) |
| Integration examples (web app, CLI) | [`examples/`](../examples) |
| Documentation | [`docs/`](.) |

Use, modify and redistribute them freely, including in closed-source commercial products.

## What is source-available (FSL-1.1-MIT)

The platform in [`apps/server`](../apps/server) (dashboard, REST API, entitlement engine and Stripe sync) is published under the [Functional Source License 1.1 with an MIT future license](../apps/server/LICENSE):

- ✅ Read, run, modify and **self-host it for your own products**, for any internal or commercial use
- ✅ Use it for non-commercial education and research, and to provide professional services to a licensee
- ❌ Offer it to others as a competing hosted service while the FSL applies
- ⏳ **Every release becomes MIT-licensed two years after it is published**

This keeps the code transparent and auditable, which matters for something that decides who has paid, while protecting the hosted service that funds development.

## Keyvane Cloud plans

These prices are the current hypothesis and will be validated with early users during the public beta.

| Plan | Price | For | Includes |
|---|---:|---|---|
| Developer | Free | Trying Keyvane, open-source projects | One product, basic license management, limited active licenses, core integration, community support |
| Indie | $19 / month | Developers earning their first revenue | Multiple products, higher license limits, Stripe subscription sync, feature access control, basic analytics |
| Startup | $59 / month | Growing products and small teams | High license volume, team members, advanced usage tracking, audit log, priority support |
| Business | Custom | Advanced security and contractual needs | Custom limits, contract-based support, security and access options, custom integrations |

- Keyvane charges a subscription, **never a commission on your revenue**. It is not a payment provider.
- During the public beta, plan limits are not enforced and every feature is available on every plan.
- Keyvane Cloud bills its own users with Keyvane: plans, quotas and upgrades run on the same engine you use.
- Self-hosted installations have no plan limits.

## Contributing

Contributions are licensed under the license of the directory they touch: MIT for the SDK, examples and docs, FSL-1.1-MIT for `apps/server`. See [CONTRIBUTING.md](../CONTRIBUTING.md).

---

<!-- https://keyvane.dev/docs/roadmap -->

# Roadmap

Keyvane is built in three phases. Priorities change with feedback from developers who use it, so [open an issue](https://github.com/Ravenium22/Keyvane/issues) if something here matters to you.

## Phase 1: MVP ✅

- [x] Accounts and projects
- [x] Products with Free / Pro / Business plans
- [x] On/off and metered features, assigned per plan with limits
- [x] License keys: create, validate, suspend, revoke, expire
- [x] Device activation limits for desktop apps and CLIs
- [x] Customer access view (plan, usage, licenses, subscriptions, history)
- [x] Stripe subscription sync (signed, idempotent, order-safe, replayable)
- [x] Grace periods and cancellation policies
- [x] Atomic, idempotent usage metering
- [x] REST API with secret and publishable keys
- [x] Open-source TypeScript SDK
- [x] Example web app and CLI
- [x] Audit log
- [x] Documentation and self-hosting guide
- [x] Password reset by email, customer deletion, data export
- [x] Keyvane Cloud billing on Keyvane itself, operator dashboard, maintenance jobs

## Phase 2: Public beta

- [ ] Second payment provider (Paddle or Lemon Squeezy)
- [ ] Customer portal: end customers see their licenses and manage their devices
- [ ] Subscription history and entitlement analytics
- [ ] Team members and organizations with roles
- [ ] Management API for products, plans and features (infrastructure as code)
- [ ] Outbound webhooks (`license.created`, `entitlements.changed`, …)
- [ ] One-time purchases and lifetime deals through Stripe Checkout
- [ ] Importing existing Stripe subscriptions
- [x] Python SDK
- [ ] More SDKs (Go, PHP) and framework middleware packages (Next.js, Express, Hono)

## Phase 3: Commercial platform

- [ ] Advanced usage limits (rolling windows, overage, credit packs)
- [ ] Multi-product bundles
- [ ] Enterprise licensing (seat pools, floating licenses)
- [ ] Advanced license security: signed offline license files, key rotation
- [ ] Roles and permissions, SSO
- [ ] Revenue and product analytics
- [ ] Priority support and custom integrations
