---
name: purpleturret
description: Manage Purpleturret checkout products as code with @purpleturret/cli and @purpleturret/sdk, meter credits, and check entitlements from a backend. Use when a project contains purpleturret.config.ts, imports @purpleturret/sdk, runs `purpleturret` or `pt` commands, or the user asks to create, price, tier, or sync Purpleturret checkout links or credits.
license: MIT
metadata:
  version: "1.0.0"
  docs: https://purpleturret.com/docs/cli
  openapi: https://api.purpleturret.com/v1/openapi.json
---

# Purpleturret CLI and SDK

Purpleturret sells access through hosted checkout links (`https://purpleturret.com/c/<slug>`).
Sellers declare products in `purpleturret.config.ts`, push them with the CLI, and read
entitlements or meter credits from their own backend with the SDK. Payouts go to the seller's
Stripe or PayPal; Stripe must be connected before `push` can create products.

## Setup

```bash
pnpm add -D @purpleturret/cli @purpleturret/sdk   # Node 20+; binaries: purpleturret, pt
npx purpleturret init                              # writes purpleturret.config.ts
npx purpleturret login                             # or set PURPLETURRET_API_KEY
npx purpleturret push --dry-run                    # plan only, never applies
```

Authentication uses a **seller API key** (`pt_sk_…`) minted in Dashboard → Settings → Developers.
Scopes: `config:read`, `config:write`, `credits:read`, `credits:write`, `entitlements:read`.
Hosting keys (`pt_host_…`) are rejected with 403 `forbidden_key_type`.

Credential resolution order: `--api-key` → `PURPLETURRET_API_KEY` (environment, then `.env.local`
and `.env` in the current directory) → saved profile from `purpleturret login`
(`$XDG_CONFIG_HOME/purpleturret/credentials.json`, default `~/.config/…`, mode 0600, `--profile`
selects a name). The API origin follows the same order with `--api-url` / `PURPLETURRET_API_URL`;
the default is `https://api.purpleturret.com` and local dev uses `http://localhost:3001/api`.

Config discovery: `purpleturret.config.{ts,mts,js,mjs,json}` in the current directory and each
parent up to the git root, or `--config <path>`. TypeScript configs are transpiled on the fly, so
`process.env` works inside them.

## Config file

```ts
import { defineConfig, product, usd } from "@purpleturret/sdk";

export default defineConfig({
  products: [
    product({
      key: "pro_monthly", // stable id, 1-64 chars [a-z0-9_-]; never rename
      name: "Pro",
      description: "Everything you need.",
      price: usd(29), // { amount: 2900, currency: "usd" }; min 50 minor units
      billing: { type: "recurring", interval: "month" }, // or { type: "one_time" }
      slug: "acme-pro-monthly", // /c/acme-pro-monthly, 3-64 chars [a-z0-9-]
      group: { key: "acme", tier: { key: "pro", name: "Pro", rank: 50 } },
      credits: [{ meter: "messages", amount: 1000, renewal: "reset" }],
      delivery: { method: "redirect", url: "https://app.acme.com/welcome" },
      checkout: { layoutMode: "one_step", paymentStyle: "express", providerMode: "inherit" },
      theme: { primaryColor: "#5F34CC", buttonRadius: "md", accentMode: "system" },
      stripe: { priceId: "price_…" }, // optional: adopt an existing Stripe price
      active: true,
    }),
  ],
});
```

Field rules that matter when editing a config:

- **Immutable after create:** `price.currency`, `billing`, `slug`, `stripe.priceId`. Changing one
  makes the whole push a `conflict` and nothing is applied. Declare the change under a new `key`
  and archive the old product with `push --prune`.
- **`key` is identity.** Renaming a key creates a new product and, with `--prune`, archives the
  old one. To rename what customers see, change `name`.
- **Changing `price.amount`** creates a new Stripe price and retires the old one. Existing
  subscribers keep paying the old price.
- **Omitted optional fields are left alone** on existing products, so dashboard-only settings
  (file delivery, order bumps, logos) survive a push.
- **Only keyed products are managed.** Products created in the dashboard have `key: null` and are
  never modified or archived, even with `--prune`.
- `group` powers the entitlements API. `tier.rank` is an integer you choose; higher means more
  access. Gate features on `rank`, not on the tier name.
- `credits[]` grants are applied on every paid invoice. `renewal: "reset"` tops the balance up to
  `amount`; `"accumulate"` adds to it. `expiresAfterDays` is optional. Meters must be unique per
  product and match `[a-z0-9][a-z0-9_-]{0,63}`.
