# Conversions

> Closing the loop between creator output and business outcome, defining conversion events, sending conversions, and thinking about attribution.

Trackagoat already tells you what your creators **produced** (views, engagement, posting consistency) and what they **cost** (payouts). Conversions tell you what they **produced for the business**: signups, purchases, installs, bookings, whatever counts as a result for you.

Once conversions are flowing, questions like *"which creator actually drove revenue last month, not just views?"* become answerable.

## The two pieces

Conversions have exactly two moving parts.

**Conversion events** are the definitions. They live in a project and you create a handful of them, once. `signup`. `purchase`. `trial_started`. Each definition carries a **key** (the stable slug your backend hard-codes), a **default value**, and a **currency**.

**Conversions** are the facts. Each one references a conversion event and is attributed to a creator, an account, or a video. You send these from your own system. Your checkout webhook, your signup handler, your nightly export: as they happen.

> **Note: Define events in the app, send conversions from your backend**
>
> Conversion events are defined in Trackagoat under **Tracking → Conversions → Manage events**, where you can also deactivate them and review what each one has recorded. No API key is needed for that.
>
> An API key is needed for the other half: *recording* conversions, which your own system does over the [v2 API](https://www.trackagoat.com/docs/api-reference/conversions.md). **There is no button in the app to add a conversion**. They always arrive from your backend. The app is where you define event types and read results. Create one from **Org Settings → API keys**. See the [API keys guide](https://www.trackagoat.com/docs/api-reference/authentication.md). The key needs the `write` scope. Everything in the app is also available through the API if you would rather provision events programmatically.

## Defining your first conversion event

Start with one. Resist the urge to model your whole funnel on day one. You can always add more, and every event type you define is a decision your backend has to keep honouring.

A good first event is the thing you already report on internally. If you run a paid product, that's `purchase`. If you run a free product, that's `signup`.

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "signup",
    "name": "Signup",
    "description": "A new account created from a creator link",
    "default_value_cents": 0,
    "currency": "USD"
  }' \
  https://www.trackagoat.com/api/v2/projects/<projectId>/conversion-events
```

A few things worth getting right the first time, because they are not changeable later:

- **`key` is permanent.** It's the identifier you'll paste into your backend. Keep it lowercase and boring: `signup`, `purchase`, `demo_booked`.
- **`currency` is permanent.** It lives on the definition, not on individual conversions, which is what makes any sum of conversions well-defined. If you sell in two currencies, define two events (`purchase_usd`, `purchase_eur`).
- **`default_value_cents` is not permanent**, but changing it only affects conversions recorded *afterwards*. The value is copied onto each conversion as it arrives.

### Which value should an event carry?

Two patterns cover almost everything:

| Pattern | `default_value_cents` | Per-conversion `value_cents` |
|---------|----------------------|------------------------------|
| **Count-style**, signups, installs, follows | `0` | omit |
| **Money-style**: purchases, subscriptions | `0` | send the real order value |
| **Fixed-worth**: a lead you value at a flat $25 | `2500` | omit |

For a count-style event, you just want volume, so leave the value at zero and read `total_conversions`. For a money-style event, send the actual order total on every call.

### How many events can I define?

`max_conversion_events_per_project` caps the number of **event types** per project: 3 on Free, 25 on Starter, unlimited on Ultra. Only active events count, so deactivating an event you no longer use frees a slot.

Conversion **volume** is never capped. Trackagoat will not quietly drop your revenue data at a limit.

## Sending your first conversion

The minimum viable conversion is a conversion event and a target:

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "conversion_event_key": "signup",
    "platform": "tiktok",
    "handle": "charlidamelio",
    "external_id": "user_88213"
  }' \
  https://www.trackagoat.com/api/v2/projects/<projectId>/conversions
```

That records one signup attributed to that creator's TikTok account.

A revenue example, attributed to a specific video, with a real order value:

