---
id: "brand/tracking/payment-tracking-stripe"
type: "doc"
url: "https://affitor.com/docs/brand/tracking/payment-tracking-stripe"
kind: "guide"
updated: "2026-10-24"
---
# Payment Tracking

> Track completed sales with server-side tracking or Stripe integration

Payment tracking records revenue after a customer pays. Affitor supports two sale-tracking paths:

1. **Server-side tracking**: your backend calls POST /api/v1/track/sale when revenue is finalized
2. **Stripe integration**: you keep charging in your own Stripe account, connect it to the program, and Affitor attributes sales from the payment events its own Stripe Connect endpoint receives

---

## Choose the Right Path

| Path | Best for |
|------|----------|
| **Server-side tracking** | custom backend, any payment provider, Paddle/LemonSqueezy/manual revenue events |
| **Stripe integration** | existing Stripe Checkout integration |

Use server-side tracking when you control the exact moment revenue is finalized on your server. Use Stripe integration when you take payments on Stripe: connect your Stripe account under **Program settings** › **Payments**, and Affitor receives its payment events through Stripe Connect, with no webhook for you to add.

---

## Option A — Server-side tracking

### Endpoint

```bash
POST https://api.affitor.com/api/v1/track/sale
Authorization: Bearer YOUR_PROGRAM_API_KEY
Content-Type: application/json
```

### Minimum real-sale payload

```json
{
  "transaction_id": "txn_abc123",
  "customer_key": "user_123",
  "amount_cents": 9999,
  "currency": "USD"
}
```

### Full example

```js
await fetch('https://api.affitor.com/api/v1/track/sale', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.AFFITOR_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    transaction_id: order.id,
    customer_key: currentUser.id,
    click_id: req.cookies.affitor_click_id,
    amount_cents: order.totalCents,
    currency: 'USD',
    sale_type: 'payment',
    line_items: [
      { name: 'Pro Plan', amount_cents: order.totalCents, quantity: 1 },
    ],
  }),
});
```

### Runtime rules

- `Authorization: Bearer <program_api_key>` is required
- `transaction_id` is required and must be unique
- `amount_cents` is required and must be a positive number
- Affitor must be able to resolve the customer via:
  - `customer_key`
  - or `click_id`
- If the same `transaction_id` is sent twice, Affitor returns `409 Conflict`

### Response

```json
{
  "success": true,
  "sale_id": 42,
  "commission_id": 18,
  "message": "Sale tracked successfully"
}
```

:::tip

Use server-side tracking if you want one server-controlled source of truth for revenue events across all payment providers.

:::

---

## Option B — Stripe integration

With the Stripe integration, you keep charging customers through your own Stripe account; Affitor does **not** become merchant of record. Connect the account under **Program settings** › **Payments** ([Connect a Stripe account](/docs/brand/tracking/connect-stripe-account)). Stripe then sends the account's payment events to Affitor's own Connect endpoint, so you do not add a webhook. Affitor attributes the conversion from those events and the metadata on each payment, then invoices you through its invoice billing workflow.

### Recommended metadata fields

| Field | Required | Description |
|-------|----------|-------------|
| `affitor_click_id` | Recommended | tracked click ID from the browser cookie |
| `affitor_customer_key` | Recommended | your internal customer/user ID |
| `program_id` | Recommended | your Affitor program ID. Without it, the sale goes to the program of the customer Affitor matches. With it, only a customer of that program can be credited, which matters when one Stripe account serves several programs |

### One-time payment example

```js
function getCookie(name) {
  const value = `; ${document.cookie}`;
  const parts = value.split(`; ${name}=`);
  if (parts.length === 2) return parts.pop().split(';').shift();
  return null;
}

const clickId = getCookie('affitor_click_id');

const session = await stripe.checkout.sessions.create({
  line_items: [{ price: 'price_xxx', quantity: 1 }],
  mode: 'payment',
  success_url: 'https://yoursite.com/success',
  cancel_url: 'https://yoursite.com/cancel',
  metadata: {
    affitor_click_id: clickId,
    affitor_customer_key: currentUser.id,
    program_id: 'YOUR_PROGRAM_ID',
  },
});
```

### Subscription example

For subscriptions, `subscription_data.metadata` is the one that counts: Affitor records a subscription's payments from its invoices, which carry the subscription's metadata, not the Checkout Session's `metadata`.

