---
id: "api-reference/track-sale"
type: "doc"
url: "https://affitor.com/docs/api-reference/track-sale"
kind: "reference"
updated: "2026-10-15"
---
# Track Sale

> Record an attributed sale and create the partner commission from your backend

Call this endpoint from your server after a payment succeeds to record the conversion and trigger commission creation.

## Endpoint

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

---

## Authentication

All requests require a program API key sent as a Bearer token in the `Authorization` header. The API key is found in your program settings in the Affitor dashboard.

```
Authorization: Bearer YOUR_PROGRAM_API_KEY
```

---

## Request Fields

<ParamList>
  <ParamField name="transaction_id" type="string" required>
    Your unique payment identifier. Used for duplicate detection — re-sending the same value returns 409.
  </ParamField>
  <ParamField name="amount_cents" type="integer" required>
    Sale amount in the smallest currency unit (e.g. cents for USD). Must be a positive integer.
  </ParamField>
  <ParamField name="tax_cents" type="integer">
    The tax included in `amount_cents`, in the same unit. Accepted but not used yet: commissions are calculated on `amount_cents` as sent.
  </ParamField>
  <ParamField name="customer_key" type="string" requirement="conditional">
    Your internal customer/user ID, set during the signup tracking step. Used to resolve attribution. At least one of `customer_key` or `click_id` must match an existing customer record.
  </ParamField>
  <ParamField name="click_id" type="string" requirement="conditional">
    The Affitor click ID (`affitor_click_id` cookie value). Used as fallback attribution when `customer_key` is not provided.
  </ParamField>
  <ParamField name="currency" type="string">
    `USD`, `EUR`, or `VND`. Defaults to `USD`. Another code returns `422 unsupported_currency` and creates no sale, commission or fee. Amounts are not converted between currencies: commissions and platform fees are calculated on the amount as sent, and invoices are issued in USD.
  </ParamField>
  <ParamField name="sale_type" type="string" enum={["payment", "subscription"]}>
    `"payment"` (default) or `"subscription"`.
  </ParamField>
  <ParamField name="line_items" type="object">
    Arbitrary line-item detail to store with the sale record.
  </ParamField>
  <ParamField name="is_recurring" type="boolean">
    `true` if this is a recurring billing charge. Defaults to `false`.
  </ParamField>
  <ParamField name="subscription_id" type="string">
    Your subscription identifier. Required for proper recurring commission attribution.
  </ParamField>
  <ParamField name="subscription_interval" type="string" enum={["monthly", "quarterly", "annual"]}>
    `"monthly"`, `"quarterly"`, or `"annual"`.
  </ParamField>
  <ParamField name="product_id" type="string">
    Your product identifier. Used for per-product commission policy matching.
  </ParamField>
  <ParamField name="occurred_at" type="string">
    When the payment happened, as ISO 8601 with an offset (for example `2026-10-16T09:30:00Z`), no more than 30 days old and at most 5 minutes in the future. Stored for reporting only: commissions, attribution windows and hold periods still use the time Affitor receives the call.
  </ParamField>
  <ParamField name="provider" type="string" enum={["stripe", "polar", "dodo", "paddle", "lemonsqueezy", "creem", "sepay", "payos", "apipay", "x402", "other"]}>
    Your payment provider. Stored for reporting.
  </ParamField>
  <ParamField name="provider_payment_id" type="string">
    The provider's own payment id (1 to 255 characters). Stored for reporting; duplicate detection still uses `transaction_id`.
  </ParamField>
  <ParamField name="livemode" type="boolean">
    `false` records a test sale, the same as `additional_data.test_mode: true`. `true` or omitted does not force a live sale: `additional_data.test_mode: true` or a test API key still records a test sale.
  </ParamField>
</ParamList>

---

## Request Body Example

<CodeGroup items={[
  {
    label: "cURL",
    lang: "bash",
    code: `curl -X POST https://api.affitor.com/api/v1/track/sale \\
  -H "Authorization: Bearer YOUR_PROGRAM_API_KEY" \\
  -H "Content-Type: application/json" \\
  -d '{
    "transaction_id": "txn_abc123",
    "customer_key": "usr_9876",
    "amount_cents": 4900,
    "currency": "USD",
    "sale_type": "payment"
  }'`
  },
  {
    label: "Node.js",
    lang: "javascript",
    code: `const res = await fetch('https://api.affitor.com/api/v1/track/sale', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_PROGRAM_API_KEY',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    transaction_id: 'txn_abc123',
    customer_key: 'usr_9876',
    amount_cents: 4900,
    currency: 'USD',
    sale_type: 'payment',
  }),
});
const data = await res.json();`
  },
  {
    label: "Python",
    lang: "python",
    code: `import requests

response = requests.post(
    'https://api.affitor.com/api/v1/track/sale',
    headers={
        'Authorization': 'Bearer YOUR_PROGRAM_API_KEY',
        'Content-Type': 'application/json',
    },
    json={
        'transaction_id': 'txn_abc123',
        'customer_key': 'usr_9876',
        'amount_cents': 4900,
        'currency': 'USD',
        'sale_type': 'payment',
    },
)
data = response.json()`
  },
]} />

