# Compliance checks on every pull request

> checkbox.json lists your Stripe Checkout params, disclosure, pages and lifecycle emails; checkbox ci checks them against automatic-renewal laws on every code change, offline, with annotations and the corrected params in the pull request.

A refactor that drops `custom_text` from your Checkout Session, a new pricing page without the renewal terms, a
"friendlier" welcome email that no longer says how to cancel: each one quietly breaks an automatic-renewal law
requirement. `checkbox ci` re-checks the files that produce your sign-up flow and emails on every pull request and
fails the build when one needs a fix, with the exact change to make.

- **Offline by default.** The rules engine ships in the CLI: no API key, no network, nothing leaves the runner.
- **Points at the code.** Annotations land on the file (and line) that needs the change; the job summary has the fix
  and the engine-verified corrected params or text.
- **Same verdicts as the API.** The checks are the [preflight checks](https://checkbox.site/docs/preflight) (`/v1/checks/*`).

## Quick start {#quick-start}

```text title="In your project root"
npx @checkbox-site/cli ci init --yes     # checkbox.json + .github/workflows/checkbox.yml, from your project
npx @checkbox-site/cli ci                # run every check locally
```

`ci init` reads the project the way `checkbox init` does: every `stripe.checkout.sessions.create({...})` call, plans
from inline `price_data`, email templates by file name (`emails/welcome.html` is an acknowledgment, `trial-ending.txt`
a trial reminder), built pricing / checkout pages and plain-text disclosures. What it cannot tell is written as a
`"TODO: …"` value, which `checkbox ci` refuses (exit 3) until a person or agent fills it in. It never overwrites an
existing file without `--force`; on a terminal it asks first, `--yes` writes without asking, `--dry-run` only shows
the result. `checkbox init --ci` does the same.

Then commit both files. The workflow:

```yaml title=".github/workflows/checkbox.yml"
name: Checkbox

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  checkbox:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      # Only needed when checkbox.json uses "module" entries that import your dependencies:
      # - run: npm ci
      - uses: checkbox-site/checkbox@v1
        # with:
        #   strict: true          # also fail when a check needs review
        #   args: --online        # URL pages via the API; needs the env below
        # env:
        #   CHECKBOX_SECRET_KEY: ${{ secrets.CHECKBOX_SECRET_KEY }}
```

## checkbox.json {#config}

The file sits at the project root (`.checkbox.json` works too; `--config` points elsewhere). Paths are relative to it.
Add `"$schema"` and editors complete and validate it from [`/schemas/checkbox.json`](https://checkbox.site/schemas/checkbox.json).

```json title="checkbox.json"
{
  "$schema": "https://checkbox.site/schemas/checkbox.json",
  "merchant": { "name": "Acme", "cancel_url": "https://acme.example/account/cancel", "support_contact": "support@acme.example" },
  "jurisdictions": "all",
  "plans": [
    { "name": "Pro", "price": 19.99, "currency": "usd", "interval": "month", "trial_days": 14 },
    { "name": "Pro yearly", "price": 199, "currency": "usd", "interval": "year" }
  ],
  "checks": {
    "stripe_checkout": [
      { "name": "Checkout route", "source": "app/api/checkout/route.ts" },
      { "name": "Yearly checkout", "module": "lib/checkout.ts#yearlyParams", "args": [{ "customerEmail": "test@acme.example" }], "plan": "Pro yearly" }
    ],
    "disclosures": [{ "name": "Pay-button terms", "file": "components/renewal-terms.txt", "plan": "Pro", "consent": { "checkbox": true } }],
    "pages": [
      { "name": "Pricing page", "file": "out/pricing.html", "kind": "pricing", "plan": "Pro" },
      { "name": "Preview checkout", "url": "https://preview.acme.example/checkout", "kind": "checkout", "plan": "Pro" }
    ],
    "emails": [
      { "name": "Welcome", "kind": "acknowledgment", "file": "emails/welcome.html", "plan": "Pro" },
      { "name": "Trial ending", "kind": "trial_reminder", "module": "emails/render.ts#trialEnding", "plan": "Pro" }
    ],
    "notice_schedule": true
  }
}
```

| Key | What it is |
|---|---|
| `merchant` | `name` (the brand subscribers see), `cancel_url` (where they cancel online), `support_contact` (shown in emails). What the code alone does not say. |
| `jurisdictions` | `"all"` (default) or codes like `["US-CA", "US-NY"]`. Federal law is always included. |
| `plans[]` | `name`, `price` (major units) or `amount` (minor units), `currency`, `interval` (`day`, `week`, `month`, `year`), `interval_count`, `trial_days`; optional `intro_price` / `intro_amount` / `intro_periods`, `cancel_timing`. |
| `checks.stripe_checkout[]` | The params your code passes to `stripe.checkout.sessions.create`, from one of: `params` (inline), `file` (JSON), `source` (a file with the call; the object literal is read statically on every run, `call` picks the n-th call), or `module` (below). `plan` gives the price when line items use a Price id. |
| `checks.disclosures[]` | The renewal terms by the pay button: `text` or `file`, optional `button_text`, `placement` and `consent` (as in [`check_disclosure`](https://checkbox.site/docs/api/checks#checkDisclosure)). |
| `checks.pages[]` | `file` (built or static HTML) or `url` (a preview deployment; checked with `--online`), `kind` `checkout` or `pricing`. |
| `checks.emails[]` | `kind`: `acknowledgment`, `trial_reminder`, `renewal_reminder`, `price_change`, `annual_terms`, `charge_notice`, `cancellation_confirmation`. Body from `file` (`.html`, `.txt`, `.md`; a first line `Subject: …` or the HTML `<title>` sets the subject), `text` / `html` with `subject`, or `module`. `previous_price` for price changes. |
| `checks.notice_schedule` | `true`: for each plan, the emails the law requires (from the [notice schedule](https://checkbox.site/docs/api/checks#checkNoticeSchedule)) must each have a template in `emails`; a missing one needs review. |

Every disclosure, page and email names its `plan` when there are several plans. Names must be unique: they label the
results. Unknown keys, a wrong `kind`, a missing file or a `TODO` value stop the run with exit 3 and a message that
names the key, for example `checks.emails[1].kind must be one of acknowledgment, … (got "welcome")`.

### Code that builds the params or renders the email {#modules}

`"module": "path/to/file.ts#exportName"` imports the file in a separate Node process and uses the export: an object,
or a function called with `args` (sample values) that returns it — the exact Checkout Session params, or
`{ subject, html }` / `{ subject, text }` for an email. That is how templates built in code are checked:

```tsx title="emails/render.ts"
import { render } from '@react-email/render';
import TrialEnding from './TrialEnding';

export async function trialEnding() {
  return { subject: 'Your Acme Pro trial ends in 3 days', html: await render(TrialEnding({ firstName: 'Sam', trialEnd: 'June 3' })) };
}
```

TypeScript runs through the project's `tsx` when it is installed, else Node's type stripping (Node 22.6+; `.tsx` needs
`tsx`). Install your dependencies first in CI (`npm ci`) when a module imports them. Prefer `source` for plain
`checkout.sessions.create({...})` calls: nothing is executed, and values that are expressions (a Price id from an
environment variable) are reported and taken from `plan`.

## Run it {#run}

```bash
mkdir -p stripe emails
cat > checkbox.json <<'EOF'
{
  "$schema": "https://checkbox.site/schemas/checkbox.json",
  "merchant": { "name": "Acme", "cancel_url": "https://acme.example/account/cancel" },
  "plans": [{ "name": "Pro", "price": 19.99, "currency": "usd", "interval": "month" }],
  "checks": {
    "stripe_checkout": [{ "name": "Pro checkout", "file": "stripe/session-params.json" }],
    "emails": [{ "name": "Welcome email", "kind": "acknowledgment", "file": "emails/welcome.txt" }],
    "notice_schedule": true
  }
}
EOF
cat > stripe/session-params.json <<'EOF'
{
  "mode": "subscription",
  "line_items": [{ "price_data": { "currency": "usd", "unit_amount": 1999, "recurring": { "interval": "month" }, "product_data": { "name": "Acme Pro" } }, "quantity": 1 }],
  "success_url": "https://acme.example/welcome"
}
EOF
cat > emails/welcome.txt <<'EOF'
Subject: Welcome to Acme Pro

Thanks for subscribing to Acme Pro. Your subscription renews automatically every month at $19.99 until you cancel.
Cancel anytime online at https://acme.example/account/cancel.
EOF
checkbox ci
```

```text title="Output"
Checkbox CI  checkbox.json · offline · rules 1.4.0 · catalog 2026.11.01.1 · 3 checks
  ✗ Pro checkout    → 4 fixes needed · 1 not checked yet
      fix: Explain the cancellation policy and exactly how to cancel in the checkout terms. (stripe/session-params.json)
  ✓ Welcome email   → Checks pass · 2 live checks pending
  ? Required emails → 2 required emails not in checkbox.json
      fix: Add a charge notice (a receipt per charge usually serves) to checks.emails (kind "charge_notice"; 13 days
           before: Renewal charge (a receipt or upcoming-charge email per charge)), or have Checkbox Notices
           send it. (checkbox.json:8)

1 needs a fix · 1 needs review · 1 pass · exit 1
Corrected params / text: --verbose, --json, or the job summary in GitHub Actions.
Automated check against Checkbox's catalog of automatic-renewal laws; not legal advice.
```

The status labels are the ones the dashboard and API use: **Checks pass**, **N fixes needed**, needs review (a
question for a person, or something only a live check can confirm). On a Stripe-hosted page, legibility of the
renewal terms is Stripe's styling and stays a note, so correct Stripe params pass.

| Flag | |
|---|---|
| `--config <path>` | Another config file. |
| `--online` | Run the checks through the API with `CHECKBOX_SECRET_KEY` (or `CHECKBOX_API_KEY`): pages given as a `url` (a preview deployment) are fetched and checked. Without it they are skipped. |
| `--strict` | A check that needs review fails too (exit 2). |
| `--changed-only` | Skip the run when no file the config reads changed against `--base` (in a pull request: `origin/$GITHUB_BASE_REF`; use `actions/checkout` with `fetch-depth: 0`). A `module` entry counts every code change. |
| `--verbose` | Every fix, the warnings and the corrected params / text. |
| `--json` | One JSON document (`"object": "ci_run"`) with each check's status, label, fixes, corrected output and full result. |

### Exit codes {#exit-codes}

| Code | Meaning |
|---|---|
| 0 | Every check passes (needs review without `--strict` is reported, not failed) |
| 1 | At least one check needs a fix |
| 2 | Only needs review, with `--strict` |
| 3 | Config or usage error: invalid checkbox.json, a missing file, a `TODO` value, a module that throws |
| 4 | `--online` without a valid key |
| 5 | API or network error (`--online`) |

## In the pull request {#github}

With `GITHUB_ACTIONS=true` (any GitHub Actions job), `checkbox ci` also:

- prints `::error` annotations (a fix is needed) and `::warning` annotations (needs review) on the file each check
  reads — the params JSON, the source line of the `checkout.sessions.create` call, the module, the email template —
  so they show in the pull request's "Files changed" tab;
- appends a summary to `$GITHUB_STEP_SUMMARY`: one row per check with its status and top fix, then each corrected
  params object or text in a collapsible block;
- writes `status=pass|fail|needs_review|error|skipped` to `$GITHUB_OUTPUT` (the Action's `status` output).

The job summary for the run above:

```markdown title="Job summary (excerpt)"
## Checkbox compliance checks

**1 needs a fix · 1 needs review · 1 pass** — `checkbox.json` · offline · rules 1.4.0 · catalog 2026.11.01.1

| Check | Status | Fix | File |
|---|---|---|---|
| ❌ Pro checkout<br><sub>Stripe Checkout</sub> | 4 fixes needed · 1 not checked yet | Explain the cancellation policy and exactly how to cancel in the checkout terms. (+4 more) | `stripe/session-params.json` |
| ✅ Welcome email<br><sub>Email</sub> | Checks pass · 2 live checks pending |  | `emails/welcome.txt` |
| ⚠️ Required emails<br><sub>Notice schedule</sub> | 2 required emails not in checkbox.json | Add a charge notice (a receipt per charge usually serves) to checks.emails (kind "charge_notice"; …), or have Checkbox Notices send it. (+1 more) | `checkbox.json:8` |

<details><summary><b>Pro checkout</b>: corrected params (verified by the rules engine)</summary>

  "consent_collection": { "terms_of_service": "required" },
  "custom_text": {
    "submit": { "message": "$19.99 every month (plus applicable taxes). Renews automatically until you cancel. Cancel anytime online at https://acme.example/account/cancel before your next billing date to avoid the next charge." },
    "terms_of_service_acceptance": { "message": "I agree that my subscription renews automatically at $19.99 every month until I cancel." }
  }
</details>
```

### The Action {#action}

`uses: checkbox-site/checkbox@v1` sets up Node and runs `npx @checkbox-site/cli@<version> ci`.

| Input | Default | |
|---|---|---|
| `config` | `checkbox.json` | Path to the config, relative to `working-directory`. |
| `strict` | `false` | `true`: needs review fails the job. |
| `working-directory` | `.` | The project root (monorepos: the app's folder). |
| `args` | | More flags, e.g. `--online`, `--changed-only`. |
| `version` | the Action's release | The `@checkbox-site/cli` version to run. |
| `node-version` | `22` | Node 22+ runs TypeScript modules without `tsx`. |

Output `status`: `pass`, `fail`, `needs_review`, `error` or `skipped`. Other CI systems run the same command:
`npx @checkbox-site/cli ci` (exit codes above).

## Offline and online {#online}

Offline (the default) runs the same rules engine and legal catalog as the API, in the job; results carry the same
`rules_version` and `catalog_version`. `--online` sends each check to [`/v1/checks/*`](https://checkbox.site/docs/api/checks) with a key:
use it for preview URLs, or when you want the checks recorded against your account. Give CI a restricted key with
only `checks: read` as the repository secret `CHECKBOX_SECRET_KEY`.

## With an AI coding agent {#agents}

After an agent fixes a checkout with Checkbox (the [Claude skill](https://checkbox.site/docs/build-with-ai), the [MCP server](https://checkbox.site/docs/mcp)),
it offers once to add these checks to CI: it writes `checkbox.json` pointing at the files it just fixed and the
workflow above, runs `checkbox ci` to confirm it passes, and leaves the commit to you.
