# Account Exclusions API: Block Entire Company Domains Source: https://docs.topo.io/api-reference/account-exclusions Block an entire company domain from Topo outreach. Suppress competitive accounts, current customers, and other domains with time-bound or permanent exclusions. Account exclusions prevent Topo from contacting anyone at a company, regardless of which sequences or lists they appear in. A rule targets a company by its `domain` (for example, `competitor.com`), by its LinkedIn company page (`linkedin_url`), or by both, at least one. A domain rule reaches every account on that domain and every contact whose corporate email carries it; a page rule reaches only the account on that page, whichever way the page is spelled (by its slug or by its numeric `/company/` form), and not the other companies sharing its domain. Exclusions can be permanent or time-limited via the optional `until` field, and carry a structured `reason` code to help you report on and audit your blocklist. > [!NOTE] > > Read endpoints require the `account_exclusions:read` scope. Create, update, and delete endpoints require `account_exclusions:write`. Contact exclusions have their own `contact_exclusions:*` scopes. ## Create an exclusion `POST /v1/account-exclusions` requires `reason` and at least one of `domain` or `linkedin_url` (a LinkedIn company page); a request with neither, or with a `linkedin_url` that is not a company page, is rejected with `400`. Optional fields include `message` (a free-form operator note) and `until` (an RFC 3339 timestamp after which the exclusion expires — omit for a permanent block). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.topo.io/v1/account-exclusions \ -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "domain": "competitor.com", "reason": "COMPETITOR" }' ``` To exclude one company without the others sharing its domain, send its page alone: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.topo.io/v1/account-exclusions \ -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "linkedin_url": "https://www.linkedin.com/company/competitor", "reason": "COMPETITOR" }' ``` ## List and manage exclusions `GET /v1/account-exclusions` returns every account exclusion in your workspace with the standard [pagination envelope](/api-reference/pagination-filtering). Filter by `domain`, `reason`, or creation timestamps. Each exclusion carries `id`, `domain`, `linkedin_url`, `reason`, `message`, `until`, `created_at` and `updated_at`; `domain` is `null` on a rule that targets only a LinkedIn company page, and `linkedin_url` is `null` on a rule that targets only a domain. `GET /v1/account-exclusions/{exclusion_id}` fetches a single rule. `PATCH` updates mutable fields (`reason`, `message`, `until`). `DELETE` permanently removes the exclusion. To block a single person without suppressing their entire company, use [contact exclusions](/api-reference/contact-exclusions). ## Exclusion reasons Every account exclusion carries a `reason` field. Valid values are: | Value | Description | | - | - | | `UNSUBSCRIBED` | Person or account has opted out. | | `CONTACT_REFUSED` | Contact explicitly asked not to be reached. | | `BOUNCED` | Email hard-bounced. | | `WRONG_PERSON` | Contact is not the right person at the company. | | `LEFT_COMPANY` | Contact has left the company. | | `BAD_TIMING` | Not a good time — revisit later. | | `NOT_INTERESTED` | Contact declined the offer without opting out. | | `OUT_OF_OFFICE` | Temporarily unavailable. | | `MEETING` | Already in progress with this contact. | | `INTERESTED` | Contact has expressed interest through another channel. | | `COMPETITOR` | Account is a direct competitor. | | `REFERRAL` | Came in through a referral channel; handle separately. | | `EXCLUDE_FROM_SEARCH` | Suppress from Topo's prospecting search. | | `EXCLUDE_FROM_CRM` | Imported CRM suppression. | | `OTHER` | None of the above. | | `UNKNOWN` | Reason not recorded. | # Account Lists API: Manage Target Company Lists Source: https://docs.topo.io/api-reference/account-lists Build and maintain curated lists of target companies for account-based outreach. Add company entries by name and domain, and manage lists programmatically. Account lists are curated collections of target companies used for account-first outreach strategies. Instead of starting with individual people, you define the companies you want to pursue, and Topo's agents identify and engage the right contacts within those accounts. This is the core primitive for ABM (account-based marketing) workflows in Topo. > [!NOTE] > > Read endpoints require the `account_lists:read` scope. Create and update endpoints require `account_lists:write`. # Accounts API: Look Up Companies and Manage Account Lists Source: https://docs.topo.io/api-reference/accounts Upsert accounts by domain, list them by domain or name, reassign owners, manage account custom variables, and manage account lists with domain-keyed entries and CRM correlation. An **account** is the org-scoped company record Topo uses for account-based workflows — the employer linked to contacts, the target of account lists, and the resolution target for first-party events keyed on `company_domain`. > [!NOTE] > > Reading accounts and their variables requires `accounts:read`. Upserting accounts, reassigning owners, and managing variables require `accounts:write`. Account lists and entries use `account_lists:read` and `account_lists:write`. ## Upsert an account `POST /v1/accounts` creates an account or returns the matching one if it already exists. Topo matches inside your organization on `domain` (normalized), so re-sending the same company is safe and always returns the same `id`. `name` is only used when the account does not exist yet. Both create and match return `201`. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.topo.io/v1/accounts \ -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "name": "Analytical Engines", "domain": "analytical.io" }' ``` Upserting a contact with `company_domain` (`POST /v1/contacts`) creates the account implicitly — use this endpoint when you want to push companies before knowing which people to pursue. ## List and look up accounts `GET /v1/accounts` returns accounts in your workspace with the standard [pagination envelope](/api-reference/pagination-filtering). | Parameter | Description | | - | - | | `domain` | Case-insensitive exact match on primary company domain | | `name` | Case-insensitive substring match on company name | `GET /v1/accounts/{account_id}` fetches a single account. ## Reassign owner `PATCH /v1/accounts/{account_id}/owner` with `{ "owner_user_id": "" }` reassigns the account owner to a workspace user. ## Custom variables Accounts carry the same kind of key-value variables as contacts. An account holds a maximum of **200 variables**. Setting a key's value to `null` clears the value but does not free its slot toward the limit — use `DELETE /v1/accounts/{account_id}/variables/{key}` to fully remove a key. * `GET /v1/accounts/{account_id}/variables` — read the account's variables * `PATCH /v1/accounts/{account_id}/variables` — merge `variables` into the account * `DELETE /v1/accounts/{account_id}/variables/{key}` — remove one key These are the same variables exposed as `account_variables` on the [Contact variables endpoints](/api-reference/contacts#custom-variables): writing through either surface updates the same account-level store, shared by every contact linked to the account. ## Account lists and entries Account lists are curated company collections for account-first outreach. The [Account lists](/api-reference/account-lists) guide covers list CRUD; entries are the rows you add to a list. `POST /v1/account-entries` accepts up to 100 items per batch. Each item needs `account_list_id`, `name`, and at least one of `domain` or `linkedin_url` (a LinkedIn company page); an item with neither, or with a `linkedin_url` that is not a company page, fails the request with `400`. Topo looks up each item in the Topo lead database by its page, else its domain. When the database holds exactly one company for it, the account's company profile is completed from it. A domain several companies share creates the account unlinked, with the values you sent. A company the database does not know, or a hosted-site or social domain, is rejected with `NOT_IN_LEAD_DATABASE`. When the lead database is briefly unavailable, the request fails with `503` (`TransientThirdPartyIssue`); the items before the failing one may already be written. Retry the same request with backoff: what was already written is matched, not created twice. Topo links each entry to a workspace account for that domain in the request, creating the account when the domain is new, so `account_id` is set in the response. Use it to correlate list imports with `GET /v1/accounts` without re-querying by domain. Each item in the response has one of these statuses: | Status | Meaning | | - | - | | `CREATED` | The entry was added to the list. `resource` is the new entry. | | `ALREADY_MEMBER` | The account is already in this list, so no entry was added. `resource` is the existing entry. Counts as succeeded in `summary`. | | `FAILED` | The item was rejected. `error.type` gives the reason: `DROPPED_MISSING_DOMAIN` when the domain is empty or unusable, `DROPPED_UNRESOLVED_ACCOUNT` when no account could be resolved for it, or `NOT_IN_LEAD_DATABASE` when the company is not found in the Topo lead database. | `GET /v1/account-entries` requires `account_list_id` and supports domain and name filters. Each entry carries its `domain` and its `linkedin_url`; an entry added with only its LinkedIn company page has a null `domain` until the lead database gives its account one. `DELETE /v1/account-entries/{account_entry_id}` removes one entry. `DELETE /v1/account-lists/{account_list_id}` deletes a list and its entries. > [!TIP] > > When upserting contacts, `company_domain` on `POST /v1/contacts` links the person to an account (and creates the account when needed). Account lists are for staging companies before you know which people to pursue. # Batch Create Account Entries Source: https://docs.topo.io/api-reference/accounts/batch-create-account-entries /openapi.json post /v1/account-entries Add entries to existing account lists in batch (up to 100 items per request). Unlike single-resource `POST` endpoints, the body wraps the payloads in `items` and the response reports a per-item status. Each item needs a `domain`, a LinkedIn company page (`linkedin_url`) or both, and is looked up in the Topo lead database by its page, else its domain. When the database holds exactly one company for it, the item's company profile is completed from it. A domain several companies share creates the account unlinked, with the values sent. An item the database does not know, or whose domain is a hosted-site or social domain, is reported as `FAILED` with `NOT_IN_LEAD_DATABASE`. An item whose account is already in the list is reported as `ALREADY_MEMBER` with the existing entry and counts as succeeded. When the lead database is briefly unavailable, the request fails with `503` (`TransientThirdPartyIssue`); the items before the failing one may already be written. Retry the same request with backoff: what was already written is matched, not created twice. Requires the `account_lists:write` scope on the API key. # Batch Create Account Lists Source: https://docs.topo.io/api-reference/accounts/batch-create-account-lists /openapi.json post /v1/account-lists Create account lists in batch (up to 100 items per request). Unlike single-resource `POST` endpoints, the body wraps the payloads in `items` and the response reports a per-item status. Requires the `account_lists:write` scope on the API key. # Create Account Exclusion Source: https://docs.topo.io/api-reference/accounts/create-account-exclusion /openapi.json post /v1/account-exclusions Create a new account-level exclusion on a domain, a LinkedIn company page, or both. Requires the `account_exclusions:write` scope on the API key. # Delete Account Entry Source: https://docs.topo.io/api-reference/accounts/delete-account-entry /openapi.json delete /v1/account-entries/{account_entry_id} Remove an account entry from its list. Requires the `account_lists:write` scope on the API key. # Delete Account Exclusion Source: https://docs.topo.io/api-reference/accounts/delete-account-exclusion /openapi.json delete /v1/account-exclusions/{exclusion_id} Permanently delete an account-level exclusion. Requires the `account_exclusions:write` scope on the API key. # Delete Account List Source: https://docs.topo.io/api-reference/accounts/delete-account-list /openapi.json delete /v1/account-lists/{account_list_id} Delete an account list and its entries. Requires the `account_lists:write` scope on the API key. # Delete Account Variable Source: https://docs.topo.io/api-reference/accounts/delete-account-variable /openapi.json delete /v1/accounts/{account_id}/variables/{key} Delete a single variable from an account. Requires the `accounts:write` scope on the API key. # Enrich Account Source: https://docs.topo.io/api-reference/accounts/enrich-account POST /v1/accounts/enrich Look up what a company does from its website domain. Look up what a company does from its website domain. Stored company data is returned when the workspace already knows the domain; otherwise the company's website is summarized on the fly. Nothing is written to the workspace, so this never creates an account — use `POST /v1/accounts` for that. Requires the `accounts:read` scope on the API key. # Get Account Source: https://docs.topo.io/api-reference/accounts/get-account /openapi.json get /v1/accounts/{account_id} Fetch a single account attached to the authenticated organization. Requires the `accounts:read` scope on the API key. # Get Account Entry Source: https://docs.topo.io/api-reference/accounts/get-account-entry /openapi.json get /v1/account-entries/{account_entry_id} Fetch a single account-list entry. Requires the `account_lists:read` scope on the API key. # Get Account Exclusion Source: https://docs.topo.io/api-reference/accounts/get-account-exclusion /openapi.json get /v1/account-exclusions/{exclusion_id} Fetch a single account-level exclusion. Requires the `account_exclusions:read` scope on the API key. # Get Account List Source: https://docs.topo.io/api-reference/accounts/get-account-list /openapi.json get /v1/account-lists/{account_list_id} Fetch a single account list. Requires the `account_lists:read` scope on the API key. # Get Account Variables Source: https://docs.topo.io/api-reference/accounts/get-account-variables /openapi.json get /v1/accounts/{account_id}/variables Fetch the custom variables stored on an account. Requires the `accounts:read` scope on the API key. # List Account Entries Source: https://docs.topo.io/api-reference/accounts/list-account-entries /openapi.json get /v1/account-entries List entries inside an account list. Requires the `account_lists:read` scope on the API key. # List Account Exclusions Source: https://docs.topo.io/api-reference/accounts/list-account-exclusions /openapi.json get /v1/account-exclusions List the account-level exclusions in the workspace. Requires the `account_exclusions:read` scope on the API key. # List Account Lists Source: https://docs.topo.io/api-reference/accounts/list-account-lists /openapi.json get /v1/account-lists List the account lists in the workspace. Requires the `account_lists:read` scope on the API key. # List Accounts Source: https://docs.topo.io/api-reference/accounts/list-accounts /openapi.json get /v1/accounts List accounts attached to the authenticated organization. Requires the `accounts:read` scope on the API key. # Look Up Account Source: https://docs.topo.io/api-reference/accounts/lookup-account POST /v1/accounts/lookup Find a company in Topo's lead database without saving it. Find a company in Topo's lead database and read its firmographics: industries, headcount and growth, headquarters country, founding year, revenue range and last funding round. The LinkedIn company page is the surest match; the domain is tried when the page is unknown. A domain shared by several companies returns `found: false`. Nothing is written to your workspace and no credit is spent: use `POST /v1/accounts` to save the company as an account. Requires the `accounts:read` scope on the API key. # Reassign Account Owner Source: https://docs.topo.io/api-reference/accounts/reassign-account-owner /openapi.json patch /v1/accounts/{account_id}/owner Assign or clear the owner of an account. Requires the `accounts:write` scope on the API key. # Update Account Crm Fields Source: https://docs.topo.io/api-reference/accounts/update-account-crm-fields PATCH /v1/accounts/{account_id}/crm Write properties to the connected CRM record for an account. Write properties to the connected CRM record for an account. Requires the `crm:write` scope on the API key. # Update Account Exclusion Source: https://docs.topo.io/api-reference/accounts/update-account-exclusion /openapi.json patch /v1/account-exclusions/{exclusion_id} Update mutable fields of an account-level exclusion. Requires the `account_exclusions:write` scope on the API key. # Update Account List Source: https://docs.topo.io/api-reference/accounts/update-account-list /openapi.json patch /v1/account-lists/{account_list_id} Update mutable fields of an account list. Requires the `account_lists:write` scope on the API key. # Update Account Variables Source: https://docs.topo.io/api-reference/accounts/update-account-variables /openapi.json patch /v1/accounts/{account_id}/variables Merge variables into an account's variables. Upsert semantics: provided keys are written, other keys are kept. An account holds at most 200 variables. A null value clears the variable but keeps the key counting toward the limit; use the DELETE endpoint to remove a key and free its slot. Requires the `accounts:write` scope on the API key. # Upsert Account Source: https://docs.topo.io/api-reference/accounts/upsert-account /openapi.json post /v1/accounts Create an account, or return the matching one if it already exists. The account is matched inside your organization on `domain` (normalized), so re-sending the same company is safe and returns the same `id`. `name` is stored on a created account and fills the name of a matched account that has none; a call with only a domain creates an account without a name. `201` is returned for both the create and the match. Upserting a contact with `company_domain` (`POST /v1/contacts`) creates the account implicitly — use this endpoint when you want to push companies before knowing which people to pursue. Requires the `accounts:write` scope on the API key. # Activities API: Stream Engagement Events from Topo Source: https://docs.topo.io/api-reference/activities Query every tracked outreach event in your Topo workspace — sent messages, opens, replies, hot leads, and LinkedIn events — via a paginated REST API. The Activities API gives you a queryable, paginated log of every engagement event Topo records across your outreach sequences. Each activity carries a strongly-typed `payload` that describes exactly what happened — which contact it involved, which sequence it came from, what channel was used, and any event-specific metadata. The payload schema is the same discriminated union that Topo delivers to webhook subscribers, so you can build consistent pipelines whether you are polling or streaming. Activities are ideal for building custom reporting dashboards, syncing engagement history to a CRM, or auditing outreach behaviour without setting up a webhook endpoint. > [!NOTE] > > All Activities endpoints require an API key with the **`activities:read`** scope. ## Event types The `payload.type` field is always one of the following values. Topo may add new event types as additive changes within `/v1`; your code should handle unknown values gracefully rather than treating them as errors. | `type` value | What it means | | - | - | | `message.sent` | An email or LinkedIn message was sent to the contact. | | `message.opened` | The contact opened an email. | | `message.link_clicked` | The contact clicked a tracked link in an email. | | `message.replied` | The contact replied to a message. Includes a `metadata.reply_category` field (filled asynchronously — may be `null` immediately after delivery). | | `invitation.sent` | A LinkedIn connection request was sent. Includes `metadata.step_number`. | | `invitation.accepted` | The contact accepted a LinkedIn connection request. | | `hot_lead.created` | Topo's AI flagged the contact as a hot lead. | | `meeting.created` | A meeting was booked with the contact. | | `sequence.created` | A contact was enrolled into a sequence. | | `sequence.paused` | The sequence was paused. May include `metadata.paused_until` and `metadata.last_step_executed`. | | `sequence.resumed` | A paused sequence was resumed. | | `sequence.stopped` | The sequence was stopped early. Includes `metadata.stopped_reason` and `metadata.error_code`. | | `sequence.completed` | The sequence ran to completion. | | `task.created` | A manual task was created for the contact. | | `task.completed` | A manual task was marked complete. | | `task.skipped` | A manual task was skipped. | | `task.reopened` | A previously completed or skipped task was reopened. | > [!TIP] > > Use the `event_type` filter to pull only the events you care about — for example, `REPLIED` and `CREATED` events to drive CRM updates without fetching the entire activity stream. ## Embedded summaries Beyond the ids naming the records an activity touched, each `payload` embeds compact copies of those records — so a single page of activities is usually enough to build a report or a CRM update without fanning out into per-contact lookups. Every payload carries `contact` and `sequence_template`, plus one event-specific embed: `message` on message events, `meeting` on `meeting.created`, `task` on task events, and `hot_lead` on `hot_lead.created`. All field names are `snake_case`. > [!NOTE] > > These are the same embeds Topo delivers to webhook subscribers. See [Embedded summaries](/api-reference/webhooks#embedded-summaries) on the Webhooks page for the field-by-field reference and the null-degradation rules. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "id": "01954b2e-aaaa-7000-eeee-777777777777", "created_at": "2026-08-03T10:15:00Z", "payload": { "type": "message.sent", "organization_id": "01954b2e-0000-7000-aaaa-111111111111", "contact_id": "01954b2e-1111-7000-bbbb-222222222222", "sequence_id": "01954b2e-2222-7000-cccc-333333333333", "sequence_template_id": "01954b2e-3333-7000-dddd-444444444444", "resource_type": "MESSAGE", "channel": "EMAIL", "message_id": "01954b2e-4444-7000-dddd-555555555555", "hot_lead_id": null, "calendar_event_id": null, "task_id": null, "contact": { "id": "01954b2e-1111-7000-bbbb-222222222222", "first_name": "Ada", "last_name": "Lovelace", "email": "ada@analytical.io", "linkedin_url": null, "job_title": "CTO", "company_name": "Analytical Engines", "company_domain": "analytical.io" }, "sequence_template": { "id": "01954b2e-3333-7000-dddd-444444444444", "name": "Outbound to CTOs" }, "message": { "id": "01954b2e-4444-7000-dddd-555555555555", "subject": "Quick question", "body": "Hi Ada, are you the right person?", "channel": "EMAIL", "direction": "OUTBOUND", "sent_at": "2026-08-03T10:14:58Z", "step_number": 2 } } } ``` ## Polling for new activities The Activities API is well-suited to incremental polling patterns for CRM sync or custom reporting. A reliable approach: 1. On first run, fetch activities with `sort_order=ASC` and store the `created_at` of the last item in your cursor. 2. On subsequent runs, pass that cursor as `created_at_after` to retrieve only new events. 3. Page through results using `has_more` until it is `false`. > [!TIP] > > If you need real-time delivery instead of polling, use [Webhooks](/api-reference/webhooks) — Topo will push events to your endpoint within seconds of them occurring. > [!NOTE] > > The pagination envelope shape — `{ items, total_count, total_pages, has_more }` — is frozen by Topo's API stability policy. New response fields may be added additively; your client should ignore unknown fields. # Get Activity Source: https://docs.topo.io/api-reference/activities/get-activity /openapi.json get /v1/activities/{activity_id} Fetch a single activity. Requires the `activities:read` scope on the API key. # List Activities Source: https://docs.topo.io/api-reference/activities/list-activities /openapi.json get /v1/activities List activities recorded in the workspace. Requires the `activities:read` scope on the API key. # Authentication Source: https://docs.topo.io/api-reference/authentication Create scoped API keys in your Topo workspace, then pass them as a Bearer token on every request. Learn available scopes and security best practices. Every request to the Topo API must be authenticated with an API key. Keys are scoped to the exact permissions your integration needs, so you can grant read-only access to one key and full write access to another — all within the same workspace. ## Create an API key **1. Open your workspace settings** Navigate to **Settings → Developers → API Keys** in the Topo dashboard. **2. Create a new key** Click **Create Key**, give it a descriptive name (e.g. `crm-sync-production`), and select the scopes your integration requires. **3. Copy and store the key securely** Your key is shown **once** immediately after creation. Copy it to a secure secret store (such as your CI/CD secrets manager or a vault). Topo does not store the raw key value — if you lose it, you must rotate to a new one. > [!WARNING] > > API keys are only displayed once at creation time. Store your key in a secrets manager immediately — you cannot retrieve it again from the Topo dashboard. ## Pass the key in requests Include your API key as a Bearer token in the `Authorization` header of every request: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.topo.io/v1/me \ -H "Authorization: Bearer topo_xxxxxxxxxxxx" ``` Requests without a valid `Authorization` header are rejected with a `401 Unauthorized` response before they reach any endpoint logic. ## Available scopes Scopes follow a `resource:action` naming convention. Grant only the scopes your integration actually uses. | Scope | What it allows | | - | - | | `contacts:read` | Read contacts and their custom variables | | `contacts:write` | Upsert contacts, reassign owners, and set or delete contact and account variables | | `accounts:read` | Read accounts and their custom variables | | `accounts:write` | Reassign an account's owner and set or delete account variables | | `contact_lists:read` | Read contact lists and their entries | | `contact_lists:write` | Create, update, and delete contact lists; add and remove entries | | `account_lists:read` | Read account lists and their entries | | `account_lists:write` | Create, update, and delete account lists; add and remove entries | | `sequences:read` | Read sequences and sequence templates | | `sequences:write` | Enroll contacts, update variables, pause, resume, and stop sequences | | `tasks:read` | Read tasks and their message threads | | `tasks:write` | Create and update to-dos; execute, skip, reopen, reassign, and complete tasks | | `contact_exclusions:read` | Read contact exclusion rules | | `contact_exclusions:write` | Create, update, and delete contact exclusion rules | | `account_exclusions:read` | Read account exclusion rules | | `account_exclusions:write` | Create, update, and delete account exclusion rules | | `activities:read` | Read the outreach activity stream | | `messages:read` | Read message content | | `events:read` | Read ingested first-party events | | `events:write` | Ingest first-party events | | `signals:read` | Read the signal catalog, your signals, and the imports feed | | `signals:write` | Create, rename, pause, resume, and archive signals | | `webhooks:read` | Read webhook subscriptions | | `webhooks:write` | Create, update, delete, and test webhook subscriptions | | `users:read` | Read the users in your organization | | `crm:write` | Write properties to the connected CRM record of a contact or an account | > [!NOTE] > > The `ApiKeyScope` enum also declares `crm:read`, `tools:execute`, `exclusions:read`, and `exclusions:write` for Topo AI agent capabilities. None of those scopes gate a `/v1` REST endpoint today — the REST exclusion endpoints use the split `contact_exclusions:*` and `account_exclusions:*` scopes. Every endpoint names the scope it requires in its own reference page. `GET /v1/me` is the one exception — it needs a valid key but no particular scope, so you can always use it to inspect a key. ## Verify your key with GET /v1/me The `/v1/me` endpoint returns the identity and permissions of the calling key — useful for confirming your key is valid and that it carries the scopes you expect. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.topo.io/v1/me \ -H "Authorization: Bearer topo_xxxxxxxxxxxx" ``` **Response** **id** `string (UUID)` *required* Unique identifier of the API key. **organization\_id** `string (UUID)` *required* The workspace this key belongs to. **name** `string` *required* The human-readable name you gave this key at creation time. **key\_prefix** `string` *required* The leading characters of the raw key value — safe to display in logs or UIs to identify which key was used. **scopes** `string[]` *required* List of permission scopes granted to this key. **created\_at** `string (ISO 8601)` *required* Timestamp of when the key was issued. **last\_used\_at** `string (ISO 8601) | null` *required* Timestamp of the last request made with this key, or `null` if it has never been used. **Example response** ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "id": "018f1a2b-3c4d-7e8f-9a0b-1c2d3e4f5a6b", "organization_id": "018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c", "name": "crm-sync-production", "key_prefix": "topo_xxxx", "scopes": ["contacts:read", "contacts:write"], "created_at": "2024-11-01T09:00:00Z", "last_used_at": "2025-01-15T14:23:11Z" } ``` ## Authentication error responses | HTTP status | `type` | When it occurs | | - | - | - | | `401 Unauthorized` | `UnauthorizedIssue` | The `Authorization` header is missing, malformed, or the key is invalid | | `403 Forbidden` | `UnauthorizedIssue` | The key is valid but does not have the required scope for this endpoint | See [Errors & Rate Limits](/api-reference/errors-rate-limits) for the full error response shape. ## Security best practices > [!TIP] > > **One key per integration.** Create a separate API key for each integration or deployment environment (e.g. `crm-sync-staging` vs `crm-sync-production`). This limits the blast radius if a key is compromised and makes it easier to audit usage. * **Least privilege** — only request the scopes your integration needs. A read-only reporting tool should never have `write` scopes. * **Rotate regularly** — retire old keys and issue fresh ones periodically, or immediately after any suspected exposure. * **Never commit keys to source control** — use environment variables or a secrets manager to inject keys at runtime. * **Monitor `last_used_at`** — keys that haven't been used recently may be safe to revoke. # Concepts Source: https://docs.topo.io/api-reference/concepts A short glossary of every Topo object you will meet in the API — organizations, contacts, accounts, lists, sequences, tasks, activities, webhooks, exclusions, and events. Topo's API is organized around a small set of objects. This page defines each one and points you at the endpoint group that exposes it, so you can map a concept to a request without reading every reference page. ## Workspace > [card] Organization > > Your workspace, and the boundary of every API call. An API key belongs to exactly one organization and can never read or write another one, so requests carry no organization parameter. Confirm which organization a key is bound to with `GET /v1/me`. > [card] [User](/api-reference/users/list-users) > > A member of your organization. Users own contacts and accounts and are the assignees of tasks, so you need their ids to reassign work through the API. ## People and companies > [card] [Contact](/api-reference/contacts) > > A person in your workspace — name, email, LinkedIn URL, job title, location, and employer. Contacts are the subject of outreach: sequences, tasks, and activities all reference one. You can also attach custom variables to a contact to feed CRM data and personalization tokens into Topo's AI. > [card] [Account](/api-reference/accounts/list-accounts) > > A company in your workspace, keyed by its domain. Accounts carry firmographic data and are the target of account-based workflows, where you choose the companies to pursue and let Topo find the right people inside them. Like contacts, accounts hold custom variables shared by every contact linked to them. ## Lists > [card] [Contact list and contact entry](/api-reference/contact-lists) > > A **contact list** is a staging area for prospects before they enter outreach. Each row in it is a **contact entry** — a raw candidate that Topo enriches and resolves to a verified contact. Create the list first, then add entries to it in batches. > [card] [Account list and account entry](/api-reference/account-lists) > > The company equivalent: an **account list** holds target companies, and each **account entry** is one company you added by name and domain. Same two-step shape — create the list, then add its entries. ## Outreach > [card] [Sequence template](/api-reference/sequence-templates) > > The outreach playbook: the ordered steps to run, which channel each one uses, the sending schedule, and how many new leads to enroll per day. Templates are read-only over the API — build and edit them in the Topo app. > [card] [Sequence](/api-reference/sequences) > > One contact's live run of a template, created when that contact is enrolled. It tracks their position and status, and it is the object you pause, resume, or stop when a deal changes. > [card] [Task](/api-reference/tasks) > > A manual action a sequence hands to a person — place a call, write a bespoke email, review a new lead. Tasks are assigned to a user and block the sequence step until they are completed or skipped. > [card] [Contact exclusion](/api-reference/contact-exclusions) > > A blocklist rule that suppresses outreach to one person by email or LinkedIn URL across every sequence. Carries a reason code and can be permanent or time-bound. > [card] [Account exclusion](/api-reference/account-exclusions) > > A blocklist rule that suppresses outreach to an entire company domain across every sequence. Carries a reason code and can be permanent or time-bound. ## Engagement > [card] [Activity](/api-reference/activities) > > An immutable record of something that happened during outreach — a message sent, opened, clicked, or replied to, a hot lead flagged, a meeting booked. Activities are the queryable engagement log for your workspace, and each one embeds summaries of the contact, sequence template, and resource it touched, so you rarely need a follow-up request. > [card] [Webhook](/api-reference/webhooks) > > A subscription that pushes activities to your endpoint as they occur, signed with HMAC-SHA256. Use webhooks instead of polling activities when you need events in near real time. > [card] [Event and buying signal](/api-reference/events) > > An **event** is something your systems observed about a prospect and pushed to Topo. Topo turns it into a **buying signal** — a scored, ICP-filtered indicator that a contact or account is worth contacting now. Signals detected by Topo itself, such as job changes and funding rounds, sit alongside the ones you send. > [!NOTE] > > Activities and events point in opposite directions. An **activity** is outbound history Topo recorded and you read; an **event** is inbound intent you record and Topo reads. # Contact Exclusions API: Block Individual People from Outreach Source: https://docs.topo.io/api-reference/contact-exclusions Block a specific person by email or LinkedIn URL from Topo outreach. Manage opt-outs, unsubscribes, and bounced contacts with time-bound or permanent exclusions. Contact exclusions prevent Topo from sending outreach to a specific person, regardless of which sequences or lists they appear in. Each rule targets an individual by `email` or `linkedin_url` (at least one is required on create). Exclusions can be permanent or time-limited via the optional `until` field, and carry a structured `reason` code to help you report on and audit your blocklist. > [!TIP] > > Connect your CRM's unsubscribe or opt-out webhook to `POST /v1/contact-exclusions` to keep Topo's blocklist in sync automatically. No more manually maintaining a suppression list. > [!NOTE] > > Read endpoints require the `contact_exclusions:read` scope. Create, update, and delete endpoints require `contact_exclusions:write`. Account exclusions have their own `account_exclusions:*` scopes. ## Create an exclusion `POST /v1/contact-exclusions` requires `reason` and at least one of `email` or `linkedin_url`. Optional fields include `message` (a free-form operator note) and `until` (an RFC 3339 timestamp after which the exclusion expires — omit for a permanent block). ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.topo.io/v1/contact-exclusions \ -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "email": "ada@analytical.io", "reason": "UNSUBSCRIBED" }' ``` ## List and manage exclusions `GET /v1/contact-exclusions` returns every contact exclusion in your workspace with the standard [pagination envelope](/api-reference/pagination-filtering). Filter by `email`, `reason`, or creation timestamps. `GET /v1/contact-exclusions/{exclusion_id}` fetches a single rule. `PATCH` updates mutable fields (`reason`, `message`, `until`). `DELETE` permanently removes the exclusion. To block an entire company domain instead of one person, use [account exclusions](/api-reference/account-exclusions). ## Exclusion reasons Every contact exclusion carries a `reason` field. Valid values are: | Value | Description | | - | - | | `UNSUBSCRIBED` | Person has opted out. | | `CONTACT_REFUSED` | Contact explicitly asked not to be reached. | | `BOUNCED` | Email hard-bounced. | | `WRONG_PERSON` | Contact is not the right person at the company. | | `LEFT_COMPANY` | Contact has left the company. | | `BAD_TIMING` | Not a good time — revisit later. | | `NOT_INTERESTED` | Contact declined the offer without opting out. | | `OUT_OF_OFFICE` | Temporarily unavailable. | | `MEETING` | Already in progress with this contact. | | `INTERESTED` | Contact has expressed interest through another channel. | | `COMPETITOR` | Account is a direct competitor. | | `REFERRAL` | Came in through a referral channel; handle separately. | | `EXCLUDE_FROM_SEARCH` | Suppress from Topo's prospecting search. | | `EXCLUDE_FROM_CRM` | Imported CRM suppression. | | `OTHER` | None of the above. | | `UNKNOWN` | Reason not recorded. | ## Webhook: `contact_exclusion.created` When a contact exclusion is created, Topo can push a `contact_exclusion.created` event to your [webhook subscriptions](/api-reference/webhooks). This is a **resource event** — it is not tied to a sequence run, so the payload has no `sequence_id` or `sequence_template_id`. Deliveries are sent only to subscriptions whose `sequence_template_ids` is `null` (all templates). The payload includes: | Field | Description | | - | - | | `exclusion_id` | UUID of the new exclusion | | `email` | Excluded email, if set | | `linkedin_url` | Excluded LinkedIn URL, if set | | `reason` | Exclusion reason code | Subscribe to `contact_exclusion.created` when you need to mirror Topo's blocklist into an external system the moment someone opts out. # Contact Lists API: Stage Prospects and Auto-Enroll Source: https://docs.topo.io/api-reference/contact-lists Create contact lists, attach a default sequence template, add entries in batch, and let Topo link contacts and enroll them into outreach. Contact lists are staging areas where you queue individual leads before they run through a Topo sequence. You might create one list per cohort, import source, or audience segment. Each entry carries prospect fields (email, LinkedIn URL, employer domain, external CRM id) and is linked to a workspace contact when you add it. When the list has an `ACTIVE` default sequence template with steps, new entries are enrolled automatically. > [!NOTE] > > Read endpoints require `contact_lists:read`. Creating lists, updating metadata, adding entries, and deleting lists or entries require `contact_lists:write`. ## Default sequence template A list's `default_sequence_template_id` controls auto-enrollment: * When you **create** a list with a default template, new entries enrolled through that list use that template. * When you **PATCH** a list to set `default_sequence_template_id`, Topo validates the template is `ACTIVE` and has at least one step, then enrolls every existing entry whose contact is already resolved. * Pass `"default_sequence_template_id": null` to clear the default without deleting the list. Inactive templates or templates without steps are rejected with `422 ValidationIssue` — the list's other fields are not changed. ## Add entries `POST /v1/contact-entries` accepts up to 100 items per request and returns a per-item `BatchResponse`. Each item needs `contact_list_id` and at least one of `email` or `linkedin_url`. Optional fields include `external_contact_id` for CRM correlation. Each item in the response has one of these statuses: | Status | Meaning | | - | - | | `CREATED` | The entry was added to the list. `resource` is the new entry. | | `ALREADY_MEMBER` | The contact is already in this list, so no new member was added. `resource` is the entry recorded for this item, with the same `contact_id` as the existing member. Counts as succeeded in `summary`. | | `FAILED` | The item was rejected. `error.type` gives the reason, for example `MISSING_MIN_IDENTITY` when neither `email` nor `linkedin_url` is usable, or `NOT_IN_LEAD_DATABASE` when the person is not found in the Topo lead database. | ### Resolution and enrollment Every entry is linked to a workspace contact in the request, so `contact_id` is always set in the response: * When the email or LinkedIn URL matches a contact Topo already knows, the entry uses that contact. If the list has an `ACTIVE` default template, enrollment starts immediately, and Topo sends the `contact_entry.resolved` webhook right away. * When the person is **not** yet in your workspace, Topo creates a new contact from the fields you sent and returns its `contact_id`. It then runs a lead-import workflow asynchronously to complete the profile and apply exclusions. Enrollment (when configured) runs after that import completes, and Topo sends the `contact_entry.resolved` webhook at that point. Adding a contact that already sits in another list creates a new entry in this list for the same contact. `GET /v1/contact-entries` requires `contact_list_id` and supports filters including `email`, `company_domain`, `external_contact_id`, and enrichment state. ## Delete lists and entries * `DELETE /v1/contact-lists/{contact_list_id}` — deletes the list and all its entries. * `DELETE /v1/contact-entries/{contact_entry_id}` — removes a single entry from its list. Deleting an entry that enrolled the contact from that list **stops the sequence** sourced from that list (`stopped_reason: REMOVED_FROM_SOURCE_LIST`). Other sequences for the same contact are unaffected. > [!TIP] > > For direct enrollment when you already have a `contact_id`, use [`POST /v1/sequences`](/api-reference/sequences) instead of going through a list. # Enroll Contact List Members Source: https://docs.topo.io/api-reference/contact-lists/enroll-contact-list-members POST /v1/contact-lists/{contact_list_id}/enroll Enroll many contacts of a list into a sequence template in one call. Enroll many contacts of a list into a sequence template in one call. Unlike `POST /v1/sequences`, which enrolls a single contact and fails on any conflict, this reports per-outcome counts and applies `conflict_strategy` to contacts already enrolled or recently contacted, so a large enrollment does not stop at the first one that cannot start. Each contact's eligibility is checked before it is enrolled, including an email verification when its address was never verified. When the checks take longer than about 20 seconds, the response returns what is known so far: `queued_count` contacts are still being checked and are enrolled in the background by the run `enrollment_run_id`. Follow them with `GET /v1/sequences?contact_id=`. Requires the `sequences:write` scope on the API key. # Contacts API: Create, Read, and Enrich Contact Data Source: https://docs.topo.io/api-reference/contacts Upsert contacts by email or LinkedIn URL, filter your workspace roster, reassign owners, and manage custom variables on contacts and their accounts. The Contacts API lets you create and update people in your Topo workspace, query who Topo already knows, and attach arbitrary key-value variables to a contact or its account. Variables are the primary way to pass CRM data, lead scores, or personalization tokens into Topo's AI sequences. > [!NOTE] > > Listing and reading contacts requires `contacts:read`. Creating or updating a contact (`POST /v1/contacts`), reassigning owners, and managing variables require `contacts:write`. ## Upsert a contact `POST /v1/contacts` creates a contact or updates the matching one if it already exists. Topo matches inside your organization on `email` first, then on `linkedin_url` (both normalized), so re-sending the same person is safe and always returns the same `id`. Fields you send overwrite stored values; fields you omit are left untouched. Both create and update return `201`. At least one of `email` or `linkedin_url` is required. ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.topo.io/v1/contacts \ -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "first_name": "Ada", "last_name": "Lovelace", "email": "ada@analytical.io", "job_title": "CTO", "company_domain": "analytical.io", "company_name": "Analytical Engines" }' ``` `company_domain` links the contact to an account — the account (and its company) is created when it does not exist yet, and `company_name` is only used for that creation. Sending `company_name` alone does not link an account. > [!TIP] > > Use upsert before enrolling someone you already know about, or when syncing CRM contacts into Topo. For bulk prospect imports with enrichment, prefer [contact lists and entries](/api-reference/contact-lists). ## List and filter contacts `GET /v1/contacts` returns every contact attached to your workspace with the standard [pagination envelope](/api-reference/pagination-filtering). | Parameter | Description | | - | - | | `email` | Case-insensitive exact match on email | | `linkedin_url` | Match against canonical LinkedIn URL variants | | `contact_id` | Filter by contact UUID | | `company_domain` | Filter by the contact's linked account domain | | `first_seen_after` | Contacts first seen strictly after this RFC 3339 timestamp | | `last_seen_after` | Contacts last seen strictly after this RFC 3339 timestamp | `GET /v1/contacts/{contact_id}` fetches a single contact. ## Custom variables Each scope (`contact` or `account`) holds a maximum of **200 variables**. Setting a key's value to `null` clears the value but does not free its slot toward the limit — use `DELETE /v1/contacts/{contact_id}/variables/{key}` to fully remove a key. * `GET /v1/contacts/{contact_id}/variables` — read contact and account variables * `PATCH /v1/contacts/{contact_id}/variables` — merge `contact_variables` and `account_variables` * `DELETE /v1/contacts/{contact_id}/variables/{key}?scope=contact|account` — remove one key `account_variables` live on the contact's linked account, so every contact at the same company shares them. The same data is also manageable directly through the [Account variables endpoints](/api-reference/accounts#custom-variables) when you hold an `account_id` — the contact response exposes it as `account_id`. ## Reassign owner `PATCH /v1/contacts/{contact_id}/owner` with `{ "owner_user_id": "" }` reassigns the contact's owner to a workspace user. # Batch Create Contact Entries Source: https://docs.topo.io/api-reference/contacts/batch-create-contact-entries /openapi.json post /v1/contact-entries Add entries to existing contact lists in batch (up to 100 items per request). Unlike single-resource `POST` endpoints, the body wraps the payloads in `items` and the response reports a per-item status. Every entry is linked to a contact in the request, so `contact_id` is always set: an existing contact of the workspace matched by email or LinkedIn URL, or a new one. An item whose contact is already in the list is reported as `ALREADY_MEMBER` and counts as succeeded. Requires the `contact_lists:write` scope on the API key. # Batch Create Contact Lists Source: https://docs.topo.io/api-reference/contacts/batch-create-contact-lists /openapi.json post /v1/contact-lists Create contact lists in batch (up to 100 items per request). Unlike single-resource `POST` endpoints, the body wraps the payloads in `items` and the response reports a per-item status. Requires the `contact_lists:write` scope on the API key. # Create Contact Exclusion Source: https://docs.topo.io/api-reference/contacts/create-contact-exclusion /openapi.json post /v1/contact-exclusions Create a new contact-level exclusion. Requires the `contact_exclusions:write` scope on the API key. # Delete Contact Entry Source: https://docs.topo.io/api-reference/contacts/delete-contact-entry /openapi.json delete /v1/contact-entries/{contact_entry_id} Remove a contact entry from its list. Requires the `contact_lists:write` scope on the API key. # Delete Contact Exclusion Source: https://docs.topo.io/api-reference/contacts/delete-contact-exclusion /openapi.json delete /v1/contact-exclusions/{exclusion_id} Permanently delete a contact-level exclusion. Requires the `contact_exclusions:write` scope on the API key. # Delete Contact List Source: https://docs.topo.io/api-reference/contacts/delete-contact-list /openapi.json delete /v1/contact-lists/{contact_list_id} Delete a contact list and its entries. Requires the `contact_lists:write` scope on the API key. # Delete Contact Variable Source: https://docs.topo.io/api-reference/contacts/delete-contact-variable /openapi.json delete /v1/contacts/{contact_id}/variables/{key} Delete a single variable from a contact or its account. Requires the `contacts:write` scope on the API key. # Enrich Contact Source: https://docs.topo.io/api-reference/contacts/enrich-contact POST /v1/contacts/enrich Find a contact's missing details from what you already know about them. Find a contact's missing details from what you already know about them. The person described in the body is saved as a contact in your workspace: an existing contact is found by LinkedIn URL, then email, then name and company domain, and is never overwritten. Its missing email is then asked from Topo's lead database and an enrichment provider, which spends enrichment credits, and saved on the contact. Use `POST /v1/contacts/get-and-enrich` to start from a contact you already store. A lookup that is still running after about 20 seconds returns `completed: false` with an `enrichment_id`: collect the answer with `GET /v1/contacts/enrichments/{enrichment_id}` rather than enriching again. Requires the `contacts:write` scope on the API key. # Enroll Contact List Members Source: https://docs.topo.io/api-reference/contacts/enroll-contact-list-members /openapi.json post /v1/contact-lists/{contact_list_id}/enroll Enroll many contacts of a list into a sequence template in one call. Unlike `POST /v1/sequences`, which enrolls a single contact and fails on any conflict, this reports per-outcome counts and applies `conflict_strategy` to contacts already enrolled or recently contacted, so a large enrollment does not stop at the first one that cannot start. Requires the `sequences:write` scope on the API key. # Get And Enrich Contact Source: https://docs.topo.io/api-reference/contacts/get-and-enrich-contact POST /v1/contacts/get-and-enrich Load a contact and complete whatever it is missing with a provider. Load a contact and complete whatever it is missing with a provider. Values already stored in the workspace always win. A person the workspace does not hold yet is saved as a contact, and a found email is saved on the contact. A provider is only called when the match is missing an email, a job title, or a company domain — or when nothing matches but the email or LinkedIn URL given is enough to enrich from scratch — so this can spend enrichment credits. Use `GET /v1/contacts/{contact_id}` for a free, read-only load. Requires the `contacts:write` scope on the API key. # Get Contact Source: https://docs.topo.io/api-reference/contacts/get-contact /openapi.json get /v1/contacts/{contact_id} Fetch a single contact attached to the authenticated organization. Requires the `contacts:read` scope on the API key. # Get Contact Enrichment Source: https://docs.topo.io/api-reference/contacts/get-contact-enrichment /openapi.json get /v1/contacts/enrichments/{enrichment_id} Collect the answer of an enrichment that returned `completed: false`. Waits up to about 20 seconds for the answer; while it is still running the response is `completed: false` again, so call back a minute later. The email credit is only spent once the answer is delivered, and collecting it again is free. Requires the `contacts:write` scope on the API key. # Get Contact Entry Source: https://docs.topo.io/api-reference/contacts/get-contact-entry /openapi.json get /v1/contact-entries/{contact_entry_id} Fetch a single contact-list entry. Requires the `contact_lists:read` scope on the API key. # Get Contact Exclusion Source: https://docs.topo.io/api-reference/contacts/get-contact-exclusion /openapi.json get /v1/contact-exclusions/{exclusion_id} Fetch a single contact-level exclusion. Requires the `contact_exclusions:read` scope on the API key. # Get Contact List Source: https://docs.topo.io/api-reference/contacts/get-contact-list /openapi.json get /v1/contact-lists/{contact_list_id} Fetch a single contact list. Requires the `contact_lists:read` scope on the API key. # Get Contact Variables Source: https://docs.topo.io/api-reference/contacts/get-contact-variables /openapi.json get /v1/contacts/{contact_id}/variables Fetch the custom variables stored on a contact and its account. Requires the `contacts:read` scope on the API key. # List Contact Entries Source: https://docs.topo.io/api-reference/contacts/list-contact-entries /openapi.json get /v1/contact-entries List entries inside a contact list. Requires the `contact_lists:read` scope on the API key. # List Contact Exclusions Source: https://docs.topo.io/api-reference/contacts/list-contact-exclusions /openapi.json get /v1/contact-exclusions List the contact-level exclusions in the workspace. Requires the `contact_exclusions:read` scope on the API key. # List Contact Lists Source: https://docs.topo.io/api-reference/contacts/list-contact-lists /openapi.json get /v1/contact-lists List the contact lists in the workspace. Requires the `contact_lists:read` scope on the API key. # List Contacts Source: https://docs.topo.io/api-reference/contacts/list-contacts /openapi.json get /v1/contacts List contacts attached to the authenticated organization. Requires the `contacts:read` scope on the API key. # Look Up Contact Source: https://docs.topo.io/api-reference/contacts/lookup-contact POST /v1/contacts/lookup Find a person in Topo's lead database without saving or enriching them. Find a person in Topo's lead database and read their profile and current employer. The LinkedIn URL is the surest match, then the email, then the first and last name with the current employer's name or domain. The response never carries an email or a phone number. Nothing is written to your workspace and no credit is spent: use [Enrich Contact](/api-reference/contacts/enrich-contact) to find an email and save the contact. A person the lead database does not hold returns `found: false`. Requires the `contacts:read` scope on the API key. # Qualify Contact Source: https://docs.topo.io/api-reference/contacts/qualify-contact POST /v1/contacts/qualify Check whether a contact matches your ideal customer profile. Check whether a contact matches your ideal customer profile. Pass a `contact_id` to qualify a stored contact, or describe the person inline to qualify one you have not imported yet. The workspace's own ICP is used unless `icp_context` overrides it. Requires the `contacts:read` scope on the API key. # Reassign Contact Owner Source: https://docs.topo.io/api-reference/contacts/reassign-contact-owner /openapi.json patch /v1/contacts/{contact_id}/owner Assign or clear the owner of a contact. Requires the `contacts:write` scope on the API key. # Unenroll Contacts Source: https://docs.topo.io/api-reference/contacts/unenroll-contacts POST /v1/contacts/unenroll Remove contacts from outreach entirely. Remove contacts from outreach entirely. Every non-finished sequence of each contact is marked failed and manually stopped — including leads still waiting for approval — and the contact is excluded from all outreach for three months. Scope it to one template with `sequence_template_id`. This is not `POST /v1/sequences/{sequence_id}/stop`: that closes one enrollment with an outcome (won or lost) and leaves the contact reachable. Requires the `sequences:write` scope on the API key. # Update Contact Crm Fields Source: https://docs.topo.io/api-reference/contacts/update-contact-crm-fields PATCH /v1/contacts/{contact_id}/crm Write properties to the connected CRM record for a contact. Write properties to the connected CRM record for a contact. Requires the `crm:write` scope on the API key. # Update Contact Exclusion Source: https://docs.topo.io/api-reference/contacts/update-contact-exclusion /openapi.json patch /v1/contact-exclusions/{exclusion_id} Update mutable fields of a contact-level exclusion. Requires the `contact_exclusions:write` scope on the API key. # Update Contact List Source: https://docs.topo.io/api-reference/contacts/update-contact-list /openapi.json patch /v1/contact-lists/{contact_list_id} Update mutable fields of a contact list. Requires the `contact_lists:write` scope on the API key. # Update Contact Variables Source: https://docs.topo.io/api-reference/contacts/update-contact-variables /openapi.json patch /v1/contacts/{contact_id}/variables Merge variables into a contact's and/or its account's variables. Upsert semantics: provided keys are written, other keys are kept. If the contact's company is not yet linked to your organization, the account is created so the account variables can be stored. Each scope holds at most 200 variables. A null value clears the variable but keeps the key counting toward the limit; use the DELETE endpoint to remove a key and free its slot. Account variables can also be managed directly through `/v1/accounts/{account_id}/variables`. Requires the `contacts:write` scope on the API key. # Upsert Contact Source: https://docs.topo.io/api-reference/contacts/upsert-contact /openapi.json post /v1/contacts Create a contact, or update the matching one if it already exists. The contact is matched inside your organization on `email` first, then on `linkedin_url` (both normalized), so re-sending the same person is safe and returns the same `id`. Fields you send overwrite the stored values; fields you omit are left untouched. `201` is returned for both the create and the update. `company_domain` is what links the contact to an account — the account (and its company) is created when it does not exist yet, and `company_name` is only used for that creation. Sending `company_name` alone does not link an account. Requires the `contacts:write` scope on the API key. # Errors & Limits Source: https://docs.topo.io/api-reference/errors-rate-limits Every Topo API error follows a consistent JSON envelope. Learn the error taxonomy, how to handle rate limits, and how to use request IDs for support. When something goes wrong, the Topo API always returns a structured JSON error body alongside an appropriate HTTP status code. The error shape is the same regardless of the endpoint or error type, so you can build a single error-handling layer for your entire integration. ## Error response shape Every error response body contains the following fields: **status\_code** `integer` *required* The HTTP status code of the response (e.g. `404`, `422`, `429`). Mirrors the HTTP status on the response itself. **type** `string` *required* A stable string identifier for the error category (e.g. `NotFoundIssue`). See the taxonomy below. This value is **frozen** — it will not be renamed in `/v1`. **message** `string` A human-readable description of what went wrong. Useful for logging and debugging; do not rely on the exact wording in application logic. **data** `object` Optional structured detail about the error — for example, which field failed validation or what the applicable rate limit is. **Example data fields** Validation errors may include the field name and the rule that failed. Rate limit errors include the `limit`, `reset_at` Unix timestamp, and `window_seconds` of the exceeded bucket. **request\_id** `string` A unique identifier for this specific request, generated by Topo's API gateway. Always include this value when contacting support — it's the fastest way to locate your request in our logs. **trace\_id** `string` Internal trace identifier. Present in non-production environments for debugging. **span\_id** `string` Internal span identifier. Present in non-production environments for debugging. **Example error response** ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "status_code": 404, "type": "NotFoundIssue", "message": "Contact not found", "request_id": "req_01j9kx3m7p0000000000000000" } ``` ## Error taxonomy The `type` field uses a frozen set of values. Branch first on `status_code`, then use `type` as a stable refinement for more specific handling. > [!NOTE] > > The `type` identifiers listed below are **frozen** and will not be renamed in `/v1`. Adding a new error type is an additive change; renaming an existing one is breaking and would require `/v2`. | `type` | HTTP status | When it occurs | | - | - | - | | `ValidationIssue` | `400` | The request body or query parameters failed validation — a required field is missing, a value is the wrong type, etc. | | `UnauthorizedIssue` | `401` | The `Authorization` header is missing, malformed, or contains an invalid key | | `UnauthorizedIssue` | `403` | The API key is valid but lacks the required scope for this endpoint | | `NotFoundIssue` | `404` | The requested resource does not exist or does not belong to your workspace | | `CommitIssue` | `409` | The operation conflicted with the current state of the resource — for example, a concurrent modification or uniqueness constraint | | `BusinessIssue` | `422` | The operation is not allowed given the current state — for example, enrolling a contact who is already excluded | | `RateLimitIssue` | `429` | Your organization or API key has exceeded a rate limit | | `InternalServerIssue` | `500` | An unexpected error occurred on Topo's side | | `TransientThirdPartyIssue` | `503` | A service Topo depends on, such as the Topo lead database, is briefly unavailable. Part of the request may already be applied, such as the items before the one that failed. Retry the same request with backoff: what was already written is matched, not created twice | ### ValidationIssue (400) Returned when the request itself is malformed. `data.param` points at the first offending field, and `data.errors` lists every validation failure with its `field`, `message`, and `type`. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "status_code": 400, "type": "ValidationIssue", "message": "value is not a valid email address", "data": { "param": "body -> email", "errors": [ { "field": "body -> email", "message": "value is not a valid email address", "type": "value_error" } ] }, "request_id": "req_01j9kx3m7p0000000000000000" } ``` ### UnauthorizedIssue (401 / 403) A `401` means no valid key was provided; a `403` means the key is valid but does not have the required scope. Check the `data.required` field to see which scope is needed. ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "status_code": 403, "type": "UnauthorizedIssue", "message": "API key lacks required scope", "data": { "required": "contacts:write" }, "request_id": "req_01j9kx3m7p0000000000000000" } ``` ### NotFoundIssue (404) Returned when a resource ID does not exist or belongs to a different workspace. Topo deliberately returns 404 (rather than 403) for cross-workspace resources to avoid leaking the existence of records. ### RateLimitIssue (429) Returned when your organization exceeds the allowed request rate. See the [rate limits section](#rate-limits) below for how to handle this response. ## Rate limits Topo enforces rate limits per organization across three time windows simultaneously: per second, per minute, and per day. All three windows are active on every request. Every API response includes headers that tell you your current rate limit state: | Header | Description | | - | - | | `X-RateLimit-Limit-second` | Maximum requests allowed per second | | `X-RateLimit-Remaining-second` | Requests remaining in the current second | | `X-RateLimit-Reset-second` | Unix timestamp when the second window resets | | `X-RateLimit-Limit-minute` | Maximum requests allowed per minute | | `X-RateLimit-Remaining-minute` | Requests remaining in the current minute | | `X-RateLimit-Reset-minute` | Unix timestamp when the minute window resets | | `X-RateLimit-Limit-day` | Maximum requests allowed per day | | `X-RateLimit-Remaining-day` | Requests remaining in the current day | | `X-RateLimit-Reset-day` | Unix timestamp when the day window resets | | `Retry-After` | Seconds to wait before retrying (present only on `429` responses) | When a rate limit is exceeded, the API returns `429` with a `RateLimitIssue` error body: ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "status_code": 429, "type": "RateLimitIssue", "message": "Rate limit exceeded", "data": { "limit": 60, "reset_at": 1737043200, "window_seconds": 60 }, "request_id": "req_01j9kx3m7p0000000000000000" } ``` ### Handling rate limits **1. Read the Retry-After header** On a `429` response, read the `Retry-After` header value (in seconds) and wait at least that long before retrying. **2. Apply exponential backoff** If you continue to receive `429` responses, increase your wait time exponentially with jitter to avoid synchronized retries across parallel workers. **3. Monitor remaining capacity proactively** Watch `X-RateLimit-Remaining-minute` on successful responses. If it drops near zero, slow down your request rate before hitting the limit. > [!WARNING] > > Repeatedly sending requests after receiving a `429` will not speed up the reset — it will only consume capacity from subsequent windows. Always respect the `Retry-After` value. Additionally, repeated authentication failures (e.g. using an invalid key in a loop) will trigger a temporary block on that key's token hash. Ensure your key is correct before automating requests. ## Debugging tips > [!TIP] > > Always log the `request_id` from every API response — successful or failed. When you contact Topo support, providing the `request_id` lets the team locate your exact request instantly. * **Branch on `status_code` first** for broad categories (auth, not found, server error), then use `type` to distinguish sub-cases like `401` vs `403`. * **Don't match on `message`** — the human-readable message text is for display only and may change over time. Use `type` for logic. * **Log `data` for validation errors** — it contains field-level detail that helps you identify what to fix in your request. * **5xx errors are Topo-side** — if you receive a `500 InternalServerIssue`, the issue is not with your request. Retry with backoff and contact support if it persists, providing the `request_id`. # Events API: Ingest First-Party Buying Signals into Topo Source: https://docs.topo.io/api-reference/events 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 **1. 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). **2. 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. **3. Point your integration at the base URL** Every endpoint on this page is relative to `https://api.topo.io/v1`. **4. 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). ## 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`. ```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"]) ``` 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::`. ### Request fields **event\_name** `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. **occurred\_at** `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. **subject** `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). **subject** **email** `string` Email address of the person the event belongs to. **linkedin\_url** `string` LinkedIn profile URL of the person the event belongs to. **company\_domain** `string` Company domain, for account-level events with no known individual. **external\_contact\_id** `string` Contact identifier in your connected CRM. **external\_company\_id** `string` Company identifier in your connected CRM. **external\_event\_id** `string` Your identifier for this event, up to 255 characters. Supply it to make retries safe — see [Idempotency](#idempotency). **payload** `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. > [!NOTE] > > The request rejects unknown top-level fields with `400`. Send exactly the fields above. **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. ## 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. > [!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). > [!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. ## 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. ## 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 · **. Without a custom event signal, an ingested event never becomes a buying signal and never lands on a list. ### Create the signal **1. Open Settings → Signals** Scroll to the **Custom events** section and click **Add custom event signal**. **2. 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. **3. 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). **4. Save** Topo creates **Signals · ** and links it from the signal's row, next to a toggle that enables or disables the signal. > [!TIP] > > You can create the same signal over REST with `POST /v1/signals` — pass the `CUSTOM_EVENT` preset, the `first_party_event_name` filter, and an `event_subject` of `PEOPLE` or `COMPANIES`. See the [Signals API](/api-reference/signals). > [!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). ### 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. > [!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. ## 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. ## 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" } ``` # Get Event Source: https://docs.topo.io/api-reference/events/get-event /openapi.json get /v1/events/{event_id} Fetch a single ingested event. Requires the `events:read` scope on the API key. # Ingest Event Source: https://docs.topo.io/api-reference/events/ingest-event /openapi.json post /v1/events Ingest a first-party event. Every well-formed event is accepted and stored, whether or not its subject can be correlated to a known contact or company: correlation is best-effort and runs asynchronously. Replaying the same `external_event_id` (or the exact same event content) is idempotent: the event is accepted but never stored or processed twice. Requires the `events:write` scope on the API key. # List Events Source: https://docs.topo.io/api-reference/events/list-events /openapi.json get /v1/events List ingested first-party events. Requires the `events:read` scope on the API key. # Topo API Reference Source: https://docs.topo.io/api-reference/introduction Explore the Topo REST API — sync contacts, manage exclusions, control sequences, read activities, and subscribe to webhooks from your own systems. The Topo API is a REST API that lets you integrate your tools and workflows directly with your Topo workspace. Every resource is available over HTTPS, every request and response body is JSON, and every list response follows the same paginated envelope so you always know what to expect. > [!WARNING] > > **The Topo API is in pre-release and not stable yet.** While the surface is being finalized, breaking changes — renamed fields, changed parameters, or restructured endpoints — may still ship inside `/v1` without a version bump. Expect to update your integration as the API evolves. The versioning rules below describe the stability guarantees that take effect once the API reaches general availability. ## Base URL All endpoints are served under a single versioned base URL: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} https://api.topo.io/v1 ``` ## What you can do * **Sync contacts & accounts** — upsert contacts by email or LinkedIn URL, query your roster, and manage custom variables on contacts and accounts. * **Manage exclusions** — add or remove contact and company suppression rules. * **Control sequences** — enroll contacts, pause or stop runs, and remove list entries that sourced a sequence. * **Read activities and messages** — pull outreach events and message content. * **Manage contact & account lists** — create and populate segmentation lists. * **Subscribe to webhooks** — register endpoints to receive real-time event payloads. * **Manage tasks** — list, create, execute, complete, and reassign outreach tasks and standalone to-dos. * **Push first-party events** — send your own product and website signals into Topo. ## Versioning The major API version lives in the URL path (`/v1`). Topo follows these rules to keep your integration stable: | Change type | Ships as | | - | - | | New endpoint, new optional field, new response field, new enum value | Additive change in `/v1` — no action needed on your side | | Removed or renamed endpoint, field, or scope; changed field type; changed HTTP status | Breaking change, ships as `/v2` | You should write your client to **ignore unknown fields** in responses. Topo may add new fields to any response object at any time within `/v1`. ## Quick start Once you have an API key (see [Authentication](/api-reference/authentication)), make your first request to confirm your credentials and see which scopes your key carries: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl https://api.topo.io/v1/me \ -H "Authorization: Bearer topo_xxxxxxxxxxxx" ``` ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "id": "018f1a2b-3c4d-7e8f-9a0b-1c2d3e4f5a6b", "organization_id": "018e9d8c-7b6a-7f5e-4d3c-2b1a0f9e8d7c", "name": "My Integration Key", "key_prefix": "topo_xxxx", "scopes": ["contacts:read", "sequences:read"], "created_at": "2024-11-01T09:00:00Z", "last_used_at": "2025-01-15T14:23:11Z" } ``` ## Explore the docs > [card] [Concepts](/api-reference/concepts) > > Meet every object the API exposes and find the endpoint group that covers it. > [card] [Authentication](/api-reference/authentication) > > Learn how to create API keys, pass them in requests, and scope them to least-privilege access. > [card] [Pagination & Filtering](/api-reference/pagination-filtering) > > Understand the standard list envelope, page through large result sets, and filter records. > [card] [Errors & Rate Limits](/api-reference/errors-rate-limits) > > Decode error responses, handle rate limiting gracefully, and debug with request IDs. > [card] [Contacts](/api-reference/contacts) > > Read contacts in your workspace and manage their custom variables. > [card] [Events](/api-reference/events) > > Push first-party events so they become scored buying signals on the right contact. # Get Me Source: https://docs.topo.io/api-reference/me/get-me /openapi.json get /v1/me Return identity, workspace, and scopes of the calling API key. # Messages API: Read Email and LinkedIn Outreach Content Source: https://docs.topo.io/api-reference/messages List and fetch messages sent or received in outreach sequences — email bodies, LinkedIn threads, channels, and senders. The Messages API exposes the actual email and LinkedIn content exchanged during sequence runs. Use it when you need message bodies in an external system, audit what was sent to a contact, or build reporting without polling the broader [Activities API](/api-reference/activities). > [!NOTE] > > All Messages endpoints require the `messages:read` scope. Message content is more sensitive than the activity stream, so it carries its own scope instead of piggybacking on `activities:read`. ## List messages `GET /v1/messages` returns messages in your workspace with the standard [pagination envelope](/api-reference/pagination-filtering). Results default to `sent_at` descending. | Parameter | Description | | - | - | | `contact_id` | Messages linked to sequences targeting this contact | | `sequence_id` | Messages from a specific sequence run | | `channel` | `EMAIL`, `LINKEDIN`, `CALENDAR`, or `MANUAL` | | `direction` | `OUTBOUND` (your team) or `INBOUND` (contact reply) | Each message includes `channel`, `direction`, `subject` (email only), `body`, `sent_at`, and for outbound messages `sender_id` plus an optional `sender` summary (mailbox name and address). `GET /v1/messages/{message_id}` fetches a single message. > [!TIP] > > For real-time delivery of send/open/reply events, use [Webhooks](/api-reference/webhooks) or poll [Activities](/api-reference/activities). Messages are the read API for full message content after you know a message id from those streams. # Categorize Reply Source: https://docs.topo.io/api-reference/messages/categorize-reply PATCH /v1/messages/{message_id}/category Recategorize an inbound reply message. Recategorize an inbound reply message. Requires the `sequences:write` scope on the API key. # Get Message Source: https://docs.topo.io/api-reference/messages/get-message /openapi.json get /v1/messages/{message_id} Fetch a single message. Requires the `messages:read` scope on the API key. # List Messages Source: https://docs.topo.io/api-reference/messages/list-messages /openapi.json get /v1/messages List messages exchanged in the workspace. Requires the `messages:read` scope on the API key. # Reply To Message Source: https://docs.topo.io/api-reference/messages/reply-to-message POST /v1/messages/{message_id}/reply Reply to a lead in the thread they wrote in, over email or LinkedIn. Reply to a lead in the thread they wrote in, over email or LinkedIn. Requires the `sequences:write` scope on the API key. # Pagination Source: https://docs.topo.io/api-reference/pagination-filtering All Topo list endpoints return a consistent paginated envelope. Use page, size, sort_by, and filter params to navigate and narrow large result sets. Every list endpoint in the Topo API returns the same envelope structure so you can handle pagination consistently across all resources. Instead of cursor-based navigation, Topo uses classic page-and-size parameters — straightforward to implement, easy to restart, and safe to cache by page number. ## Response envelope All list responses wrap their records in a standard pagination envelope: **items** `array` *required* The records on the current page. **total\_count** `integer` *required* Total number of records matching the current query (across all pages). **total\_pages** `integer` *required* Total number of pages given the current `size` value. **has\_more** `boolean` *required* `true` if there are additional pages after the current one; `false` on the last page. **Example** ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "items": [ { "id": "018f1a2b-3c4d-7e8f-9a0b-1c2d3e4f5a6b", "email": "alex@example.com" }, { "id": "019a2b3c-4d5e-6f7a-8b9c-0d1e2f3a4b5c", "email": "jordan@example.com" } ], "total_count": 142, "total_pages": 15, "has_more": true } ``` ## Pagination parameters Control which page of results you receive using these query parameters on any list endpoint: **page** `integer` (default: 1) The page number to retrieve. Must be `1` or greater. **size** `integer` (default: 10) Number of records to return per page. Must be between `1` and `100` inclusive. > [!NOTE] > > `size` is capped at **100** records per request. To retrieve more than 100 records, increment `page` on successive requests until `has_more` is `false`. ## Sorting Most list endpoints accept `sort_by` and `sort_order` query parameters to control the order of results: **sort\_by** `string` The field name to sort by (e.g. `created_at`, `email`). If omitted, the endpoint uses its default ordering. Refer to individual endpoint documentation for the supported sort fields. **sort\_order** `string` (default: asc) Sort direction. Accepted values: `asc` (ascending) or `desc` (descending). ## Filtering Most list endpoints accept additional query parameters to narrow results. Common filter parameters include: | Parameter | Type | Description | | - | - | - | | `email` | string | Filter by exact email address | | `status` | string | Filter by record status | | `created_after` | ISO 8601 datetime | Include only records created after this timestamp | | `created_before` | ISO 8601 datetime | Include only records created before this timestamp | Filter parameters vary by resource — see each endpoint's reference for the full list of supported filters. > [!TIP] > > All query parameters use **snake\_case** naming. No aliasing or camelCase variants are accepted. ## Example: paginated request with sorting and filtering Retrieve the second page of contacts, 25 per page, sorted by most recently created, filtered to a specific email domain: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl 'https://api.topo.io/v1/contacts?page=2&size=25&sort_by=created_at&sort_order=desc' \ -H "Authorization: Bearer topo_xxxxxxxxxxxx" ``` **Iterate through all records** To fetch every record matching a query, loop until `has_more` is `false`: ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}} # Increment page until has_more is false. curl -G 'https://api.topo.io/v1/contacts' \ -d 'page=1' \ -d 'size=100' \ -d 'sort_by=created_at' \ -d 'sort_order=asc' \ -H "Authorization: Bearer topo_xxxxxxxxxxxx" ``` ```javascript JavaScript theme={"theme":{"light":"github-light","dark":"github-dark"}} const API_KEY = "topo_xxxxxxxxxxxx"; const BASE_URL = "https://api.topo.io/v1"; async function fetchAllContacts() { let page = 1; const allContacts = []; while (true) { const response = await fetch( `${BASE_URL}/contacts?page=${page}&size=100&sort_by=created_at&sort_order=asc`, { headers: { Authorization: `Bearer ${API_KEY}` }, }, ); if (!response.ok) { throw new Error(`Topo API error: ${response.status}`); } const data = await response.json(); allContacts.push(...data.items); if (!data.has_more) { break; } page += 1; } return allContacts; } ``` ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import requests API_KEY = "topo_xxxxxxxxxxxx" BASE_URL = "https://api.topo.io/v1" def fetch_all_contacts(): page = 1 all_contacts = [] while True: response = requests.get( f"{BASE_URL}/contacts", headers={"Authorization": f"Bearer {API_KEY}"}, params={ "page": page, "size": 100, "sort_by": "created_at", "sort_order": "asc", }, ) response.raise_for_status() data = response.json() all_contacts.extend(data["items"]) if not data["has_more"]: break page += 1 return all_contacts ``` ## Envelope stability The `{ items, total_count, total_pages, has_more }` envelope shape is **frozen** — it will not change within `/v1`. You can safely build your deserialization logic against this structure. # Get Playbook Source: https://docs.topo.io/api-reference/playbooks/get-playbook GET /v1/playbooks/{playbook_id} Fetch a single playbook configuration and run stats. Fetch a single playbook configuration and run stats. Requires the `sequences:read` scope on the API key. # List Playbook Runs Source: https://docs.topo.io/api-reference/playbooks/list-playbook-runs GET /v1/playbooks/{playbook_id}/runs List execution runs for a playbook. List execution runs for a playbook. Requires the `sequences:read` scope on the API key. # List Playbooks Source: https://docs.topo.io/api-reference/playbooks/list-playbooks GET /v1/playbooks List automations (playbooks) in the workspace. List automations (playbooks) in the workspace. Requires the `sequences:read` scope on the API key. # Pause Playbook Source: https://docs.topo.io/api-reference/playbooks/pause-playbook POST /v1/playbooks/{playbook_id}/pause Pause an active playbook, suspending automated runs. Pause an active playbook, suspending automated runs. Requires the `sequences:write` scope on the API key. # Resume Playbook Source: https://docs.topo.io/api-reference/playbooks/resume-playbook POST /v1/playbooks/{playbook_id}/resume Resume a paused playbook, re-enabling automation after validation. Resume a paused playbook, re-enabling automation after validation. Requires the `sequences:write` scope on the API key. # Run Playbook Source: https://docs.topo.io/api-reference/playbooks/run-playbook POST /v1/playbooks/{playbook_id}/run Manually trigger a run for a manual-trigger playbook. Manually trigger a run for a manual-trigger playbook. Requires the `sequences:write` scope on the API key. # Fetch Source: https://docs.topo.io/api-reference/search/fetch GET /v1/fetch Load the record behind one typed id returned by `GET /v1/search`. Load the record behind one typed id returned by `GET /v1/search`. Whatever kind the id names, the response has the same shape: a readable rendering plus every field that kind's detail read returns, so a client can follow a search result without branching per endpoint. The read scope of the named kind is required. # Search Source: https://docs.topo.io/api-reference/search/search GET /v1/search Find leads, accounts, sequence templates and tasks by name in one call. Find leads, accounts, sequence templates and tasks by name in one call. Each match carries a typed id `GET /v1/fetch` turns back into the full record, so a name is enough to reach any of them without knowing which endpoint owns it. Kinds the key may not read are dropped from the search rather than rejected, so a contacts-only key searching everything still gets its leads. # Sequence Templates API — Read Your Outreach Playbooks Source: https://docs.topo.io/api-reference/sequence-templates Fetch the outreach playbooks configured in your workspace — the templates that define steps, schedule, and daily targets for automated sequences. A **sequence template** is the top-level outreach playbook for a given target segment. It defines the full sequence of steps (emails, LinkedIn messages, manual tasks), the sending schedule and timezone, and how many new leads to process each day. When a contact is enrolled, Topo instantiates a live [sequence](/api-reference/sequences) from the template and executes steps on the configured schedule. The Sequence Templates API gives you read access to the templates in your workspace. This is useful for building integration UIs (e.g., a lead import tool that lets users pick which playbook to enroll contacts into) or for querying template metadata in analytics pipelines. > [!NOTE] > > All Sequence Templates endpoints require the `sequences:read` scope. Sequence templates are **read-only** via the API — create and edit templates in the Topo UI. ## Template statuses | Status | Description | | - | - | | `PENDING_SETUP` | The template is being configured and is not yet ready to accept leads. | | `ACTIVE` | The template is live and can accept new lead enrollments. | | `INACTIVE` | The template has been deactivated; existing sequences continue but no new leads are enrolled. | | `FINISHING` | The template is winding down; existing sequences continue to execute but no new enrollments are accepted. | | `ARCHIVED` | The template has been archived and is no longer in use. | > [!TIP] > > Only `ACTIVE` templates accept new lead enrollments. If you're building a lead import workflow, filter by `status=ACTIVE` to show only templates available for enrolment. # Approve Leads Source: https://docs.topo.io/api-reference/sequence-templates/approve-leads POST /v1/sequence-templates/{sequence_template_id}/leads/approve Approve leads waiting for review for a sequence template. Approve leads waiting for review for a sequence template. Requires the `sequences:write` scope on the API key. # Get Sequence Metrics Source: https://docs.topo.io/api-reference/sequence-templates/get-sequence-metrics GET /v1/sequence-templates/metrics Report outreach performance over a date window. Report outreach performance over a date window. Counters cover the window, not the lifetime of a template, and are counts rather than rates: leads found, sent, opened, clicked, replied, positive replies, hot leads, bounced and unsubscribed, plus how the replies were categorised. Bounced and unsubscribed counts come from replies categorised as a bounce or an unsubscribe request. Templates are returned busiest first. Omit `sequence_template_id` to cover the whole portfolio. Requires the `sequences:read` scope on the API key. # Get Sequence Template Source: https://docs.topo.io/api-reference/sequence-templates/get-sequence-template /openapi.json get /v1/sequence-templates/{sequence_template_id} Fetch a single sequence template. Requires the `sequences:read` scope on the API key. # List Sequence Templates Source: https://docs.topo.io/api-reference/sequence-templates/list-sequence-templates /openapi.json get /v1/sequence-templates List the sequence templates available in the workspace. Requires the `sequences:read` scope on the API key. # Pause Sequence Template Source: https://docs.topo.io/api-reference/sequence-templates/pause-sequence-template POST /v1/sequence-templates/{sequence_template_id}/pause Pause an active sequence template (sets status to INACTIVE). Pause an active sequence template (sets status to INACTIVE). Requires the `sequences:write` scope on the API key. # Refuse Leads Source: https://docs.topo.io/api-reference/sequence-templates/refuse-leads POST /v1/sequence-templates/{sequence_template_id}/leads/refuse Refuse leads waiting for review for a sequence template. Refuse leads waiting for review for a sequence template. Requires the `sequences:write` scope on the API key. # Resume Sequence Template Source: https://docs.topo.io/api-reference/sequence-templates/resume-sequence-template POST /v1/sequence-templates/{sequence_template_id}/resume Resume a paused sequence template (validates readiness and sets status to ACTIVE). Resume a paused sequence template (validates readiness and sets status to ACTIVE). Requires the `sequences:write` scope on the API key. # Sequences API — Enroll, Monitor, and Control Outreach Runs Source: https://docs.topo.io/api-reference/sequences Enroll contacts into sequence templates, filter runs by contact, pause, resume, stop, and update per-run variables. A **sequence** is a live outreach run for a single contact enrolled in a sequence template. It executes each configured step — emails, LinkedIn messages, manual tasks — according to the template's schedule and tracks the contact's current position and state. > [!NOTE] > > Read endpoints require `sequences:read`. Enrolling, updating variables, pausing, resuming, and stopping require `sequences:write`. ## Enroll a contact The most direct path is `POST /v1/sequences` with the contact and template you want: ```bash theme={"theme":{"light":"github-light","dark":"github-dark"}} curl -X POST https://api.topo.io/v1/sequences \ -H "Authorization: Bearer topo_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "contact_id": "01954b2e-1111-7000-bbbb-222222222222", "sequence_template_id": "01954b2e-3333-7000-dddd-444444444444" }' ``` The template must be `ACTIVE` with at least one step. Topo rejects duplicate enrollments (same contact already running that template) and contacts on an exclusion list. Before the sequence starts, Topo checks the contact's eligibility, including an email verification when the address was never verified: * **`201`** — the check finished within about 20 seconds and the response is the new sequence resource. * **`422`** — the contact cannot be enrolled: it has neither an email nor a LinkedIn URL, it is excluded, or it lacks a channel the template needs (no email, a bounced email, or no LinkedIn URL for LinkedIn steps) while the template stops on missing data or has no other channel left. * **`202`** — the check is still running. The body carries `enrollment_run_id`, `contact_id` and `sequence_template_id`. Topo enrolls the contact in the background; poll `GET /v1/sequences?contact_id=` to see its sequence once it starts. ### Enroll via contact lists Alternatively, attach a `default_sequence_template_id` to a [contact list](/api-reference/contact-lists) and add entries: 1. **Known contact** — `POST /v1/contact-entries` returns `contact_id` immediately; enrollment is queued in the same request when the list has an active default template, and the contact enters its sequence once its eligibility is checked. 2. **New person** — `contact_id` is `null` until lead-import finishes; enrollment runs after resolution when a default template is set. Verify enrollment with `GET /v1/sequences?contact_id=` — filter by the contact you enrolled. ## List and inspect sequences `GET /v1/sequences` supports filters including `contact_id`, `sequence_template_id`, `status`, and `created_at_before` / `created_at_after`. `GET /v1/sequences/{sequence_id}` returns a single run. `PATCH /v1/sequences/{sequence_id}` merges per-run `variables` into the sequence. Action endpoints: * `POST /v1/sequences/{sequence_id}/pause` — optional `paused_until` body * `POST /v1/sequences/{sequence_id}/resume` * `POST /v1/sequences/{sequence_id}/stop` — `reason` (`WIN` or `LOSE`) and `scope` (`CONTACT` stops this run only, `ACCOUNT` also stops sequences for other contacts of the same account) ## Sequence statuses | Status | Description | | - | - | | `ACTIVE` | The sequence is running and steps will execute on schedule. | | `WAITING_FOR_LEAD` | Your team sent a message and the sequence waits for the contact to reply. | | `WAITING_FOR_USER` | Blocked on a user action — a pending manual task or a pause until `paused_until`. | | `SUCCEEDED` | Ended on a positive outcome (interest, or stopped with `reason=WIN`). | | `FAILED` | Ended negatively (bounce, unsubscribe, or stopped with `reason=LOSE`). | | `STOPPED_EARLY` | Ended before completion (template stopped, sender removed, or removed from source list). | | `COMPLETED` | All steps executed without a positive reply. | | `FOUND` | Intermediate state during lead processing before full activation. | | `REFUSED` | Contact rejected at enrollment; the sequence never started. | | `DRAFT` | Written for the contact but not started: no step runs until a user enrolls it. | > [!TIP] > > Terminal states (`SUCCEEDED`, `COMPLETED`, `FAILED`, `STOPPED_EARLY`) cannot be resumed. # Enroll Contact Source: https://docs.topo.io/api-reference/sequences/enroll-contact /openapi.json post /v1/sequences Enroll a contact into a sequence template, starting a sequence run. The template must be `ACTIVE` and have at least one step; the sequence is instantiated from the template's steps and scheduled exactly like an enrollment made from the app, including task-owner assignment. A contact already running this template is rejected rather than enrolled twice, and so is a contact (or its company) on an exclusion list. A contact with an active sequence from a *different* template is also rejected: stop that sequence first (`POST /v1/sequences/{id}/stop`) to avoid two sequences messaging the same person at once. Templates the contact only has completed or stopped sequences for do not block enrollment. Before the sequence starts, the contact's eligibility is checked, email verification included. When that finishes within about 20 seconds the response is `201` with the sequence, or `422` when the contact cannot be enrolled (no email nor LinkedIn URL, excluded, or missing a channel the template needs: no email or LinkedIn URL for its steps, or a bounced email, when the template stops on missing data or has no other channel left). Otherwise the response is `202` with the enrollment run: the contact is enrolled in the background, and `GET /v1/sequences?contact_id=` shows its sequence once it starts. Requires the `sequences:write` scope on the API key. # Get Sequence Source: https://docs.topo.io/api-reference/sequences/get-sequence /openapi.json get /v1/sequences/{sequence_id} Fetch a single sequence run. Requires the `sequences:read` scope on the API key. # List Sequences Source: https://docs.topo.io/api-reference/sequences/list-sequences /openapi.json get /v1/sequences List sequence runs in the workspace. Requires the `sequences:read` scope on the API key. # Pause Sequence Source: https://docs.topo.io/api-reference/sequences/pause-sequence /openapi.json post /v1/sequences/{sequence_id}/pause Pause a running sequence until `paused_until`. Requires the `sequences:write` scope on the API key. # Resume Sequence Source: https://docs.topo.io/api-reference/sequences/resume-sequence /openapi.json post /v1/sequences/{sequence_id}/resume Resume a paused sequence. Requires the `sequences:write` scope on the API key. # Stop Sequence Source: https://docs.topo.io/api-reference/sequences/stop-sequence /openapi.json post /v1/sequences/{sequence_id}/stop Permanently stop a sequence. Requires the `sequences:write` scope on the API key. # Update Sequence Source: https://docs.topo.io/api-reference/sequences/update-sequence /openapi.json patch /v1/sequences/{sequence_id} Partially update a sequence run (currently supports runtime variables). Requires the `sequences:write` scope on the API key. # Signals API: Create and Run Buying Signals from Your Own Systems Source: https://docs.topo.io/api-reference/signals 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`**. ## Before you start **1. 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). **2. 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). **3. 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). ## 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. 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. ```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"]) ``` 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 **signal\_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`. **name** `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. **search\_filters** `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. **import\_target** `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. **event\_subject** `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. **import\_search\_filters** `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`. **sequence\_template\_id** `string (uuid)` Sequence template that imported leads are enrolled into. People destinations only. A template from another workspace returns `404`. **enrich\_email** `boolean` (default: true) Whether imported leads get their email enriched. **enrich\_phones** `boolean` (default: true) Whether imported leads get their phone enriched. **max\_per\_run** `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`. **max\_companies\_per\_run** `integer` Daily ceiling on detected companies consumed. Only valid on a company signal importing people. **max\_contacts\_per\_company** `integer` How many people to import per detected company. Requires `max_companies_per_run`. ### 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. ## 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. ## 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"}' ``` **name** `string` Name of the copy and of the destination list created for it. Leave it out to get the source name followed by ` (Copy)`. 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`. | # Archive Signal Source: https://docs.topo.io/api-reference/signals/archive-signal /openapi.json post /v1/signals/{signal_id}/archive Retire a signal: it stops checking and importing, disappears from the default listing, and cannot be resumed until it is unarchived. The destination list and everything already imported are kept. Prefer pausing when the signal should only stop for now. Requires the `signals:write` scope on the API key. # Create Signal Source: https://docs.topo.io/api-reference/signals/create-signal /openapi.json post /v1/signals Create a signal from a catalog preset. The signal checks its provider daily and imports the qualified leads or companies into a destination list it creates under the same name. Read `GET /v1/signals/presets` first to pick the preset and learn which detection filters it takes. The signal starts active and runs a first check immediately. Requires the `signals:write` scope on the API key. # Duplicate Signal Source: https://docs.topo.io/api-reference/signals/duplicate-signal /openapi.json post /v1/signals/{signal_id}/duplicate Copy a signal: same preset, filters, caps and enrichment, onto a new destination list. The copy starts active and runs a first check immediately; the original is left unchanged. Without a `name`, the copy is named after the source plus " (Copy)". Requires the `signals:write` scope on the API key. # Get Signal Source: https://docs.topo.io/api-reference/signals/get-signal /openapi.json get /v1/signals/{signal_id} Read one signal in full, including its detection and import filters. Requires the `signals:read` scope on the API key. # List Signal Imports Source: https://docs.topo.io/api-reference/signals/list-signal-imports /openapi.json get /v1/signals/imports List what the workspace's signals brought in, most recent fact first. An entry records which signal fired, who or which company it was about, and whether it was imported, was already in the list, or was filtered out. Requires the `signals:read` scope on the API key. # List Signal Presets Source: https://docs.topo.io/api-reference/signals/list-signal-presets /openapi.json get /v1/signals/presets List the presets a signal can be created from. Each preset names the detection filters `POST /v1/signals` accepts and which of them are mandatory. Presets not yet available for creation are omitted. Requires the `signals:read` scope on the API key. # List Signals Source: https://docs.topo.io/api-reference/signals/list-signals /openapi.json get /v1/signals List the workspace's signals, most recently created first. Requires the `signals:read` scope on the API key. # Pause Signal Source: https://docs.topo.io/api-reference/signals/pause-signal /openapi.json post /v1/signals/{signal_id}/pause Hold a signal: it stops checking its provider and stops importing until it is resumed. Everything it already imported stays in the destination list. Requires the `signals:write` scope on the API key. # Preview Signal Source: https://docs.topo.io/api-reference/signals/preview-signal /openapi.json post /v1/signals/preview Sample what a preset would detect, without creating a signal. Returns up to 10 recent matches over a 30-day window. The sample is wider than a live run: no audience filter and no dedup are applied, so use it to check the detection filters, not to predict volume. Requires the `signals:read` scope on the API key. # Resume Signal Source: https://docs.topo.io/api-reference/signals/resume-signal /openapi.json post /v1/signals/{signal_id}/resume Restart a paused signal. It becomes due right away, so the next check happens without waiting for the daily slot. An archived signal cannot be resumed until it is unarchived. Requires the `signals:write` scope on the API key. # Unarchive Signal Source: https://docs.topo.io/api-reference/signals/unarchive-signal /openapi.json post /v1/signals/{signal_id}/unarchive Restore an archived signal. It comes back paused, with its filters, destination list and everything already imported intact, and does not check its provider again until it is resumed. Requires the `signals:write` scope on the API key. # Update Signal Source: https://docs.topo.io/api-reference/signals/update-signal /openapi.json patch /v1/signals/{signal_id} Change what a signal watches, or rename it. Fields left out of the body keep their current value; send `null` to clear a cap or the enrollment template. Renaming also renames the destination list. Changing the detection filters restarts the signal's baseline, so the next check re-absorbs the new result set before importing. The preset and whether it imports people or companies cannot change — duplicate the signal and archive the original instead. Archived signals cannot be edited. Requires the `signals:write` scope on the API key. # Tasks API — Manage Manual Outreach and Standalone To-Dos Source: https://docs.topo.io/api-reference/tasks List, create, execute, complete, skip, reopen, and reassign tasks — including sequence steps and standalone to-do items. A **task** is a manual action item — either generated when a sequence reaches a step that needs a human (call, email reply, lead review) or created directly as a standalone to-do. Tasks are assigned to a workspace user until completed, skipped, or reassigned. > [!NOTE] > > Read endpoints require `tasks:read`. Creating to-dos, executing actionable tasks, skipping, reopening, reassigning, and completing require `tasks:write`. ## Task types | Type | Description | | - | - | | `CALL` | Place a phone call to the contact. | | `EMAIL_REPLY` | Send or review an email reply in an active thread. | | `LINKEDIN_REPLY` | Reply to a LinkedIn conversation. | | `NEW_LEAD_REVIEW` | Review and approve or reject a new lead before outreach. | | `PLAYBOOK_APPROVAL` | Approve or reject a playbook-generated action. | | `TODO` | Standalone to-do not tied to a sequence step. | ## Task statuses | Status | Description | | - | - | | `PENDING` | Open and waiting for action. | | `IN_PROGRESS` | Opened by an assignee and actively being worked. | | `COMPLETED` | Carried out and marked done. | | `SKIPPED` | Deliberately skipped without execution. | ## List and filter tasks `GET /v1/tasks` supports filters including `contact_id`, `sequence_id`, `sequence_template_ids` (repeat for multiple), `types`, `status`, `priorities`, `assignee_user_id`, and due-date bounds. `GET /v1/tasks/{task_id}` returns one task. `GET /v1/tasks/{task_id}/messages` returns the email thread for reply tasks. ## Standalone to-dos `POST /v1/tasks` creates a `TODO` task. Required fields: `title` and `due_date` (ISO date). Optional: `notes`, `contact_id`, `assignee_user_id` (defaults to the API key owner). `PATCH /v1/tasks/{task_id}` updates a standalone to-do (`title`, `notes`, `due_date`, `assignee_user_id`). ## Execute actionable tasks `POST /v1/tasks/{task_id}/execute` runs the task in Topo — send a reply, approve a lead, log a call, etc. The body is a discriminated `payload` keyed by task type: | `payload.type` | Used for | | - | - | | `email_reply` | `EMAIL_REPLY` tasks | | `linkedin_reply` | `LINKEDIN_REPLY` tasks | | `new_lead_review` | `NEW_LEAD_REVIEW` tasks | | `call` | `CALL` tasks (optional `phone`, `disposition`) | | `playbook_approval` | `PLAYBOOK_APPROVAL` tasks | ## Complete, skip, reopen, reassign * `POST /v1/tasks/{task_id}/complete` — optional body with `completion_source` (`TASK_EXECUTION`, `EXTERNAL_REPLY`, `SEQUENCE_TRANSITION`, `EXTERNAL_CRM`, `PLAYBOOK_RESOLUTION`; default `TASK_EXECUTION`) and `disposition` for `CALL` tasks (`ANSWERED`, `VOICEMAIL_LEFT`, `NO_ANSWER`) * `POST /v1/tasks/{task_id}/skip` * `POST /v1/tasks/{task_id}/reopen` * `PATCH /v1/tasks/{task_id}/assignee` with `{ "assignee_user_id": "" }` > [!TIP] > > Mark tasks complete from your CRM with `completion_source: EXTERNAL_CRM` so Topo records the outcome without re-executing the step in-app. # Complete Task Source: https://docs.topo.io/api-reference/tasks/complete-task /openapi.json post /v1/tasks/{task_id}/complete Mark an actionable task as completed. Requires the `tasks:write` scope on the API key. # Create Todo Task Source: https://docs.topo.io/api-reference/tasks/create-todo-task /openapi.json post /v1/tasks Create a standalone to-do task. Requires the `tasks:write` scope on the API key. # Execute Task Source: https://docs.topo.io/api-reference/tasks/execute-task /openapi.json post /v1/tasks/{task_id}/execute Execute an actionable task (send a reply, approve a lead, log a call, etc.). Requires the `tasks:write` scope on the API key. # Get Task Source: https://docs.topo.io/api-reference/tasks/get-task /openapi.json get /v1/tasks/{task_id} Fetch a single task. Requires the `tasks:read` scope on the API key. # List Task Messages Source: https://docs.topo.io/api-reference/tasks/list-task-messages /openapi.json get /v1/tasks/{task_id}/messages List messages exchanged on a task's sequence thread. Requires the `tasks:read` scope on the API key. # List Tasks Source: https://docs.topo.io/api-reference/tasks/list-tasks /openapi.json get /v1/tasks List tasks in the workspace. Requires the `tasks:read` scope on the API key. # Reassign Task Source: https://docs.topo.io/api-reference/tasks/reassign-task /openapi.json patch /v1/tasks/{task_id}/assignee Reassign a task (or every actionable task in its sequence) to another user. Requires the `tasks:write` scope on the API key. # Reopen Task Source: https://docs.topo.io/api-reference/tasks/reopen-task /openapi.json post /v1/tasks/{task_id}/reopen Reopen a completed or skipped task. Requires the `tasks:write` scope on the API key. # Skip Task Source: https://docs.topo.io/api-reference/tasks/skip-task /openapi.json post /v1/tasks/{task_id}/skip Skip an actionable task. Requires the `tasks:write` scope on the API key. # Snooze Task Source: https://docs.topo.io/api-reference/tasks/snooze-task POST /v1/tasks/{task_id}/snooze Snooze an actionable task until a future datetime. Snooze an actionable task until a future datetime. Requires the `tasks:write` scope on the API key. # Update Todo Task Source: https://docs.topo.io/api-reference/tasks/update-todo-task /openapi.json patch /v1/tasks/{task_id} Update a standalone to-do task. Requires the `tasks:write` scope on the API key. # Archive Thread Source: https://docs.topo.io/api-reference/threads/archive-thread POST /v1/threads/{thread_id}/archive Archive an inbox conversation thread. Archive an inbox conversation thread. Requires the `contacts:write` scope on the API key. # Get Thread Source: https://docs.topo.io/api-reference/threads/get-thread GET /v1/threads/{thread_id} Fetch a single conversation thread with its messages. Fetch a single conversation thread with its messages. Requires the `messages:read` scope on the API key. # List Threads Source: https://docs.topo.io/api-reference/threads/list-threads GET /v1/threads List conversation threads newest first. List conversation threads newest first. Requires the `messages:read` scope on the API key. # Unarchive Thread Source: https://docs.topo.io/api-reference/threads/unarchive-thread POST /v1/threads/{thread_id}/unarchive Unarchive an inbox conversation thread. Unarchive an inbox conversation thread. Requires the `contacts:write` scope on the API key. # List Users Source: https://docs.topo.io/api-reference/users/list-users /openapi.json get /v1/users List users in the authenticated organization. Requires the `users:read` scope on the API key. # Webhooks API: Receive Real-Time Outreach Events in Topo Source: https://docs.topo.io/api-reference/webhooks Create and manage webhook subscriptions so Topo pushes outreach engagement events to your endpoint in real-time — no polling required. Webhooks let you receive outreach engagement events from Topo the instant they occur. When a tracked event happens in your workspace — a contact replies, a hot lead is created, a meeting is booked — Topo sends an HTTP `POST` to the URL you register, with a signed JSON payload describing the event. A webhook subscription defines which events you want to receive and where to receive them. You can optionally scope a subscription to events from specific sequence templates, keeping your integration focused on the templates that matter. Every delivery is signed with an HMAC-SHA256 signature so you can verify that requests genuinely originate from Topo. > [!NOTE] > > **Reading** subscriptions requires the **`webhooks:read`** scope. **Creating, updating, deleting, and testing** subscriptions requires the **`webhooks:write`** scope. ## Subscription status | Status | Behaviour | | - | - | | `ACTIVE` | Topo delivers matching events to the endpoint. | | `PAUSED` | Delivery is temporarily suspended. Events that arrive while paused are not queued — resume the subscription to start receiving new events again. | | `DISABLED` | The subscription has been administratively disabled. Set `status` to `ACTIVE` via `PATCH` to re-enable it. | ## Event types Each subscription declares which event types it wants to receive. The `type` field in every delivered payload is the discriminator. | `type` value | What it means | | - | - | | `message.sent` | An email or LinkedIn message was sent to the contact. | | `message.opened` | The contact opened an email. | | `message.link_clicked` | The contact clicked a tracked link in an email. | | `message.replied` | The contact replied to a message. | | `invitation.sent` | A LinkedIn connection request was sent. | | `invitation.accepted` | The contact accepted a LinkedIn connection request. | | `hot_lead.created` | Topo's AI flagged this contact as a hot lead. | | `meeting.created` | A meeting was booked with the contact. | | `sequence.created` | A contact was enrolled into a sequence. | | `sequence.paused` | An active sequence was paused. | | `sequence.resumed` | A paused sequence was resumed. | | `sequence.stopped` | The sequence was stopped early. | | `sequence.completed` | The sequence ran to completion. | | `task.created` | A manual task was created for the contact. | | `task.completed` | A manual task was marked complete. | | `task.skipped` | A manual task was skipped. | | `task.reopened` | A previously completed or skipped task was reopened. | | `contact_entry.resolved` | A contact-list entry resolved to a workspace contact. | | `event.resolved` | A first-party ingested event finished subject resolution. | | `contact_exclusion.created` | A contact exclusion was created. | `sequence.paused`, `sequence.resumed`, `sequence.stopped` and `sequence.completed` fire for every change of a sequence's status, including the ones Topo applies to many sequences at once: an exclusion of a company, a sender removed or disconnected, a member leaving the workspace. One such change can send one event per affected sequence, shortly after it is saved. > [!NOTE] > > Topo may add new event types as additive changes within `/v1`. Your endpoint **must** accept and ignore unknown `type` values — do not return a non-`2xx` status just because you don't recognise an event type. ## Payload families Topo delivers two payload families. Branch on `type` before reading fields beyond the shared base. ### Sequence-tied events Most outreach events (`message.*`, `invitation.*`, `hot_lead.created`, `meeting.created`, `sequence.*`, `task.*`) come from sequence activity. They always include `sequence_id`, `sequence_template_id`, `contact`, and `sequence_template` embeds when the underlying rows exist. `sequence_template_id` is `null` for a sequence without template: one whose template was deleted, which keeps its history, or one created without a template. ### Resource events `contact_entry.resolved`, `event.resolved`, and `contact_exclusion.created` describe resources that exist outside any sequence run. They use a slimmer base: `type`, `organization_id`, `contact_id`, and `contact` — **no** `sequence_id` or `sequence_template_id`. | `type` | Key fields beyond the base | | - | - | | `contact_entry.resolved` | `contact_entry_id`, `contact_list_id`, `external_contact_id` | | `event.resolved` | `event_id`, `event_name`, `resolution_status`, `account_id`, `external_event_id` | | `contact_exclusion.created` | `exclusion_id`, `email`, `linkedin_url`, `reason` | Deliveries for resource events are sent only to subscriptions whose `sequence_template_ids` is `null` (all templates). Subscriptions narrowed to specific templates receive sequence-tied traffic only — see [Sequence template scoping](#sequence-template-scoping). ## Webhook payload structure Every delivery is an HTTP `POST` with a JSON body. The payload is the same discriminated `OutboundWebhookEventPayload` object surfaced by the [Activities API](/api-reference/activities). ```json theme={"theme":{"light":"github-light","dark":"github-dark"}} { "type": "hot_lead.created", "organization_id": "01954b2e-0000-7000-aaaa-111111111111", "contact_id": "01954b2e-1111-7000-bbbb-222222222222", "sequence_id": "01954b2e-2222-7000-cccc-333333333333", "sequence_template_id": "01954b2e-3333-7000-dddd-444444444444", "resource_type": "HOT_LEAD", "channel": "EMAIL", "message_id": null, "hot_lead_id": "01954b2e-5555-7000-ffff-666666666666", "calendar_event_id": null, "task_id": null, "contact": { "id": "01954b2e-1111-7000-bbbb-222222222222", "first_name": "Ada", "last_name": "Lovelace", "email": "ada@analytical.io", "linkedin_url": null, "job_title": "CTO", "company_name": "Analytical Engines", "company_domain": "analytical.io" }, "sequence_template": { "id": "01954b2e-3333-7000-dddd-444444444444", "name": "Outbound to CTOs" }, "hot_lead": { "id": "01954b2e-5555-7000-ffff-666666666666", "engagement_score": 87 } } ``` All field names are `snake_case`. ### Embedded summaries Alongside the raw ids, every **sequence-tied** payload carries compact copies of the records the event is about. Resource payloads carry `contact` when a contact is known, plus event-specific ids listed above. | Embed | Present on | | - | - | | `contact` | Sequence-tied events; resource events when a contact is known | | `sequence_template` | Sequence-tied events only | | `message` | `message.sent`, `message.opened`, `message.link_clicked`, `message.replied` | | `meeting` | `meeting.created` | | `task` | `task.created`, `task.completed`, `task.skipped`, `task.reopened` | | `hot_lead` | `hot_lead.created` | **contact** `object | null` The person the event is about. Fetch the full record from the [Contacts API](/api-reference/contacts) when you need more than these fields. **contact** **id** `string (UUID)` *required* Org-scoped contact identifier, always equal to the payload's `contact_id`. **first\_name** `string | null` First name. **last\_name** `string | null` Last name. **email** `string | null` Email address Topo reaches the contact on. **linkedin\_url** `string | null` LinkedIn profile URL. **job\_title** `string | null` Job title. **company\_name** `string | null` Name of the account the contact works at. **company\_domain** `string | null` Primary domain of that account. **sequence\_template** `object | null` The playbook the sequence is running. Use it to branch on the template a delivery came from without keeping your own id-to-name mapping. **sequence\_template** **id** `string (UUID)` *required* Always equal to the payload's `sequence_template_id`. **name** `string | null` Template name as it appears in the Topo app. **message** `object | null` The email or LinkedIn message the event describes. **message** **id** `string (UUID)` *required* Always equal to the payload's `message_id`. **subject** `string | null` Email subject line. Not meaningful on LinkedIn messages, which have no subject. **body** `string | null` Rendered message body, with personalization variables already substituted. **channel** `string | null` `EMAIL`, `LINKEDIN`, `CALENDAR`, or `MANUAL`. **direction** `string | null` `OUTBOUND` for messages your team sent, `INBOUND` for contact replies. **sent\_at** `string (ISO 8601) | null` When the message left Topo. **step\_number** `integer | null` Position of the sequence step that produced the message, starting at `1`. **meeting** `object | null` The booked calendar event. **meeting** **id** `string (UUID)` *required* Always equal to the payload's `calendar_event_id`. **title** `string | null` Calendar event title. **start\_time** `string (ISO 8601) | null` When the meeting starts. **end\_time** `string (ISO 8601) | null` When the meeting ends. **timezone** `string | null` IANA timezone the meeting was booked in, such as `Europe/Paris`. **meeting\_link** `string | null` Video conferencing URL, when the calendar event carries one. **task** `object | null` The manual action item the event describes. **task** **id** `string (UUID)` *required* Always equal to the payload's `task_id`. **type** `string | null` What the task asks for, such as `CALL` or `EMAIL_REPLY`. See the [Tasks API](/api-reference/tasks) for the types you are most likely to receive. **priority** `string | null` `HOT`, `TODAY`, or `EXTRA`. **status** `string | null` `PENDING`, `IN_PROGRESS`, `COMPLETED`, or `SKIPPED` — the status at the moment the event fired. **title** `string | null` What the assignee is being asked to do. **due\_date** `string (ISO 8601 date) | null` Day the task is due. **hot\_lead** `object | null` The hot lead Topo's AI flagged. **hot\_lead** **id** `string (UUID)` *required* Always equal to the payload's `hot_lead_id`. **engagement\_score** `integer | null` Weighted score Topo computed from the contact's tracked opens and clicks. Higher means more engaged; the scale depends on the point weights configured for your workspace. > [!NOTE] > > Embeds are a convenience, not the contract — the id fields are. Every embed is nullable, and every field inside one except `id` is optional. When a referenced row was deleted or cannot be read, Topo still delivers the event with the ids intact and sets the embed to `null`, so read ids for correlation and treat embeds as best effort. > [!TIP] > > The [Activities API](/api-reference/activities) returns the same payload object, embeds included, so a handler written against these fields works whether you stream events or poll for them. ### Delivery headers Every POST Topo sends includes these headers: | Header | Description | | - | - | | `Content-Type` | `application/json` | | `X-Topo-Signature` | HMAC-SHA256 hex digest of the raw request body, prefixed with `sha256=`. | | `X-Topo-Event-Type` | The `type` value of the payload (e.g. `hot_lead.created`). | ## Verifying signatures Every delivery is signed. You should reject requests that fail signature verification to prevent spoofed events from affecting your system. The signature is computed as: ```text theme={"theme":{"light":"github-light","dark":"github-dark"}} HMAC-SHA256(key=, message=) ``` Compare the result (hex-encoded, prefixed with `sha256=`) against the `X-Topo-Signature` header. Always compare using a constant-time equality function to prevent timing attacks. > [!WARNING] > > Always verify the signature **before** acting on a payload. Do not trust the `organization_id` in the body without first confirming the signature is valid. ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}} import hashlib import hmac def verify_topo_signature( raw_body: bytes, signature_header: str, secret: str, ) -> bool: """Return True if the request signature is valid.""" expected = "sha256=" + hmac.new( secret.encode("utf-8"), raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature_header) # Flask example from flask import Flask, request, abort app = Flask(__name__) WEBHOOK_SECRET = "whsec_••••••••••••••••" @app.route("/topo-events", methods=["POST"]) def topo_webhook(): signature = request.headers.get("X-Topo-Signature", "") if not verify_topo_signature(request.get_data(), signature, WEBHOOK_SECRET): abort(401) payload = request.get_json() event_type = payload["type"] # handle event_type ... return "", 204 ``` ```javascript Node.js theme={"theme":{"light":"github-light","dark":"github-dark"}} const crypto = require("crypto"); /** * Returns true if the request signature is valid. * @param {Buffer} rawBody - The raw (unparsed) request body. * @param {string} signatureHeader - Value of the X-Topo-Signature header. * @param {string} secret - Your webhook signing secret. */ function verifyTopoSignature(rawBody, signatureHeader, secret) { const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); // Use timingSafeEqual to prevent timing attacks const a = Buffer.from(expected, "utf8"); const b = Buffer.from(signatureHeader, "utf8"); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } // Express example const express = require("express"); const app = express(); const WEBHOOK_SECRET = "whsec_••••••••••••••••"; app.post( "/topo-events", express.raw({ type: "application/json" }), (req, res) => { const signature = req.headers["x-topo-signature"] || ""; if (!verifyTopoSignature(req.body, signature, WEBHOOK_SECRET)) { return res.status(401).send("Invalid signature"); } const payload = JSON.parse(req.body); const eventType = payload.type; // handle eventType ... res.status(204).send(); } ); ``` > [!TIP] > > Make your endpoint **idempotent**. In rare cases Topo may deliver the same event more than once (e.g. after a network timeout). Use the activity's `id` — available via the Activities API — to deduplicate if needed. ## Sequence template scoping By default a subscription receives events from every sequence template in your workspace. If you want to isolate events from a particular template — for example, routing events from your enterprise outbound template to a different CRM pipeline — set `sequence_template_ids` when creating or updating the subscription. A subscription with `sequence_template_ids` receives only events where `payload.sequence_template_id` is in that list. Resource-family events and events of sequences without template are **not** delivered to narrowed subscriptions — only subscriptions with `sequence_template_ids: null` receive them. To remove scoping and receive events from all templates again, `PATCH` the subscription with `"sequence_template_ids": null`. ## Webhook lifecycle ``` create (POST /v1/webhooks) │ ▼ ACTIVE ─── events delivered ──▶ PAUSED (PATCH status=PAUSED) │ │ │ └──▶ ACTIVE (PATCH status=ACTIVE) │ └──▶ DISABLED (administratively disabled) │ └──▶ ACTIVE (PATCH status=ACTIVE) ``` > [!NOTE] > > Topo's API stability policy guarantees the subscription object shape, pagination envelope, and event type discriminator values are frozen within `/v1`. New event types may be added additively — your integration should ignore unknown `type` values rather than treating them as errors. # Create Webhook Subscription Source: https://docs.topo.io/api-reference/webhooks/create-webhook-subscription /openapi.json post /v1/webhooks Create a webhook subscription. Requires the `webhooks:write` scope on the API key. # Delete Webhook Subscription Source: https://docs.topo.io/api-reference/webhooks/delete-webhook-subscription /openapi.json delete /v1/webhooks/{webhook_id} Permanently delete a webhook subscription. Requires the `webhooks:write` scope on the API key. # Get Webhook Subscription Source: https://docs.topo.io/api-reference/webhooks/get-webhook-subscription /openapi.json get /v1/webhooks/{webhook_id} Fetch a single webhook subscription. Requires the `webhooks:read` scope on the API key. # List Webhook Subscriptions Source: https://docs.topo.io/api-reference/webhooks/list-webhook-subscriptions /openapi.json get /v1/webhooks List the webhook subscriptions configured in the workspace. Requires the `webhooks:read` scope on the API key. # Test Webhook Subscription Source: https://docs.topo.io/api-reference/webhooks/test-webhook-subscription /openapi.json post /v1/webhooks/{webhook_id}/test Send a synthetic test event to a webhook subscription. Requires the `webhooks:write` scope on the API key. # Update Webhook Subscription Source: https://docs.topo.io/api-reference/webhooks/update-webhook-subscription /openapi.json patch /v1/webhooks/{webhook_id} Update mutable fields of a webhook subscription. Requires the `webhooks:write` scope on the API key. # Topo MCP connector Source: https://docs.topo.io/mcp-connector Connect Claude and other MCP clients to your Topo workspace: tools, scopes, sign-in, safeguards, lead data sources, and troubleshooting. The Topo MCP connector lets an AI assistant such as Claude work in your Topo workspace: triage replies, clear tasks, review waiting leads, run sequences, source new leads, and manage your exclusion list. It is the same workspace you use in the Topo app, reached through the [Model Context Protocol](https://modelcontextprotocol.io). * **Server URL:** `https://mcp.topo.io/mcp` (streamable HTTP) * **Requirement:** a Topo account. The connector only sees the workspace your Topo user belongs to. ## Connect and sign in **1. Add the connector** In Claude, open **Customize → Connectors**, click **Add custom connector**, and paste `https://mcp.topo.io/mcp`. The same URL works in any MCP client that supports OAuth. **2. Sign in and consent** The first tool call opens a Topo sign-in (OAuth 2.0 with PKCE). The consent screen names the application asking for access and the permissions below. Nothing is shared until you accept. **3. Revoke access** Remove the connector from your MCP client to revoke it. A consent is remembered for 90 days at most, after which you are asked again. Clients that cannot run OAuth can authenticate with a Topo API key created in **Settings → Developers**, sent as a bearer token. The key's scopes limit what the tools can do. ## Permissions | OAuth scope | What it grants | | - | - | | `mcp:read` | Read contacts, accounts, lists, sequences, tasks, inbox threads, activities, exclusions, signals, playbooks and their runs, events, webhooks, CRM deals, and workspace members. | | `mcp:write` | Everything in `mcp:read`, plus writes on the same records: replies, task outcomes, enrollments, lists, exclusions, sequence templates, signals, playbooks, CRM fields, webhooks, and lead search runs. | Every tool declares the scope it needs, and a tool the credential does not cover returns an error instead of running. ## Tools The connector publishes about 170 tools. Every tool carries MCP annotations: `readOnlyHint` on reads, `destructiveHint` on anything that sends a message, starts or stops outreach, spends credits, overwrites a value without keeping the previous one, or cannot be undone in one step, and `openWorldHint` when it reaches a third party (an email recipient, an enrichment, lead-data or signal provider, a web search, a webhook endpoint), directly or through the outreach or automation it turns on. | Area | Main tools | | - | - | | Inbox and replies | `list_threads`, `get_thread`, `reply_to_message`, `categorize_reply`, `archive_thread` | | Tasks | `list_tasks`, `get_task`, `resolve_task`, `snooze_task`, `create_todo` | | Sequences | `list_sequence_templates`, `get_sequence_template`, `create_sequence_template`, `clone_sequence_template`, `update_sequence_template_steps`, `update_sequence_template_settings`, `set_sequence_template_senders`, `set_sequence_template_status`, `get_sequence_metrics` | | Enrollments | `enroll`, `approve_leads`, `refuse_leads`, `pause_sequence`, `resume_sequence`, `stop_sequence`, `unenroll`, `update_sequence_steps` | | Contacts and accounts | `list_contacts`, `get_contact`, `create_or_update_contact`, `list_accounts`, `get_account`, `find_or_create_account`, `lookup_contact`, `lookup_account`, `enrich_contact`, `get_and_enrich_contact`, `enrich_account`, `qualify_lead`, `update_crm_field` | | Lists and imports | `create_contact_list`, `add_contact_list_members`, `import_leads`, `import_csv`, `create_account_list` | | Lead search | `create_lead_search`, `refine_lead_search`, `preview_lead_search`, `run_lead_search`, `get_lead_search`, `list_lead_search_results`, `import_lead_search_results` | | Exclusions | `list_exclusions`, `add_contact_exclusions`, `add_domain_exclusions`, `update_exclusion`, `remove_exclusion` | | Signals | `list_signal_presets`, `preview_signal`, `create_signal`, `update_signal_config`, `pause_signal`, `archive_signal` | | AI variables | `list_ai_variables`, `draft_ai_variable`, `simulate_ai_variable`, `create_ai_variable`, `update_ai_variable`, `estimate_ai_variable_run`, `run_ai_variable`, `get_ai_variable_values`, `delete_ai_variable` | | Playbooks | `list_playbooks`, `get_playbook`, `list_playbook_templates`, `get_playbook_building_blocks`, `list_playbook_field_options`, `create_playbook`, `get_playbook_draft`, `edit_playbook_draft`, `discard_playbook_draft`, `test_playbook_step`, `start_playbook_test_run`, `stop_playbook_test_run`, `list_playbook_test_cases`, `create_playbook_test_case`, `publish_playbook`, `list_playbook_versions`, `rollback_playbook`, `set_playbook_status`, `run_playbook`, `list_playbook_runs`, `get_playbook_run`, `list_playbook_parameters`, `set_playbook_parameter`, `delete_playbook` | | Knowledge, webhooks | `search_knowledge_documents`, `get_knowledge_document`, `create_webhook`, `ingest_event` | | Discovery | `whoami`, `search`, `fetch` | The complete, machine-readable list with every input schema is served by the server itself (`tools/list`). ## Safeguards Some decisions stay with a person in the Topo app, whatever the assistant is asked to do: * **Opt-outs cannot be undone from the connector.** An exclusion whose reason is `UNSUBSCRIBED` or `CONTACT_REFUSED` cannot be removed, have its expiry date changed, or be relabeled over MCP. Lifting one is done by a person in **Settings → Organization → Exclusions**. * **Unsubscribe rules cannot be overridden per sequence.** The per-sequence reply-exclusion override never changes how unsubscribe replies are handled. * **Account exclusions cannot be bypassed.** Letting a sequence contact companies on the account exclusion list is only possible in the app. * **Nobody is contacted without approval.** Sequences created or cloned over MCP hold every new lead until someone approves it (`COPILOT`), and the connector cannot switch a sequence to automatic approval (`AUTOPILOT`). `approve_leads` takes the explicit list of leads a person chose. * **Enrollment keeps its protections.** `enroll` skips anyone already in a running sequence, and re-enrolls people with past sequences only after a cooldown of at least 90 days. Exclusions are checked before enrollment and again before every step. * **Spending credits is confirmed.** Running a lead search requires an explicit confirmation of the volume (see below). Running an AI variable on the rows of the Accounts or Contacts table, or of one list or sequence, requires `confirmed_by_user=true` after you have seen the `estimate_ai_variable_run` quote; a failed value is never charged. * **Playbooks are tested before they go live.** `publish_playbook` refuses a definition without a passing test run unless you explicitly ask to publish it untested. Publishing, turning a playbook on, rolling back and deleting ask for your confirmation. Authoring requires a signed-in user: an API key can read playbooks but not change them. * **Playbooks only run connector tools.** Over MCP, a playbook can only be run or tested when every step uses an action that is also a tool of the connector. A playbook that calls an HTTP endpoint, notifies a teammate, or runs a free-form AI step runs from the Topo app. * **Secrets never pass through the connector.** `set_playbook_parameter` stores plain values only. Secrets and credentials are entered by a person on the playbooks **Vault** page, which the tool links to. ## Lead search flow Lead search finds new prospects in Topo's lead database and never spends credits before the user confirms the volume. **1. Draft** `create_lead_search` turns a description ("VPs of Sales at French SaaS companies over 50 employees") into filters and returns a summary for the user to review. Nothing is fetched yet. **2. Refine** `refine_lead_search` applies one change at a time ("remove France", "only over 100 employees") and returns the updated summary. **3. Preview (optional)** `preview_lead_search` samples up to 25 matching leads cheaply, to check the filters before a full run. **4. Run** `run_lead_search` sources up to the requested number of leads and spends credits. It only starts when called with `confirmed_by_user=true`, which the assistant sets once the user agreed to that volume. It returns at once; `get_lead_search` reports progress. **5. Review and import** `list_lead_search_results` pages through what was found, and `import_lead_search_results` adds the chosen leads, or a number of them the user picked, to a contact list. The volume is capped by your workspace's per-import limit. ## Where lead data comes from * **Lead database.** Lead search, `lookup_contact`, `lookup_account` and the first enrichment step query Topo's lead database: business profiles of professionals (name, headline, current and past job titles and employers, location, education, skills, languages, public profile URL, and follower count) and company records, compiled from third-party B2B data providers. It contains no special categories of personal data (GDPR Article 9) and no consumer data. * **Enrichment.** `enrich_contact`, `get_and_enrich_contact`, and phone enrichment on import fill missing emails, phone numbers, and job details through FullEnrich, a third-party enrichment provider. Email addresses are verified before outreach. * **Company research.** `enrich_account` and signals can read a company's public website and public company signals (funding, hiring, technologies). * **Signals.** A signal checks one source once a day: public company facts (hiring, funding, news, technology adoption), people's job changes and public social media activity read through third-party data providers, or the workspace's own product events and CRM lists. It only imports into the list it owns, within the caps you set, and you can pause or archive it at any time. * **AI variables with web search.** When an AI variable has web search on, computing a row searches the public web about that row's company or person through a web search provider. * **Your own data.** Contacts you import, sync from your CRM, or add by hand stay in your workspace. **Roles and legal basis.** The Topo customer who runs the outreach is the data controller for the people it contacts and Topo acts as its processor under a data processing agreement. For B2B prospecting in the EU, controllers typically rely on legitimate interest (GDPR Article 6(1)(f)), which gives every contacted person the right to object at any time (Article 21). **Opting out and removal.** * A person who replies to stop receiving messages is detected and added to the workspace's exclusion list as `UNSUBSCRIBED`, which stops every running sequence to them and blocks new ones for the duration set in the workspace's reply-exclusion settings (one year by default). Only a person in the Topo app can change that duration or lift the opt-out; the connector cannot. * Anyone can ask Topo to access, correct, or delete the data held about them, or to be removed from Topo's lead database, by emailing [security@topo.io](mailto:security@topo.io). Requests are handled within 30 days, as described in the [privacy policy](https://www.topo.io/consents/privacy-policy). ## Troubleshooting | Symptom | What to do | | - | - | | Every call returns `unauthorized` | The sign-in expired or was revoked. Reconnect the connector to sign in again. | | A tool returns a missing-scope error | The consent or API key does not cover that action. Reconnect and accept `mcp:write`, or use a key with the listed scope. | | "This exclusion is an opt-out" | Expected: opt-outs can only be lifted by a person in **Settings → Organization → Exclusions**. | | A lead search says it uses a legacy provider | That search was created before the current lead database. Open the link it returns in the Topo app, or create a new search. | | `enroll` reports `queued_count` | Eligibility checks (email verification included) are still running. Enrollment finishes in the background and shows on the sequence in Topo. | | The assistant sees an old tool list | MCP clients cache tool lists. Refresh or reconnect the connector. `upsert_contact` and `upsert_account` still answer under their old names. | | "This playbook uses actions only the Topo app can run" | Expected: run or test that playbook from the Topo app. | | Anything else | Contact [support](https://support.topo.io) with the time of the call and the tool name. |