> ## 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.

# Signals API: Create and Run Buying Signals from Your Own Systems

> Create buying signals from Topo's catalog, preview them before creating, edit, duplicate, pause, resume, archive and restore them, and read the imports feed that shows which people and companies each signal brought in.

A **signal** watches one thing happening in the market — a company opening roles you care about, a funding round, a technology showing up in a stack, a person engaging with your LinkedIn page — and turns it into leads. Topo checks the signal's provider every day, qualifies what it finds against your import filters, and adds the matching people or companies to a **destination list** it creates for the signal and keeps under the same name.

The Signals API covers the full lifecycle of a signal: browse the catalog, preview what a preset would detect, create a signal, edit what it watches, duplicate it, hold it, retire it, restore it, and read back what it imported. Everything the dashboard's **Signals** section does with a signal is available here. Definitions for your own first-party events live in **Settings → Signals**.

<Note>
  **Reading** the catalog, previewing a preset, and reading your signals and the imports feed requires the **`signals:read`** scope. **Creating, editing, duplicating, pausing, resuming, archiving, and unarchiving** requires **`signals:write`**.
</Note>

## 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 **`signals:read`**, **`signals:write`**, or both. The raw key is shown once — see [Authentication](/api-reference/authentication).
  </Step>

  <Step title="Pick a preset">
    A signal is always built from a catalog preset, which decides what gets detected and which detection filters you may send. See [Browse the catalog](#browse-the-catalog).
  </Step>

  <Step title="Preview, then create">
    When the preset's `preview_supported` is true, send your filters to `POST /v1/signals/preview` and check the sample before you create the signal. See [Preview a signal](#preview-a-signal-before-creating-it).
  </Step>
</Steps>

## Browse the catalog

`GET /v1/signals/presets` returns every preset you can create a signal from. Presets that are announced but not open for creation yet are omitted, so anything in this response is safe to pass as `signal_type`.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl https://api.topo.io/v1/signals/presets \
  -H "Authorization: Bearer topo_live_xxxxxxxxxxxx"
```

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
[
  {
    "signal_type": "HIRING_SIGNALS",
    "title": "Hiring signals",
    "description": "Companies opening roles that match your target job titles and keywords, checked daily.",
    "category_label": "Hiring",
    "scope": "COMPANY",
    "scope_selectable_at_creation": false,
    "filter_keys": [
      "job_title_or",
      "job_location_pattern_or",
      "job_description_contains_or",
      "company_type",
      "company_country_code_or",
      "industry_or",
      "industry_not",
      "company_size"
    ],
    "required_filter_keys": ["job_title_or"],
    "requires_event_subject": false,
    "preview_supported": true
  }
]
```

| Field | What it tells you |
| - | - |
| `signal_type` | The value to send as `signal_type` when you create the signal. |
| `scope` | What the preset's provider detects: `COMPANY` facts or `PEOPLE`. |
| `scope_selectable_at_creation` | Whether `import_target` may pick either entity freely, instead of being decided by `scope`. |
| `filter_keys` | Every detection filter key the preset accepts in `search_filters`. Any other key is rejected. |
| `required_filter_keys` | The keys the preset refuses to go without. |
| `requires_event_subject` | Whether creation must declare `event_subject` — true only for the custom-event preset. |
| `preview_supported` | Whether `POST /v1/signals/preview` can sample the preset before you create a signal. |

The catalog covers LinkedIn engagement and keyword mentions, hiring, job-description keywords, funding, technology adoption, news, and your own first-party events (`CUSTOM_EVENT`, fed by the [Events API](/api-reference/events)).

## Preview a signal before creating it

`POST /v1/signals/preview` samples what a preset would detect with a given set of detection filters, without creating anything. Use it to check that your `search_filters` catch the right companies or people before you commit to a signal. It takes the same `signal_type` and `search_filters` as `POST /v1/signals`, and only needs `signals:read`.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST https://api.topo.io/v1/signals/preview \
  -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "signal_type": "HIRING_SIGNALS",
    "search_filters": {
      "job_title_or": ["VP Sales", "Head of Revenue"],
      "company_country_code_or": ["US"]
    }
  }'
