Developer docs
CLI and SDK
Keep your checkout products in code. Declare them once in purpleturret.config.ts, review a plan, and push. The same API key also meters credits granted by your products and checks entitlements from your own backend.
OpenAPI 3.1 document · Entitlements API and webhooks · AI skills
Install
pnpm add -D @purpleturret/cli @purpleturret/sdk
# or: npm install --save-dev @purpleturret/cli @purpleturret/sdk
npx purpleturret --help # also available as `pt`@purpleturret/cli is the command-line tool. @purpleturret/sdk provides the typed defineConfig helpers your config file imports, plus a small fetch-based client for the REST API. Both need Node 20 or newer. The SDK has a single runtime dependency (zod) and works anywhere fetch exists: Node, Bun, Deno, edge runtimes.
Authenticate
Mint a seller API key (pt_sk_…) in Settings → Developers. Keys carry scopes: config:read, config:write, credits:read, credits:write, and entitlements:read. Hosting keys (pt_host_…) are rejected by these endpoints with forbidden_key_type.
purpleturret login # paste the key; it is validated and stored in
# ~/.config/purpleturret/credentials.json (mode 0600)
purpleturret whoami # account, key prefix, scopes, Stripe and PayPal status
purpleturret logout
purpleturret login --api-key pt_sk_… # non-interactive
purpleturret login --profile staging # keep several accounts side by sideCredentials are resolved in this order: --api-key, then PURPLETURRET_API_KEY (from the environment or .env.local / .env in the current directory), then the saved profile (--profile, default default). The credentials file honors XDG_CONFIG_HOME. The API base URL follows the same order with --api-url and PURPLETURRET_API_URL; a non-default URL is printed in yellow so it is never a surprise.
Init
purpleturret init # writes purpleturret.config.ts with an example product
purpleturret init --from-remote # seeds it with the products already managed on your account
purpleturret init --format json # purpleturret.config.json instead of TypeScript
purpleturret init --force # overwrite an existing fileThe CLI looks for purpleturret.config.{ts,mts,js,mjs,json} in the current directory and each parent up to the git root, or wherever --config points. TypeScript files are loaded on the fly, so process.env and helpers work as usual. The config must be the default export, or a named export called config.
Config reference
import { defineConfig, product, usd } from "@purpleturret/sdk";
export default defineConfig({
products: [
product({
key: "pro_monthly", // stable id; never changes
name: "Pro",
price: usd(29), // { amount: 2900, currency: "usd" }
billing: { type: "recurring", interval: "month" },
slug: "acme-pro-monthly", // https://purpleturret.com/c/acme-pro-monthly
group: { key: "acme", tier: { key: "pro", name: "Pro", rank: 1 } },
credits: [{ meter: "messages", amount: 1000, renewal: "reset" }],
delivery: { method: "redirect", url: "https://app.acme.com/welcome" },
theme: { primaryColor: "#5F34CC" },
}),
],
});| Field | Type | Notes |
|---|---|---|
key | string | Required. 1–64 chars: a–z, 0–9, _ or -, starting with a letter or digit. Unique per account. |
name | string | Required, up to 120 chars. Shown on the checkout and in Stripe. |
description | string | Optional, up to 5,000 chars. |
price | { amount, currency } | Required. Integer minor units, minimum 50. usd(29) → 2900 cents; money(1500, "eur") for other currencies. currency is immutable. |
billing | { type: "one_time" } | { type: "recurring", interval: "month" | "year" } | Required. Immutable. |
slug | string | Optional public path (/c/<slug>). 3–64 chars: a–z, 0–9, -. Create-only. |
group | { key, tier?: { key, name, rank } } | Optional. Groups power the entitlements API and tier routing. rank is an integer ≥ 0; higher means more access. |
credits | { meter, amount, renewal, expiresAfterDays? }[] | Optional. Granted on every paid invoice. renewal "reset" tops the balance up to amount; "accumulate" adds to it. One entry per meter. |
delivery | { method: "none" | "redirect" (url) | "instructions" (instructions) } | Optional. redirect.url must be https. File delivery stays dashboard-managed. |
checkout | { layoutMode?, paymentStyle?, providerMode?, contactFields? } | Optional. two_step | one_step; classic | express; inherit | stripe | paypal | both; { name?, phone? }. |
theme | { primaryColor?, buttonRadius?, accentMode? } | Optional. Hex color; sm | md | lg; light | dark | system. |
stripe | { priceId } | Optional. Adopt an existing Stripe price on create. Immutable. |
active | boolean | Optional. Inactive products stop accepting new checkouts. |
price.currency, billing, slug, and stripe.priceId. Changing one is reported as a conflict and nothing is applied. Declare the change under a new key and archive the old product with push --prune. Fields you omit are left alone on existing products, so dashboard-only settings such as file delivery, order bumps, and logos keep working.The SDK also exports validateConfig(value) and validateProduct(value), which return the normalized config or throw a ConfigValidationError listing every issue with a dotted path such as products[0].price.amount. The CLI runs the same validation before every command that reads the file.
Push
purpleturret push --dry-run # plan only
purpleturret push # plan, confirm, apply
purpleturret push --prune --yes # also archive managed products missing from the file; no promptEvery push starts with a server-side dry run and prints a plan in the style of Terraform:
Config: purpleturret.config.ts (3 products)
+ create team_yearly Team (yearly) — $990.00/year
~ update pro_monthly
~ price.amount 2900 → 3900 (new Stripe price; existing subscriptions keep the old price)
~ name "Pro" → "Pro plan"
= unchanged pro_yearly Pro (yearly) — $269.00/year
- archive legacy Legacy — $9.00/month
Plan: 1 to create, 1 to update, 1 to archive, 1 unchanged.
Apply these changes? … yes
+ create team_yearly Team (yearly) — $990.00/year
~ update pro_monthly
= unchanged pro_yearly
- archive legacy
Apply complete: 1 created, 1 updated, 1 archived, 1 unchanged.
Checkout links:
team_yearly https://purpleturret.com/c/acme-team-yearlyRaising or lowering price.amount creates a new Stripe price and retires the old one; customers already subscribed keep paying what they signed up for. Applies are sent with an Idempotency-Key and retried once on a network error, so an interrupted push can simply be run again. Add --json for a machine-readable plan and result.
Each plan item carries one of six actions: create, update, unchanged, archive, conflict (an immutable field changed), or error (the product cannot be applied, for example because Stripe is not connected). A plan with any conflict or error stops before applying anything.
Status
purpleturret status
Config purpleturret.config.ts (3 products)
API URL https://api.purpleturret.com
Credentials saved profile "default"
create team_yearly
update pro_monthly
Plan: 1 to create, 1 to update, 0 to archive, 1 unchanged.
Run `purpleturret push` to apply.status is a dry run without the per-field detail: a quick way to see whether the file and the account have drifted, and which credentials and API URL are in effect. It never applies anything.
Pull and products
purpleturret pull # writes purpleturret.config.ts (refuses to overwrite without --force)
purpleturret pull --out catalog.json # JSON when the extension is .json
purpleturret pull --json # print the products to stdout instead
purpleturret products list # table: KEY, NAME, PRICE, STATUS, URL
purpleturret products list --all # every product, including dashboard-only and archived onesOnly products created with a key are managed by config. Products made in the dashboard are listed with key: null and are never modified or archived by push, even with --prune.
Command reference
| Command | Flags | What it does |
|---|---|---|
init | --from-remote --format ts|json --force | Write a starter config, or seed it from the account's managed products. |
login | --api-key --profile | Validate a seller key and save it to a profile. Needs --api-key or the env var when stdin is not a terminal. |
logout | --profile | Delete the saved profile. |
whoami | Account, key prefix and scopes, Stripe and PayPal status, API URL, credential source. | |
status | Dry-run the local config and print a summary of pending changes. | |
push | --dry-run --prune --yes / -y | Plan, confirm, apply. Always starts with a server-side dry run. |
pull | --out <file> --force | Write the account's managed products to a config file. |
products list | --all | Table of every product; --all includes archived ones. |
Global flags
| Flag | Environment | Meaning |
|---|---|---|
--config <path> | Config file to use instead of searching up to the git root. | |
--api-key <key> | PURPLETURRET_API_KEY | Seller key for this invocation. |
--api-url <origin> | PURPLETURRET_API_URL | API origin without /v1. Printed when non-default. |
--profile <name> | Credentials profile (default "default"). | |
--json | Machine-readable JSON on stdout; diagnostics stay on stderr. | |
--no-color | NO_COLOR | Disable colors. Also automatic when stdout is not a TTY. |
--verbose | Log every request, its status and request id, and stack traces to stderr. |
Global flags work before or after the subcommand: pt --json push and pt push --json are equivalent.
SDK client
PurpleturretClient is a thin, typed wrapper over the REST API. It speaks camelCase and converts to the API's snake_case wire format for you. It holds a seller key, so use it on the server only.
import { PurpleturretClient } from "@purpleturret/sdk";
const purpleturret = new PurpleturretClient({
apiKey: process.env.PURPLETURRET_API_KEY!,
// baseUrl: "https://api.purpleturret.com", // origin without /v1; local dev: http://localhost:3001/api
// timeoutMs: 30_000,
// fetch: customFetch, // tests, polyfills
// userAgent: "acme-backend/1.4", // the SDK version is appended
});
const account = await purpleturret.account.get();
account.email; account.key.scopes; account.stripe.connected;Every customer-facing method identifies the customer with { externalId } or { email }. You can pass both; externalId is matched first. It is whatever you appended as ?external_id= to the checkout link.
SDK reference
| Method | Returns | Notes |
|---|---|---|
account.get() | Account | Seller behind the key, Stripe and PayPal status, key scopes. |
config.get() | { products, unmanagedCount } | Every config-managed product. |
config.push({ products, prune?, dryRun?, idempotencyKey? }) | { applied, summary, results } | What the CLI runs. A 409 conflict comes back as a result with summary.conflict > 0 rather than throwing, so you can render the plan. |
products.list({ cursor?, limit?, includeArchived? }) | { data, nextCursor } | Paginated, limit up to 100. Dashboard-only products have key: null. |
products.get(key) | Product | By config key. |
products.archive(key) | Product | Archives and frees the key. Never hard-deletes. |
credits.balances({ externalId | email, meter? }) | { customer, balances[] } | Each balance has meter, balance, expiresAt, lifetimeGranted, lifetimeConsumed. |
credits.consume({ meter, amount, externalId | email, idempotencyKey, note? }) | { meter, balance, consumed, replayed } | idempotencyKey is required. Throws insufficient_credits (402) with details { balance, requested }. |
credits.adjust({ meter, amount, externalId | email, idempotencyKey?, note? }) | { meter, balance } | Signed amount: positive grants, negative removes. |
credits.ledger({ externalId | email, cursor?, limit? }) | { data, nextCursor } | Newest first. Entry kinds: grant, consume, adjust, expire, revoke. |
entitlements.get({ groupKey, externalId | email }) | Entitlement | Resolves with hasAccess: false instead of throwing when there is no subscription. Includes tier, subscription, currentPeriodEnd, and credits by meter. |
Config helpers exported alongside the client: defineConfig, product, usd(dollars), money(minorUnits, currency), validateConfig, validateProduct, and the KEY_PATTERN and SLUG_PATTERN regular expressions. Wire-format converters (toWireProduct, fromWireProduct, productToInput) are exported for tooling that talks to the API directly.
Credits
Products can grant metered credits on each paid invoice. Consume them from your backend with the SDK. Every consume call needs an idempotencyKey; replaying the same key returns the original result with replayed: true instead of charging twice. Derive the key from the business action (a message id, a job id) rather than generating a random one, so a retried request reuses it.
import { PurpleturretClient, PurpleturretApiError } from "@purpleturret/sdk";
const purpleturret = new PurpleturretClient({ apiKey: process.env.PURPLETURRET_API_KEY! });
export async function sendMessage(userId: string, messageId: string) {
try {
const { balance } = await purpleturret.credits.consume({
meter: "messages",
amount: 1,
externalId: userId, // or email: user.email
idempotencyKey: `msg-${messageId}`,
note: "chat message",
});
return { ok: true, remaining: balance };
} catch (error) {
if (error instanceof PurpleturretApiError && error.code === "insufficient_credits") {
return { ok: false, remaining: 0 }; // 402; error.details = { balance, requested }
}
throw error;
}
}
// Read balances, adjust manually, or inspect the ledger:
await purpleturret.credits.balances({ externalId: userId });
await purpleturret.credits.adjust({ meter: "messages", amount: 50, externalId: userId, idempotencyKey: "promo-2026-01" });
await purpleturret.credits.ledger({ externalId: userId, limit: 50 });
// Entitlements include the credit balances for the matched customer:
const access = await purpleturret.entitlements.get({ groupKey: "acme", externalId: userId });
access.hasAccess; access.tier?.key; access.credits?.messages?.balance;Customers are identified by the external_id you passed on the checkout link, or by email. Grants are keyed per invoice, so webhook retries never double-grant, and a full refund revokes what is still unspent.
Errors
| Class | Fields | When |
|---|---|---|
PurpleturretApiError | status, code, message, requestId, details, retryAfter | Any non-2xx response. retryAfter is seconds, parsed from the Retry-After header on 429. |
PurpleturretNetworkError | url, timedOut, cause | No HTTP response: DNS, connection refused, or the timeout elapsed. |
ConfigValidationError | issues[] { path, message } | validateConfig or validateProduct rejected the input. |
Common error codes and how to react:
| Status | Code | Meaning |
|---|---|---|
401 | unauthorized | Missing or rejected key, or a legacy key without scopes. Mint a new seller key. |
403 | forbidden_key_type | A hosting key was used on a seller endpoint. |
403 | insufficient_scope | details.required_scope names the missing scope. |
402 | insufficient_credits | details = { balance, requested }. Show an upgrade path. |
409 | immutable_field_changed | Push touched price.currency, billing, slug, or stripe.priceId. |
409 | idempotency_conflict | The Idempotency-Key was reused with a different body. |
422 | validation codes | The request body failed validation; details lists the fields. |
429 | rate_limited | Back off for retryAfter seconds. |
422 | stripe_not_connected | Connect Stripe in Settings → Payments before pushing. |
Every error body uses the envelope { "error": { "code", "message", "request_id", "details" } }. Quote the request_id when you contact support.
REST endpoints
The CLI and SDK are conveniences over these endpoints. Base URL https://api.purpleturret.com/v1, header Authorization: Bearer pt_sk_…, JSON in and out, snake_case field names. The OpenAPI document has the full schemas.
| Endpoint | Scope | Notes |
|---|---|---|
GET /account | config:read | Describe the seller behind the key. |
GET /config | config:read | Managed products plus unmanaged_count. |
PUT /config | config:write | Body { products, prune?, dry_run? }. Idempotency-Key required. 409 on an immutable change. |
GET /products | config:read | cursor, limit (≤ 100), include_archived. |
GET /products/{key} | config:read | One product by config key. |
PUT /products/{key} | config:write | Single-product upsert. Body is one ProductInput. 201 on create. |
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). Newest first. |
GET /entitlements | entitlements:read | group_key plus external_id or email. 404 with has_access: false when no subscription. |
curl -X PUT 'https://api.purpleturret.com/v1/config' \
-H 'Authorization: Bearer pt_sk_...' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: deploy-2026-09-09-1' \
-d '{"dry_run": true, "products": [{
"key": "pro_monthly", "name": "Pro",
"price": {"amount": 2900, "currency": "usd"},
"billing": {"type": "recurring", "interval": "month"}
}]}'Replaying the same Idempotency-Key with the same body returns the stored response; a different body returns 409 idempotency_conflict.
Rate limits
Limits use a 60-second window and return HTTP 429 with a Retry-After header in seconds:
- Per API key: 600 requests per minute across all seller endpoints.
- Config pushes: 30
PUT /configcalls per key per minute. Dry runs count. - Per IP: 1,200 requests per minute.
The SDK exposes the wait as error.retryAfter; the CLI prints it and exits 1. Need more? Get in touch.
CI usage
# GitHub Actions, GitLab CI, etc.
PURPLETURRET_API_KEY: ${{ secrets.PURPLETURRET_API_KEY }}
pnpm purpleturret push --dry-run # on pull requests: fail the job on conflicts (exit 4)
pnpm purpleturret push --yes --prune # on main: apply without a promptWithout a TTY, push refuses to apply unless --yes is given. Colors are disabled automatically when stdout is not a terminal or NO_COLOR is set; --no-color forces it. Use --json to parse results, and --verbose to log each request.
# GitHub Actions example
- run: pnpm install --frozen-lockfile
- run: pnpm purpleturret push --dry-run --json > plan.json
env:
PURPLETURRET_API_KEY: ${{ secrets.PURPLETURRET_API_KEY }}
- run: pnpm purpleturret push --yes
if: github.ref == 'refs/heads/main'
env:
PURPLETURRET_API_KEY: ${{ secrets.PURPLETURRET_API_KEY }}Exit codes
| Code | Meaning |
|---|---|
0 | Success, including a plan with no changes. |
1 | API or apply error (network failure, rate limit, Stripe not connected, a product failed to apply). |
2 | Usage or validation error: bad flags, config not found or invalid, confirmation required without --yes. |
3 | Authentication: missing or rejected key, wrong key type, or missing scope (401 / 403). |
4 | Conflict: an immutable field changed or an Idempotency-Key was reused (409). Nothing was applied. |
130 | Aborted at the confirmation prompt. |
Errors follow the API envelope { "error": { "code", "message", "request_id", "details" } }; the CLI prints the message plus a hint, and the request id when one is available.
AI skills
Using Claude Code, Codex, Cursor, or another coding agent? Install the Purpleturret skill and the agent learns everything on this page, plus guardrails such as always showing a dry-run plan before applying and never editing immutable fields in place.
curl -fsSL https://purpleturret.com/skills/purpleturret/SKILL.md \
--create-dirs -o .claude/skills/purpleturret/SKILL.mdSee the AI skills guide for other agents, a paste-in snippet for AGENTS.md, and example prompts.