```js
const session = await stripe.checkout.sessions.create({
  line_items: [{ price: 'price_xxx', quantity: 1 }],
  mode: 'subscription',
  success_url: 'https://yoursite.com/success',
  cancel_url: 'https://yoursite.com/cancel',
  metadata: {
    affitor_click_id: clickId,
    affitor_customer_key: currentUser.id,
    program_id: 'YOUR_PROGRAM_ID',
  },
  subscription_data: {
    metadata: {
      affitor_click_id: clickId,
      affitor_customer_key: currentUser.id,
      program_id: 'YOUR_PROGRAM_ID',
    },
  },
});
```

### Why subscriptions need `subscription_data.metadata`

- One-time payments are handled from Stripe checkout session completion
- Subscription commissions, for the first payment and each renewal, are created from the invoice events (`invoice.payment_succeeded`, `invoice.paid`)
- An invoice carries the subscription's metadata (`subscription_data.metadata`), not the Checkout Session's `metadata`, so that is where the click reaches the subscription
- Renewals do not depend on it: once the first payment is attributed, Affitor saves the buyer's Stripe customer ID and matches each renewal by the buyer's email, when Affitor has recorded it, or else by that Stripe customer ID
- Without it, the subscription is attributed only if the buyer's email or its Stripe customer ID matches a customer Affitor already has. The email matches only a customer whose email Affitor has recorded, from a lead your server sent with `trackLead` or an earlier attributed payment.

---

## Pay-as-you-go / Repeated One-Time Purchases

Some products charge per usage event rather than via a subscription — independent checkout sessions for things like API credit top-ups, usage-based batches, or metered add-ons.

### How it works

Every `checkout.session.completed` event with `mode: 'payment'` is processed independently by Affitor. Each one goes through the full attribution and commission calculation flow.

:::note

**The invoice events are not involved here.**
Affitor uses the invoice events (`invoice.payment_succeeded`, `invoice.paid`) only for subscriptions: a subscription's first payment and each renewal. Pay-as-you-go purchases use `checkout.session.completed`, one event per purchase, each attributed and commissioned separately.

:::

### Attribution across multiple purchases

The first purchase is straightforward — if the customer clicked an affiliate link recently, `affitor_click_id` is still in the browser cookie.

Later purchases may arrive weeks or months after that click, and the cookie may be gone. Attribution still works if you pass a stable `affitor_customer_key` in every checkout's metadata. Affitor finds the customer by that key (saved on the customer when its signup lead settled) and links all subsequent purchases to the same partner.

**Metadata to set on every checkout, including repeat purchases:**

```js
const session = await stripe.checkout.sessions.create({
  line_items: [{ price: 'price_xxx', quantity: 1 }],
  mode: 'payment',
  success_url: 'https://yoursite.com/success',
  cancel_url: 'https://yoursite.com/cancel',
  metadata: {
    affitor_click_id: getCookie('affitor_click_id') || '', // may be empty, that is expected
    affitor_customer_key: currentUser.id,                  // links repeat purchases; must be stable
    program_id: 'YOUR_PROGRAM_ID',
  },
});
```

:::tip

`affitor_click_id` may be absent for repeat purchases — that is expected and not an error. Affitor will fall back to `affitor_customer_key` for attribution. Still send it when it is available.

:::

### Pre-requisite: signup tracking

Affitor creates the customer record when the partner click is tracked. Signup tracking saves your `customer_key` on it, so a later checkout that carries only `affitor_customer_key` finds the customer. Send the lead from your server with the `click_id` and your user ID ([Track Lead](/docs/api-reference/track-lead)), or call the browser helper at user registration:

```js
await window.affitor.signup(currentUser.id, currentUser.email);
```