```

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "window_days": 30,
  "total_count": 214,
  "items": [
    {
      "event_type": "LEADERSHIP_HIRING",
      "company_name": "Acme Corp",
      "company_domain": "acmecorp.com",
      "first_name": null,
      "last_name": null,
      "job_title": null,
      "linkedin_url": null,
      "email": null
    }
  ]
}
```

The sample holds up to 10 recent matches over a 30-day window, newest first. `total_count` is how many facts the provider reports over that window, or `null` when the provider does not count. Company-scope presets fill the company fields; people-scope presets fill the person fields.

<Warning>
  A preview is wider than a live run: no import filters, no dedup against your lists, and no daily cap are applied. Use it to validate the detection filters, not to predict how many entries the signal will import per day.
</Warning>

A preset whose `preview_supported` is `false` returns `422`.

## Create a signal

`POST /v1/signals` provisions the whole thing in one call: the detection search, the destination list, and the import rules. The signal starts `ACTIVE` and runs a first check immediately instead of waiting for its daily slot.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
  curl -X POST https://api.topo.io/v1/signals \
    -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \
    -H "Content-Type: application/json" \
    -d '{
      "signal_type": "HIRING_SIGNALS",
      "name": "Enterprise sales hiring",
      "search_filters": {
        "job_title_or": ["VP Sales", "Head of Revenue"],
        "company_country_code_or": ["US"]
      },
      "import_target": "PEOPLE",
      "import_search_filters": {
        "job_titles": ["Chief Revenue Officer"]
      },
      "max_companies_per_run": 20,
      "max_contacts_per_company": 2
    }'
  ```

  ```javascript JavaScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
  const response = await fetch("https://api.topo.io/v1/signals", {
    method: "POST",
    headers: {
      Authorization: "Bearer topo_live_xxxxxxxxxxxx",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      signal_type: "HIRING_SIGNALS",
      name: "Enterprise sales hiring",
      search_filters: {
        job_title_or: ["VP Sales", "Head of Revenue"],
        company_country_code_or: ["US"],
      },
      import_target: "PEOPLE",
      max_companies_per_run: 20,
      max_contacts_per_company: 2,
    }),
  });

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

  const signal = await response.json();
  console.log(signal.id, signal.contact_list_id);
  ```

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

  response = httpx.post(
      "https://api.topo.io/v1/signals",
      headers={
          "Authorization": "Bearer topo_live_xxxxxxxxxxxx",
          "Content-Type": "application/json",
      },
      json={
          "signal_type": "HIRING_SIGNALS",
          "name": "Enterprise sales hiring",
          "search_filters": {
              "job_title_or": ["VP Sales", "Head of Revenue"],
              "company_country_code_or": ["US"],
          },
          "import_target": "PEOPLE",
          "max_companies_per_run": 20,
          "max_contacts_per_company": 2,
      },
      timeout=30.0,
  )
  response.raise_for_status()

  signal = response.json()
  print(signal["id"], signal["contact_list_id"])
  ```
</CodeGroup>

