> ## Documentation Index
> Fetch the complete documentation index at: https://highseat.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Connect a payment provider

> Connect Stripe or Polar with a webhook so every sale lands in Highseat, and pass attribution_id at checkout to credit each sale to the visit that made it.

Highseat records revenue from your payment provider's webhook. The amount, the currency and the product names come from the provider, never from the browser, so a blocked tracker cannot lose a sale and an edited request cannot invent one. Each site connects its own providers in **Site settings › Revenue**, on the **Payment providers** card.

Connecting takes three steps, which the **Connect** button on a provider's row walks you through: add a webhook endpoint in the provider, select its events, and paste the secret the provider shows for that endpoint. The guides below follow the same steps in each provider's own dashboard.

## Stripe

**1. Copy the webhook URL**

In Highseat, open **Site settings › Revenue** and click **Connect** on the Stripe row. Copy the URL from the first step. It looks like `https://app.highseat.com/api/webhooks/stripe/…` and belongs to this site alone.

**2. Add the endpoint in Stripe**

In the Stripe Dashboard, open the **Webhooks** tab in Workbench (**Developers › Webhooks**, or [dashboard.stripe.com/webhooks](https://dashboard.stripe.com/webhooks)) and click **Create an event destination**.

1. Select **Your account**.
2. Choose the **Snapshot** payload format. Highseat reads the payment from the event itself, and a thin event carries only the object's id.
3. Select these six events: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `invoice.paid`, `refund.created`, `charge.dispute.funds_withdrawn` and `charge.dispute.funds_reinstated`.
4. Click **Continue**, select **Webhook endpoint**, and click **Continue** again.
5. Paste the URL from Highseat as the **Endpoint URL** and create the destination.

**3. Paste the signing secret**

On the endpoint's page in Stripe, reveal the signing secret and copy it. It starts with `whsec_`. Paste it into the third step in Highseat and click **Connect Stripe**.

The row then reads "Connected. Waiting for the first event." until Stripe sends one.

Stripe keeps a sandbox and live mode apart, each with its own endpoints and signing secrets. Highseat records a sandbox sale the same way as a live one, so connect the live endpoint once a test sale has shown up.

### What Stripe sends

* **A one-time Checkout payment** is recorded when the session is paid. A payment method that settles later, such as a bank transfer or SEPA Direct Debit, is recorded on `checkout.session.async_payment_succeeded`, when the money arrives.
* **A subscription** is recorded from `invoice.paid`, for the first payment and every renewal. Any other paid invoice is recorded the same way.
* **A refund** comes off the sale it belongs to.
* **A dispute** comes off the sale it belongs to when Stripe withdraws the disputed amount (`charge.dispute.funds_withdrawn`). When you win the dispute and Stripe returns the funds (`charge.dispute.funds_reinstated`), the amount goes back on.

A payment made outside Checkout and invoices, such as a PaymentIntent your own code confirms, is not recorded. Stripe amounts include tax.

An endpoint that subscribes to only the first four events records no disputes. To record them on a site that is already connected, open its endpoint in the Stripe Dashboard and add `charge.dispute.funds_withdrawn` and `charge.dispute.funds_reinstated` to the events it sends. The URL and the signing secret stay the same.

### Product names for Checkout

Stripe's Checkout webhook carries no line items, so a one-time Checkout sale shows its product as "Unknown". A restricted API key lets Highseat read the line items and name the product. Invoices carry their lines already and need no key.

1. In the Stripe Dashboard, open the **API keys** page ([dashboard.stripe.com/apikeys](https://dashboard.stripe.com/apikeys)) and click **Create restricted key**.
2. Name the key, set **Checkout Sessions** to **Read**, and leave every other permission at **None**.
3. Click **Create key** and copy the key. It starts with `rk_live_`.
4. In Highseat, click **Add key** under **Restricted API key** on the Stripe row, paste the key and click **Save**.

Highseat makes one test read with the key when you save it, and tells you when the key is rejected or lacks the permission.

## Polar

**1. Copy the webhook URL**

In Highseat, open **Site settings › Revenue** and click **Connect** on the Polar row. Copy the URL from the first step. It looks like `https://app.highseat.com/api/webhooks/polar/…` and belongs to this site alone.

**2. Add the endpoint in Polar**

In Polar, open your organization's **Settings › Webhooks** and click **Add Endpoint**.

1. Paste the URL from Highseat into the URL field.
2. Leave the format on **Raw**.
3. Generate a secret, or set your own, and copy it.
4. Select these two events: `order.paid` and `refund.created`.
5. Create the endpoint.

**3. Paste the webhook secret**

Paste the secret into the third step in Highseat and click **Connect Polar**.

Polar is the merchant of record, so Highseat records each order's net amount: after discounts, before tax. A one-time order and every subscription payment arrive as `order.paid`, and a refund comes off the order it belongs to. Polar sends no webhook event for a dispute, so Highseat does not see a chargeback on a Polar order. Polar's sandbox is a separate organization with its own webhooks, and Highseat records its orders like live ones.

## Pass the attribution id to your checkout

A webhook says what was sold. It cannot say which visit sold it, because the provider never sees your pages. The tracker gives each browser tab an attribution id, and your checkout carries it to the provider in the checkout's metadata, under the key `attribution_id`. The sale then joins the visit that opened the checkout.

Read the id in the browser with `getAttributionId()` from the `highseat` package:

```ts theme={null} theme={null} theme={null}
import { getAttributionId } from "highseat"

const attributionId = await getAttributionId()
await createCheckout({ metadata: { attribution_id: attributionId } })
```

`getAttributionId()` never makes a checkout hang. It resolves to `undefined` at once when the page has no tracker script or the script failed to load, and after 3 seconds when the script is still loading. Pass `{ timeout }` in milliseconds to wait less, for example `getAttributionId({ timeout: 500 })` on a page where the tracker has long loaded. On a site with the plain script tag, `window.highseat.getAttributionId()` returns the id once the tracker has loaded.

Where the id goes depends on the checkout:

| Checkout | Field |
| - | - |
| Stripe Checkout, `mode: "payment"` | `metadata.attribution_id` on the Checkout Session |
| Stripe Checkout, `mode: "subscription"` | `subscription_data.metadata.attribution_id`, which Stripe copies to the subscription and its invoices |
| Stripe invoice you create yourself | `metadata.attribution_id` on the invoice |
| Polar checkout | `metadata.attribution_id`, which Polar copies to the order |

### Create the checkout on your server

A provider checkout is created on your server, with your secret key, so the id travels in two hops: the browser reads it and sends it with the checkout request, and the server writes it into the metadata.

```ts Browser theme={null}
import { getAttributionId } from "highseat"

async function openCheckout(priceId: string) {
  const attributionId = await getAttributionId()
  const response = await fetch("/api/checkout", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ priceId, attributionId }),
  })
  const { url } = await response.json()
  location.assign(url)
}
```

The server side, for each provider:

**Stripe**

```ts app/api/checkout/route.ts theme={null}
import Stripe from "stripe"

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "")

export async function POST(request: Request) {
  const { priceId, attributionId } = await request.json()
  const metadata = attributionId ? { attribution_id: attributionId } : {}

  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: "https://example.com/thanks",
    metadata,
  })

  return Response.json({ url: session.url })
}
```

For a subscription, set `mode: "subscription"` and pass the same object as `subscription_data: { metadata }`. Stripe Checkout runs on Stripe's own page in the buyer's browser, so Stripe reads the buyer's IP address itself.

**Polar**

```ts app/api/checkout/route.ts theme={null}
/**
 * The buyer's IP address. Behind a proxied Cloudflare record, the first X-Forwarded-For entry is
 * a Cloudflare edge address, so cf-connecting-ip comes first.
 */
function getBuyerIp(request: Request) {
  return (
    request.headers.get("cf-connecting-ip") ??
    request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
    null
  )
}

export async function POST(request: Request) {
  const { productId, attributionId } = await request.json()

  const response = await fetch("https://api.polar.sh/v1/checkouts/", {
    method: "POST",
    headers: {
      authorization: `Bearer ${process.env.POLAR_ACCESS_TOKEN}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      products: [productId],
      success_url: "https://example.com/thanks",
      customer_ip_address: getBuyerIp(request),
      metadata: attributionId ? { attribution_id: attributionId } : {},
    }),
  })
  const checkout = await response.json()

  return Response.json({ url: checkout.url })
}
```

Polar takes the buyer's IP address as `customer_ip_address`, and your server has to read it from the request. Trust `cf-connecting-ip` only when your origin accepts traffic from Cloudflare alone: anyone who reaches the origin directly can set that header to any value. It is the same rule the proxy handler's `clientIp: "cloudflare"` option follows.

## How a sale finds its visit

The attribution id reaches the events of the tab that opened the checkout. Their sessions bring in every page view of the same visit, so a checkout opened in a new tab still credits the tab that arrived from a search engine. When your site calls `identify()` for a signed-in buyer, every earlier session of that user within the lookback window joins too. A buyer who signed up from a Reddit post two weeks before paying in a direct visit then credits Reddit under first touch. Only a user id seen before the payment counts, so a later sign-in never changes a sale's credit.

The **Attribution** card in **Site settings › Revenue** picks the **Model** (Last touch by default, or First touch, or Linear) and the **Lookback** (30 days by default, or 7, 14 or 90). A change applies to every past sale.

## A provider sale and a tracker purchase

With a provider connected, its webhook is the authoritative record of a sale. A `purchase` event from `track()` whose `order_id` matches the provider's order id is left out of revenue totals, so connecting a provider never counts a sale twice. The order id is:

| Sale | Order id |
| - | - |
| Stripe one-time Checkout payment | The PaymentIntent id (`pi_…`), or the Checkout Session id (`cs_…`) when the session has no PaymentIntent |
| Stripe invoice or subscription payment | The invoice id (`in_…`) |
| Polar order | The order id |

Send `purchase` without `revenue` for a checkout a provider reports. Send `revenue` only for a checkout no provider reports, and then with an `order_id`. A tracker `purchase` with a matching `order_id` still lends the sale its session when the checkout passed no attribution id.

## When a sale shows no source

`getAttributionId()` resolves to `undefined` when an ad blocker or a privacy browser blocks the tracker, or when the tracker has not loaded within the timeout. The checkout still opens and the sale is still recorded, with no visit to credit. It counts in your revenue totals but appears under no source, since a source is read from the page view that started a visit. A paid order with no attribution id and no tracker events from the buyer is this case, not a broken integration.

## Status of a connected provider

Each provider's row on the **Payment providers** card shows one line:

| Line | Meaning |
| - | - |
| Checkout, subscriptions, refunds and disputes (Stripe), Orders and refunds (Polar) | Not connected. The line says what the provider sends. |
| Connected. Waiting for the first event. | The secret is saved and no event has arrived yet. |
| Last event 5 minutes ago | Events arrive and verify. |
| Webhook signature failed 2 hours ago. | An event arrived with a signature the saved secret does not match. |
| Couldn’t read a webhook 2 hours ago. | An event verified but its body could not be read. |
| Couldn’t read the stored secret 2 hours ago. | Highseat could not read the secret it saved. Paste the secret again. |

A failing row stays connected. The provider retries each delivery, and the next event that verifies turns the row healthy again.

### Fix a failing signature

A signature fails when the secret Highseat holds is not the one the provider signs with. Either the secret was rotated in the provider, or the pasted secret belongs to another endpoint, such as the sandbox endpoint or another site's. Copy the current secret from the endpoint whose URL matches the one on the row, click **Replace** next to the secret in Highseat, paste it and save. The provider's next retry then verifies.

To stop receiving sales from a provider, click **Disconnect…** on its row, and delete the endpoint in the provider too, or the provider keeps retrying.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.