- `delivery.redirect.url` must be `https://`. `delivery.instructions` is free text up to 10k chars.
- `usd(19.99)` → 1999 cents. `money(amount, currency)` takes integer minor units for other
  currencies. `validateConfig(value)` throws `ConfigValidationError` with a list of `issues`.

## CLI commands

| Command                                                           | What it does                                                                                          |
| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `purpleturret init [--from-remote] [--format ts\|json] [--force]` | Write a starter config, or seed it from the account's managed products.                               |
| `purpleturret login [--api-key …]`                                | Validate a seller key and save it to a profile. Non-interactive runs need `--api-key` or the env var. |
| `purpleturret logout`                                             | Delete the saved profile.                                                                             |
| `purpleturret whoami`                                             | Account, key prefix, scopes, Stripe and PayPal status, API URL, credential source.                    |
| `purpleturret status`                                             | Dry-run the local config and print a summary of pending changes.                                      |
| `purpleturret push [--dry-run] [--prune] [--yes\|-y]`             | Plan, confirm, apply. Always starts with a server-side dry run.                                       |
| `purpleturret pull [--out <file>] [--force]`                      | Write the account's managed products to a config file. Refuses to overwrite without `--force`.        |
| `purpleturret products list [--all]`                              | Table of every product; `--all` includes archived ones.                                               |

Global flags on every command: `--config <path>`, `--api-key <key>`, `--api-url <origin>`,
`--profile <name>`, `--json` (machine-readable stdout), `--no-color`, `--verbose` (log each
request and stack traces to stderr). Flags may appear before or after the subcommand.

Plan actions in `push` / `status` output: `create`, `update`, `unchanged`, `archive`, `conflict`,
`error`. `--json` returns `{ plan, result?, applied }` where each item has `key`, `action`,
`changes[] { path, from, to }`, and an optional `error { code, message }`.

Exit codes: `0` success (including no changes) · `1` API or apply error · `2` usage or invalid
config, or confirmation needed without `--yes` · `3` auth (missing, rejected, wrong key type, or
missing scope) · `4` conflict (immutable field changed or Idempotency-Key reused; nothing applied) ·
`130` aborted at the prompt.

Behavior worth knowing: without a TTY, `push` refuses to apply unless `--yes` is passed. Applies
send an `Idempotency-Key` and retry once on a network error, so re-running an interrupted push is
safe. Colors are disabled automatically when stdout is not a terminal or `NO_COLOR` is set.

## SDK client (server-side only)

```ts
import { PurpleturretClient, PurpleturretApiError, PurpleturretNetworkError } from "@purpleturret/sdk";

const pt = new PurpleturretClient({
  apiKey: process.env.PURPLETURRET_API_KEY!,
  // baseUrl?: "https://api.purpleturret.com", timeoutMs?: 30_000, fetch?, userAgent?
});
```

Every customer-facing call takes `{ externalId }` or `{ email }` (or both; `externalId` wins).
`externalId` is whatever you appended as `?external_id=` on the checkout link.

| Method                                                                              | Notes                                                                                                                                                                                                                |
| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pt.account.get()`                                                                  | `{ id, email, name, stripe: { connected, chargesEnabled }, paypal: { connected }, key: { prefix, name, scopes } }`                                                                                                   |
| `pt.config.get()`                                                                   | `{ products, unmanagedCount }`                                                                                                                                                                                       |
| `pt.config.push({ products, prune?, dryRun?, idempotencyKey? })`                    | Returns `{ applied, summary, results }`. A 409 conflict is returned as a result with `summary.conflict > 0`, not thrown.                                                                                             |
| `pt.products.list({ cursor?, limit?, includeArchived? })`                           | `{ data, nextCursor }`; limit max 100.                                                                                                                                                                               |
| `pt.products.get(key)` / `pt.products.archive(key)`                                 | Archive never hard-deletes.                                                                                                                                                                                          |
| `pt.credits.balances({ externalId \| email, meter? })`                              | `{ customer, balances: [{ meter, balance, expiresAt, lifetimeGranted, lifetimeConsumed }] }`                                                                                                                         |
| `pt.credits.consume({ meter, amount, externalId \| email, idempotencyKey, note? })` | `idempotencyKey` is required. Replaying it returns `{ replayed: true }` instead of charging again. Throws `PurpleturretApiError` with `code === "insufficient_credits"` (402) and `details: { balance, requested }`. |
| `pt.credits.adjust({ meter, amount, externalId \| email, idempotencyKey?, note? })` | Signed amount: positive grants, negative removes.                                                                                                                                                                    |
| `pt.credits.ledger({ externalId \| email, cursor?, limit? })`                       | Newest first. Kinds: `grant`, `consume`, `adjust`, `expire`, `revoke`.                                                                                                                                               |
| `pt.entitlements.get({ groupKey, externalId \| email })`                            | Resolves `{ hasAccess: false }` instead of throwing when there is no subscription. Includes `tier`, `subscription`, `currentPeriodEnd`, and `credits` keyed by meter.                                                |

Typical gate and metering pattern:

```ts
const access = await pt.entitlements.get({ groupKey: "acme", externalId: user.id });
if (!access.hasAccess) return redirect("/pricing");
if ((access.tier?.rank ?? 0) < 100) return redirect("/upgrade");