A successful call returns `201 Created` with the signal:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c",
  "name": "Enterprise sales hiring",
  "signal_type": "HIRING_SIGNALS",
  "status": "ACTIVE",
  "cadence": "DAILY",
  "max_per_run": null,
  "max_companies_per_run": 20,
  "max_contacts_per_company": 2,
  "max_per_day": 40,
  "enrich_email": true,
  "enrich_phones": true,
  "imported_count": 0,
  "contact_list_id": "018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d80",
  "account_list_id": null,
  "created_at": "2025-06-15T14:32:00Z",
  "archived_at": null,
  "url": "https://agent.topo.io/organization/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7b/signals/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c/edit",
  "list_url": "https://agent.topo.io/organization/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7b/lists/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d80"
}
```

`url` opens the signal in the Topo dashboard and `list_url` opens its destination list, so you can link from your own tools straight to what a signal collected.

### Request fields

<ParamField body="signal_type" type="string" required>
  The catalog preset to build. Must be one of the `signal_type` values from `GET /v1/signals/presets`; a preset that is not open for creation returns `404`.
</ParamField>

<ParamField body="name" type="string" required>
  Name of the signal **and** of the destination list Topo creates for it. Leading and trailing whitespace is trimmed, and a name that is empty after trimming is rejected.
</ParamField>

<ParamField body="search_filters" type="object">
  Detection filters, as an object of filter key to a list of plain strings — `{"job_title_or": ["VP Sales"]}`. Only keys in the preset's `filter_keys` are accepted, and every key in `required_filter_keys` must be present with a non-empty value.
</ParamField>

<ParamField body="import_target" type="string">
  `PEOPLE` or `COMPANIES`. Leave it out to follow the preset's own `scope`. A `COMPANY`-scope preset may set `PEOPLE` to import the people behind the companies it detects; a `PEOPLE`-scope preset cannot import companies unless its `scope_selectable_at_creation` is true.
</ParamField>

<ParamField body="event_subject" type="string">
  What your own events identify — `PEOPLE` for email or LinkedIn subjects, `COMPANIES` for domains. Required for the custom-event preset and rejected for every other preset.
</ParamField>

<ParamField body="import_search_filters" type="object">
  Filters narrowing who or what is imported once a fact is detected — personas for a `PEOPLE` target, firmographics for a `COMPANIES` target. Same shape as `search_filters`.
</ParamField>

<ParamField body="sequence_template_id" type="string (uuid)">
  Sequence template that imported leads are enrolled into. People destinations only. A template from another workspace returns `404`.
</ParamField>

<ParamField body="enrich_email" type="boolean" default="true">
  Whether imported leads get their email enriched.
</ParamField>

<ParamField body="enrich_phones" type="boolean" default="true">
  Whether imported leads get their phone enriched.
</ParamField>

<ParamField body="max_per_run" type="integer">
  Daily ceiling on imported entries. Must be at least 1 and within your organization's lead limit. Cannot be combined with `max_companies_per_run`.
</ParamField>

<ParamField body="max_companies_per_run" type="integer">
  Daily ceiling on detected companies consumed. Only valid on a company signal importing people.
</ParamField>

<ParamField body="max_contacts_per_company" type="integer">
  How many people to import per detected company. Requires `max_companies_per_run`.
</ParamField>

### How the daily ceiling is resolved

`max_per_day` on the response is the number Topo actually enforces:

| What you set | `max_per_day` |
| - | - |
| `max_per_run` | That value. |
| `max_companies_per_run` (people target) | `max_companies_per_run × max_contacts_per_company`, with `max_contacts_per_company` defaulting to 1. |
| Neither | 100. |

<Warning>
  Setting both `max_per_run` and `max_companies_per_run` is rejected — the daily contact ceiling is derived from the company pair, so the two would contradict each other.
</Warning>

## Signal status

| Status | Behaviour |
| - | - |
| `ACTIVE` | The signal checks its provider daily and imports what qualifies. |
| `PAUSED` | Checking and importing are held until you resume. Everything already imported stays in the destination list. |
| `ARCHIVED` | The signal is retired: it stops checking, disappears from the default listing, and cannot be resumed or reconfigured until it is unarchived. Its destination list and imported entries are kept. |

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

Each of `POST /v1/signals/{signal_id}/pause`, `/resume`, `/archive`, and `/unarchive` returns the updated signal with its new `status`.

* **Resume** makes the signal due right away, so the next check runs without waiting for the daily slot.
* **Archive** retires the signal. Resuming or re-archiving an archived signal returns `409`.
* **Unarchive** restores an archived signal as `PAUSED`, with its filters, destination list, and imported entries intact. Resume it when you want it checking again. Unarchiving a signal that is not archived returns `409`.

<Note>
  Prefer pausing when a signal should only stop for now. There is no delete in `/v1` — archiving preserves the provenance linking every imported entry to the event that produced it, and unarchiving brings the signal back whenever you need it.
</Note>

## Edit a signal

`PATCH /v1/signals/{signal_id}` is a partial update: fields you leave out keep their current value, and the response is the full signal detail after the change. Renaming also renames the destination list.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X PATCH https://api.topo.io/v1/signals/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c \
  -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Enterprise hiring — EMEA",
    "search_filters": {
      "job_title_or": ["VP Sales", "Head of Revenue"],
      "company_country_code_or": ["FR", "DE", "GB"]
    },
    "max_companies_per_run": 30
  }'
```

