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

# Events API: Ingest First-Party Buying Signals into Topo

> Send your own product, website, and CRM events to Topo, turn each event name into a custom event signal that collects the matching people or companies in its own list, and read the events back to debug what Topo stored and resolved.

The Events API is how your systems tell Topo that something happened. You `POST` an event — a pricing page view, a form submission, a product usage milestone — and Topo stores it, matches it to a contact or account in your workspace, and makes it actionable: playbooks with an event trigger run within minutes, and every `event_name` you turn into a **custom event signal** feeds its own list of people or companies.

Unlike the third-party signals Topo detects on its own (job changes, funding rounds, technology adoption), first-party events come only from you. They are usually the strongest intent you have, because they describe behaviour in your own product rather than a public event.

Ingestion is deliberately forgiving: every well-formed event is accepted with `202 Accepted` and stored, then matched to a contact or account asynchronously. Reading events back with `GET /v1/events` is how you confirm what Topo received and how far resolution got.

Topo groups first-party events strictly by **`event_name`**. That string is the key that wires ingestion to everything downstream — custom event signals in **Settings → Signals**, their dedicated lists, and playbook event triggers all match on the exact, case-sensitive `event_name` you send. Two different names are two independent event types, even when the payloads look similar.

## Before you start

<Steps>
  <Step title="Create an API key">
    In the Topo dashboard, go to **Settings → Developers → API keys**, click **Create Key**, and grant **`events:write`** to ingest and **`events:read`** to read events back. The raw key is shown once — see [Authentication](/api-reference/authentication).
  </Step>

  <Step title="Check the scopes on your key">
    `GET https://api.topo.io/v1/me` returns the key's `scopes`. It needs no scope of its own, so it always works.
  </Step>

  <Step title="Point your integration at the base URL">
    Every endpoint on this page is relative to `https://api.topo.io/v1`.
  </Step>

  <Step title="Decide your event names">
    One stable, machine-readable name per event type (`pricing_page_visit`, `trial_started`). You bind a name to a signal later, and that binding cannot be changed — see [Custom event signals](#custom-event-signals).
  </Step>
</Steps>

## Ingest an event

Every event needs a name, the time it occurred, and a `subject` that tells Topo who it is about. All field names are `snake_case`.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
  curl -X POST https://api.topo.io/v1/events \
    -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "event_name": "form_submitted",
      "occurred_at": "2025-06-15T14:32:00Z",
      "external_event_id": "evt_9f3a2b1c",
      "subject": {
        "email": "jane.smith@acmecorp.com"
      },
      "payload": {
        "form_id": "demo-request",
        "source": "website"
      }
    }'
  ```

  ```javascript JavaScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
  const response = await fetch("https://api.topo.io/v1/events", {
    method: "POST",
    headers: {
      Authorization: "Bearer topo_live_xxxxxxxxxxxx",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      event_name: "form_submitted",
      occurred_at: new Date().toISOString(),
      external_event_id: "evt_9f3a2b1c",
      subject: {
        email: "jane.smith@acmecorp.com",
      },
      payload: {
        form_id: "demo-request",
        source: "website",
      },
    }),
  });

  if (!response.ok) {
    throw new Error(`Topo API error: ${response.status}`);
  }

  const result = await response.json();
  console.log(result.idempotency_key);
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
  import httpx

  response = httpx.post(
      "https://api.topo.io/v1/events",
      headers={
          "Authorization": "Bearer topo_live_xxxxxxxxxxxx",
          "Content-Type": "application/json",
      },
      json={
          "event_name": "form_submitted",
          "occurred_at": "2025-06-15T14:32:00Z",
          "external_event_id": "evt_9f3a2b1c",
          "subject": {
              "email": "jane.smith@acmecorp.com",
          },
          "payload": {
              "form_id": "demo-request",
              "source": "website",
          },
      },
      timeout=30.0,
  )
  response.raise_for_status()

  result = response.json()
  print(result["idempotency_key"])
  ```
</CodeGroup>

A successful call returns `202 Accepted`:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "accepted": true,
  "idempotency_key": "first-party:018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c:evt_9f3a2b1c"
}
```

`accepted` is always `true` on a `202`; it confirms the event was durably stored, not that it has been correlated or scored yet. `idempotency_key` is the key Topo deduplicated the event under — `first-party:<organization_id>:<external_event_id or content hash>`.

