---
id: "brand/cli/commands"
type: "doc"
url: "https://affitor.com/docs/brand/cli/commands"
---
# CLI Command Reference

> Complete reference for all npx affitor commands, flags, and options.

Every `npx affitor` command, flag, and option in one place.

## Global Flags

Available on every command:

| Flag | Description |
|---|---|
| `--json` | Output as JSON (for AI agents and scripts) |
| `--no-interactive` | Skip all prompts, fail on missing values |
| `--auto-confirm` | Auto-yes to confirmation prompts |
| `--quiet` | Suppress non-essential output |
| `--api-key <key>` | Override API key from config |
| `--api-url <url>` | Override API URL |
| `--verbose` | Show debug output |
| `-V, --version` | Show version number |
| `-h, --help` | Show help |

---

## Which commands need a login session

Two different credentials are in play, and they are not interchangeable.

| Credential | Where it lives | Which commands use it |
|---|---|---|
| Account session | `~/.affitor/credentials.json`, written by `affitor login` | `affitor init`, `affitor programs`, `affitor whoami` |
| Program API key | `AFFITOR_API_KEY` in the environment or `.affitor/.env`, or `--api-key` | `affitor onboard`, `affitor status`, `affitor test`, `affitor setup stripe` |

Run `affitor login` before the first group. Without a session, `init` and `programs` stop and exit non-zero (`not_logged_in` in `--json` mode) — they never fall back to a program API key.

---

## `affitor login`

Log in to your Affitor account from the browser. Required before `affitor init` and `affitor programs`.

```bash
npx affitor login
```

The command prints a login URL and opens it, then waits while you sign in. Once the browser finishes, the session is written to `~/.affitor/credentials.json` and the token is good for 90 days.

Takes no flags of its own. The session is per machine and per account, not per project, so you log in once and it covers every project directory.

If you are already logged in, `login` reports the account and stops without starting a new session — run `affitor logout` first to switch accounts.

---

## `affitor logout`

Remove the stored session from this machine.

```bash
npx affitor logout
```

---

## `affitor whoami`

Show which account the stored session belongs to, and when its token expires.

```bash
npx affitor whoami
```

With no session it exits non-zero. `whoami --json` instead prints `{"logged_in": false}` and exits `0`, so a script should read that field rather than the exit code.

---

## `affitor programs`

List the affiliate programs on the logged-in account, with each program's ID, status, commission, and partner count.

```bash
npx affitor programs
```

Needs a login session. This is how you find a program ID without opening the dashboard.

---

## `affitor init`

Create a **new** affiliate program and generate config files. Requires a login session — run `affitor login` first.

```bash
npx affitor init
```

`init` always creates a new program. It does not adopt a program you already have and it does not issue a replacement key for one: if a program's key stopped working, a workspace owner regenerates it in Affitor under Settings → API Key. Running `init` in a directory that already has `.affitor/config.json` stops without changing anything.

| Flag | Description | Default |
|---|---|---|
| `--name <name>` | Program name | (prompted) |
| `--domain <domain>` | Root domain | (prompted) |
| `--commission-type <type>` | `percent`, `fixed`, `recurring_percent`, `recurring_fixed` | (prompted) |
| `--commission-rate <rate>` | Commission rate (% or $) | Prompt pre-fills 40 for the percent types only; no default for the fixed types |
| `--cookie-duration <days>` | Cookie window in days | 90, applied in both modes |
| `--duration-months <months>` | Recurring commission duration in months, saved on the program (see the warning below) | Prompt pre-fills 12, and is only asked for the recurring types |
| `--no-wizard` | Skip the auto-install wizard and print manual setup steps | — |

The 40 and 12 above are values the prompt offers, not values the CLI falls back to.

:::warning

`init` saves `--commission-type`, `--commission-rate` and `--duration-months` on the program, but it does not set what partners are paid. Payouts follow the program's **Default** partner group, which starts from Affitor's platform default commission. After `init`, open the program in the dashboard and set the commission on the Default group; that is the value used for every sale.

In `affitor` 0.4.1 the duration prompt and the summary also label `0` as "lifetime". In Affitor, `0` means one-time (the first purchase, per product when the sale carries a product id) and an empty duration means lifetime.

:::

With `--no-interactive` there is no prompt, so `--name`, `--domain`, `--commission-type` and `--commission-rate` must all be passed or the run stops with `missing_options`.

**Example (non-interactive):**

```bash
npx affitor init \
  --name "My SaaS" \
  --domain example.com \
  --commission-type recurring_percent \
  --commission-rate 30 \
  --duration-months 12 \
  --no-interactive
```

**Files created:**

All four land inside `.affitor/`, not in the project root:

