# Affitor Integration Skill

> A self-contained runbook for an AI coding agent. If a founder told you:
> *"Follow https://affitor.com/docs/skill.md to integrate Affitor. Program `<id>` on `<api_base>`. Key in `AFFITOR_API_KEY`."*
> — you are in the right place. Execute the steps below **in order**. Do not re-read Affitor source; everything you need is here.
>
> Source of truth for fields/endpoints is the OpenAPI spec at `<api_base>/openapi.yaml` (mirror: https://affitor.com/docs/openapi.yaml). Never invent fields not in this file or that spec.

Affitor tracks affiliate **click → lead (signup) → sale** and attributes revenue to the partner who referred each customer. Your job: wire those three events into this brand's app, then prove the loop closes via the readiness endpoint.

---

## 0. Inputs (from the trigger line)

| Input | Where it comes from | Example |
|---|---|---|
| `PROGRAM_ID` | the trigger line | `42` |
| `API_BASE` | the trigger line (per-env — do **not** hardcode prod) | `https://api.affitor.com` |
| API key | the env var `AFFITOR_API_KEY` (already set by the founder) | *(never print it)* |

- `API_BASE` is **per environment**. Production is `https://api.affitor.com`, but uat/staging differ. Always use the value from the trigger line. If it was not given, ask — do not assume prod.
- The API key authenticates **server-side** calls (lead/sale/refund) as `Authorization: Bearer $AFFITOR_API_KEY`. **Never** print it, inline it into a file, paste it into a client bundle, or commit it. Read it from the environment only.

---

## 1. Prerequisites — verify before writing code

1. Confirm `AFFITOR_API_KEY` is present in the environment (`test -n "$AFFITOR_API_KEY"`). **Do not echo its value.** If missing, stop and tell the founder to set it.
2. Note `PROGRAM_ID` and `API_BASE` from the trigger line. Keep `PROGRAM_ID` as a non-secret config value (a public env var like `NEXT_PUBLIC_AFFITOR_PROGRAM_ID` is fine — it is not a secret).
3. Detect the stack: package manager (`npm` / `pnpm` / `yarn` / `bun` from the lockfile), framework (Next.js App Router vs Pages Router, Vite/React, plain HTML), and payment provider (Stripe vs other).

---

### Optional local agent connection

CLI 0.4.5 can preview project-scoped MCP configuration and an Affitor skill with `npx affitor@0.4.5 agent setup --dry-run --json`. A detected agent selects itself and can configure only itself. Review the plan before applying it. See [Set up a coding agent](https://affitor.com/docs/brand/cli/agent-setup) for explicit client selection, private key configuration, and Codex's manual step.

`affitor doctor` checks local files and credential format only; it does not authenticate the key or prove a live session. Restart the client, call `affitor_readiness`, and confirm the program identity. Ask before synthetic verification or other writes.

## 2. Identifier contract — the #1 attribution breaker

Attribution only works if **one stable internal user ID** is identical across every surface. Pick the brand's canonical user id (e.g. `user.id`) and use the **same value** everywhere:

| Surface | Field on the wire | SDK argument |
|---|---|---|
| Browser signup helper | `customer_key` | `signup(customerKey, email)` — 1st arg |
| Lead API (server) | `customer_key` | `trackLead({ customerExternalId })` |
| Sale API (server) | `customer_key` | `trackSale({ customerExternalId })` |
| Stripe metadata | `affitor_customer_key` | (set on the Checkout Session) |
| Stripe click linkage | `affitor_click_id` | (set on the Checkout Session) |

If signup uses `user.id` but checkout uses `user.email`, sales will not attribute. **Same id, every time.**

---

## 3. Install the SDK

Use the **detected** package manager. One package, two entry points:

- **`@affitor/sdk`** — browser/click + signup (`init`, `signup`, `getClickId`). SSR-safe (no-ops on the server).
- **`@affitor/sdk/server`** — server-side lead/sale/refund, Bearer-authenticated.

```bash
# browser + server tracking (any framework that bundles JS / Node backend)
npm i @affitor/sdk
```

**Non-Node sites** (plain HTML, Rails, PHP, Django, etc.): skip `@affitor/sdk` and use the script tag (§4c). Use raw HTTP `POST` (§6c) for server events instead of `@affitor/sdk/server`.

---

## 4. Click tracking (browser)

Load the tracker on **every page** so `?via=<code>` and `?aff=<code>` visits are captured into the `affitor_click_id` first-party cookie. **Always pass `API_BASE`** when it is not prod.

### 4a. Next.js — App Router

```tsx
// app/affitor-init.tsx — client component
'use client';
import { useEffect } from 'react';
import { loadAffitor } from '@affitor/sdk';

const programId = process.env.NEXT_PUBLIC_AFFITOR_PROGRAM_ID!; // = PROGRAM_ID
const apiBase = process.env.NEXT_PUBLIC_AFFITOR_API_BASE;      // = API_BASE, unset for prod

export function AffitorInit() {
  useEffect(() => {
    // The tracker sends events to window.AFFITOR_API_BASE when it is set, otherwise to production.
    // scriptUrl only chooses where the tracker script is loaded from.
    if (apiBase) (window as Window & { AFFITOR_API_BASE?: string }).AFFITOR_API_BASE = apiBase;
    void loadAffitor(programId, {
      scriptUrl: apiBase ? `${apiBase}/js/affitor-tracker.js` : undefined,
    });
  }, []);
  return null;
}
```

```tsx
// app/layout.tsx — render once, inside <body>
import { AffitorInit } from './affitor-init';
// ...
<body>
  <AffitorInit />
  {children}
</body>
```

### 4b. Next.js — Pages Router / Vite / plain React

**Pages Router:** use the same `AffitorInit` component as 4a without the `'use client';` line, and render `<AffitorInit />` once in `pages/_app.tsx`.

**Vite / plain React:** read the Vite env instead of `process.env`:

```tsx
// src/AffitorInit.tsx — render <AffitorInit /> once in your root component
import { useEffect } from 'react';
import { loadAffitor } from '@affitor/sdk';

export function AffitorInit() {
  useEffect(() => {
    const apiBase: string | undefined = import.meta.env.VITE_AFFITOR_API_BASE; // = API_BASE, unset for prod
    if (apiBase) (window as Window & { AFFITOR_API_BASE?: string }).AFFITOR_API_BASE = apiBase;
    void loadAffitor(import.meta.env.VITE_AFFITOR_PROGRAM_ID, { // = PROGRAM_ID
      scriptUrl: apiBase ? `${apiBase}/js/affitor-tracker.js` : undefined,
    });
  }, []);
  return null;
}
```

### 4c. Plain HTML — script tag (no bundler)

Add to `<head>` on every page. `data-affitor-api-base` is **only needed for non-prod** (uat/staging):

```html
<script src="https://api.affitor.com/js/affitor-tracker.js"
  data-affitor-program-id="PROGRAM_ID"
  data-affitor-api-base="API_BASE"></script>
```

For production you may drop `data-affitor-api-base` (defaults to `https://api.affitor.com`). The script exposes `window.affitor.init(...)` and `window.affitor.signup(...)`. The tracker `src` host should match `API_BASE` (e.g. `API_BASE/js/affitor-tracker.js`).

---

## 5. Lead tracking (signup)

Fire **once** when a user account is created, with the canonical `customerKey` (§2).

### 5a. Browser (after the tracker has loaded)

```javascript
import { loadAffitor } from '@affitor/sdk';
// in your post-registration success handler:
const affitor = await loadAffitor(PROGRAM_ID); // same instance as §4
await affitor?.signup(user.id, user.email); // customerKey = user.id (same id used at sale)
// script-tag equivalent:  await window.affitor.signup(user.id, user.email);
```

The browser call needs no API key — the click cookie proves attribution.

### 5b. Server (recommended — survives ad-blockers / SSR auth)

```ts
import { Affitor } from '@affitor/sdk/server';
const affitor = new Affitor({
  apiKey: process.env.AFFITOR_API_KEY!,
  apiUrl: process.env.AFFITOR_API_BASE, // = API_BASE; omit for prod
});

// in your signup handler: read the affitor_click_id cookie and send it on every lead
await affitor.trackLead({
  customerExternalId: user.id, // → customer_key on the wire
  email: user.email,
  clickId: req.cookies['affitor_click_id'], // a first lead without it answers 400 (Customer not found)
});
```

> Every program uses `lead_auth: server_required` unless it is explicitly set to `browser_allowed`. Under `server_required`, a **browser-only** lead is recorded as *provisional* and only settles when your **server** sends the lead (5b) or a Stripe Checkout payment for that customer arrives through your connected Stripe account (a sale sent to `/api/v1/track/sale` does not settle it). When in doubt, do **both** 5a and 5b: they are idempotent on the customer.

---

## 6. Sale tracking — 3 modes, in priority order

Pick the **first** mode that fits the brand's stack. Do not double-report the same sale across modes.

### 6a. Stripe (recommended) — metadata + webhook autocapture

**Connect Stripe first.** This mode records nothing until the brand's Stripe account is connected to the program in the Affitor dashboard: program menu → **Program settings** → **Payments** → **Connect Stripe**. Only the program owner can do this, in the browser; the API key cannot. Ask the founder to do it, and rely on this mode only once the Stripe card there reads **Connected**. If Stripe will not be connected, use §6b or §6c instead.

If the brand uses Stripe Checkout, you do **not** call a sale API. Inject the metadata fields below when creating the Checkout Session; Affitor's Stripe Connect webhook reads them and creates the sale + commission automatically. Once Stripe is connected, do not also call `trackSale` for the same Stripe payments (inspect any existing manual call before combining the paths): a subscription's first payment can be recorded twice.

```ts
import { loadAffitor } from '@affitor/sdk';
const clickId = (await loadAffitor(PROGRAM_ID))?.getClickId(); // client: read the cookie click id
// pass clickId to your server when starting checkout, OR read the
// affitor_click_id cookie server-side.

const session = await stripe.checkout.sessions.create({
  // ...your normal params...
  metadata: {
    affitor_click_id: clickId,        // from the affitor_click_id cookie
    affitor_customer_key: user.id,    // SAME id as the lead (§2)
    program_id: 'PROGRAM_ID',
  },
  // For subscriptions, DUPLICATE the same metadata onto the subscription:
  subscription_data: {
    metadata: {
      affitor_click_id: clickId,
      affitor_customer_key: user.id,
      program_id: 'PROGRAM_ID',
    },
  },
});
```

Missing `subscription_data.metadata` is the most common subscription-attribution bug: without it the click id never reaches the subscription's invoices (its first payment and renewals), and Affitor can only match the buyer by email or Stripe customer id.

With CLI 0.4.5, `onboard` reads the selected program's `stripe_account_bound` when the app's runtime key is certain. A confirmed binding skips sale injection. A false/missing binding, failed status read, or uncertain key leaves the webhook unchanged in unattended runs; false does not rule out shared-account or delayed Connect events. An interactive manual path needs explicit confirmation. Existing calls are never removed automatically. See [the CLI binding decision](https://affitor.com/docs/brand/cli/commands#stripe-binding-in-045).

### 6b. `@affitor/sdk/server` at your payment webhook (Stripe-but-custom, or non-Checkout)

```ts
// in your payment-success / invoice-paid webhook handler:
await affitor.trackSale({
  customerExternalId: user.id, // → customer_key
  amount: 4900,                // integer CENTS ($49.00 = 4900)
  invoiceId: invoice.id,       // → transaction_id (unique idempotency key)
  currency: 'USD',
  saleType: 'subscription',    // optional; 'payment' (default) | 'subscription'
  isRecurring: true,           // optional, for renewals
});
```

### 6c. Raw `POST /api/v1/track/sale` — non-Stripe / pure S2S (no client tag)

Many brands have **no browser tag at all** and track entirely server-side. This branch is fully supported: bind the customer at lead time (§5b), then report sales by `customer_key` alone. Authenticated with the Bearer key.

With no tag, nothing sets the `affitor_click_id` cookie, and a first lead without a click id answers 400. So record the click on your server when a visitor lands with `?via=` or `?aff=`, keep the `click_id` in your own first-party cookie or session, and send it as `clickId` in §5b. Send the id you already hold as `existingClickId`, so a visitor who comes back through another partner keeps their first partner (the same rule the browser tracker follows):

```ts
// landing request handler, only when the URL carries ?via= or ?aff=
const code = url.searchParams.get('aff') ?? url.searchParams.get('via'); // same order as the server
const stored = readClickCookie(req); // { clickId, code } saved on an earlier visit, if any
if (stored?.code !== code) {        // same partner as last time (a reload): record nothing, like the tracker
  const res = await affitor.trackClick({
    affiliateUrl: url.href,         // includes ?via=<code> or ?aff=<code>
    pageUrl: url.href,
    referrerUrl: req.headers.referer,
    existingClickId: stored?.clickId, // keeps the customer and its first partner
  });
  const data = res.data as { click_id?: string; cookie_window_days?: number } | null;
  if (data?.click_id) {
    // save { clickId: data.click_id, code } for data.cookie_window_days days; pass clickId to trackLead
  }
}
```

The SDK call sends no key, so the click gets your server's location and User-Agent. For your visitor's, use raw HTTP: `POST /api/v1/track/click` with `affiliate_url`, `existing_click_id`, `user_agent`, plus the Bearer key and `visitor_ip`. See `/docs/api-reference/track-click`.

Then report each sale:

```bash
curl -X POST "$AFFITOR_API_BASE/api/v1/track/sale" \
  -H "Authorization: Bearer $AFFITOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "transaction_id": "txn_unique_per_sale",
    "customer_key": "user_123",
    "amount_cents": 4900,
    "currency": "USD"
  }'
```

- Required: `transaction_id` (unique — a duplicate returns **HTTP 409**, treat as already-recorded, not a retry) and `amount_cents` (positive integer cents), plus **one of** `customer_key` or `click_id`.
- Do **not** send a partner code or a pre-computed commission — Affitor resolves the partner and computes commission from the customer's attribution.
- If the customer's attribution window expired, you get `200` with `attributed: false` and nothing is created (the sale is organic). That is expected, not an error.

---

## 7. Refund / chargeback

Reverse the commission when a sale is refunded. Keyed by the **original sale's** `transaction_id` (not the refund event's id). Stripe webhook tracking handles `charge.refunded` automatically — only call this for non-Stripe / S2S.

```ts
await affitor.trackRefund({
  invoiceId: invoice.id,          // → transaction_id of the ORIGINAL sale
  refundAmountCents: 5000,        // THIS refund's own amount (adds up); omit to refund the rest
  refundReason: 'customer_request',
});
```

Raw HTTP equivalent:

```bash
curl -X POST "$AFFITOR_API_BASE/api/v1/track/refund" \
  -H "Authorization: Bearer $AFFITOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "transaction_id": "txn_unique_per_sale", "refund_reason": "customer_request" }'
```

---

## 8. VERIFY — close the loop (do this LAST, after deploying the tracker)

Affitor can prove the integration works end-to-end without a real customer or real money.

### 8a. Fire a synthetic click → lead → sale chain

```bash
curl -X POST "$AFFITOR_API_BASE/api/v1/cli/test-event" \
  -H "Authorization: Bearer $AFFITOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "type": "chain" }'
```

Response shape:

```json
{
  "data": {
    "type": "chain",
    "short_code": "afftest42",
    "customer_key": "aff_test_ck_42_...",
    "verdict": { "click": "attributed", "lead": "attributed", "sale": "attributed" },
    "attributed": true
  }
}
```

Every step should read `attributed`. If `sale` is `unattributed`, the program has no active sale-commission policy yet (economics gate) — see §8c. `HTTP 429` means too many chains (max 10/program/hour); respect `Retry-After`.

### 8b. Poll readiness until verified

```bash
curl "$AFFITOR_API_BASE/api/v1/programs/me/readiness" \
  -H "Authorization: Bearer $AFFITOR_API_KEY"
```

Poll every `poll.retry_after_seconds` (default 5s) until `integration_verified: true`. Response (abridged):

```json
{
  "program_id": 42,
  "state": "integration_verified",
  "integration_verified": true,
  "blocker": null,
  "gates": {
    "profile":   { "status": "pass" },
    "economics": { "status": "pass" },
    "payout":    { "status": "pass", "mode": "stripe" },
    "payments":  { "status": "pass", "method": "stripe_connect" },
    "tracking":  { "status": "pass", "detail": "tracker_installed" },
    "live":      { "status": "pass", "test_chain": { "click": "attributed", "lead": "attributed", "sale": "attributed" } }
  },
  "last_event_at": { "click": null, "lead": null, "sale": null },
  "counts_24h": { "clicks": 0, "leads": 0, "sales": 0 },
  "poll": { "retry_after_seconds": 5 }
}
```

### 8c. Self-correct from the gates

`blocker` names the first of the four completion gates (`profile`, `economics`, `tracking`, `live`, in that order) that has not passed. `payout` and `payments` are reported but never become `blocker`. Each failing gate carries a `next_action` string. Map it to an action:

| Gate / `next_action` | What it means | What to do |
|---|---|---|
| `profile` / `complete_program_profile` | The program has no name or no webpage URL | Tell the founder to finish the program profile in the dashboard |
| `economics` / `set_commission_policy` | No active sale-commission policy → synthetic sale can't resolve | Founder sets a commission policy (CPS) in the dashboard |
| `payout` / `surface_stripe_connect_url` | Stripe Connect not live (reported only, never `blocker`) | Founder connects Stripe (gate `endpoint` points to the connect URL) |
| `payments` / `send_test_sale` or `connect_stripe` | The program cannot record sales yet: its Stripe account is not connected and your server has not sent a sale. Required to go live, never `blocker` | `send_test_sale`: send one sale from your server to `/api/v1/track/sale` (§6c; a test sale with `additional_data.test_mode: true` counts). `connect_stripe`: founder connects Stripe in the dashboard |
| `tracking` / `install_tracker_or_send_events` | No tracker tag found AND no real/S2S events yet | Re-check §4 (client) or that your server is hitting `/api/v1/track/*` |
| `live` / `run_synthetic_sale_chain` | No real event and no attributed synthetic chain | Run §8a |

`integration_verified: true` is your **done** signal: the four completion gates pass, and `live` passed through an attributed synthetic chain or a **real** (non-test) event. `state` tells the two apart: `integration_verified` while no real event exists, `live_verified` (even stronger) once one has flowed.

---

## 9. Guardrails (non-negotiable)

- **Diff, don't auto-commit.** Edits to checkout/signup/webhook code must be shown to the founder as a reviewable diff. Never auto-commit or push.
- **Never print, inline, or commit `AFFITOR_API_KEY`.** Read it from the environment. Add `.env*` to `.gitignore` if not already ignored. The key is server-side only — never ship it to a browser bundle.
- **Never hardcode the API base to prod.** Use `API_BASE` from the trigger line everywhere (client `window.AFFITOR_API_BASE` set before `loadAffitor` / `data-affitor-api-base`, server `apiUrl`).
- **Never invent fields or endpoints.** The wire contract is exactly what is in this file. The authoritative spec is `<API_BASE>/openapi.yaml`. If you need a field that is not documented, stop and ask.
- **Money is in cents.** `$49.00 = 4900`. `transaction_id` is unique per sale.

---

## 10. Anti-patterns (these silently break attribution)

- **Mismatched customer ids** — `user.id` at signup but `user.email` (or a Stripe customer id) at sale. Use the same id everywhere (§2).
- **Missing `affitor_click_id` in Stripe metadata** — the webhook can't link the payment to the click. Always set it on the Checkout Session.
- **Missing `subscription_data.metadata`**: one-time payments attribute but subscriptions may not, because the click id never reaches the subscription's invoices.
- **Firing the verify chain (§8) before the tracker is deployed** — readiness will report `tracking`/`live` failing and you'll chase a non-bug. Deploy first, verify last.
- **Hardcoding `https://api.affitor.com`** when the founder gave a uat/staging `API_BASE` — events land in the wrong environment.
- **Shipping the API key to the client** — `@affitor/sdk/server` and the Bearer key are server-only; the browser uses `@affitor/sdk` (no key).
- **Treating `409` (duplicate `transaction_id`) or `attributed: false` (window expired) as failures** — both are normal, expected outcomes.

---

## Endpoint quick reference

| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
| `/api/v1/track/click` | POST | none (public; Bearer key optional, for `visitor_ip`) | Record a click (`?via=` or `?aff=` code proves attribution). Usually the browser tracker; your server when there is no browser tag (§6c). |
| `/api/v1/track/lead` | POST | Bearer (server) or none (browser) | Tie `customer_key` to the click at signup |
| `/api/v1/track/sale` | POST | Bearer | Report revenue; Affitor creates the commission |
| `/api/v1/track/refund` | POST | Bearer | Reverse a commission on refund/chargeback |
| `/api/v1/cli/test-event` `{ "type": "chain" }` | POST | Bearer | Fire a synthetic click→lead→sale to verify the pipeline |
| `/api/v1/programs/me/readiness` | GET | Bearer | Poll the 6-gate verdict until `integration_verified: true` |

Full spec: `<API_BASE>/openapi.yaml`  ·  Docs: https://affitor.com/docs