try {
  const { balance } = await pt.credits.consume({
    meter: "messages",
    amount: 1,
    externalId: user.id,
    idempotencyKey: `msg-${messageId}`, // derive from the action, never random
  });
} catch (error) {
  if (error instanceof PurpleturretApiError && error.code === "insufficient_credits") {
    return { ok: false, balance: (error.details as { balance: number }).balance };
  }
  throw error;
}
```

Errors: `PurpleturretApiError { status, code, message, requestId, details, retryAfter }` for any
non-2xx response; `PurpleturretNetworkError { url, timedOut }` when no response arrived;
`ConfigValidationError { issues: [{ path, message }] }` from `validateConfig`. Honor
`retryAfter` (seconds) on 429.

## REST endpoints (base `https://api.purpleturret.com/v1`, `Authorization: Bearer pt_sk_…`)

| Endpoint                                | Scope             | Notes                                                                                        |
| --------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
| `GET /account`                          | config:read       | Describe the key's seller.                                                                   |
| `GET /config`                           | config:read       | Managed products plus `unmanaged_count`.                                                     |
| `PUT /config`                           | config:write      | Body `{ products, prune?, dry_run? }`. `Idempotency-Key` required. 409 on immutable change.  |
| `GET /products` · `GET /products/{key}` | config:read       | `cursor`, `limit` (≤100), `include_archived`.                                                |
| `PUT /products/{key}`                   | config:write      | Single-product upsert; body is one ProductInput.                                             |
| `DELETE /products/{key}`                | config:write      | Archive.                                                                                     |
| `GET /credits/balances`                 | credits:read      | `external_id` or `email`, optional `meter`.                                                  |
| `POST /credits/consume`                 | credits:write     | `Idempotency-Key` required. 402 `insufficient_credits`.                                      |
| `POST /credits/adjust`                  | credits:write     | `Idempotency-Key` required. Signed `amount`.                                                 |
| `GET /credits/ledger`                   | credits:read      | `cursor`, `limit` (≤100).                                                                    |
| `GET /entitlements`                     | entitlements:read | `group_key` plus `external_id` or `email`. 404 `{ has_access: false }` when no subscription. |

Wire format is snake_case (`external_id`, `dry_run`, `include_archived`); the SDK converts to
camelCase. Errors use `{ "error": { "code", "message", "request_id", "details" } }`. Rate limits:
600 requests/min per key, 30 `PUT /config` per key per minute, 1,200/min per IP; a 429 carries
`Retry-After` in seconds.

## Rules for agents

1. **Never print, log, or commit an API key.** Read it from the environment or the saved profile.
   Never put a seller key in browser, mobile, or client-side code.
2. **Plan before you apply.** Run `purpleturret push --dry-run` (or `status`) and show the plan
   to the user. Only run `push` without `--dry-run` when the user has asked to apply, and let the
   interactive prompt confirm unless the user explicitly requested `--yes`.
3. **Do not pass `--prune` unless the user asked to archive missing products.** It closes
   checkouts for every managed product that is not in the file.
4. **Never change immutable fields in place** (`price.currency`, `billing`, `slug`,
   `stripe.priceId`). Add a new product with a new `key` instead and explain the archive step.
5. **Never rename a `key` to rename a product.** Change `name`.
6. **Idempotency keys for `credits.consume` must be deterministic** and derived from the
   business action (message id, job id, order id), so retries do not double-charge.
7. **Check entitlements on the server** and gate on `tier.rank`, not the tier name.
8. Prefer `--json` when parsing CLI output in scripts; exit code 4 means a conflict with nothing
   applied, exit code 2 means the config itself is invalid.
9. If Stripe is not connected the API returns `stripe_not_connected`; tell the user to connect
   Stripe in Dashboard → Settings → Payments rather than retrying.

Docs: https://purpleturret.com/docs/cli · Entitlements and webhooks: https://purpleturret.com/docs ·
OpenAPI: https://api.purpleturret.com/v1/openapi.json