### Request fields

<ParamField body="event_name" type="string" required>
  Your name for the event, such as `pricing_page_visit` or `trial_started`. Between 1 and 255 characters. Use a stable, machine-readable name — Topo groups events, feeds signal lists, and matches playbook triggers on this exact value. Matching is case-sensitive and exact: `Pricing_Page_Visit` and `pricing_page_visit` are two different event types.
</ParamField>

<ParamField body="occurred_at" type="string (RFC 3339)" required>
  When the event happened in your system, not when you sent it. Use UTC with a `Z` suffix or an explicit offset. It is the timestamp Topo displays, sorts on, and filters with `occurred_at_after` / `occurred_at_before`. Backdating is safe: custom event signals collect events in the order Topo received them, so a historical import is picked up on the next poll like any other event.
</ParamField>

<ParamField body="subject" type="object" required>
  Identifies who or what the event is about. Supply **at least one** identifier; a request with an empty subject is rejected with `400`. Which identifier you send also decides which signal level can use the event — see [Subject resolution](#subject-resolution) and [Which subject field each level needs](#which-subject-field-each-level-needs).

  <Expandable title="subject">
    <ParamField body="email" type="string">
      Email address of the person the event belongs to.
    </ParamField>

    <ParamField body="linkedin_url" type="string">
      LinkedIn profile URL of the person the event belongs to.
    </ParamField>

    <ParamField body="company_domain" type="string">
      Company domain, for account-level events with no known individual.
    </ParamField>

    <ParamField body="external_contact_id" type="string">
      Contact identifier in your connected CRM.
    </ParamField>

    <ParamField body="external_company_id" type="string">
      Company identifier in your connected CRM.
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="external_event_id" type="string">
  Your identifier for this event, up to 255 characters. Supply it to make retries safe — see [Idempotency](#idempotency).
</ParamField>

<ParamField body="payload" type="object">
  Free-form JSON describing the event. Topo stores it whole and hands it to playbooks as the trigger payload, so include whatever context matters.
</ParamField>

<Note>
  The request rejects unknown top-level fields with `400`. Send exactly the fields above.
</Note>

**How much of the payload reaches a signal.** `GET /v1/events` always returns your payload exactly as you sent it. When a custom event signal picks up an event, Topo copies the payload into the signal event's metadata exactly as you sent it too — nested objects, arrays, and `null` values included, with no limit on the number of keys and no truncation of long strings. The only constraint is total size: if the payload serializes to more than 32 KB of JSON, the copy is left out of the signal metadata. The event is never dropped — it still creates a signal event with its name and subject — and the complete payload always remains available from `GET /v1/events`.

## Read events back

`GET /v1/events` and `GET /v1/events/{event_id}` let you verify what Topo stored and how resolution went — useful when you are wiring up an integration or debugging a subject that never matched.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl "https://api.topo.io/v1/events?event_name=form_submitted&resolution_status=RESOLVED" \
  -H "Authorization: Bearer topo_live_xxxxxxxxxxxx"
```

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl "https://api.topo.io/v1/events/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c" \
  -H "Authorization: Bearer topo_live_xxxxxxxxxxxx"
```

Every event carries the `subject` you submitted plus what Topo made of it: `resolution_status`, and once resolution succeeds, `contact_id` and `account_id` — org-scoped Topo identifiers, never raw CRM or person ids. `received_at` is when Topo stored the event; `resolved_at` is when subject matching finished.

List responses use the standard [pagination envelope](/api-reference/pagination-filtering) and default to `occurred_at` descending, so the newest events come first. `occurred_at` is the only supported `sort_by` value. Narrow the list with these filters:

| Parameter            | Description                                                |
| -------------------- | ---------------------------------------------------------- |
| `event_name`         | Exact match on your event name                             |
| `resolution_status`  | `PENDING`, `RESOLVED`, or `UNRESOLVED`                     |
| `external_event_id`  | Exact match on your stable event id                        |
| `occurred_at_after`  | Events strictly after this RFC 3339 timestamp (exclusive)  |
| `occurred_at_before` | Events strictly before this RFC 3339 timestamp (exclusive) |

<Tip>
  The same data is available without writing any code: open **Settings → Developers → Ingested events** in the Topo dashboard to see the latest events, their subjects, and their resolution status.
</Tip>

## Resolution status

| Status       | What it means                                                                                             |
| ------------ | --------------------------------------------------------------------------------------------------------- |
| `PENDING`    | The event is stored and queued. Subject matching has not run yet.                                         |
| `RESOLVED`   | Topo matched the subject to a contact and/or account. Read `contact_id` and `account_id` for the matches. |
| `UNRESOLVED` | Matching ran but none of the identifiers matched a person or company Topo knows. The event is kept as-is. |

Resolution runs on a background queue that drains every couple of minutes, so an event normally leaves `PENDING` within about two minutes of the `202`.

## Subject resolution

Topo accepts every well-formed event, even when the subject does not match anything you know yet. Resolution runs asynchronously and is **best effort**.

When several subject fields are supplied, Topo tries the contact identifiers in this order and stops at the first match:

1. `email`
2. `linkedin_url`
3. `external_contact_id`, via your connected CRM

The account is resolved separately: `company_domain` first, then `external_company_id` via your CRM, and finally the employer of the contact that just resolved.

<Note>
  Matching runs against Topo's whole people and company directory, not just the contacts already in your workspace. When the subject matches a person or company that is not in your workspace yet, Topo creates the contact (and its account) for you as part of resolution — the same way a lead search import would. An event can still never resolve onto another organization's records: the contact Topo creates or links is always your workspace's own.
</Note>

<Warning>
  **Resolution happens once, and only once.** An `UNRESOLVED` event is never re-resolved when the contact or company appears in your workspace later. If you need the match, send the event again (with a new `external_event_id`, since the original id is already deduplicated).
</Warning>

<Note>
  You do not need to import a contact before sending events about them — a subject that matches a person Topo knows gets its contact created automatically. An event only stays `UNRESOLVED` when the identifiers match no one in Topo's directory, and it still counts for custom event signals: it lands on the signal's list as a raw email or domain entry, just without a link to a Topo contact or account.
</Note>

## Idempotency

Ingestion is idempotent, so retrying after a timeout or a redelivery from your queue never produces a duplicate signal. Topo deduplicates on two things:

* **`external_event_id`** — the reliable option. Pass your own event id and Topo treats any replay of that id as the same event.
* **Event content** — if you omit `external_event_id`, Topo hashes the event itself, so byte-identical resends are still collapsed. Change any field and you get a new event.

A deduplicated replay is not an error: it returns the same `202 Accepted` and the same `idempotency_key` as the original call, and the event is neither stored nor processed a second time.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
# Your worker sends an event but times out before reading the response.
# Retrying with the same external_event_id is safe — no duplicate signal.
curl -X POST https://api.topo.io/v1/events \
  -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "event_name": "form_submitted",
    "occurred_at": "2025-06-15T14:32:00Z",
    "external_event_id": "evt_9f3a2b1c",
    "subject": {"email": "jane.smith@acmecorp.com"}
  }'
```

<Tip>
  Send `external_event_id` whenever your source system has an event id. Content-based deduplication cannot tell a genuine repeat action apart from a retry — two real pricing page visits with an identical timestamp and payload collapse into one signal.
</Tip>

## Custom event signals

Ingesting events stores them and runs your playbooks. To also get **people or companies collected in a list**, turn an `event_name` into a **custom event signal**. Each signal watches exactly one `event_name` and feeds a dedicated list that Topo provisions for it, named **Signals · {event_name}**. Without a custom event signal, an ingested event never becomes a buying signal and never lands on a list.

### Create the signal

<Steps>
  <Step title="Open Settings → Signals">
    Scroll to the **Custom events** section and click **Add custom event signal**.
  </Step>

  <Step title="Enter the event name">
    Type the `event_name` exactly as you send it — matching is case-sensitive. The field suggests names Topo has already received from you, so sending one event first makes this step self-checking. You can also create the signal before sending anything: the first poll backfills the history for that name.
  </Step>

  <Step title="Choose who the signal tracks">
    **People** creates a contact list; **Companies** creates an account list. Pick the one that matches the subject field you send (see the table below).
  </Step>

  <Step title="Save">
    Topo creates **Signals · {event_name}** and links it from the signal's row, next to a toggle that enables or disables the signal.
  </Step>
</Steps>

<Warning>
  **One signal per event name, and the name and level are fixed at creation.** A second signal for a name you already track is rejected, and so is an update that changes the name or the level — both decide which list the signal feeds, and changing them would orphan everything already collected. If you need the same behaviour tracked at both levels, ingest it under two event names (for example `demo_requested` for people and `demo_requested_account` for companies).
</Warning>

### Which subject field each level needs

| Level         | List         | The event counts when it has                                                                                                         | Otherwise                            |
| ------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ |
| **People**    | Contact list | `subject.email`, or a subject that resolved to a person (via `linkedin_url` or `external_contact_id`)                                | The event is skipped for this signal |
| **Companies** | Account list | `subject.company_domain`, or a subject that resolved to a company (via `external_company_id`, or the employer of the matched person) | The event is skipped for this signal |

A skip is silent and permanent: each poll advances a watermark past the events it read, so a `company_domain`-only event will never appear on a People list, even after you fix your integration. Send `email` **and** `company_domain` whenever you know both — resolution has more to match on, and a People entry then also carries the company.

### When entries appear

Custom event signals are polled, not pushed, so list entries are never instant:

* Topo checks for due signals **every hour**, so the first poll runs within an hour of creating the signal.
* After that, custom event signals poll **once a day**. An event ingested just after a poll waits for the next one.
* The first poll **backfills** every event already stored under that name, oldest first.
* A poll reads at most **500 events** per signal. A larger backlog is worked through over the following polls, 500 per day.

### What lands on the list

* **People** — one entry per email (or per matched person when the event has no email), carrying the company domain from the event.
* **Companies** — one entry per domain.
* When resolution succeeded, the entry is linked to the matched Topo contact or account. When it did not, the entry keeps the raw email or domain you sent and stays unlinked — Topo does not link it later.
* First-party events are trusted rather than scored against your ICP, so every event a signal keeps lands on the list.

Ingestion idempotency and list deduplication are separate layers. A retried `POST` with the same `external_event_id` is not stored twice (see [Idempotency](#idempotency)), and an event re-read by a later poll never creates a second entry.

### Example: two event types, two lists

Anonymous traffic is company-level; a form submission is people-level. Send them under two names and wire each to its own signal.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
# Companies signal: only the domain is known.
curl -X POST https://api.topo.io/v1/events \
  -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "event_name": "website_visit",
    "occurred_at": "2025-06-15T14:32:00Z",
    "external_event_id": "visit_8812",
    "subject": {"company_domain": "acmecorp.com"},
    "payload": {"page": "/pricing", "referrer": "google"}
  }'
```

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
# People signal: the form gives you an email, so send the domain too.
curl -X POST https://api.topo.io/v1/events \
  -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "event_name": "filled_form_on_landing_page",
    "occurred_at": "2025-06-15T14:35:00Z",
    "external_event_id": "form_4471",
    "subject": {
      "email": "jane.smith@acmecorp.com",
      "company_domain": "acmecorp.com"
    },
    "payload": {"form_id": "demo-request", "source": "website"}
  }'