### Subscription example

```json
{
  "transaction_id": "inv_sub_001",
  "customer_key": "usr_9876",
  "amount_cents": 4900,
  "currency": "USD",
  "sale_type": "subscription",
  "is_recurring": true,
  "subscription_id": "sub_stripe_xyz",
  "subscription_interval": "monthly",
  "product_id": "prod_pro_plan"
}
```

---

## Response

<ResponseTabs items={[
  {
    status: 200,
    label: "200 Attributed",
    code: `{
  "success": true,
  "sale_id": 42,
  "commission_id": 17,
  "message": "Sale tracked successfully"
}`
  },
  {
    status: 200,
    label: "200 Window Expired",
    code: `{
  "success": true,
  "attributed": false,
  "reason": "attribution_window_expired",
  "click_age_days": 75,
  "window_days": 60,
  "sale_id": null,
  "commission_id": null
}`
  },
  {
    status: 409,
    label: "409 Duplicate",
    code: `{
  "data": null,
  "error": {
    "status": 409,
    "name": "ConflictError",
    "message": "Duplicate transaction_id. This sale has already been recorded.",
    "details": { "sale_id": 42 },
    "code": "duplicate_transaction"
  }
}`
  },
]} />

| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | `true` on success. |
| `sale_id` | integer or null | ID of the created sale event record; `null` when the attribution window expired. |
| `commission_id` | integer or null | ID of the created commission record; `null` when the attribution window expired. |
| `message` | string | Human-readable confirmation. |

<Callout type="info" title="Idempotency">
  Re-sending the same `transaction_id` returns `409 Conflict` with `error.code` `duplicate_transaction` and the first sale's id in `error.details.sale_id`. Affitor does not create a duplicate sale, so you can safely retry on network failure.
</Callout>

<Callout type="warning" title="409 is not an error">
  A `409` response means the sale was already recorded successfully. Do not retry a 409 — treat it as a successful prior call and continue your payment flow normally.
</Callout>

---

## Error Responses

Most errors use the [platform envelope](/docs/api-reference/errors#the-platform-envelope) `{ "data": null, "error": { "status", "name", "message", "details", "code" } }`. A retired API key and rate limiting use the [typed envelope](/docs/api-reference/errors#the-typed-envelope) `{ "error": { "code", "message", ... } }`. Either way, branch on `error.code`; the message text may change.

| Status | `error.code` | When |
|--------|--------------|------|
| `400` | `invalid_request` | `transaction_id` missing or not a string; `amount_cents` not a positive integer; an invalid `occurred_at`, `provider`, `provider_payment_id` or `livemode` |
| `400` | `no_attribution` | Neither `customer_key` nor `click_id` resolved a customer, the customer belongs to another program, or no partner relationship was found |
| `401` | `unauthorized` | Missing or malformed `Authorization` header, or the API key does not match any active program |
| `401` | `api_key_rotation_required` | A retired API key (the body also carries `hint` and `docs_url`) |
| `409` | `duplicate_transaction` | `transaction_id` already recorded; `error.details.sale_id` is the recorded sale |
| `422` | `unsupported_currency` | `currency` is not `USD`, `EUR` or `VND`; no sale, commission or fee is created |
| `429` | `rate_limited` | Too many requests; wait `retry_after_seconds` |
| `500` | `internal_error` | Sale or commission creation failed internally |

---

## Test Mode

Send `additional_data.test_mode: true` (or `livemode: false`) to create a test sale event without affecting production data. The API key is still required but `transaction_id` and `amount_cents` are optional in test mode.

```json
{
  "additional_data": {
    "test_mode": true
  }
}
```

Test mode response:

```json
{
  "success": true,
  "message": "Test sale event tracked successfully",
  "data": {
    "eventId": 12,
    "programId": 3,
    "test_mode": true
  }
}
```

---

## Related

- [Lead Tracking (Signup)](/docs/brand/tracking/lead-tracking-signup/)
- [Payment Flow](/docs/brand/tracking/payment-flow/)
- [Testing Integration](/docs/brand/tracking/testing-integration/)
