Polar

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.

Info

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

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 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 and Step 2 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

// 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
}

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:

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:

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);
    }
  },
});
Info

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

Verify success
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)
If it doesn't work
  • Run `npx affitor test sale` (isolated `is_test` events, no real commissions), then `npx affitor status` — or poll readiness until `integration_verified`

Common mistakes

Common mistakes
  • 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.
Next recommended step
Prefer the full CLI flow?

affitor onboard detects Polar, injects the sale call into an existing webhook route, and proves attribution with a synthetic chain.

Continue
© 2026 Affitor