```

In **Settings → Signals**, create `website_visit` at the **Companies** level and `filled_form_on_landing_page` at the **People** level. Within the hour, the account list **Signals · website\_visit** holds `acmecorp.com` and the contact list **Signals · filled\_form\_on\_landing\_page** holds `jane.smith@acmecorp.com`. Each signal ignores the other's events.

## After acceptance

`202 Accepted` means Topo has taken responsibility for the event, not that the work is finished. Everything downstream happens after the response is sent:

| When                                                                          | What happens                                                                                                                                                             |
| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Immediately                                                                   | The event is stored and returned by `GET /v1/events` with `resolution_status: PENDING`.                                                                                  |
| Within \~2 minutes                                                            | Topo resolves the `subject`, stamps `resolution_status`, `resolved_at`, `contact_id`, and `account_id`, and runs playbooks whose event trigger matches the `event_name`. |
| On the signal's next poll (within an hour of creating the signal, then daily) | Each matching custom event signal adds the person or company to its list, and the event shows as a first-party buying signal on the matched contact or account.          |

Playbooks run whether or not the subject resolved — the trigger payload carries the raw `email`, `linkedin_url`, and `company_domain` you sent.

## Track resolution with webhooks

Subscribe to `event.resolved` on a webhook whose `sequence_template_ids` is `null` (all templates). Topo delivers a resource-family payload when subject matching finishes — `event_id`, `event_name`, `resolution_status`, and once resolved, `contact_id` and `account_id` on the payload. This is the push counterpart to polling `GET /v1/events` for the same resolution fields.

Narrowed subscriptions (specific `sequence_template_ids`) do not receive resource-family events.

<Warning>
  Because resolution is asynchronous, a `202` does not confirm the subject matched a record in your workspace. Do not treat ingestion as a lookup — use the [Contacts API](/api-reference/contacts) to check whether a person exists in Topo, or poll `GET /v1/events` to see how an event resolved.
</Warning>

<Tip>
  To watch what happens next, poll the [Activities API](/api-reference/activities) or subscribe to [Webhooks](/api-reference/webhooks) so downstream tasks and hot leads reach your systems as they are created.
</Tip>

## Troubleshooting

| Symptom                                    | What to check                                                                                                                                                                                                                                                                     |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401` or `403` on every call               | Call `GET /v1/me` — it works with any valid key and lists the scopes. Ingesting needs `events:write`, reading needs `events:read`.                                                                                                                                                |
| The event is missing from `GET /v1/events` | Filter by `external_event_id` rather than scanning. If it is absent, the call did not return `202`, or it was deduplicated against an earlier event with the same id or identical content.                                                                                        |
| `resolution_status` stays `PENDING`        | The event is stored either way, and resolution normally completes within a couple of minutes; failed attempts are retried automatically. Do not resend on `PENDING` alone.                                                                                                        |
| `resolution_status` is `UNRESOLVED`        | None of the identifiers matched a person or company **Topo knows**. Check the identifiers for typos (the email actually in use, the canonical LinkedIn URL). The event still feeds custom event signals as a raw entry, but it will not be re-resolved later.                     |
| Nothing lands on the signal's list         | In order: the signal exists for that exact, case-sensitive `event_name`; its toggle is on; the subject carries the field its level needs (`email` for People, `company_domain` for Companies); and the poll has run — up to an hour after you create the signal, then once a day. |
| Only some events land                      | A poll reads 500 events per signal, so a backfill catches up over several days. Events missing the level's subject field are skipped permanently.                                                                                                                                 |
| An entry has no name or company            | The event resolved to nothing, so the entry only holds what you sent. Include `email` and `company_domain` in the subject to give Topo more to match on.                                                                                                                          |

