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.
Conversion Events#
GET/api/v2/projects//conversion-events#
List the conversion event definitions in a project. Active events only by default.
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//conversion-events#
Define a new conversion event. Requires the write scope.
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-eventsBody
| 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.
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.
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:
{
"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
}
}GET/api/v2/projects//conversion-events/#
Fetch a single event. Returns 404 conversion_event_not_found if the event does not exist in that project.
PATCH/api/v2/projects//conversion-events/#
Update an event. Requires the write scope. At least one field must be supplied.
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//conversion-events/#
Deactivates by default. Requires the write scope (admin for ?mode=hard).
# 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.
Deactivating also frees a slot against max_conversion_events_per_project, which counts active events only.
Conversions#
GET/api/v2/projects//conversions#
List conversions in a project, newest occurred_at first.
# 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 |
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//conversions#
Record one conversion, or a batch. Requires the write scope.
# 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# 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>/conversionsBody: 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 → creatorA 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 × quantityvalue_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.
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//conversions/#
Fetch a single conversion. Returns 404 conversion_not_found if it does not exist in that project.
DELETE/api/v2/projects//conversions/#
Two modes, because both are legitimately needed.
# 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.
{
"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.
See the Conversions guide for a walkthrough of defining your first event and sending your first conversion.