You can change `name`, `search_filters`, `import_search_filters`, the caps (`max_per_run`, `max_companies_per_run`, `max_contacts_per_company`), `enrich_email`, `enrich_phones`, and `sequence_template_id`. Sending a filters object **replaces** the current filters for that key set, so send the complete set you want, not just the changed keys. Send `null` for a cap or `sequence_template_id` to clear it.

| What changes | What Topo does |
| - | - |
| Detection filters | For presets that baseline on their first run, the baseline restarts: the next check re-absorbs the new result set before importing, so a broadened filter does not flood the list with everything it now matches. |
| Caps | Apply from the next check. `max_per_day` on the response reflects the new resolution. |
| Enrichment | Applies to entries imported from now on; nothing already imported is re-enriched. |
| Import filters | Apply to the next qualification. Entries already in the list are kept. |

The `signal_type` and `import_target` are fixed once the signal exists — to change what kind of thing a signal watches, or whether it imports people or companies, [duplicate](#duplicate-a-signal) it with the new configuration and archive the original. An archived signal can only be renamed; any other edit returns `422` until you unarchive it. The same `422` rules as creation apply: unknown filter keys, missing required filters, or contradictory caps are rejected and the signal is left unchanged.

## Duplicate a signal

`POST /v1/signals/{signal_id}/duplicate` copies a signal — same preset, detection filters, import filters, caps, enrichment, and sequence template — onto a **new** destination list. The copy starts `ACTIVE` and runs a first check immediately; the original is left as it was. Use it to fan a working configuration out across regions or personas, or as the first step of changing something `PATCH` cannot.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST https://api.topo.io/v1/signals/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c/duplicate \
  -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"name": "Enterprise sales hiring — APAC"}'
```

<ParamField body="name" type="string">
  Name of the copy and of the destination list created for it. Leave it out to get the source name followed by ` (Copy)`.
</ParamField>

A successful call returns `201 Created` with the new signal, in the same shape as `POST /v1/signals`. Duplicating an archived signal works: the copy is active even though the source is not.

## List and read signals

`GET /v1/signals` returns the workspace's signals, most recently created first, in the standard [pagination envelope](/api-reference/pagination-filtering). Archived signals are hidden unless you ask for them with `include_archived=true`.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl "https://api.topo.io/v1/signals?page=1&size=25&include_archived=true" \
  -H "Authorization: Bearer topo_live_xxxxxxxxxxxx"
```

`GET /v1/signals/{signal_id}` returns the same fields plus the signal's full configuration: `search_filters`, `import_search_filters`, `sequence_template_id`, `import_target`, and `event_subject`. The filters come back in exactly the shape `POST /v1/signals` and `PATCH /v1/signals/{signal_id}` accept, so you can read one signal, tweak it, and send it back without translation.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c",
  "name": "Enterprise sales hiring",
  "signal_type": "HIRING_SIGNALS",
  "status": "ACTIVE",
  "url": "https://agent.topo.io/organization/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7b/signals/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c/edit",
  "list_url": "https://agent.topo.io/organization/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7b/lists/018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d80",
  "import_target": "PEOPLE",
  "event_subject": null,
  "search_filters": {
    "job_title_or": ["VP Sales", "Head of Revenue"],
    "company_country_code_or": ["US"]
  },
  "import_search_filters": {
    "job_titles": ["Chief Revenue Officer"]
  },
  "sequence_template_id": null
}
```

A signal that belongs to another workspace returns `404`, never `403`.

## Read the imports feed

`GET /v1/signals/imports` is the audit trail of every signal in the workspace: which signal fired, who or which company it was about, and what Topo did with it. Entries are ordered by when the underlying fact occurred, most recent first.

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

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "items": [
    {
      "id": "018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d90",
      "signal_id": "018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c",
      "signal_name": "Enterprise sales hiring",
      "signal_type": "HIRING_SIGNALS",
      "kind": "CONTACT",
      "status": "IMPORTED",
      "skip_detail": null,
      "occurred_at": "2025-06-14T09:02:00Z",
      "processed_at": "2025-06-15T06:11:00Z",
      "contact_id": "018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d91",
      "account_id": "018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d92",
      "first_name": "Ada",
      "last_name": "Lovelace",
      "job_title": "VP Sales",
      "email": "ada@acmecorp.com",
      "linkedin_url": "https://www.linkedin.com/in/ada",
      "company_name": "Acme Corp",
      "company_domain": "acmecorp.com",
      "account_name": null,
      "account_domain": null
    }
  ],
  "total_count": 1,
  "total_pages": 1,
  "has_more": false
}
```

