diff --git a/content/docs/api-reference/integrations/polar.mdx b/content/docs/api-reference/integrations/polar.mdx new file mode 100644 index 0000000..f4cc1de --- /dev/null +++ b/content/docs/api-reference/integrations/polar.mdx @@ -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:///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= +``` + +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 + + + +## Common mistakes + + + + diff --git a/content/docs/api-reference/meta.json b/content/docs/api-reference/meta.json index 7436ea2..cb08a5b 100644 --- a/content/docs/api-reference/meta.json +++ b/content/docs/api-reference/meta.json @@ -18,6 +18,7 @@ "integrations/nextjs-supabase", "integrations/nextjs-nextauth", "integrations/stripe", + "integrations/polar", "integrations/node-express", "integrations/fastify", "---Concepts---", diff --git a/content/docs/brand/cli/commands.mdx b/content/docs/brand/cli/commands.mdx index 1ee9029..9933cd6 100644 --- a/content/docs/brand/cli/commands.mdx +++ b/content/docs/brand/cli/commands.mdx @@ -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 @@ -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 `` 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. @@ -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:///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 ` | 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 ` | 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` @@ -219,7 +255,7 @@ Configuration is stored in `.affitor/config.json`: ```json { - "version": 1, + "version": 2, "program_id": "430", "domain": "example.com", "commission": { @@ -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