# Guide to Sublime Security Webhooks: Features and Best Practices

Sublime Security webhooks send flagged email to the rest of your security stack. When a detection rule or automation flags a message as phishing, malware or impersonation, a webhook Action can forward the event to your SIEM, start a SOAR playbook, trigger a cloud function, or record every message for your own statistics. If your organisation uses Sublime Security for email protection, webhooks are how its verdicts reach your other tools.

This guide covers how Sublime webhook Actions are created and scoped, the payload and its `type` values, how to verify the `X-Sublime-Signature` header, and the practices that keep a receiver reliable.

## What are Sublime Security webhooks?

In Sublime, a webhook is one kind of Action, alongside actions such as trash, quarantine or warning banners. You create the webhook Action with an endpoint URL, then add it to the Detection Rules or Automations that should fire it. When one of those rules flags a message, Sublime sends a JSON `POST` describing the message, the mailbox, the flagged rules and any actions that were triggered.

Every request is signed. The `X-Sublime-Signature` header carries a timestamp and an HMAC-SHA256 signature:

```
X-Sublime-Signature: t=1626139320,v0=3d35d777510a7ccf73ded723f141b0ada2986aebe63c4e45a9c7e4bb1abaca06

```

The signature covers `<t>.<raw body>`, keyed with the webhook Action's signing secret. `v0` is the only scheme today, and Sublime says to ignore any other scheme to prevent downgrade attacks.

## Sublime Security webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Actions > New Action > Webhook**, then add the Action to rules or automations |
| Scope | Flagged messages in a group, the first message in a group, or all messages in a group |
| Custom headers | Any number, sent with every notification |
| Payload customisation | Attack Score, MDM fields such as headers or attachments, and `actor` |
| Method | `POST`, JSON body |
| Signature header | `X-Sublime-Signature: t=<unix seconds>,v0=<hex>` |
| Scheme | HMAC-SHA256, hex digest as in Sublime's example |
| Signed content | `<t>.<raw body>` |
| Secret | Per webhook Action: select it on the **Actions** page and click **Click to reveal** or **Copy** |
| Replay protection | Signed timestamp. Tolerance is up to you |
| Response window | A `2xx` within 10 seconds |
| Retries | Sublime retries failed notifications, with a new timestamp and signature each time. The schedule is not documented |
| Source IPs | Two stable IPv4 addresses per managed instance. IPv6 addresses change frequently |

## The webhook payload

Here is Sublime's example, trimmed:

```json
{
  "id": "5e02026c-55c1-4cbb-8a18-76eb2f3e06d3",
  "api_version": "0",
  "created_at": "2021-06-30T16:17:22.937642Z",
  "type": "message.flagged",
  "data": {
    "message": {
      "id": "dff3be1d-b348-4dab-bb60-582842909a88",
      "canonical_id": "767f6519458092af9994a79ae73ca055e1365618",
      "external_id": "17a5db5ea7fa760d",
      "message_source_id": "a5843e7d-4ba5-40df-954e-73cb2f1e3e7e",
      "mailbox": {
        "id": "f0654b68-2686-491e-8fcb-e4ad61c61ad8",
        "external_id": "109412126579921477721"
      }
    },
    "classification": "malicious",
    "flagged_rules": [
      {
        "id": "756b841c-4560-49b3-a8c0-dbacb2900aa5",
        "name": "Brand impersonation: Internal Revenue Service",
        "severity": "high",
        "tags": ["brand-impersonation", "suspicious-sender"]
      }
    ],
    "triggered_actions": [
      { "id": "6f45c807-a9c7-47a8-8c3e-5391e4f477ca", "name": "Auto-trash", "type": "trash_message" },
      { "id": "8edd8b97-7b85-40d2-9045-a619ae9b4e52", "name": "Notify SIEM", "type": "webhook" }
    ]
  }
}

```

The top-level `type` says why the webhook was sent:

| `type` | Meaning |
| --- | --- |
| `message.flagged` | A rule that triggers the webhook flagged this message |
| `group` | The webhook fired on another message in the same group, and its scope covers all messages in the group |
| `manual` | A Sublime user triggered the action on this message by hand. The payload adds `user_id`, and `actor` if enabled |

`type_extended` lists every reason that applies, and can add `group_late_arriving` for a message that joined its group after the webhook had already fired for the group. Combinations such as `["group", "message.flagged", "group_late_arriving"]` are possible, so check `type_extended` when the reason matters.

Don't confuse the top-level `type` with `type` inside `triggered_actions`, which names the action: `trash_message`, `quarantine_message`, `warning_banner`, `webhook` and so on.

`data.message.id` lets you fetch more about the message through Sublime's API, and `canonical_id` identifies its message group for group-level actions such as trashing the whole group. `data.classification` is the group's current classification, such as `malicious`, `benign` or `spam`, or `null` if none has been assigned. `data.user_reports` lists user reports for the group.

## Setting up Sublime Security webhooks

1. Create the receiving endpoint first, so you have its URL.
2. In the Sublime Dashboard, click **Actions**, then **New Action**, and select **Webhook**.
3. Give it a name, enter the endpoint URL, and optionally add custom headers.
4. Customise the payload if you want the Attack Score or MDM fields, then click **Save**.
5. Select the new Action on the **Actions** page and click **Click to reveal** to read its signing secret. Store it server-side, for example as `SUBLIME_SECURITY_WEBHOOK_SECRET`.
6. Open each Detection Rule or Automation that should fire it, click **Edit**, choose the Action in the **Actions** dropdown, and click **Save Rule**.

To receive Sublime webhooks through Hookdeck, create a source with the Sublime Security type, paste the signing secret into **Webhook Signing Secret**, and use the Hookdeck source URL as the Action's endpoint. Hookdeck verifies `X-Sublime-Signature` on every request. The source accepts `POST` only, which is the method Sublime uses.

