# Conversions

> Define conversion event types per project and record conversions attributed to a creator, account, or video via the trackagoat v2 API.

Trackagoat tracks creator **output** (views) and creator **cost** (payouts). Conversions add creator **outcome**: signups, purchases, installs, or whatever else you count as a result.

There are two resources:

- **Conversion events**, the *definitions*, scoped to a project. `signup`, `purchase`, `trial_started`. Each carries a default value and a currency.
- **Conversions**: the *facts*. Each one references a conversion event and is attributed to a creator, an account, or a video.

All endpoints require a Bearer API key. Responses follow the standard `{ data, error, meta }` envelope. Money is in integer cents. Timestamps are ISO 8601.

> **Warning: Scopes**
>
> `GET` needs the `read` scope. `POST` and `PATCH` need `write`. `DELETE` needs `write` for the default (soft) mode and **`admin`** for `?mode=hard`: conversions are financial records, and a write-scoped webhook key should not be able to erase revenue history. A key without the required scope is rejected with **403 `insufficient_scope`**.

---

## Conversion Events

### GET /api/v2/projects/{projectId}/conversion-events

List the conversion event definitions in a project. Active events only by default.

```bash
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/projects/<projectId>/conversion-events"

# Include deactivated events
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/projects/<projectId>/conversion-events?include_inactive=true"
```

**Query parameters**

| Parameter | Type | Description |
|-----------|------|-------------|
| `include_inactive` | `true` | Include events with `is_active: false` |

Returns an array sorted newest first, with `meta.count`.

**Response fields**

| Field | Type | Description |
|-------|------|-------------|
| `id` | uuid | Conversion event ID |
| `project_id` | uuid | Owning project |
| `org_id` | uuid | Owning organization |
| `key` | string | Stable slug used by API callers (e.g. `purchase`) |
| `name` | string | Human label (e.g. `Purchase`) |
| `description` | string \| null | Free-form description |
| `default_value_cents` | number | Value applied to a conversion that does not supply its own |
| `currency` | string | ISO 4217 code. Applies to every conversion recorded under this event |
| `is_active` | boolean | Inactive events reject new conversions |
| `metadata` | object | Agent-writable structured data |
| `created_at` | ISO 8601 | |
| `updated_at` | ISO 8601 | |

---

### POST /api/v2/projects/{projectId}/conversion-events

Define a new conversion event. Requires the `write` scope.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "key": "purchase",
    "name": "Purchase",
    "description": "A completed checkout attributed to a creator link",
    "default_value_cents": 0,
    "currency": "USD"
  }' \
  https://www.trackagoat.com/api/v2/projects/<projectId>/conversion-events
```

**Body**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `key` | string | ✓ | Lowercase slug, max 63 chars. Must start with a letter or digit and may contain letters, digits, `.`, `-`, and `_`. Unique per project (case-insensitive) |
| `name` | string | ✓ | Display name, max 100 chars |
| `description` | string | | Max 500 chars |
| `default_value_cents` | number | | Integer ≥ 0. Default `0` |
| `currency` | string | | 3-letter ISO 4217 code, uppercased. Default `USD` |
| `metadata` | object | | Agent-writable structured data. Default `{}` |

Returns `201` with the created event.

> **Note: Currency is fixed at definition time**
>
> A conversion event's `currency` cannot be changed later, and individual conversions have no currency field of their own. They inherit the event's. One event type means one currency, which is what makes `SUM(total_value_cents)` well-defined for any set of conversions. If you need to record purchases in EUR as well as USD, define two events (`purchase_usd`, `purchase_eur`).

Reusing an existing `key` in the same project returns **409 `conversion_event_exists`**. Exceeding the plan's `max_conversion_events_per_project` returns **402 `limit_reached`**. See [Limits](#limits).

The `key` is unique across *all* events in the project, deactivated ones included, so the 409 also fires when the clashing event is deactivated and therefore invisible in the app's event pickers. The response names it so you can reactivate rather than guess:

```json
{
  "data": null,
  "error": "A conversion event with key \"trial_started\" already exists in this project but is deactivated, so it is hidden from event pickers. Reactivate it with PATCH .../conversion-events/<eventId> {\"is_active\": true}. Note that DELETE deactivates an event unless you pass ?mode=hard.",
  "meta": {
    "code": "conversion_event_exists",
    "conversion_event_id": "<eventId>",
    "is_active": false
  }
}
```

> **Warning: Test suites that create and delete events**
>
> `DELETE` deactivates unless you pass `?mode=hard`, so a create → exercise → delete loop leaves the key taken and 409s on its second run. Either tear down with `?mode=hard` (needs the `admin` scope, and only works while the event has no conversions), or create the events once and reuse them.

---

### GET /api/v2/projects/{projectId}/conversion-events/{eventId}

Fetch a single event. Returns **404 `conversion_event_not_found`** if the event does not exist in that project.

---

### PATCH /api/v2/projects/{projectId}/conversion-events/{eventId}

Update an event. Requires the `write` scope. At least one field must be supplied.

```bash
curl -X PATCH \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{"default_value_cents": 2500}' \
  https://www.trackagoat.com/api/v2/projects/<projectId>/conversion-events/<eventId>
