---
id: "api-reference/integrations/polar"
type: "doc"
url: "https://affitor.com/docs/api-reference/integrations/polar"
---
# 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.

:::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
- Access to your Polar organization's webhook settings. Sandbox is a separate environment with its own tokens and its own webhook endpoints (`sandbox-api.polar.sh`).

## What you will set up

In your Polar organization, add a webhook endpoint delivering `order.paid`, `refund.created` and `refund.updated` to a route in your own app — `https://<your domain>/api/polar/webhook` in the examples below — and store the signing secret Polar gives you as `POLAR_WEBHOOK_SECRET`. The rest of this page is the route that receives those deliveries, plus the click and signup steps that come before it.

## 1. Capture clicks and track signups

Identical to the framework integration — follow [Step 1](/docs/api-reference/integrations/nextjs#1-capture-the-click) and [Step 2](/docs/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

Create `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);
    }
  },
  // One call per refund, with that refund's own amount. Polar sends refund.created
  // "regardless of status", so report only once the refund has succeeded; a repeat
  // with the same amount (created + updated both succeeded) is ignored by Affitor.
  onRefundCreated: async (payload) => reportRefund(payload.data),
  onRefundUpdated: async (payload) => reportRefund(payload.data),
});

async function reportRefund(refund: { status: string; orderId: string; amount: number; taxAmount?: number }) {
  if (refund.status !== 'succeeded') return;
  const res = await affitor.trackRefund({
    invoiceId: refund.orderId,                             // the ORDER id you sent as the sale's invoiceId
    refundAmountCents: refund.amount + (refund.taxAmount ?? 0), // same basis as the sale (order.totalAmount includes tax)
  });
  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

Report each refund once, with **that refund's own amount**, after it has succeeded (the handler above does this from `refund.created` / `refund.updated`). Use the same tax basis as the sale amount you sent; amounts add up on the sale, and the commission is reversed once they reach the sale amount. Omit `refundAmountCents` to refund everything not yet refunded. Do **not** pass `order.refundedAmount` from `order.refunded`: it is a running total, and each call is counted as a new refund. A retry with the same amount is ignored; to report two separate refunds of the same amount, call the [HTTP endpoint](/docs/api-reference/track-refund) with a distinct `refund_id` for each.

## 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; add the production endpoint and its own signing secret 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="Check the whole chain end to end?" description="affitor onboard fires a synthetic click, lead and sale through the real attribution pipeline, then polls readiness until it passes." href="/docs/brand/cli/commands#affitor-onboard" />
