> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trackplay.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom Events API

> Send an event that happened outside the player and land it on the viewer's session.

Something happened that the player never saw. A lead form was filled in. A quiz was
completed. An upsell was accepted three pages later. Send it to TrackPlay and it lands on
the same session as the play, so you can ask the only question that matters: **did the
people who did this buy?**

<Note>
  A custom event is **not** a conversion. `value` is reported on its own (count, sum,
  rate) and never feeds revenue, ROAS, EPC or RPV. Money goes through the
  [Conversions API](/api-reference/conversions).
</Note>

## Two ways in

<Tabs>
  <Tab title="From the browser">
    No token. The page already has a session, so the player sends the event for you.
    Use this for anything that happens on the page.

    **The event name must be declared first.** See [Declare the event
    name](#declare-the-event-name) below. An undeclared name is rejected.

    On a page **with** a TrackPlay player:

    ```js theme={null}
    window.trackplay.track('quiz_completed', { value: 10, plan: 'pro' });
    ```

    On a page **without** a player, load the standalone script:

    ```html theme={null}
    <script src="https://scripts.trackplay.io/tp-events.js"
            data-workspace="YOUR_WORKSPACE_CODE" async></script>
    ```

    ```js theme={null}
    tp('event', 'quiz_completed', { value: 10, plan: 'pro' });
    ```

    `value` is the numeric value. Every other key becomes a property, with one
    exception: an identity field.

    **You can tie the viewer on the event itself.** Pass any of `email`,
    `email_sha256`, `email_md5`, `phone`, `phone_sha256`, `phone_md5` or
    `external_id` in the same call, and TrackPlay ties this viewer to their profile,
    the same as a dedicated [identify](/api-reference/identify). Send the hash when you
    would rather the raw address never leave the page:

    ```js theme={null}
    // ties the buyer AND records the event, in one call
    window.trackplay.track('upsell_accepted', {
      value: 49,
      email_sha256: '7bb9e30283184c3b4bbbf2262a600be7165d7e6f50e424a611aa64394e7ecdb5'
    });
    ```

    An identity field is **never** stored as a property. A raw `email` is hashed
    before it leaves the page, and the identity fields are stripped from the event, so
    a plaintext address is never written as event data. Everything else in the call
    still lands in `properties`.
  </Tab>

  <Tab title="From your backend">
    A scoped token, server to server. Use this when the event happens where the browser
    is not present: a webhook from your cart, a CRM automation, a nightly job.

    ```bash theme={null}
    curl https://e.trackplay.io/v1/event \
      -H "Authorization: Bearer tplt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
      -H "Content-Type: application/json" \
      -d '{
        "event": "quiz_completed",
        "session_id": "3b1e5c90-77aa-4f2d-8c31-6de0a9b41c22",
        "value": 10,
        "event_id": "quiz_10482",
        "properties": { "plan": "pro" }
      }'
    ```

    Needs a token with the `events:write` scope. See
    [Authentication](/api-reference/authentication).
  </Tab>
</Tabs>

## Declare the event name

Before the **browser** can send an event, the name has to exist as a custom metric.

<Steps>
  <Step title="Open custom metrics">
    Go to **Analytics → Custom metrics** in your workspace.
  </Step>

  <Step title="Add the metric">
    Create a metric on the **custom** event and give it the exact name you will send,
    e.g. `quiz_completed`.
  </Step>
</Steps>

<Warning>
  An undeclared name sent from the browser is **rejected**, not quietly stored. This is
  deliberate: the browser path is untrusted, and letting any page mint new event names
  would let a stranger fill your reports with junk.
</Warning>

The backend API does not require this: a token is already proof of trust. It does still
enforce the [name cap](#keep-the-name-set-small).

## The identity fields

The whole point is landing the event on a real viewer. Pass whichever of these you have.
TrackPlay tries them in order and stops at the first that resolves a known viewer. For the
concepts behind them, see [Viewer identity](/identity/overview).

<ParamField body="session_id" type="string">
  The viewer's session. Best: the exact viewer, with their attribution. This is the same
  mechanism a conversion postback uses.
</ParamField>

<ParamField body="device_id" type="string">
  The viewer's browser, across visits. A fallback when you have no session.
</ParamField>

<ParamField body="profile_id" type="string">
  A profile you already resolved. A fallback.
</ParamField>

<ParamField body="email" type="string">
  Raw email. Resolved through the identity graph, and only a match if that address was
  linked to a viewer earlier (a captured lead, an earlier sale). Hashed on arrival to both
  SHA-256 and MD5 with `sha256(lower(trim(email)))`, and never stored raw.
</ParamField>

<ParamField body="email_sha256" type="string">
  A pre-hashed email: `sha256(lower(trim(email)))`, 64 lowercase hex characters. Send the
  hash when you would rather the raw address never leave your servers.
</ParamField>

<ParamField body="email_md5" type="string">
  A pre-hashed email: MD5 of `lower(trim(email))`, 32 lowercase hex characters.
</ParamField>

<ParamField body="phone" type="string">
  Raw phone. Normalized to E.164 on the server (`+<country><subscriber>`) and then hashed
  to both SHA-256 and MD5. A number with no `+` and no leading `00` is dropped, because its
  country is ambiguous. Never stored raw.
</ParamField>

<ParamField body="phone_sha256" type="string">
  A pre-hashed phone: SHA-256 of the number in E.164 form, 64 lowercase hex characters.
</ParamField>

<ParamField body="phone_md5" type="string">
  A pre-hashed phone: MD5 of the number in E.164 form, 32 lowercase hex characters.
</ParamField>

<ParamField body="external_id" type="string">
  Your own id for the customer. Resolved through the identity graph, same as email.
</ParamField>

<Note>
  A raw `email` or `phone` bridges the two hash algorithms: TrackPlay computes both the
  SHA-256 and the MD5 and writes both, so a viewer later seen through a SHA-256 only
  platform and one seen through an MD5 only platform unify. Two pre-hashed values sent in
  separate calls (one SHA-256, one MD5) do not merge on their own. See [Viewer
  identity](/identity/overview#the-two-hash-algorithms-and-why-raw-values-bridge-them).
  Sending a field TrackPlay does not know is rejected with `400 INVALID_PAYLOAD`.
</Note>

<Note>
  Whether you carry these fields on an event (browser `track()` above, or this backend call) or
  send them alone through the [Identify API](/api-reference/identify), the effect on identity is
  the same: the viewer ties to their profile. What they do **not** do is store a raw email or
  phone for outreach. A hashed value ties the person, it cannot be emailed or called. To store a
  real contact, send a **raw** `email` with a `video_code`, which upserts a lead so [Audience and
  Hot Leads](/analytics/overview#audience-and-hot-leads) show an address instead of "Email
  (hashed)".
</Note>

The response tells you which one worked:

```json theme={null}
{ "ok": true, "event": "quiz_completed", "event_id": "quiz_10482",
  "attributed": true, "matched_by": "session_id" }
```

`matched_by` is one of `session_id`, `device_id`, `profile_id`, or `identity` (an email or
`external_id` resolved through the graph). It is `null` when nothing matched.

## Getting the ids into your server

This is the question every S2S integration hits: the event fires on your backend, but the
session and device ids live in the browser. You bridge them. Do it on the funnel domain,
where the ids are readable, then carry them to your call.

<Steps>
  <Step title="Read the ids on the page">
    On a page **with** a player:

    ```js theme={null}
    const sessionId = window.trackplay.getSessionId();
    const deviceId  = window.trackplay.getDeviceId();
    ```

    On a page **without** a player, the standalone script exposes the session:

    ```js theme={null}
    const sessionId = window.tp('session');   // or window.tp.getSessionId()
    ```

    Both read the same first-party cookies (`trackplay_session_id`,
    `trackplay_device_id`) the player set. You can read those cookies directly if you
    prefer.
  </Step>

  <Step title="Hand them to your backend">
    Put the ids in a hidden form field, your server session, or the payload of your own
    AJAX call. Then include `session_id` (and `device_id` as a fallback) in the request
    to `/v1/event`.
  </Step>
</Steps>

### Carrying the session across a domain

When checkout is on another domain (`clickbank.com`), the funnel's first-party cookie is
unreadable there. Carry the session in the URL and the player adopts it on landing:

```
https://checkout.example.com/order?tp_sid=3b1e5c90-77aa-4f2d-8c31-6de0a9b41c22
```

The player reads `?tp_sid=` and joins the same session instead of minting a new one, so an
event fired from the checkout page still lands on the viewer who watched.

<Tip>
  Where a cart's buy URL supports a passthrough or vendor parameter (ClickBank's vendor
  variables, ElasticFunnels), carry the session through it. Where it does not, do not fight
  it: identify by `email` instead. If the buyer was linked to that address earlier (an
  opt-in, an upsell page), the profile still connects.
</Tip>

### When the browser is long gone

An affiliate-network postback arrives minutes later, server to server, with no browser
context at all. Attach it by the identity you already hold. A cart **conversion** attaches
by the session id you carried through checkout (see the [Conversions
API](/api-reference/conversions) and [how a sale finds its
video](/identity/overview#how-a-sale-finds-its-video)). A **custom event** can attach by
`email` or `external_id`: the same viewer was linked to that address on an earlier opt-in,
so the profile connects even without a session.

## When nothing matches

You get **`202`** and `"attributed": false`.

The event is still recorded. It lands in the Unattributed bucket, with no session identity
attached at all. It is never dropped, and never guessed at. A fabricated session would
corrupt your traffic sources and affiliate attribution, which is worse than an honest gap.

<Tip>
  A `202` means your integration is not passing a resolvable identifier. It is a signal
  to fix the plumbing, not a failure to retry. Retrying the same payload will produce the
  same `202`.
</Tip>

## Do not double count

Send an **`event_id`**. It is your idempotency key: a retry carrying the same `event_id`
collapses into the same row rather than counting twice.

<CodeGroup>
  ```bash curl theme={null}
  curl https://e.trackplay.io/v1/event \
    -H "Authorization: Bearer tplt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{ "event": "lead_captured", "email": "buyer@example.com", "event_id": "lead_88213" }'
  ```

  ```js Node theme={null}
  await fetch('https://e.trackplay.io/v1/event', {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer tplt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      event: 'lead_captured',
      email: 'buyer@example.com',
      event_id: 'lead_88213',   // your own id — makes the retry safe
    }),
  });
  ```

  ```php PHP theme={null}
  $ch = curl_init('https://e.trackplay.io/v1/event');
  curl_setopt_array($ch, [
      CURLOPT_POST => true,
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => [
          'Authorization: Bearer tplt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
          'Content-Type: application/json',
      ],
      CURLOPT_POSTFIELDS => json_encode([
          'event' => 'lead_captured',
          'email' => 'buyer@example.com',
          'event_id' => 'lead_88213',
      ]),
  ]);
  $response = curl_exec($ch);
  ```
</CodeGroup>

<Warning>
  Without an `event_id`, delivery is **at-least-once**: if your job retries, the event is
  counted twice. Use your own primary key: an order ID, a submission ID, anything
  stable for that one real-world event.
</Warning>

Deduplication holds for **24 hours** from the first time we see an `event_id`. A
duplicate arriving later than that (far outside any sane retry schedule) will count
again.

## Keep the name set small

`quiz_completed` is an event name. `quiz_completed_8f21ba` is a bug.

A workspace may use **100 distinct event names**. Past that, new names are rejected with
`422 TOO_MANY_EVENT_NAMES`. Names are a grouping dimension across tens of millions of
rows; an integration that mints one per visitor destroys every report built on them.

<Check>
  Put the varying part in `properties`, never in the name:
  `{"event": "quiz_completed", "properties": {"quiz_id": "8f21ba"}}`
</Check>

Names are normalised on arrival: lowercased, reduced to `[a-z0-9_-]`, truncated to 64
characters. `Quiz Completed!` and `quiz completed` both become `quiz_completed`.

## Rate limits

| Limit                | Value                                    |
| -------------------- | ---------------------------------------- |
| Per workspace        | 600 events per minute                    |
| Per IP               | 120 requests per minute                  |
| Properties per event | 20 (keys ≤ 40 chars, values ≤ 100 chars) |
| `value` range        | ±1,000,000,000                           |

Over the limit returns `429 RATE_LIMITED`. Back off and retry: with your `event_id`, the
retry is free.

## Errors

<AccordionGroup>
  <Accordion title="400: INVALID_PAYLOAD">
    A field failed validation, or you sent a field we do not know. Unknown fields are
    rejected rather than ignored, so a typo like `sessionId` fails loudly instead of
    silently costing you attribution.
  </Accordion>

  <Accordion title="400: INVALID_EVENT_NAME">
    `event` contained nothing usable after normalisation (e.g. `"!!!"`). We reject rather
    than invent a name for you.
  </Accordion>

  <Accordion title="403: INSUFFICIENT_SCOPE">
    Your token lacks `events:write`. The legacy `tp_` workspace key always lands here: it
    is rejected on purpose. See [Authentication](/api-reference/authentication).
  </Accordion>

  <Accordion title="422: TOO_MANY_EVENT_NAMES">
    You have hit the 100-name cap. The response tells you how many names are in use.
  </Accordion>

  <Accordion title="429: RATE_LIMITED">
    Too many events for this workspace or IP.
  </Accordion>

  <Accordion title="500: WRITE_FAILED">
    We could not write the event. Retry with the same `event_id`.
  </Accordion>

  <Accordion title="503: AUTH_UNAVAILABLE">
    Token verification is temporarily unavailable. Your token is fine. Retry shortly.
  </Accordion>
</AccordionGroup>

## Where the data shows up

Custom events land alongside player events on the same session. Once a name has been
declared as a custom metric in **Analytics → Custom metrics**, you can:

* chart it on the video's metric cards,
* use it as the **goal metric of a split test**, so the winner is decided on your event
  rather than a generic play,
* segment retention by whether the viewer fired it.

<Note>
  A backend event whose name has never been declared as a custom metric is still stored,
  but nothing in the dashboard is grouped by it yet. Declare the metric to see it.
</Note>
