Skip to content

Conversions

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

Updated Oct 10, 2026 · 13 min read

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.

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

ParameterTypeDescription
include_inactivetrueInclude events with is_active: false

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

Response fields

FieldTypeDescription
iduuidConversion event ID
project_iduuidOwning project
org_iduuidOwning organization
keystringStable slug used by API callers (e.g. purchase)
namestringHuman label (e.g. Purchase)
descriptionstring | nullFree-form description
default_value_centsnumberValue applied to a conversion that does not supply its own
currencystringISO 4217 code. Applies to every conversion recorded under this event
is_activebooleanInactive events reject new conversions
metadataobjectAgent-writable structured data
created_atISO 8601
updated_atISO 8601

POST/api/v2/projects//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

FieldTypeRequiredDescription
keystring✓Lowercase slug, max 63 chars. Must start with a letter or digit and may contain letters, digits, ., -, and _. Unique per project (case-insensitive)
namestring✓Display name, max 100 chars
descriptionstringMax 500 chars
default_value_centsnumberInteger ≥ 0. Default 0
currencystring3-letter ISO 4217 code, uppercased. Default USD
metadataobjectAgent-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:

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
  }
}

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.

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//conversion-events/#

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"
ParameterTypeDescription
modehardAnything 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.

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

ParameterTypeDefaultDescription
conversion_event_iduuidOnly conversions of this event type
creator_iduuidRollup filter. See below
account_iduuidRollup filter. See below
video_iduuidOnly conversions attributed to this video
sourcestringExact match on the source field
statusrecorded | voided | allrecordedVoided conversions are hidden unless you ask for them
fromISO 8601 with offsetoccurred_at on or after this instant. Bare dates and offset-less timestamps are rejected.
toISO 8601 with offsetoccurred_at on or before this instant. Bare dates and offset-less timestamps are rejected.
limitnumber50Items per page (max 200)
cursorstringPagination 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

FieldTypeDescription
iduuidConversion ID
conversion_event_iduuidThe event definition
conversion_event_keystring | nullThe event's key, denormalized for convenience
conversion_event_namestring | nullThe event's name
project_iduuidOwning project
target_typecreator | account | videoWhat the caller attributed to
creator_iduuid | nullAlways populated (rollup)
account_iduuid | nullPopulated for account and video targets
video_iduuid | nullPopulated for video targets only
value_centsnumberUnit value. Falls back to the event's default_value_cents
quantitynumberUnits. Default 1
total_value_centsnumbervalue_cents × quantity, computed by the database
currencystringInherited from the conversion event
external_idstring | nullYour own ID for this conversion (permanent dedupe key)
sourcestring | nullFree-form attribution source label
labelstring | nullShort label, max 200 chars
notestring | nullLonger note, max 1000 chars
metadataobjectAgent-writable structured data
statusrecorded | voidedVoided conversions are excluded from totals
voided_atISO 8601 | null
voided_reasonstring | null
occurred_atISO 8601 with offsetWhen 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_atISO 8601When trackagoat received it

POST/api/v2/projects//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:

FieldTypeRequiredDescription
conversion_event_iduuidone ofThe event definition, by ID
conversion_event_keystringone ofThe event definition, by key (case-insensitive)
creator_iduuidone ofAttribute to a creator
account_iduuidone ofAttribute to a platform account
video_iduuidone ofAttribute to a video
platform + handlestring + stringone ofAttribute to an existing account by handle
value_centsnumberInteger ≥ 0. Defaults to the event's default_value_cents
quantitynumberInteger ≥ 1, max 1,000,000. Default 1
external_idstringYour own ID, max 255 chars. Permanent per-project dedupe key
sourcestringMax 100 chars
labelstringMax 200 chars
notestringMax 1000 chars
metadataobjectDefault {}
occurred_atISO 8601When 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 supplytarget_typeResolution
video_idvideoVideo must be in the project and have an owning account, else 409 video_unattributed
account_idaccountAccount must be in the project and linked to a creator, else 409 account_unlinked
creator_idcreatorCreator must be in the project
platform + handleaccountMatches 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.

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.

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"
ParameterTypeDefaultDescription
modevoid | hardvoidhard requires the admin scope
reasonstringMax 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.

PlanConversion events per project
Free3
Starter25
UltraUnlimited

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#

CodeHTTPDescription
invalid_api_key401Missing, expired, or invalid Bearer token
insufficient_scope403Key lacks read, write, or (for ?mode=hard) admin
limit_reached402max_conversion_events_per_project exceeded
invalid_body400Zod validation failed: including a missing or ambiguous target. See meta.errors
invalid_query400Bad query parameter
invalid_cursor400Cursor could not be decoded. Pass meta.nextCursor back verbatim
conversion_event_not_found404No conversion event with that ID or key in the project
creator_not_found404No creator with that ID in the project
account_not_found404No account with that ID in the project
video_not_found404No video with that ID in the project
account_handle_not_found404No account with that platform + handle in the project. Add the account first
conversion_not_found404No conversion with that ID in the project
conversion_event_inactive409The event is deactivated and cannot accept new conversions
conversion_event_exists409An event with that key already exists in the project. meta.conversion_event_id and meta.is_active identify it
account_unlinked409The account is standalone (not attached to a creator), so nothing can roll up
video_unattributed409The video has no owning account
idempotency_conflict409Idempotency-Key reused against a different endpoint
idempotency_in_progress409A request with this Idempotency-Key is still running. Retry after Retry-After seconds
idempotency_key_reused422Idempotency-Key reused with a different request body. Use a new key
rate_limit_exceeded429Daily 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.