## Securing Sublime Security webhooks

Split the header on commas, take `t` and every `v0` value, compute an HMAC-SHA256 of `<t>.<raw body>` with the signing secret, and compare against each `v0` in constant time. Accept the request if any one matches.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifySublimeSignature(rawBody, header, secret) {
  if (!header || !secret) return false;
  let t;
  const candidates = [];
  for (const part of header.split(',')) {
    const index = part.indexOf('=');
    if (index <= 0) continue;
    const key = part.slice(0, index).trim();
    const value = part.slice(index + 1).trim();
    if (key === 't') t = value;
    else if (key === 'v0') candidates.push(value); // ignore any other scheme
  }
  if (!/^\d+$/.test(t || '') || candidates.length === 0) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-f]{64}$/i.test(candidate)) return false; // before Buffer.from(x, 'hex')
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/sublime-security', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.SUBLIME_SECURITY_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifySublimeSignature(req.body, req.get('x-sublime-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, route on event.type / event.type_extended, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re

HEX_64 = re.compile(r"[0-9a-fA-F]{64}")

def verify_sublime_signature(raw_body: bytes, header, secret: str) -> bool:
    if not header or not secret:
        return False
    t, candidates = None, []
    for part in header.split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t":
            t = value
        elif key == "v0":
            candidates.append(value)
    if not t or not (t.isascii() and t.isdigit()) or not candidates:
        return False
    expected = hmac.new(
        secret.encode("utf-8"), t.encode("utf-8") + b"." + raw_body, hashlib.sha256
    ).hexdigest().encode("utf-8")
    return any(
        HEX_64.fullmatch(c) and hmac.compare_digest(c.lower().encode("utf-8"), expected)
        for c in candidates
    )

```

To check your verifier, use the secret `sublime-test-signing-secret`, the timestamp `1626139320` and this exact body:

```
{"id":"5e02026c-55c1-4cbb-8a18-76eb2f3e06d3","api_version":"0","type":"message.flagged"}

```

The header should be:

```
t=1626139320,v0=a413ceddb63fc0fd383751857bb0d711f9638ec052e8ddfe8b21db609dfff554

```

Sublime says that if the timestamp is too old, "you can have your application reject the payload", and leaves the tolerance to you. The code above doesn't check it. If you add a window, remember that Sublime's own retries carry a fresh timestamp, but a gateway's retries and replays resend the original request with the original `t`, so a strict window rejects them. Keep any window generous and deduplicate on `id`.

> **Make Sublime Security webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `X-Sublime-Signature` on every notification, answers within Sublime's 10-second window, and durably queues each flagged message for your SIEM or SOAR.

## Sublime Security webhook limitations and pain points

### Verifying against parsed JSON

**The Problem:** Signatures never match, even with the right secret.

**Why It Happens:** The HMAC covers the exact request bytes after `<t>.`. Middleware that parses and re-serialises the JSON changes whitespace and key order, and the signature no longer matches.

**Workarounds:**

* Read the raw body (`express.raw()` in Express, `await request.body()` in FastAPI) and parse only after verifying.
* Test against the known-good header above.

**How Hookdeck Can Help:** Hookdeck's Sublime Security source verifies the signature against the bytes Sublime sent. Requests that fail are marked as failed verification and are not delivered to your destinations.

### An undocumented retry schedule

**The Problem:** You can't tell how long Sublime keeps trying after your endpoint fails.

**Why It Happens:** Sublime documents that it retries notifications, for example after a non-`2xx` response, and that each retry is re-signed, but not how many times or over what period.

**Workarounds:**

* Answer with a `2xx` within 10 seconds and do slow work, such as enrichment or ticket creation, afterwards.
* After an outage, use Sublime's message API to review what was flagged in the gap.

**How Hookdeck Can Help:** Hookdeck accepts and stores each notification as it arrives, then delivers it to your service with [automatic retries](/docs/retries) on a schedule you control. [Issues](/docs/issues) alert you when deliveries fail, and you can replay any stored event.

### Group scopes multiply notifications

**The Problem:** One phishing campaign produces a burst of webhooks, some for messages that arrived after the first alert.

**Why It Happens:** With a scope of all messages in a group, Sublime sends a notification for each message in the group, including late arrivals marked `group_late_arriving`.

**Workarounds:**

* Choose the first-message scope when one alert per campaign is enough.
* Group notifications by `canonical_id` in your SIEM or SOAR.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route `group_late_arriving` notifications separately, and a destination [rate limit](/docs/destinations#set-a-max-delivery-rate) smooths bursts so a large campaign doesn't overwhelm your SOAR.

## Best practices

### Deduplicate on `id`

Each notification has a top-level `id`. Retries are re-signed but describe the same notification, so store `id` before processing and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Route on `type_extended`

`type` gives one reason, while `type_extended` lists them all. Route manual triage events, rule hits and late group arrivals to the workflows that suit them.

### Allowlist IPs as a second layer

Sublime publishes two stable IPv4 addresses per managed instance, but recommends checking the signature instead. If you allowlist, do it in addition to verification, and remember that IPv6 senders change frequently.

### Acknowledge fast and process asynchronously

Verify, persist the notification, return `200`, then do the work. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Sublime Security signs every webhook Action notification with `X-Sublime-Signature`: a timestamp and a hex HMAC-SHA256 of `<t>.<raw body>`, keyed with the Action's signing secret. Verify against the raw body, accept any matching `v0` and ignore other schemes, keep any timestamp check optional and generous, and answer within 10 seconds.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Sublime signature, and holds each flagged message until your SIEM or SOAR has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Sublime Security webhooks reliably in minutes.