### Import status

| Status | What it means |
| - | - |
| `FOUND` | The fact was detected but the signal has not decided what to do with it yet. |
| `IMPORTED` | The person or company landed in the destination list. |
| `ALREADY_IN_LIST` | A duplicate of an entry the list already had. Not a failure. |
| `REJECTED` | The entry was filtered out. `skip_detail` says why. |

### Filters

| Parameter | Description |
| - | - |
| `signal_id` | Only what this signal brought in |
| `signal_type` | Only entries from signals built on this preset |
| `kind` | `CONTACT` or `ACCOUNT` |
| `status` | One of the four import statuses above |
| `page`, `size` | Standard [pagination](/api-reference/pagination-filtering) |

`kind` tells you which half of the entry is filled in: a `CONTACT` entry carries the person fields (`first_name`, `email`, `company_domain`), an `ACCOUNT` entry carries `account_name` and `account_domain`. `contact_id` and `account_id` are populated once the record exists in your workspace.

## Errors

| HTTP status | When it occurs |
| - | - |
| `400` | The request failed validation — a missing required field, a blank `name`, or an unknown field |
| `401` | Missing, malformed, or invalid API key |
| `403` | Valid key without the required scope (`signals:read` to read, `signals:write` to change) |
| `404` | The `signal_id` or `sequence_template_id` does not exist in your workspace, or the preset is not open for creation |
| `409` | The lifecycle transition is not allowed — resuming or re-archiving an archived signal, or unarchiving one that is not archived |
| `422` | The preset cannot be previewed, the signal is archived and you tried to reconfigure it, or the configuration is inconsistent — a filter key the preset does not expose, a missing required filter, contradictory caps, or an `import_target` the preset cannot serve |
| `429` | Your organization exceeded a rate limit |

See [Errors & Limits](/api-reference/errors-rate-limits) for the full error envelope and the `X-RateLimit-*` headers.

**Example configuration error (`422`)**

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "status_code": 422,
  "type": "ValidationIssue",
  "message": "Signal search_filters is missing the definition's required filters",
  "data": {
    "signal_type": "HIRING_SIGNALS",
    "missing_keys": ["job_title_or"]
  },
  "request_id": "req_01j9kx3m7p0000000000000000"
}
```

## Troubleshooting

| Symptom | What to check |
| - | - |
| `403` on every call | Call `GET /v1/me` — it works with any valid key and lists the scopes. Reading needs `signals:read`, changing needs `signals:write`. |
| `404` on create | The `signal_type` is not open for creation. Re-read `GET /v1/signals/presets`; only the presets it returns can be created. |
| `422` on create mentioning filter keys | `search_filters` used a key outside the preset's `filter_keys`, or omitted one of its `required_filter_keys`. Send the filters to `POST /v1/signals/preview` first to catch this before creating. |
| `422 Archived signals cannot be edited` | The signal is archived. Call `POST /v1/signals/{signal_id}/unarchive`, then retry the `PATCH`. |
| The signal exists but its list stays empty | Check the imports feed for that `signal_id`. `REJECTED` entries mean your `import_search_filters` are too narrow; no entries at all means the detection filters matched nothing yet. Signals check once a day. |
| The daily import count is lower than expected | Read `max_per_day` on the signal — with no caps set it is 100, and a company-to-people signal is capped at `max_companies_per_run × max_contacts_per_company`. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.