Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions content/docs/api-reference/integrations/polar.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
---
title: "Polar"
description: "Canonical recipe for attributing Polar payments to Affitor partners — one-time, subscriptions, renewals, and refunds. No Stripe account needed."
---

This guide is the **canonical reference for the Polar sale path**. Polar is a merchant of record, so there is no Stripe account to connect — instead, your app hosts a small webhook route that reports every paid order to Affitor. The CLI generates all of it with one command.

:::note

The `@affitor/sdk` package is **Beta**. The documented happy-path works; report issues on GitHub.

:::

## Prerequisites

- Your **program ID** and a **program API key** (run `npx affitor init`, or dashboard → program settings)
- A Polar organization with checkout working in your app
- A Polar **Organization Access Token** with the `webhooks:write` scope (Polar dashboard → Settings → Developers → New Token). Sandbox is a separate environment with its own tokens (`sandbox-api.polar.sh`).

## Quick path: one command

```bash
npx affitor setup polar # add --sandbox while testing
```

This creates the webhook endpoint on your Polar org (`order.paid` + `order.refunded`, delivered to `https://<your domain>/api/polar/webhook`), saves the signing secret to `.affitor/.env` and your app's `.env` as `POLAR_WEBHOOK_SECRET`, and — for Next.js App Router — generates the glue route below. It is idempotent and never overwrites an existing route; see the [CLI reference](/brand/cli/commands#affitor-setup-polar) for flags and the `--json` agent mode.

The rest of this page documents what that command wires, plus the click/signup steps it does not cover.

## 1. Capture clicks and track signups