| File | Purpose |
|---|---|
| `.affitor/config.json` | Program ID, domain, commission and cookie settings. No secrets |
| `.affitor/.env.example` | Environment variables template |
| `.affitor/AGENTS.md` | Tracking code snippets and AI agent instructions |
| `.affitor/skills.md` | Backward-compatible copy of `AGENTS.md` |

The API key itself goes to `.affitor/.env`, which `init` adds to your `.gitignore` along with `.affitor/.env.*`.

---

## `affitor onboard`

The recommended one-shot integration. Wires Affitor into this app end-to-end: detect the stack → install browser tracking → add the sale call → verify. Run it from your project root after `affitor init`.

```bash
npx affitor onboard
```

This is the flagship path for AI coding agents: a single command that finds your framework and payment provider, applies the integration, and proves attribution works — instead of pasting snippets by hand.

| Flag | Description | Default |
|---|---|---|
| `--api-key <key>` | Program API key — overrides the env var / `.affitor/.env` | (from config) |
| `--yes` | Auto-confirm all diffs (apply every change without prompting) | `false` |
| `--json` | Machine-readable output for agents; never auto-edits files | `false` |
| `--no-interactive` | Skip prompts and apply changes without confirmation | `false` |

An API key is required. `onboard` resolves it from `--api-key`, the `AFFITOR_API_KEY` env var, or `.affitor/.env` (written by `affitor init`). If none is found it exits non-zero with `no_api_key`.

### What it does

`onboard` runs four phases in order:

1. **Detect** — inspects the project to identify the framework (Next.js app/pages router, Fastify, Express, plain Node) and the payment provider (Stripe, Polar, Lemon Squeezy, Paddle).
2. **Browser tracking** — installs `@affitor/sdk` and wires the `<AffitorTracker />` component via a diff-preview, scaffolding `lib/affitor.ts` (the same install wizard `affitor init` uses).
3. **Server sale** — for Stripe, locates your webhook handler and injects the `affitor.trackSale` call after `stripe.webhooks.constructEvent` in the `checkout.session.completed` case. Stripe is the only provider with an automatic edit; for every other provider it prints the snippet for you to paste and reports the step as `manual`. It also persists `AFFITOR_API_KEY` into `.env` / `.env.local` (never overwriting an existing value).
4. **Verify** — fires the synthetic click → lead → sale chain through the real attribution pipeline, then polls program readiness until `integration_verified` is reached.

### Safety and idempotency

`onboard` is **idempotent** — re-running it skips any step already applied (an existing `AFFITOR_API_KEY`, a webhook that already reports the sale). It **never force-edits payment code it can't place confidently**: when the webhook shape isn't cleanly recognized, or no webhook is found, or the provider is not Stripe, it degrades to **printing the exact snippet** for you to paste rather than guessing an edit site. Auto-edits to the payment handler always show a diff and ask for confirmation first (unless `--yes` / `--no-interactive`).

In `--json` mode `onboard` performs **no file edits** — the steps that would have written a file report `manual` so an agent drives the edits explicitly, then it still fires the verification chain and polls readiness.

### Example (agent / non-interactive)

```bash
npx affitor onboard --api-key affitor_xxx --yes --json
```

The JSON summary reports the per-step status and the final verdict:

```json
{
  "program_id": "430",
  "steps": [
    { "step": "detect", "status": "ok", "detail": "framework=next-app, provider=stripe" },
    { "step": "browser_tracking", "status": "skipped", "detail": "json mode" },
    { "step": "server_sale", "status": "manual", "detail": "json mode (no auto-edit)" },
    { "step": "env_key", "status": "manual", "detail": ".env: json mode (no auto-edit)" }
  ],
  "integration_verified": true
}
```

`detect` reports `ok`, `browser_tracking` reports `skipped` in `--json` mode, and the steps that would edit a file report `manual`.

Once `onboard` reaches the verification phase, `--json` always prints this summary. `program_id`, `steps` and `integration_verified` are always present, though `program_id` is `null` when no readiness verdict was obtained. `blocker`, `next_action` and `error` are omitted rather than sent as null, so read them with that in mind.

The only way to get no summary is for `onboard` to stop before it runs: with no API key it prints `{"error": "no_api_key"}` and exits non-zero.

**What `integration_verified: false` means.** The verification chain and the readiness poll are separate: if the chain fails to run, `onboard` carries on polling anyway, so a chain failure never decides the verdict. Readiness does. Three cases produce `false`, and the other fields tell them apart:

| Case | What the summary shows | What to do |
|---|---|---|
| Readiness answered, a gate has not passed | `blocker` and `next_action` name the gate | Resolve it and re-run `affitor onboard` |
| No readiness verdict — every poll failed | No `blocker`, no `next_action`, no `error` | Nothing was determined. Check the API is reachable and re-run |
| The API key is past its rotation deadline | `error.code` is `api_key_rotation_required` | Replace the key, then re-run |

A readiness poll that succeeds after a failed chain still reports `integration_verified: true` — the chain is one way to generate events, not the thing being measured.

---

## `affitor setup stripe`

Connect your Stripe account so payments are tracked automatically.

```bash
npx affitor setup stripe
```

**What happens:**

1. Opens Stripe Connect OAuth in your browser
2. You authorize Affitor to read your payment data
3. A webhook endpoint is created on your Stripe account
4. Connection is saved to your program config

| Flag | Description |
|---|---|
| `--stripe-client-id <id>` | Override Stripe Connect client ID |
| `--stripe-secret-key <key>` | Override Stripe secret key |

**Environment variables (alternative to flags):**

- `STRIPE_CONNECT_CLIENT_ID` or `AFFITOR_STRIPE_CLIENT_ID`
- `STRIPE_SECRET_KEY` or `AFFITOR_STRIPE_SECRET_KEY`

**Webhook events subscribed:**

```text
customer.created
checkout.session.completed
invoice.paid
invoice.payment_failed
charge.refunded
customer.subscription.deleted
```

The endpoint is created on your connected Stripe account with the delivery URL `<api-url>/webhooks/stripe/<program-id>` — for the default API URL, `https://api.affitor.com/webhooks/stripe/430`. That is the URL this command sends to Stripe; it is the value to check in the Stripe dashboard if deliveries are not arriving.

<Callout type="warning" title="Confirm delivery before relying on it">
  After running this command, send a test event from the Stripe dashboard and confirm the sale shows up in Affitor before you treat Stripe tracking as live. If nothing arrives, report the sale from your own backend instead — see [Track Sale](/docs/api-reference/track-sale) or the [Stripe integration guide](/docs/api-reference/integrations/stripe), which does not depend on this endpoint.
</Callout>

---

## `affitor status`

Check program health — tracking status, Stripe connection, and recent events.

```bash
npx affitor status
```

**Example output:**

```
  ╭──────────────────╮
  │   My SaaS        │
  │   example.com    │
  │                  │
  │   Program ID: 42 │
  ╰──────────────────╯

✓ Stripe: connected
⚠ DNS: not configured

  Events (last 24h):
    Clicks:  142
    Leads:   23
    Sales:   8

  Active partners:      5
  Pending commissions:  3
```

---

## `affitor test [event-type]`

Write one flagged test event for your program, to confirm your key works and that events reach your dashboard.

```bash
npx affitor test click    # Test click event
npx affitor test lead     # Test lead event
npx affitor test sale     # Test sale event
```

The event type defaults to `click`. Anything other than `click`, `lead` or `sale` stops with `invalid_event_type`.

**What this does and does not prove.** The command creates a single record flagged `is_test: true`, which appears in your dashboard with a test badge. It confirms that your API key authenticates and that the program accepts events.

It does **not** exercise attribution. The record is inserted directly rather than run through the attribution pipeline, so no partner is resolved and no commission is created — the response always reports `attributed: false`, for every event type, and that is expected rather than a fault.

To check that attribution itself works, run `affitor onboard`, which fires a synthetic click → lead → sale chain through the real pipeline and then polls readiness.

---

## Config File

Configuration is stored in `.affitor/config.json`:

```json
{
  "version": 2,
  "program_id": "430",
  "domain": "example.com",
  "commission": {
    "type": "recurring_percent",
    "rate": 40,
    "duration_months": 12
  },
  "cookie": {
    "name": "affitor_click_id",
    "duration_days": 90
  },
  "stripe_connected": false,
  "api_url": "https://api.affitor.com"
}
```

Secrets (the program API key, and provider webhook secrets) are **not** stored in `config.json` — they live in `.affitor/.env` (`AFFITOR_API_KEY`, `AFFITOR_PROGRAM_ID`), which the CLI adds to `.gitignore`. Legacy v1 configs that carried an inline `api_key` are migrated automatically on the next CLI run.

---

## When a command fails

A command that cannot do its job exits non-zero. Three outcomes are the exception, because they are results rather than failures, and each one is reported in the output instead:

| Command | Reports a negative outcome with exit `0` | Read instead |
|---|---|---|
| `affitor onboard` | Verification did not pass | `integration_verified` in the summary |
| `affitor test` | The test event was not received | `received` in the JSON result |
| `affitor whoami --json` | No session is stored | `logged_in` |

### What `--json` puts on `error`