```

**Body:** `name?`, `description?`, `default_value_cents?`, `is_active?`, `metadata?`

`key` and `currency` are deliberately **not** updatable. The key is the stable identifier you hard-code into your backend; changing the currency would retroactively reinterpret every conversion already recorded under the event.

Changing `default_value_cents` affects only conversions recorded *after* the change. The value is copied onto each conversion at write time.

---

### DELETE /api/v2/projects/{projectId}/conversion-events/{eventId}

Deactivates by default. Requires the `write` scope (`admin` for `?mode=hard`).

```bash
# Deactivate: the event stops accepting new conversions, history is preserved
curl -X DELETE -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/projects/<projectId>/conversion-events/<eventId>"

# Hard delete: removes the definition AND every conversion recorded under it
curl -X DELETE -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/projects/<projectId>/conversion-events/<eventId>?mode=hard"
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `mode` | `hard` | Anything else (including omitting it) deactivates |

Returns `{ id, mode }` with `meta.conversions_affected`: the number of conversions currently attached to the event. Check that number before hard-deleting.

> **Important: Hard delete cascades**
>
> `?mode=hard` deletes the event row, and conversions cascade with it. That is revenue history and it cannot be recovered. Deactivating (`is_active: false`) is almost always what you want, because the event stops accepting new conversions while every past conversion stays queryable.

Deactivating also frees a slot against `max_conversion_events_per_project`, which counts active events only.

---

## Conversions

### GET /api/v2/projects/{projectId}/conversions

List conversions in a project, newest `occurred_at` first.

```bash
# All conversions rolled up to one creator, in a date window
curl -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/projects/<projectId>/conversions?creator_id=<uuid>&from=2026-07-01T00:00:00Z"
```

**Query parameters**

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `conversion_event_id` | uuid | | Only conversions of this event type |
| `creator_id` | uuid | | Rollup filter. See below |
| `account_id` | uuid | | Rollup filter. See below |
| `video_id` | uuid | | Only conversions attributed to this video |
| `source` | string | | Exact match on the `source` field |
| `status` | `recorded` \| `voided` \| `all` | `recorded` | Voided conversions are hidden unless you ask for them |
| `from` | ISO 8601 **with offset** | | `occurred_at` on or after this instant. Bare dates and offset-less timestamps are rejected. |
| `to` | ISO 8601 **with offset** | | `occurred_at` on or before this instant. Bare dates and offset-less timestamps are rejected. |
| `limit` | number | 50 | Items per page (max 200) |
| `cursor` | string | | Pagination cursor from `meta.nextCursor` |

