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

# Webhooks API: Receive Real-Time Outreach Events in Topo

> 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.
</Note>

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

<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.
</Note>

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

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

<ResponseField name="contact" type="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.

  <Expandable title="contact">
    <ResponseField name="id" type="string (UUID)" required>
      Org-scoped contact identifier, always equal to the payload's `contact_id`.
    </ResponseField>

    <ResponseField name="first_name" type="string | null">
      First name.
    </ResponseField>

    <ResponseField name="last_name" type="string | null">
      Last name.
    </ResponseField>

    <ResponseField name="email" type="string | null">
      Email address Topo reaches the contact on.
    </ResponseField>

    <ResponseField name="linkedin_url" type="string | null">
      LinkedIn profile URL.
    </ResponseField>

    <ResponseField name="job_title" type="string | null">
      Job title.
    </ResponseField>

    <ResponseField name="company_name" type="string | null">
      Name of the account the contact works at.
    </ResponseField>

    <ResponseField name="company_domain" type="string | null">
      Primary domain of that account.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="sequence_template" type="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.

  <Expandable title="sequence_template">
    <ResponseField name="id" type="string (UUID)" required>
      Always equal to the payload's `sequence_template_id`.
    </ResponseField>

    <ResponseField name="name" type="string | null">
      Template name as it appears in the Topo app.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="message" type="object | null">
  The email or LinkedIn message the event describes.

  <Expandable title="message">
    <ResponseField name="id" type="string (UUID)" required>
      Always equal to the payload's `message_id`.
    </ResponseField>

    <ResponseField name="subject" type="string | null">
      Email subject line. Not meaningful on LinkedIn messages, which have no subject.
    </ResponseField>

    <ResponseField name="body" type="string | null">
      Rendered message body, with personalization variables already substituted.
    </ResponseField>

    <ResponseField name="channel" type="string | null">
      `EMAIL`, `LINKEDIN`, `CALENDAR`, or `MANUAL`.
    </ResponseField>

    <ResponseField name="direction" type="string | null">
      `OUTBOUND` for messages your team sent, `INBOUND` for contact replies.
    </ResponseField>

    <ResponseField name="sent_at" type="string (ISO 8601) | null">
      When the message left Topo.
    </ResponseField>

    <ResponseField name="step_number" type="integer | null">
      Position of the sequence step that produced the message, starting at `1`.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="meeting" type="object | null">
  The booked calendar event.

  <Expandable title="meeting">
    <ResponseField name="id" type="string (UUID)" required>
      Always equal to the payload's `calendar_event_id`.
    </ResponseField>

    <ResponseField name="title" type="string | null">
      Calendar event title.
    </ResponseField>

    <ResponseField name="start_time" type="string (ISO 8601) | null">
      When the meeting starts.
    </ResponseField>

    <ResponseField name="end_time" type="string (ISO 8601) | null">
      When the meeting ends.
    </ResponseField>

    <ResponseField name="timezone" type="string | null">
      IANA timezone the meeting was booked in, such as `Europe/Paris`.
    </ResponseField>

    <ResponseField name="meeting_link" type="string | null">
      Video conferencing URL, when the calendar event carries one.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="task" type="object | null">
  The manual action item the event describes.

  <Expandable title="task">
    <ResponseField name="id" type="string (UUID)" required>
      Always equal to the payload's `task_id`.
    </ResponseField>

    <ResponseField name="type" 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.
    </ResponseField>

    <ResponseField name="priority" type="string | null">
      `HOT`, `TODAY`, or `EXTRA`.
    </ResponseField>

    <ResponseField name="status" type="string | null">
      `PENDING`, `IN_PROGRESS`, `COMPLETED`, or `SKIPPED` — the status at the moment the event fired.
    </ResponseField>

    <ResponseField name="title" type="string | null">
      What the assignee is being asked to do.
    </ResponseField>

    <ResponseField name="due_date" type="string (ISO 8601 date) | null">
      Day the task is due.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="hot_lead" type="object | null">
  The hot lead Topo's AI flagged.

  <Expandable title="hot_lead">
    <ResponseField name="id" type="string (UUID)" required>
      Always equal to the payload's `hot_lead_id`.
    </ResponseField>

    <ResponseField name="engagement_score" type="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.
    </ResponseField>
  </Expandable>
</ResponseField>

<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.
</Note>

<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.
</Tip>

### 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=<your_signing_secret>, message=<raw_request_body>)
```

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.
</Warning>

<CodeGroup>
  ```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();
    }
  );
  ```
</CodeGroup>

<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.
</Tip>

## 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 (no 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.
</Note>
