> For the complete documentation index, see [llms.txt](https://docs.sumble.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.sumble.com/system-setup-and-configuration/data-destinations/webhooks.md).

# Webhooks

Get an HTTPS POST from Sumble for each new signal and priority signal, and verify that it came from us.

{% hint style="info" %}
*Webhooks are in **beta** and available on the Enterprise plan. They're turned on per account. To try them, contact your account executive and we'll enable them for you.*
{% endhint %}

## What webhooks do

A webhook sends an HTTPS POST to an endpoint you run each time Sumble creates a new signal for your account. You can also subscribe to priority signals. Sumble checks for new events every five minutes, so an event usually reaches you within a few minutes of being created.

Admins with an Admin seat manage webhooks under **My Team > Webhooks**. Each delivered event costs one credit.

## Set up a webhook

If you don't see a **Webhooks** tab under **My Team**, webhooks aren't enabled for your account yet. Contact your account executive.

1. Go to **My Team > Webhooks** and click **New webhook**.
2. Fill in the fields:
   * **Name**: 1 to 100 characters, for your own reference.
   * **URL**: must start with `https://`, can't contain a username or password, and its host must resolve to a public IPv4 address.
   * **Event types**: **Signals**, **Priority signals**, or both.
   * **Signal types** and **priorities**: filters that apply to signals only. Priorities are high, medium, and low. Leave a filter empty to get everything.
   * **Burst threshold**: between 10 and 25,000. The default is 25,000. See [Held bursts](#held-bursts).
   * **Send events from**: **Now**, or **A past date** up to 90 days back. With a past date, Sumble shows how many matching events it will send before you save. See [Send past events](#send-past-events).
3. Save. Sumble shows the signing secret, which starts with `whsec_`. Copy it now, because it isn't shown again. If you lose it, [rotate it](#rotate-the-signing-secret).
4. Send a test to check your endpoint and your signature verification. See [Test events](#test-events).

With **Now**, a new webhook sends events created from the moment you save it. To get older events too, pick a past date when you create it, or rewind it later.

Each account can have up to 10 webhooks.

## Test events

Use **Send test** on a webhook to see what your endpoint will receive before real events arrive.

Pick **Raw signal** or **Priority signal**. For a raw signal, also pick a signal type, such as a champion movement or a funding round. Sumble sends a sample event built by the same code that builds real events, so its fields and groups match a real event of that type. A champion movement sample has a `person` group with the previous and new company, a job post sample has a `job` group, and so on.

Kinds and types your webhook doesn't subscribe to are marked **Not subscribed**. You can still send them, which helps when you build your handler before changing the subscription.

A test event has the real `type`, `signal.raw.created` or `signal.priority.created`, and `test` set to `true`. Real events always have `"test": false`. The data is fake: ids are `0`, the organization is "Example Corp", and Sumble links in the sample don't lead anywhere useful. Check `test` and skip side effects, such as creating a CRM record or alerting a rep, when it's `true`.

Tests are signed like real events. They're free, get one attempt with no retries, and are sent even while the webhook is paused. After a test, Sumble shows your endpoint's status code, the start of its response, and the JSON body it sent.

## Allow our IP addresses

Deliveries come from these two addresses, over IPv4 only:

* `8.231.165.219`
* `136.87.138.118`

Allow both, even if you only see traffic from one of them today. Having two lets us add capacity or replace an address without you changing your firewall.

An endpoint whose host has no IPv4 address, that is no A record, can't receive webhooks. Sumble rejects it when you save the webhook.

## What we send

Each delivery is a `POST` with a JSON body and these headers:

| Header              | Value                                                                                                                              |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `content-type`      | `application/json`                                                                                                                 |
| `user-agent`        | `Sumble-Webhooks/1.0`                                                                                                              |
| `webhook-id`        | The event's id. Same as `id` in the body.                                                                                          |
| `webhook-timestamp` | When this attempt was signed, in Unix seconds.                                                                                     |
| `webhook-signature` | One or more signatures, separated by spaces, each in the form `v1,<signature>`. See [Verify the signature](#verify-the-signature). |

The three `webhook-` headers follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification.

`webhook-id` stays the same when we retry an event and when you replay it, so you can use it to drop duplicates. `webhook-timestamp` changes on every attempt.

### The envelope

Every body has the same outer fields:

| Field               | Type                   | Null? | Meaning                                            |
| ------------------- | ---------------------- | ----- | -------------------------------------------------- |
| `id`                | string (UUID)          | No    | The event id. Same as the `webhook-id` header.     |
| `type`              | string                 | No    | `signal.raw.created` or `signal.priority.created`. |
| `schema_version`    | integer                | No    | Always `1` today.                                  |
| `created_at`        | string (ISO 8601, UTC) | No    | When Sumble created the event.                     |
| `webhook_config_id` | integer                | No    | The webhook this event was sent for.               |
| `test`              | boolean                | No    | `true` only for test sends.                        |
| `data`              | object                 | No    | A signal or a priority signal, described below.    |

A [test event](#test-events) has the same `type` and `data` shape as a real event, with `test` set to `true`.

### Signal (`signal.raw.created`)

This example is the test sample for a `technology_recent_hires` signal. A real delivery has the same shape, with real data and `"test": false`.

```json
{
  "id": "01a14300-0000-7000-8000-000000000001",
  "type": "signal.raw.created",
  "schema_version": 1,
  "created_at": "2026-10-16T12:00:00Z",
  "webhook_config_id": 42,
  "test": true,
  "data": {
    "signal": {
      "signal_id": 0,
      "type": "technology_recent_hires",
      "display_type": "Target persona hire",
      "priority": "medium",
      "title": "Example Corp hired Example Person as Senior Data Engineer",
      "explanation": "This is a sample signal sent by a webhook test.",
      "date": "2026-01-01T12:00:00Z",
      "sumble_url": "https://sumble.com/orgs/example-corp/signals/s/0"
    },
    "organization": {
      "organization_id": null,
      "organization_name": "Example Corp",
      "domain": "example.com"
    },
    "signal_config": {
      "signal_config_id": 0,
      "signal_config_name": "Sample signal config"
    },
    "match": {
      "matched_technologies": [
        { "name": "Snowflake", "slug": "snowflake", "label": null }
      ],
      "matched_projects": null,
      "job_function": "Data Engineer",
      "job_level": "Senior",
      "location": "Springfield, Illinois, United States",
      "country": "United States"
    },
    "person": {
      "person_id": 0,
      "name": "Example Person",
      "title": null,
      "linkedin_url": "https://www.linkedin.com/in/example-person",
      "sumble_url": "https://sumble.com/l/person/mk5tg8ysxj",
      "previous_organization_name": null,
      "previous_title": null,
      "new_organization_name": null,
      "new_title": null,
      "start_date": null
    },
    "job": null
  }
}
```

The body we send is compact JSON on one line. The example is formatted for reading.

`data` has these groups. A group that doesn't apply to the signal is `null`.

| Field           | Type   | Null? | Meaning                                                    |
| --------------- | ------ | ----- | ---------------------------------------------------------- |
| `signal`        | object | No    | The signal itself.                                         |
| `organization`  | object | No    | The organization the signal is about.                      |
| `signal_config` | object | Yes   | The signal config that produced it.                        |
| `match`         | object | Yes   | What the signal matched. `null` when nothing below is set. |
| `person`        | object | Yes   | The person involved, for people signals.                   |
| `job`           | object | Yes   | The job post, for job post signals.                        |

`signal`:

| Field          | Type              | Null? | Meaning                                                                     |
| -------------- | ----------------- | ----- | --------------------------------------------------------------------------- |
| `signal_id`    | integer           | No    | The signal's id in Sumble.                                                  |
| `type`         | string            | No    | The signal type, for example `technology_recent_hires` or `champion_alert`. |
| `display_type` | string            | No    | A readable label for the type.                                              |
| `priority`     | string            | No    | `high`, `medium`, or `low`. Champion alerts are always `high`.              |
| `title`        | string            | No    | A one-line summary.                                                         |
| `explanation`  | string            | Yes   | Why the signal matters.                                                     |
| `date`         | string (ISO 8601) | No    | When Sumble found the signal.                                               |
| `sumble_url`   | string            | Yes   | The signal's page on Sumble.                                                |

`organization`:

| Field               | Type    | Null? | Meaning                                                                   |
| ------------------- | ------- | ----- | ------------------------------------------------------------------------- |
| `organization_id`   | integer | Yes   | Sumble's organization id.                                                 |
| `organization_name` | string  | Yes   | The organization's name.                                                  |
| `domain`            | string  | Yes   | The domain of the matching account in your account list, if there is one. |

`signal_config`:

| Field                | Type    | Null? | Meaning                   |
| -------------------- | ------- | ----- | ------------------------- |
| `signal_config_id`   | integer | No    | The signal config's id.   |
| `signal_config_name` | string  | Yes   | The signal config's name. |

`match`:

| Field                  | Type   | Null? | Meaning                                                                                                |
| ---------------------- | ------ | ----- | ------------------------------------------------------------------------------------------------------ |
| `matched_technologies` | array  | Yes   | Technologies the signal matched. Each item has `name`, `slug`, and `label`, and `label` can be `null`. |
| `matched_projects`     | array  | Yes   | Projects the signal matched.                                                                           |
| `job_function`         | string | Yes   | The job function.                                                                                      |
| `job_level`            | string | Yes   | The job level.                                                                                         |
| `location`             | string | Yes   | The location.                                                                                          |
| `country`              | string | Yes   | The country.                                                                                           |

`person`:

| Field                        | Type    | Null? | Meaning                                               |
| ---------------------------- | ------- | ----- | ----------------------------------------------------- |
| `person_id`                  | integer | Yes   | Sumble's person id.                                   |
| `name`                       | string  | Yes   | The person's name.                                    |
| `title`                      | string  | Yes   | Their current title.                                  |
| `linkedin_url`               | string  | Yes   | Their LinkedIn profile.                               |
| `sumble_url`                 | string  | Yes   | Their page on Sumble.                                 |
| `previous_organization_name` | string  | Yes   | Where they worked before. Champion alerts only.       |
| `previous_title`             | string  | Yes   | Their previous title. Champion alerts only.           |
| `new_organization_name`      | string  | Yes   | Where they work now. Champion alerts only.            |
| `new_title`                  | string  | Yes   | Their new title. Champion alerts only.                |
| `start_date`                 | string  | Yes   | When they started the new role. Champion alerts only. |

`job`:

| Field         | Type    | Null? | Meaning                 |
| ------------- | ------- | ----- | ----------------------- |
| `job_post_id` | integer | No    | Sumble's job post id.   |
| `title`       | string  | Yes   | The job title.          |
| `location`    | string  | Yes   | The job's location.     |
| `sumble_url`  | string  | Yes   | The job post on Sumble. |

### Priority signal (`signal.priority.created`)

Sumble generates priority signals for each user with a seat on your account. Each one says who it was generated for in `user`. This example is the priority signal test sample.

```json
{
  "id": "01a14300-0000-7000-8000-000000000002",
  "type": "signal.priority.created",
  "schema_version": 1,
  "created_at": "2026-10-16T12:00:00Z",
  "webhook_config_id": 42,
  "test": true,
  "data": {
    "id": 0,
    "headline": "Example Corp is building a data platform team",
    "content": "This is a sample priority signal sent by a webhook test.",
    "generated_at": "2026-01-01T12:00:00Z",
    "sumble_url": "https://sumble.com/signals/priority/item/0",
    "signal_ids": [0],
    "user": {
      "id": 0,
      "email": "user@example.com",
      "name": "Example User"
    }
  }
}
```

| Field          | Type              | Null? | Meaning                        |
| -------------- | ----------------- | ----- | ------------------------------ |
| `id`           | integer           | No    | The priority signal's id.      |
| `headline`     | string            | No    | A one-line summary.            |
| `content`      | string            | No    | The full write-up.             |
| `generated_at` | string (ISO 8601) | No    | When Sumble generated it.      |
| `sumble_url`   | string            | No    | Its page on Sumble.            |
| `signal_ids`   | array of integers | No    | The signals it is based on.    |
| `user.id`      | integer           | No    | The user it was generated for. |
| `user.email`   | string            | No    | That user's email.             |
| `user.name`    | string            | Yes   | That user's name.              |

## Verify the signature

Anyone who learns your URL can send it a POST. Check the signature on every request before you trust it.

1. Read the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers.
2. Reject the request if the timestamp is more than 5 minutes away from your clock. This stops someone from replaying an old request.
3. Remove the `whsec_` prefix from your signing secret and base64-decode the rest. The result is the key.
4. Build the signed content: `{webhook-id}.{webhook-timestamp}.{body}`, where `body` is the **raw request body bytes**. Don't parse and re-serialize the JSON first; any change to the bytes breaks the signature.
5. Compute HMAC-SHA256 of the signed content with the key, and base64-encode the result.
6. Split `webhook-signature` on spaces. Accept the request if any `v1,<signature>` entry matches your result. Compare in constant time.

After you [rotate the secret](#rotate-the-signing-secret), the header carries two signatures for 24 hours, one per secret. A match on either is fine, so check every entry.

### Python

Uses only the standard library and needs Python 3.9 or later. Pass the header values and the raw body, for example `request.get_data()` in Flask or `await request.body()` in FastAPI.

```python
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_webhook(secret: str, headers, body: bytes) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures:
        return False

    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(
        hmac.new(key, signed_content, hashlib.sha256).digest()
    ).decode()

    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False
```

### Node.js

Uses the built-in `crypto` module, no extra package. `body` must be the raw body as a `Buffer`, for example from `express.raw({ type: "application/json" })`. Node.js lower-cases incoming header names, so `req.headers` works as is.

```javascript
const crypto = require("node:crypto");

const TOLERANCE_SECONDS = 5 * 60;

function verifyWebhook(secret, headers, body) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;

  const sentAt = Number(timestamp);
  if (!Number.isInteger(sentAt)) return false;
  if (Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.`)
    .update(body)
    .digest();

  return signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",", 2);
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}
```

You can also use one of the [Standard Webhooks libraries](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries), which do the same checks.

## Respond quickly

Answer with any 2xx status within 10 seconds, then do the work. Store the event and process it in the background if processing takes longer. A request that takes more than 10 seconds counts as a failure and is retried.

We don't follow redirects. A 3xx response counts as a failure, so give us the final URL.

Answer `410 Gone` to tell us to stop. We pause the webhook straight away and email your admins. A 410 to a test send doesn't pause anything.

## Retries and delivery guarantees

Any response other than 2xx or 410, and any connection error or timeout, counts as a failed attempt. We retry each event up to 20 attempts in total, over about 8 hours. The wait between attempts starts at 30 seconds, doubles each time, and levels off at 35 minutes. After the last attempt the event is marked failed. You can send it again with a [replay](#delivery-log-and-replay).

Delivery is at least once, and events can arrive in any order:

* The same event can arrive more than once. Use `webhook-id` to drop duplicates.
* Don't rely on order. Use the timestamps in the body if you need one.

## Held bursts

Each webhook has a burst threshold. We check for new events every five minutes. If one check finds more new events for a webhook than its threshold, we hold them instead of sending them and email your admins. Only events created in the last 24 hours count towards the threshold, so past events you ask for with a start date or a rewind are never held.

Held events show up on the webhook's **Deliveries** tab. For each held batch you can:

* **Send** it. Each event costs one credit when it's delivered.
* **Skip** it. Skipped events are never sent, and a replay won't send them either.

If you do nothing, we skip a held batch automatically after 7 days. You can only send held events while the webhook is active.

If your normal volume trips the threshold, raise it in the webhook's settings.

## Pauses

You can pause and resume a webhook at any time. While a webhook is paused, we don't send it any events. Test sends still go through.

We also pause a webhook automatically, and email your admins, when:

* Your endpoint has failed for 24 hours without accepting a single event, with at least 20 failed attempts in that time. Test sends don't count.
* Your endpoint answers `410 Gone`.
* The billing owner runs out of credits.
* The billing owner is no longer an admin, and no other admin can take over.

When you resume, you choose where to start:

* **From where it paused** sends everything that happened while the webhook was paused, oldest first. Each delivery costs one credit.
* **From now** skips everything that happened while it was paused.

If you resume a webhook that was paused for credits and the billing owner still has none, we pause it again.

## Delivery log and replay

The **Deliveries** tab lists the events from the last 30 days with their status, and you can filter by status and event type. Open an event to see its id (the `id` in the body and the `webhook-id` header) and its `type`, even before the first attempt. You also see each attempt, with the request we sent and the response we got. We keep attempt details for 90 days.

A replay sends the events from a time range again, with the same ids. The range can start before the 30 days the **Deliveries** tab shows. A receiver that drops duplicates on `webhook-id` skips the ones it already accepted. A replay covers events that were delivered, failed, or skipped because the webhook was paused or out of credits. It doesn't cover test sends or held events, and you can only replay while the webhook is active.

Re-sending an event you already paid for is free.

## Send past events

A webhook can send events from before it existed or from before a point you choose, up to 90 days back. There are two ways:

* **When you create it**: set **Send events from** to **A past date**.
* **Later**: on an active webhook, use **Rewind** and pick a date.

Either way, Sumble sends the events created since that date that match the webhook's filters and that it hasn't received yet. Events it already received aren't sent again; use [Replay](#delivery-log-and-replay) to resend those. Events that were skipped while the webhook was paused or out of credits are included.

Before you confirm, Sumble shows how many events it will send. Each costs one credit when it's delivered, and the billing owner must have enough credits for all of them. If they don't, pick a later date or narrow the filters. If more events match by the time you confirm, Sumble asks you to confirm the new count.

Delivery starts within five minutes, and a large range can take a while. Past events are never held as a burst.

## Credits and the billing owner

Each delivered event costs one credit, charged to the webhook's billing owner. The billing owner is the admin who created the webhook, and any admin can change it to another admin with an Admin seat.

We don't charge for failed attempts, test sends, or events that were already charged once.

If the billing owner leaves or loses their Admin seat, we move the webhook to another admin and email your admins. If no admin is left, we pause the webhook.

See [Credits](/web-app/exports-credits/credits-and-exports.md) for how credits work.

## Rotate the signing secret

Click **Rotate signing secret** on the webhook. Sumble shows the new secret once. Copy it right away.

The old secret keeps working for 24 hours. During that time every delivery carries a signature made with each secret, so you can update your receiver without dropping events.

## What's not included

Webhook payloads don't include suggested contacts or account status. To get those, look the organization or person up with the [organizations](/api/core-data/organizations.md) and [people](/api/core-data/people.md) endpoints of the [Sumble API](/api/api.md).

There is no public API for managing webhooks. Set them up in **My Team > Webhooks**.
