# 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 and Affitor attributes sales from metadata plus webhook events

---

## 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 already use Stripe Checkout and want Affitor to attribute revenue from webhook events.

---

## 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. Affitor attributes the conversion through Stripe metadata and webhook processing, 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` | **Yes** | your Affitor program ID |

### 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, set the same values in both `metadata` and `subscription_data.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 both locations

- One-time payments are handled from Stripe checkout session completion
- Subscription commissions are created from `invoice.payment_succeeded`
- Renewals rely on subscription metadata still being present later
- If `subscription_data.metadata` is missing, renewal attribution can fail

---

## 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

**`invoice.payment_succeeded` is not involved here.**
Affitor only uses `invoice.payment_succeeded` for recurring subscription renewals. 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 looks up the customer record created at signup and links all subsequent purchases to the same partner.

**Required metadata 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,                  // required and 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

Cross-purchase attribution requires an affiliate-customer record. That record is created when the signup tracker fires at user registration:

```js
affitor.signup({ customer_key: currentUser.id });
```

If signup tracking was not set up or did not fire for a user, no customer record exists and later purchases cannot be attributed.

### 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.

Your tier configuration may include a `duration_months` cap (for example, "pay commission for 12 months after signup"). That is a **commission duration limit** — it controls how long a partner earns on a customer, 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 | Signup tracker never fired — no customer record exists |
| Commissions created but stop after N months | Tier has a `duration_months` cap — this is expected behavior, 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. 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` is present in metadata
- `affitor_click_id` and `affitor_customer_key` are passed when available
- signup tracking used the same internal customer ID earlier
- the Stripe webhook was delivered successfully

### 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 signup tracker fired for this user — without it, no customer record exists and attribution fails silently

### Stripe renewals not attributed

Check:
- `subscription_data.metadata` contains all three fields
- `invoice.payment_succeeded` is enabled and delivered
- the same customer key was used at signup and in Stripe metadata

---

## Related Guides

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