Gareth Wilson Gareth Wilson

Guide to Sublime Security Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationActions > New Action > Webhook, then add the Action to rules or automations
ScopeFlagged messages in a group, the first message in a group, or all messages in a group
Custom headersAny number, sent with every notification
Payload customisationAttack Score, MDM fields such as headers or attachments, and actor
MethodPOST, JSON body
Signature headerX-Sublime-Signature: t=<unix seconds>,v0=<hex>
SchemeHMAC-SHA256, hex digest as in Sublime's example
Signed content<t>.<raw body>
SecretPer webhook Action: select it on the Actions page and click Click to reveal or Copy
Replay protectionSigned timestamp. Tolerance is up to you
Response windowA 2xx within 10 seconds
RetriesSublime retries failed notifications, with a new timestamp and signature each time. The schedule is not documented
Source IPsTwo stable IPv4 addresses per managed instance. IPv6 addresses change frequently

The webhook payload

Here is Sublime's example, trimmed:

{
  "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:

typeMeaning
message.flaggedA rule that triggers the webhook flagged this message
groupThe webhook fired on another message in the same group, and its scope covers all messages in the group
manualA 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.

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:

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 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 on a schedule you control. 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 can route group_late_arriving notifications separately, and a destination rate limit 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.

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.

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 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 for free and handle Sublime Security webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.