
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.
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.
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. 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. The key needs the write scope. Everything in the app is also available through the API if you would rather provision events programmatically.
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.
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-eventsA 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.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.
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.
The minimum viable conversion is a conversion event and a target:
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>/conversionsThat records one signup attributed to that creator's TikTok account.
A revenue example, attributed to a specific video, with a real order value:
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>/conversionsquantity 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": [ ... ]}.
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.
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.
external_idexternal_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 |
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.
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.
Two properties are worth internalising, because they explain most "wait, why does it say that?" moments.
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:
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 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.
Business outcomes get reversed. Orders get refunded, cards get charged back, fraudulent signups get cleaned up. The answer is to void the conversion:
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:
voidedreason)?status=voided or ?status=allSo 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.
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.
Pick the outcome you already report on internally. One event, correct, beats five events, half-wired.
From the place that already knows the outcome happened. Your checkout webhook, your signup handler. Always send an external_id.
Creator-level is fine. Rollup means you can start coarse and get more precise later without losing comparability.
GET /conversions?creator_id=<uuid> and confirm the rows look right: target type, value, currency, timestamp.
max_conversion_events_per_project is enforcedWire refunds and chargebacks to a DELETE with a reason. Do this early; retrofitting it later means a period of overstated revenue.
Then compare cost (payouts) against outcome (conversions) per creator.