On a program that requires server-verified leads (the default), a browser signup is provisional: the key is saved only when your server sends the lead or a Stripe Checkout payment for that customer arrives ([Lead auth policy](/docs/api-reference/track-lead#lead-auth-policy)). If the key was never saved, a later purchase that carries no click is attributed only if the buyer's email or Stripe customer ID matches a customer Affitor already has.

### Attribution does not expire

Once an affiliate-customer record is linked to a partner, that link persists indefinitely — there is no time-based cut-off on the customer-to-partner relationship.

If your commission **Type** is **Recurring**, the months in **For** (**Program settings** › **Commission**) limit how long a partner earns on a customer. They count from the customer's first click on the partner link, not from signup or the first payment. Later payments are still recorded as sales, with a $0 commission: the limit controls what a partner earns, not whether purchases are recognized.

### Troubleshooting pay-as-you-go

| Symptom | Likely cause |
|---------|--------------|
| First purchase attributed, later purchases are not | `affitor_customer_key` is missing or inconsistent across checkouts |
| No purchases attributed at all | Checkouts carry no `affitor_click_id` (the tracker is missing on the checkout page, or your Checkout Session does not set it), or Stripe is not connected to the program |
| Commissions are $0 after N months | The commission is **Recurring** with **For** set to N months, counted from the customer's first click: later payments are still recorded, and earn $0. Expected, not a bug |

---

## How Affitor Resolves Attribution from Stripe

When a Stripe event arrives, Affitor resolves attribution through a fallback chain.

### Simplified lookup order

1. click metadata (`affitor_click_id`)
2. customer email matching
3. Stripe customer ID
4. the click in `client_reference_id`, used only when the metadata has no click (the Affitor tracker writes it on Payment Links, the Buy Button and the Pricing Table)
5. customer key (`affitor_customer_key`)

This is why the best production setup is:
- install the tracker first
- send a stable internal customer ID at signup
- pass the same internal ID into Stripe metadata later

---

## Compatibility Notes

The runtime accepts legacy metadata aliases for backward compatibility, such as older click/customer-key field names. Do **not** use them in new integrations.

For new implementations, use only:
- `affitor_click_id`
- `affitor_customer_key`
- `program_id`

---

## Test Mode for Server-side tracking

Send a test sale event without creating a real commission or platform fee.

```bash
curl -X POST https://api.affitor.com/api/v1/track/sale \
  -H "Authorization: Bearer YOUR_PROGRAM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "additional_data": { "test_mode": true },
    "amount_cents": 9999,
    "currency": "USD",
    "sale_type": "payment"
  }'
```

### Test mode behavior

- still requires the Bearer token
- creates a test sale event with `is_test: true`
- does **not** create commissions, platform fees, or production metrics
- does not require an existing customer record

---

## Troubleshooting

### Server-side tracking returns `401`

Check:
- the `Authorization` header is present
- the value is `Bearer YOUR_PROGRAM_API_KEY`
- the API key belongs to the same program you intend to track

### Server-side tracking returns `409`

Check:
- `transaction_id` is unique
- retries are not resending the same completed sale without idempotency control on your side

### Stripe one-time sale not attributed

Check:
- `program_id` in metadata, if you set it, is this program's ID: with another ID, this program's customers are not credited
- `affitor_click_id` and `affitor_customer_key` are passed when available
- signup tracking used the same internal customer ID earlier
- the program's Stripe card under **Program settings** › **Payments** reads **Connected**. Affitor receives the events through Stripe Connect, so there is no webhook of yours to check ([Connect a Stripe account](/docs/brand/tracking/connect-stripe-account))

### Stripe pay-as-you-go second/third purchase not attributed

Check:
- `affitor_customer_key` is present in the metadata of every checkout session (not just the first)
- the value is exactly the same stable ID used at signup — any mismatch means the customer record cannot be found
- the customer's first lead or purchase was attributed through a click: Affitor saves `affitor_customer_key` on the customer only then, so a later checkout with only the key finds no customer if the first one carried no click

### Stripe renewals not attributed

Check:
- the subscription's first payment was attributed: renewals follow it through the buyer's recorded email or the saved Stripe customer ID
- `subscription_data.metadata` contains all three fields
- the program's Stripe card under **Program settings** › **Payments** reads **Connected**, and the subscription bills on that Stripe account. Affitor receives the invoice events (`invoice.payment_succeeded`, `invoice.paid`) through Stripe Connect; you do not add a webhook
- the same customer key was used at signup and in Stripe metadata

---

## Related Guides

- [3-Step Integration Guide](/docs/brand/tracking/quickstart-integration/)
- [Payment Flow](/docs/brand/tracking/payment-flow/)
- [Testing Integration](/docs/brand/tracking/testing-integration/)