```bash
curl -X POST \
  -H "Authorization: Bearer tga_<key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "conversion_event_key": "purchase",
    "video_id": "<uuid>",
    "value_cents": 4999,
    "quantity": 2,
    "external_id": "order_10021",
    "occurred_at": "2026-07-21T14:03:00Z",
    "note": "Bundle promo"
  }' \
  https://www.trackagoat.com/api/v2/projects/<projectId>/conversions
```

`quantity` multiplies: this records a total of $99.98 (`value_cents × quantity`), which trackagoat computes for you as `total_value_cents`.

If you're batching from a nightly job, send up to 500 conversions in one request as `{"conversions": [ ... ]}`.

### Picking a target

You attribute a conversion by supplying **exactly one** of these:

| Target | When to use it |
|--------|----------------|
| `video_id` | You know which specific post drove it (a per-video link or code) |
| `account_id` | You know the platform account but not the post |
| `platform` + `handle` | Same as `account_id`, but you only have the handle |
| `creator_id` | You know the person, not the platform |

Supplying none of them, or two: is a 400. That's deliberate: an ambiguous target is a silent data-quality bug, and it's better to fail the webhook loudly.

> **Warning: platform + handle never creates an account**
>
> Attributing by handle resolves only to an account that **already exists in that project**. Matching is case-insensitive and the `@` is optional, so `@CharliDAmelio` and `charlidamelio` both work. A handle trackagoat doesn't know returns `404 account_handle_not_found` and creates nothing. A typo in a webhook should surface as an error, not spawn a junk account and burn an account slot.

### Don't skip `external_id`

`external_id` is your own ID for the thing that converted: the order ID, the user ID, the webhook event ID. It is unique per project, forever.

If your webhook fires twice, or your retry logic double-fires, or you re-run last night's export, the second attempt does **not** create a duplicate. It returns the original conversion's ID with `deduped: true`, as a success. This is the single cheapest thing you can do to keep your numbers correct.

There is also an `Idempotency-Key` header, which replays the entire response for 24 hours if you retry with the same key. The two work together and cover different failure modes:

| Layer | Scope | Protects against |
|-------|-------|------------------|
| `Idempotency-Key` header | 24 hours, per key | The request timed out and you don't know if it landed |
| `external_id` field | Forever, per project | Anything, ever, resending the same event |

> **Tip: A fresh key with a repeated external_id is not an error**
>
> If you retry with a **new** `Idempotency-Key` but the **same** `external_id`, you get `HTTP 200`, `deduped: true`, and the **original** conversion's ID. Nothing was created and nothing went wrong. Treat it as success.

## How rollup works

Attribution rolls **up** the hierarchy:

```
video  →  account  →  creator
```

A conversion recorded against a video also counts for that video's account, and for that account's creator. A conversion recorded against an account also counts for its creator.

This means you can attribute at whatever precision your data actually supports, and still read totals at whatever level you want to report at. If your checkout only knows which creator sent the customer, attribute to the creator. If you run per-video discount codes, attribute to the video, and the creator's total still includes it.

Concretely: `GET /conversions?creator_id=X` returns conversions recorded against creator X, against X's accounts, **and** against X's videos. You don't have to fan out the query yourself.

The original precision isn't lost. Every conversion keeps a `target_type` (`creator`, `account`, or `video`) recording what you actually attributed to, so a per-video report is still exact.

## How to think about attribution

Two properties are worth internalising, because they explain most "wait, why does it say that?" moments.

### Attribution is frozen at receipt time

The rollup is computed once, when the conversion arrives. If a video is later moved to a different account, **its past conversions do not move with it.**

That's intentional. A conversion is a historical fact: it was earned under the ownership that existed when it happened. Silently rewriting old conversions when today's org chart changes would make last quarter's report irreproducible: you'd run the same query twice and get two answers.

If you're staring at a creator's total wondering why some conversions "didn't follow" a reassigned video, this is why.

**You can override it deliberately.** Freezing is the default, not a hard rule. When trackagoat notices that a video has moved since its conversions were recorded, the video's **Conversions** tab shows an attribution-drift notice with two choices:

- **Freeze**. Keep history as-is (the default, and usually correct).
- **Reassign**: rewrite that video's recorded conversions to its current creator.

