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 side

Credentials 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 file

The 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" },
    }),
  ],
});
FieldTypeNotes
keystringRequired. 1–64 chars: a–z, 0–9, _ or -, starting with a letter or digit. Unique per account.
namestringRequired, up to 120 chars. Shown on the checkout and in Stripe.
descriptionstringOptional, 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.
slugstringOptional 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.
activebooleanOptional. Inactive products stop accepting new checkouts.
Immutable after create: 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 prompt

Every 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-yearly

Raising 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 ones

Only 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

CommandFlagsWhat it does
init--from-remote --format ts|json --forceWrite a starter config, or seed it from the account's managed products.
login--api-key --profileValidate a seller key and save it to a profile. Needs --api-key or the env var when stdin is not a terminal.
logout--profileDelete the saved profile.
whoamiAccount, key prefix and scopes, Stripe and PayPal status, API URL, credential source.
statusDry-run the local config and print a summary of pending changes.
push--dry-run --prune --yes / -yPlan, confirm, apply. Always starts with a server-side dry run.
pull--out <file> --forceWrite the account's managed products to a config file.
products list--allTable of every product; --all includes archived ones.

Global flags

FlagEnvironmentMeaning
--config <path>Config file to use instead of searching up to the git root.
--api-key <key>PURPLETURRET_API_KEYSeller key for this invocation.
--api-url <origin>PURPLETURRET_API_URLAPI origin without /v1. Printed when non-default.
--profile <name>Credentials profile (default "default").
--jsonMachine-readable JSON on stdout; diagnostics stay on stderr.
--no-colorNO_COLORDisable colors. Also automatic when stdout is not a TTY.
--verboseLog 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

MethodReturnsNotes
account.get()AccountSeller 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)ProductBy config key.
products.archive(key)ProductArchives 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 })EntitlementResolves 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

ClassFieldsWhen
PurpleturretApiErrorstatus, code, message, requestId, details, retryAfterAny non-2xx response. retryAfter is seconds, parsed from the Retry-After header on 429.
PurpleturretNetworkErrorurl, timedOut, causeNo HTTP response: DNS, connection refused, or the timeout elapsed.
ConfigValidationErrorissues[] { path, message }validateConfig or validateProduct rejected the input.

Common error codes and how to react:

StatusCodeMeaning
401unauthorizedMissing or rejected key, or a legacy key without scopes. Mint a new seller key.
403forbidden_key_typeA hosting key was used on a seller endpoint.
403insufficient_scopedetails.required_scope names the missing scope.
402insufficient_creditsdetails = { balance, requested }. Show an upgrade path.
409immutable_field_changedPush touched price.currency, billing, slug, or stripe.priceId.
409idempotency_conflictThe Idempotency-Key was reused with a different body.
422validation codesThe request body failed validation; details lists the fields.
429rate_limitedBack off for retryAfter seconds.
422stripe_not_connectedConnect 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.

EndpointScopeNotes
GET /accountconfig:readDescribe the seller behind the key.
GET /configconfig:readManaged products plus unmanaged_count.
PUT /configconfig:writeBody { products, prune?, dry_run? }. Idempotency-Key required. 409 on an immutable change.
GET /productsconfig:readcursor, limit (≤ 100), include_archived.
GET /products/{key}config:readOne product by config key.
PUT /products/{key}config:writeSingle-product upsert. Body is one ProductInput. 201 on create.
DELETE /products/{key}config:writeArchive.
GET /credits/balancescredits:readexternal_id or email, optional meter.
POST /credits/consumecredits:writeIdempotency-Key required. 402 insufficient_credits.
POST /credits/adjustcredits:writeIdempotency-Key required. Signed amount.
GET /credits/ledgercredits:readcursor, limit (≤ 100). Newest first.
GET /entitlementsentitlements:readgroup_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 /config calls 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 prompt

Without 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

CodeMeaning
0Success, including a plan with no changes.
1API or apply error (network failure, rate limit, Stripe not connected, a product failed to apply).
2Usage or validation error: bad flags, config not found or invalid, confirmation required without --yes.
3Authentication: missing or rejected key, wrong key type, or missing scope (401 / 403).
4Conflict: an immutable field changed or an Idempotency-Key was reused (409). Nothing was applied.
130Aborted 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.md

See the AI skills guide for other agents, a paste-in snippet for AGENTS.md, and example prompts.