<Tip>
  **Settings → Developers → Ingested events** shows the latest events with their subject, resolution status, and timestamps — the fastest way to confirm ingestion without writing a script.
</Tip>

## Errors

| HTTP status | When it occurs                                                                                                              |
| ----------- | --------------------------------------------------------------------------------------------------------------------------- |
| `400`       | The request failed validation — a missing required field, an invalid `occurred_at`, an empty `subject`, or an unknown field |
| `401`       | Missing, malformed, or invalid API key                                                                                      |
| `403`       | Valid key without the required scope (`events:write` to ingest, `events:read` to read back)                                 |
| `404`       | The requested `event_id` does not exist in your workspace                                                                   |
| `429`       | Your organization exceeded a rate limit                                                                                     |

Validation failures return `400` with the standard `ValidationIssue` envelope, not `422`. See [Errors & Limits](/api-reference/errors-rate-limits) for the full error shape, the per-organization rate limits, and the `X-RateLimit-*` headers.

**Example validation error (`400`)**

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "status_code": 400,
  "type": "ValidationIssue",
  "message": "Value error, subject requires at least one identifier",
  "data": {
    "param": "body -> subject",
    "errors": [
      {
        "field": "body -> subject",
        "message": "Value error, subject requires at least one identifier",
        "type": "value_error"
      }
    ]
  },
  "request_id": "req_01j9kx3m7p0000000000000000"
}
```

**Example scope error (`403`)**

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "status_code": 403,
  "type": "UnauthorizedIssue",
  "message": "API key lacks required scope",
  "data": {
    "required": "events:write"
  },
  "request_id": "req_01j9kx3m7p0000000000000000"
}
```