> **Note: creator_id and account_id are rollup filters**
>
> `creator_id` and `account_id` match the **rolled-up** attribution, not just what the caller originally targeted. `?creator_id=X` returns every conversion recorded against creator X *plus* those recorded against X's accounts and X's videos. See [Rollup](#rollup) below.

`from` and `to` are full ISO 8601 timestamps (`2026-07-01T00:00:00Z`), not bare dates.

**Response**

`meta` carries `{ hasMore, nextCursor }`. Cursors are opaque base64url strings. Pass `nextCursor` back verbatim as `cursor`, do not parse or modify it.

**Conversion fields**

| Field | Type | Description |
|-------|------|-------------|
| `id` | uuid | Conversion ID |
| `conversion_event_id` | uuid | The event definition |
| `conversion_event_key` | string \| null | The event's `key`, denormalized for convenience |
| `conversion_event_name` | string \| null | The event's `name` |
| `project_id` | uuid | Owning project |
| `target_type` | `creator` \| `account` \| `video` | What the caller attributed to |
| `creator_id` | uuid \| null | Always populated (rollup) |
| `account_id` | uuid \| null | Populated for `account` and `video` targets |
| `video_id` | uuid \| null | Populated for `video` targets only |
| `value_cents` | number | Unit value. Falls back to the event's `default_value_cents` |
| `quantity` | number | Units. Default `1` |
| `total_value_cents` | number | `value_cents × quantity`, computed by the database |
| `currency` | string | Inherited from the conversion event |
| `external_id` | string \| null | Your own ID for this conversion (permanent dedupe key) |
| `source` | string \| null | Free-form attribution source label |
| `label` | string \| null | Short label, max 200 chars |
| `note` | string \| null | Longer note, max 1000 chars |
| `metadata` | object | Agent-writable structured data |
| `status` | `recorded` \| `voided` | Voided conversions are excluded from totals |
| `voided_at` | ISO 8601 \| null | |
| `voided_reason` | string \| null | |
| `occurred_at` | ISO 8601 **with offset** | When the conversion happened (defaults to receipt time). Must carry an explicit offset: `2026-07-21T14:03:00Z` or `+01:00`. A bare local timestamp is a 400. |
| `created_at` | ISO 8601 | When trackagoat received it |

---

### POST /api/v2/projects/{projectId}/conversions

Record one conversion, or a batch. Requires the `write` scope.

```bash
# Single conversion, attributed by platform + handle
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "conversion_event_key": "purchase",
    "platform": "tiktok",
    "handle": "charlidamelio",
    "value_cents": 4999,
    "quantity": 2,
    "external_id": "order_10021",
    "occurred_at": "2026-07-21T14:03:00Z"
  }' \
  https://www.trackagoat.com/api/v2/projects/<projectId>/conversions
```

```bash
# Batch (up to 500 per request)
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "conversions": [
      {"conversion_event_key": "signup", "video_id": "<uuid>", "external_id": "user_881"},
      {"conversion_event_key": "signup", "creator_id": "<uuid>", "external_id": "user_882"}
    ]
  }' \
  https://www.trackagoat.com/api/v2/projects/<projectId>/conversions
```

**Body**: a single conversion object, or `{ "conversions": [ ... ] }` with 1 to 500 objects. Each object:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `conversion_event_id` | uuid | one of | The event definition, by ID |
| `conversion_event_key` | string | one of | The event definition, by key (case-insensitive) |
| `creator_id` | uuid | one of | Attribute to a creator |
| `account_id` | uuid | one of | Attribute to a platform account |
| `video_id` | uuid | one of | Attribute to a video |
| `platform` + `handle` | string + string | one of | Attribute to an existing account by handle |
| `value_cents` | number | | Integer ≥ 0. Defaults to the event's `default_value_cents` |
| `quantity` | number | | Integer ≥ 1, max 1,000,000. Default `1` |
| `external_id` | string | | Your own ID, max 255 chars. Permanent per-project dedupe key |
| `source` | string | | Max 100 chars |
| `label` | string | | Max 200 chars |
| `note` | string | | Max 1000 chars |
| `metadata` | object | | Default `{}` |
| `occurred_at` | ISO 8601 | | When it happened. Defaults to now |

Supply **exactly one** of `conversion_event_id` or `conversion_event_key`, and **exactly one** target: `creator_id`, `account_id`, `video_id`, or the `platform` + `handle` pair. Supplying none, or more than one, returns **400 `invalid_body`**. `platform` and `handle` must be supplied together.

**Response.** A single-conversion request returns `201` with the created conversion. A batch returns `201` with an array in request order. `meta.deduped_count` reports how many rows in the request were deduplicated against an existing `external_id`.

#### Choosing a target

| You supply | `target_type` | Resolution |
|-----------|---------------|------------|
| `video_id` | `video` | Video must be in the project and have an owning account, else **409 `video_unattributed`** |
| `account_id` | `account` | Account must be in the project and linked to a creator, else **409 `account_unlinked`** |
| `creator_id` | `creator` | Creator must be in the project |
| `platform` + `handle` | `account` | Matches an account already in the project |

`platform` + `handle` matching is case-insensitive and a leading `@` is optional. It resolves **only** to an account that already exists in that project: a handle trackagoat does not know returns **404 `account_handle_not_found`** and **no account is created**. A typo in a webhook should surface as an error, not silently spawn a junk account.

#### Rollup

Attribution rolls **up** the hierarchy, and the rollup is written onto the row at insert time:

```
video  →  account  →  creator
```

A conversion recorded against a video also counts for that video's account and that account's creator. A conversion recorded against an account also counts for its creator. `target_type` preserves what you actually attributed to, so a report can still say "attributed to video X" while creator-level totals include it.

That is why `?creator_id=X` on the list endpoint returns conversions recorded against X's accounts and videos too.

#### Attribution is frozen

The rollup is computed once, when the conversion is received. If a video is later reassigned to a different account, its past conversions **do not move by default**. They were earned under the ownership that existed when they happened, and silently rewriting them would make past reports non-reproducible.

To override that for a single video, `POST /api/videos/{videoId}/conversions` with `{"mode": "reassign"}` rewrites every recorded conversion on that video to its current creator; `{"mode": "freeze"}` explicitly keeps history and dismisses the drift notice. Both require an **org admin** session. This endpoint is not part of the API-key surface.

#### Value and quantity

```
total_value_cents = value_cents × quantity
```

`value_cents` falls back to the conversion event's `default_value_cents`, so a count-style event (a signup) needs no value on every call. `quantity` defaults to `1`. Currency comes from the event. A conversion has no currency field.

---

### Idempotency

Two independent layers apply, and they answer different questions.

**1. The `Idempotency-Key` header: replays the whole response for 24 hours.**

Send a fresh UUID per logical request. If the call times out, retry with the *same* key and body: the server returns the original response body and status unchanged, with an `Idempotency-Replay: true` header, and creates nothing new. A retry that arrives while the first request is still running gets **409 `idempotency_in_progress`** with `Retry-After`. Reusing a key with a different body returns **422 `idempotency_key_reused`**, and against a different endpoint **409 `idempotency_conflict`**. Keys must be ≤ 255 characters.

Rejections are replayed too: if a batch fails with a `404 conversion_event_not_found`, fix the body and send it with a **new** key. `5xx` responses are not stored, so a retry with the same key runs again.

**2. The `external_id` field: permanent per-project dedupe.**

`external_id` is unique per project, forever. If you POST a conversion whose `external_id` already exists in the project, trackagoat does not create a duplicate and does not error: it returns the **original** conversion's `id` with `deduped: true`.

> **Tip: The case that trips people up**
>
> A retry with a **fresh** `Idempotency-Key` but a **repeated** `external_id` is not an error. The header cache misses (new key), so the handler runs, but the `external_id` collides, so nothing is inserted. You get **HTTP 200** with `deduped: true` and the **original** conversion's `id`. Treat that as success. The conversion is already recorded.
>
> (Status `200` rather than `201` applies to the single-conversion form, where the write created nothing. A batch always returns `201`; check each row's `deduped` flag and `meta.deduped_count`.)

Send `external_id` whenever you have a natural key: an order ID, a user ID, a webhook event ID. It is the difference between "my retry logic is correct" and "my revenue numbers are correct".

---

### GET /api/v2/projects/{projectId}/conversions/{conversionId}

Fetch a single conversion. Returns **404 `conversion_not_found`** if it does not exist in that project.

---

### DELETE /api/v2/projects/{projectId}/conversions/{conversionId}

Two modes, because both are legitimately needed.

```bash
# Void (default): soft, auditable, reversible in the sense that the row survives
curl -X DELETE -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/projects/<projectId>/conversions/<conversionId>?reason=Refunded"

# Hard delete: irreversible, requires the admin scope
curl -X DELETE -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/projects/<projectId>/conversions/<conversionId>?mode=hard"
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `mode` | `void` \| `hard` | `void` | `hard` requires the `admin` scope |
| `reason` | string | | Max 500 chars. Stored as `voided_reason` (void mode only) |

**`mode=void`** sets `status: 'voided'`, records `voided_at`, `voided_by`, and `voided_reason`, and excludes the conversion from every total. The row stays queryable via `?status=voided` or `?status=all`. This is the right answer for a refund, a chargeback, or a fraudulent order. Voiding an already-voided conversion is a **no-op**, not an error: safe to retry. Returns the conversion with `mode: "void"`.

**`mode=hard`** deletes the row outright. For genuine mistakes and test data only. Requires the `admin` scope. Returns `{ id, mode: "hard", deleted: true }`.

Hard delete is refused with **409 `conversion_has_accruals`** once the conversion has produced a payout accrual, because removing it at that point would silently change what a creator is owed. Void it instead: voiding reverses an unpaid accrual automatically, and flags an already-paid one for manual review rather than rewriting settled history.

```json
{
  "data": null,
  "error": "This conversion has already produced a payout accrual. Void it with ?mode=void instead of deleting it.",
  "meta": { "code": "conversion_has_accruals", "grain": "every_conversion" }
}
```

---

## Limits

`max_conversion_events_per_project` caps how many conversion event **types** a project may define. It counts active events only: deactivating one frees a slot.

| Plan | Conversion events per project |
|------|-------------------------------|
| Free | 3 |
| Starter | 25 |
| Ultra | Unlimited |

Conversion **volume** is not capped. Silently dropping a customer's revenue data at a cap would be worse than the revenue the cap protects. Exceeding the event-type limit returns **402 `limit_reached`** with `meta.current` and `meta.max`.

---

## Error codes

| Code | HTTP | Description |
|------|------|-------------|
| `invalid_api_key` | 401 | Missing, expired, or invalid Bearer token |
| `insufficient_scope` | 403 | Key lacks `read`, `write`, or (for `?mode=hard`) `admin` |
| `limit_reached` | 402 | `max_conversion_events_per_project` exceeded |
| `invalid_body` | 400 | Zod validation failed: including a missing or ambiguous target. See `meta.errors` |
| `invalid_query` | 400 | Bad query parameter |
| `invalid_cursor` | 400 | Cursor could not be decoded. Pass `meta.nextCursor` back verbatim |
| `conversion_event_not_found` | 404 | No conversion event with that ID or key in the project |
| `creator_not_found` | 404 | No creator with that ID in the project |
| `account_not_found` | 404 | No account with that ID in the project |
| `video_not_found` | 404 | No video with that ID in the project |
| `account_handle_not_found` | 404 | No account with that `platform` + `handle` in the project. Add the account first |
| `conversion_not_found` | 404 | No conversion with that ID in the project |
| `conversion_event_inactive` | 409 | The event is deactivated and cannot accept new conversions |
| `conversion_event_exists` | 409 | An event with that `key` already exists in the project. `meta.conversion_event_id` and `meta.is_active` identify it |
| `account_unlinked` | 409 | The account is standalone (not attached to a creator), so nothing can roll up |
| `video_unattributed` | 409 | The video has no owning account |
| `idempotency_conflict` | 409 | `Idempotency-Key` reused against a different endpoint |
| `idempotency_in_progress` | 409 | A request with this `Idempotency-Key` is still running. Retry after `Retry-After` seconds |
| `idempotency_key_reused` | 422 | `Idempotency-Key` reused with a different request body. Use a new key |
| `rate_limit_exceeded` | 429 | Daily API quota or per-minute rate limit hit |

A duplicate conversion event `key` returns **409 `conversion_event_exists`**, naming the clashing event and whether it is merely deactivated. See [POST conversion-events](#post-apiv2projectsprojectidconversion-events).

---

See the [Conversions guide](https://www.trackagoat.com/docs/guides/conversions.md) for a walkthrough of defining your first event and sending your first conversion.

---
Source: https://www.trackagoat.com/docs/api-reference/conversions