Reassign is restricted to org admins, and it applies only to the video you're viewing. Use it when a video was attributed to the wrong account by mistake, not to reflect a legitimate change of ownership.

### Trackagoat records attribution, it doesn't decide it

Trackagoat has no view into your funnel. It doesn't set cookies, run last-click logic, or guess. It stores the attribution **you** send it.

That means the quality of your conversion data is entirely the quality of the decision your backend makes before it POSTs. Whatever you already use to connect a customer to a creator: a UTM parameter, a discount code, a tracked link, a "how did you hear about us" answer: is the thing to encode in the target. The `source` field is there to record which of those mechanisms was responsible, so you can compare them later.

## Handling refunds: void, don't delete

Business outcomes get reversed. Orders get refunded, cards get charged back, fraudulent signups get cleaned up. The answer is to **void** the conversion:

```bash
curl -X DELETE -H "Authorization: Bearer tga_<key>" \
  "https://www.trackagoat.com/api/v2/projects/<projectId>/conversions/<conversionId>?reason=Refunded%20-%20order%2010021"
```

Voiding is the default behaviour of `DELETE`. You don't need `?mode=void`. It:

- sets the conversion's status to `voided`
- excludes it from every total and every default list
- records **when**, **who**, and **why** (`reason`)
- keeps the row queryable via `?status=voided` or `?status=all`

So your creator totals drop by exactly the refunded amount, and you can still answer "what did we void last month, and why". Voiding an already-voided conversion is a no-op, so a retry is safe.

Because voiding lowers any unpaid payout earned from that conversion, voiding in the app (the conversion's detail drawer) is limited to **org admins**, the same as voiding a payout accrual.

> **Important: Hard delete is for mistakes, not reversals**
>
> `?mode=hard` removes the row permanently and requires an API key with the `admin` scope. Use it for test data and genuine mistakes: a conversion recorded against the wrong project, say. Never use it for a refund: you would lose the fact that revenue was ever recorded, and your history would no longer reconcile with your own books.

The same logic applies to conversion events themselves. `DELETE` on an event **deactivates** it: it stops accepting new conversions while all its history survives. `?mode=hard` on an event deletes every conversion ever recorded under it. The response tells you how many conversions are attached (`meta.conversions_affected`). Read that number before you do anything irreversible.

A deactivated event still holds its `key`, so re-creating it returns **409** rather than a second copy. It is also hidden from the event pickers used when building a payout structure. Bring it back under **Tracking → Conversions → Manage events → Reactivate**, or with `PATCH .../conversion-events/{eventId}` and `{"is_active": true}`. Reactivating counts against `max_conversion_events_per_project` again, so it can return a `402` if the project is at its cap.

## A sensible rollout

**Step 1: Define one event**

Pick the outcome you already report on internally. One event, correct, beats five events, half-wired.

**Step 2: Wire your backend to POST it**

From the place that already knows the outcome happened. Your checkout webhook, your signup handler. Always send an `external_id`.

**Step 3: Attribute at the precision you actually have**

Creator-level is fine. Rollup means you can start coarse and get more precise later without losing comparability.

**Step 4: Check it landed**

`GET /conversions?creator_id=<uuid>` and confirm the rows look right: target type, value, currency, timestamp.

**Step 5: Void, don't delete, when outcomes reverse**

Wire refunds and chargebacks to a `DELETE` with a `reason`. Do this early; retrofitting it later means a period of overstated revenue.

**Step 6: Add more event types once the first is trustworthy**

Then compare cost (payouts) against outcome (conversions) per creator.

## Related

- [Conversions API reference](https://www.trackagoat.com/docs/api-reference/conversions.md): every endpoint, field, and error code
- [API keys](https://www.trackagoat.com/docs/api-reference/authentication.md): creating a key and choosing scopes
- [Payouts](https://www.trackagoat.com/docs/guides/payouts.md): the cost side of the same equation
- [Plans and limits](https://www.trackagoat.com/docs/guides/limits.md): how `max_conversion_events_per_project` is enforced

---
Source: https://www.trackagoat.com/docs/guides/conversions