Identical to the framework integration — follow [Step 1](/api-reference/integrations/nextjs#1-capture-the-click) and [Step 2](/api-reference/integrations/nextjs#2-track-the-signup) in the Next.js guide, or your framework's equivalent.

Keep the `userId` you send at signup (`signup(userId)` / `trackLead({ customerExternalId: userId })`) — you will plant it on every Polar checkout as `user_id`.

## 2. Carry attribution into the checkout

Polar copies checkout metadata onto the resulting Order **and** Subscription, so the webhook can resolve the partner days later. Two carriers work; use whichever fits your checkout:

### Server-created Checkout Sessions

```ts
// When creating the Polar checkout server-side, attach metadata:
metadata: {
affitor_click_id: affitorClickId, // from the `affitor_click_id` cookie
user_id: user.id, // SAME stable id you used at signup
}
```

### Checkout Links (zero server code)

Append the click id to the link — Polar automatically copies a `?reference_id=` query param into the checkout's metadata, and it propagates to every resulting order, **renewals included**:

```text
https://buy.polar.sh/polar_cl_xxx?reference_id=<affitor_click_id>
```

If you use the `@polar-sh/nextjs` `Checkout` route handler instead, pass `customerExternalId` (your user id) and a URL-encoded `metadata` JSON query param — the adapter forwards both.

## 3. The webhook glue route

`affitor setup polar` generates `app/api/polar/webhook/route.ts` (Next.js App Router). The `Webhooks()` helper validates the Standard-Webhooks signature with `POLAR_WEBHOOK_SECRET` before any callback runs:

```ts
import { Webhooks } from '@polar-sh/nextjs';
import { Affitor } from '@affitor/sdk/server';

const affitor = new Affitor({ apiKey: process.env.AFFITOR_API_KEY ?? '' });

export const POST = Webhooks({
webhookSecret: process.env.POLAR_WEBHOOK_SECRET!,
onOrderPaid: async (payload) => {
const order = payload.data;
// Skip $0 orders (free tiers, 100%-off) — no revenue to attribute.
if (!order.totalAmount || order.totalAmount <= 0) return;
const res = await affitor.trackSale({
customerExternalId: (order.metadata?.user_id as string | undefined)
?? order.customer?.externalId ?? order.customerId,
clickId: (order.metadata?.affitor_click_id ?? order.metadata?.reference_id) as string | undefined,
amount: order.totalAmount, // integer cents
currency: order.currency,
invoiceId: order.id, // idempotency key — 409 = already recorded
saleType: order.subscriptionId ? 'subscription' : 'payment',
isRecurring: order.billingReason === 'subscription_cycle',
subscriptionId: order.subscriptionId ?? undefined,
});
if (!res.ok && res.status !== 409) {
console.error('[affitor] trackSale failed', res.status, res.error);
}
},
onOrderRefunded: async (payload) => {
const order = payload.data;
const res = await affitor.trackRefund({ invoiceId: order.id });
if (!res.ok) {
console.error('[affitor] trackRefund failed', res.status, res.error);
}
},
});
```

:::note

**Field names are the SDK's camelCase.** The `Webhooks()` helper (and `validateEvent` from `@polar-sh/sdk/webhooks`) parse the raw webhook JSON into typed objects: `order.totalAmount`, `order.subscriptionId`, `order.billingReason`. The wire format's snake_case names (`total_amount`, …) are `undefined` on the parsed payload — reading them silently loses the sale amount and attribution.

:::

**Attribution resolution.** Affitor resolves the partner from, in order: `clickId` (the planted `affitor_click_id`, or the checkout link's `reference_id`), then `customerExternalId` matched against the lead you tracked at signup. Supplying both is the recommended default.

## Subscription renewals

Nothing extra to wire: Polar fires `order.paid` on **every** billing cycle, and the metadata planted at checkout (or carried by `reference_id`) propagates to renewal orders. The handler above marks renewals precisely via `billingReason === 'subscription_cycle'` (first subscription payments arrive as `subscription_create`).

## Refunds

`order.refunded` delivers the updated order; `trackRefund({ invoiceId: order.id })` reverses the commission in full (idempotent by `invoiceId`). For **partial** refunds, pass the refunded amount instead: `trackRefund({ invoiceId, refundAmountCents: order.refundedAmount })`.

## Verify

<VerifySuccess
browser={["Visit your site with `?aff=TESTCODE` — an `affitor_click_id` cookie is set"]}
network={["Complete a sandbox checkout — confirm Polar delivers `order.paid` to `/api/polar/webhook` (HTTP 200) and the order's metadata carries your `user_id` / `reference_id`"]}
dashboard={["The sale appears under your program's tracking events with the partner correctly attributed; re-delivering the same webhook is a no-op (Affitor answers 409)"]}
ifNotWorking={["Run `npx affitor test sale` (isolated `is_test` events, no real commissions), then `npx affitor status` — or poll readiness until `integration_verified`"]}
/>

## Common mistakes

<CommonMistakes items={[
"Reading snake_case fields (`order.total_amount`) off the SDK-parsed payload — they are `undefined`; the parsed objects are camelCase (`order.totalAmount`).",
"Subscribing to `order.created` instead of `order.paid` — `order.created` fires before payment; you would record unpaid orders.",
"Forgetting `POLAR_WEBHOOK_SECRET` (or `AFFITOR_API_KEY`) in the deploy environment — the route validates every delivery with it, so webhooks fail after deploy even though local dev worked.",
"Mixing environments — sandbox and production are separate Polar environments with separate tokens AND separate webhook endpoints; re-run `affitor setup polar` (without `--sandbox`) at go-live.",
"Different user IDs at signup vs checkout — `metadata.user_id` must equal the `customerExternalId` you sent at signup, or sales stop attributing once the click cookie expires.",
"Calling trackSale from the browser — sale tracking is server-side only; the program API key must never ship to a client."
]} />

<NextStep title="Prefer the full CLI flow?" description="affitor onboard detects Polar, injects the sale call into an existing webhook route, and proves attribution with a synthetic chain." href="/brand/cli/commands#affitor-onboard" />
1 change: 1 addition & 0 deletions content/docs/api-reference/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"integrations/nextjs-supabase",
"integrations/nextjs-nextauth",
"integrations/stripe",
"integrations/polar",
"integrations/node-express",
"integrations/fastify",
"---Concepts---",
Expand Down
51 changes: 44 additions & 7 deletions content/docs/brand/cli/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ npx affitor init \

## `affitor onboard`

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

```bash
npx affitor onboard
Expand All @@ -89,12 +89,12 @@ An API key is required. `onboard` resolves it from `--api-key`, the `AFFITOR_API

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** — locates your Stripe webhook handler and injects the `affitor.trackSale` call after the event is verified. It also persists `AFFITOR_API_KEY` into `.env` / `.env.local` (never overwriting an existing value).
3. **Server sale** — locates your payment webhook handler and injects the `affitor.trackSale` call after the event is verified: for Stripe, after `stripe.webhooks.constructEvent` in the `checkout.session.completed` case; for Polar, inside the `@polar-sh/nextjs` `Webhooks({ onOrderPaid })` callback (no webhook route yet? it points you at `affitor setup polar`). 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 isn't Stripe, it degrades to **printing the exact snippets** for you to paste rather than guessing an edit site. Auto-edits to the Stripe handler always show a diff and ask for confirmation first (unless `--yes` / `--no-interactive`).
`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 has no auto-edit path (Lemon Squeezy, Paddle), it degrades to **printing the exact snippets** 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** — it reports each step as `manual` so an agent drives the edits explicitly, then still fires the verification chain and polls readiness.

Expand Down Expand Up @@ -154,11 +154,47 @@ npx affitor setup stripe
|---|---|
| `customer.created` | Lead tracking |
| `checkout.session.completed` | Sale tracking |
| `invoice.payment_succeeded` | Recurring commission |
| `invoice.paid` | Recurring commission |
| `invoice.payment_failed` | Failed payment alerts |
| `charge.refunded` | Automatic commission clawback |
| `customer.subscription.deleted` | Churn tracking |

The endpoint is created on your Stripe account pointing at Affitor's global webhook ingest route (`https://api.affitor.com/api/webhook-distributor/stripe`).

---

## `affitor setup polar`

Connect Polar (merchant of record — no Stripe account needed): creates the webhook endpoint on your Polar organization and generates the self-hosted glue route that reports every sale and refund to Affitor.

```bash
npx affitor setup polar
```

**What happens:**

1. A webhook endpoint is created on your Polar org (events `order.paid` + `order.refunded`, format `raw`) pointing at your app — default `https://<your domain>/api/polar/webhook`
2. The signing secret is saved to `.affitor/.env` (gitignored) and written as `POLAR_WEBHOOK_SECRET` into your app's `.env` / `.env.local`
3. For Next.js (App Router), the glue route `app/api/polar/webhook/route.ts` is generated — it validates the Standard-Webhooks signature via `@polar-sh/nextjs` and calls `trackSale` / `trackRefund` from `@affitor/sdk/server`

| Flag | Description |
|---|---|
| `--token <token>` | Polar Organization Access Token (or `POLAR_ACCESS_TOKEN` env; prompted otherwise) |
| `--sandbox` | Use the Polar sandbox environment (`sandbox-api.polar.sh` — sandbox tokens are separate) |
| `--url <url>` | Webhook delivery URL override |

The token needs the `webhooks:write` scope (Polar dashboard → Settings → Developers → New Token).

**Idempotent:** re-running reuses an endpoint that already delivers to the same URL (the signing secret is recovered from the Polar API), adds any missing events, and never overwrites an existing route file — run `affitor onboard` to inject the sale call into a route you wrote yourself.

**Agent mode:** with `--json` no app files are edited; the output includes the full route (`route.path`, `route.source`, `route.deps`, `route.env`) plus `next_actions` so an agent applies it explicitly:

```bash
npx affitor setup polar --token $POLAR_ACCESS_TOKEN --sandbox --json
```

Renewals need nothing extra — Polar fires `order.paid` on every billing cycle and checkout metadata (including a checkout link's `?reference_id=`) propagates to those orders. See the [Polar integration guide](/api-reference/integrations/polar) for the full recipe.

---

## `affitor status`
Expand Down Expand Up @@ -219,7 +255,7 @@ Configuration is stored in `.affitor/config.json`:

```json
{
"version": 1,
"version": 2,
"program_id": "430",
"domain": "example.com",
"commission": {
Expand All @@ -228,15 +264,16 @@ Configuration is stored in `.affitor/config.json`:
"duration_months": 12
},
"cookie": {
"name": "_aff",
"name": "affitor_click_id",
"duration_days": 90
},
"stripe_connected": false,
"api_key": "affitor_...",
"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.

---

## Error Messages
Expand Down