`error` does not have one shape across the CLI. Check which of the three you are reading before you branch on it.

| Shape | Where | Example |
|---|---|---|
| A machine code | Every check a command makes before it calls the API | `{"error": "not_logged_in"}` |
| The API's message, with the HTTP status beside it | `init`, `status`, `test` when the API rejects the call | `{"error": "Invalid API token", "status": 401}` |
| A code with the message in its own field | `setup stripe` | `{"error": "api_error", "message": "..."}` |

`onboard` is different again. Once it gets past its own preflight checks it prints a summary object, and in that summary `error` is itself an **object** rather than a string — the one place the CLI hands you the API's own error code:

```json
{
  "program_id": "430",
  "steps": [],
  "integration_verified": false,
  "error": { "code": "api_key_rotation_required", "message": "..." }
}
```

Branch on `error.code` there; elsewhere, compare `error` as a string.

Two gaps are worth knowing before you build on this output:

- **`login` and `programs` do not convert failures into JSON.** If the API call inside them fails, they surface the raw error rather than an `{"error": ...}` object, so a script should not assume parseable JSON from them.
- **`onboard` does not report a lost connection as an error.** If every readiness poll fails, it still prints a summary with `integration_verified: false` and no `error` field. Not verified is not the same as verified-as-broken — see [what that verdict means](#example-agent--non-interactive).

### Codes

| Condition | `--json` code | Commands | What to do |
|---|---|---|---|
| Not logged in | `not_logged_in` | `init`, `programs` | Run `affitor login`, then re-run the command |
| No config in this directory | `no_config` | `status`, `test`, `setup` | Run the command from the project root where you ran `affitor init`, or run `affitor init` to create a program here |
| Already configured | `already_configured` | `init` | `init` found an existing `.affitor/config.json` and changed nothing. Use `affitor status` to check that program. Do not run `init` to recover a key — it only ever creates a new program |
| Missing required options | `missing_options` | `init` with `--no-interactive` | Pass `--name`, `--domain`, `--commission-type` and `--commission-rate` |
| Invalid event type | `invalid_event_type` | `test` | `affitor test` takes `click`, `lead`, or `sale` |
| No API key | `no_api_key` | `onboard` | Pass `--api-key`, set `AFFITOR_API_KEY`, or run from a directory with `.affitor/.env` |
| API rejected the call, account session | `error` is the API message, `status` the HTTP status | `init` | `init` authenticates with your account session, not a program key. A `401` here means the session is invalid or expired — run `affitor login` again. Do **not** regenerate a program key for this: it will not fix the session, and it breaks whatever is still using the old key |
| API rejected the call, program key | `error` is the API message, `status` the HTTP status | `status`, `test` | On a `401`, the key matches no program or was replaced. Compare it against the value you stored when it was issued; if you no longer hold that value, ask a workspace owner to regenerate the key under Settings → API Key and deploy the new one |
| API rejected the call | `api_error`, with the API message on `message` and no `status` | `setup stripe` | Read `message`. A `401` here is a program-key problem, as in the row above |
| Stripe authorization failed | `stripe_oauth_error` | `setup stripe` | Read `message`, then run `affitor setup stripe` again |
| No usable response | `network_error` | `init`, `status`, `test`, `setup stripe` | See below |
| Anything else | `unexpected_error` | `init`, `status`, `test`, `setup stripe` | Re-run with `--verbose` and keep the output |

### `network_error` does not mean the request was ignored

The CLI makes at most three attempts in total — the first call plus two retries — before reporting `network_error`, so by the time you see it the request may already have been sent three times. (API errors, including `429`, are not retried; they surface on the first response.)

`network_error` covers two different situations, and the CLI cannot tell them apart: a connection that never delivered the request, and a response that arrived with a success status but a body the CLI could not parse. In the second case the server may have done the work and only the reply was lost — a success status is not proof that the write completed.

So treat the server state as **unknown, not untouched**, and verify the specific record before repeating anything that creates one:

- **`init`** — run `affitor programs` and look for the program by name before running `init` again, or it will create a second one. There is no idempotency key on this call.
- **`test`** — open your dashboard and look for the test event. The command sends no `transaction_id`, and the API generates a new event id on every call, so a repeat always writes another record.
- **Ordinary sales you report yourself** through `POST /api/v1/track/sale` — resend the same `transaction_id`. The duplicate guard answers `409` for one already recorded, which is the one case here where a safe repeat is built in. A `409` confirms the sale exists; it does not confirm the commission was created, so check the sale in your dashboard if the first attempt may have failed partway. This does not extend to test-mode sales: a call carrying `additional_data.test_mode` is handled before the duplicate check and is not promised a `409`.
