Agent skill

Sublime Security Webhooks Skill

Receive and verify Sublime Security webhooks. Use when setting up Sublime Security webhook Action handlers, debugging X-Sublime-Signature verification (t=...,v0=...), or handling message.flagged, group, and manual email security events.

Install this skill

npx skills add hookdeck/webhook-skills --skill sublime-security-webhooks


When to Use This Skill

  • How do I receive Sublime Security webhooks?
  • How do I verify the X-Sublime-Signature header (t=...,v0=...)?
  • How do I handle message.flagged, group, or manual events?
  • Why is my Sublime Security webhook signature verification failing?
  • How do I send flagged email alerts from Sublime to my own service?

Essential Code (USE THIS)

Sublime signs every webhook Action delivery with X-Sublime-Signature:

X-Sublime-Signature: t=1626139320,v0=3d35d777510a7ccf73ded723f141b0ada2986aebe63c4e45a9c7e4bb1abaca06
  • t is the Unix timestamp (seconds). Each signature is prefixed by a scheme (v plus an integer); v0 is the only scheme today. There is no space after the comma.
  • Each v0 value is the lowercase hex HMAC-SHA256 of "<t>.<raw body>", keyed with the webhook Action's signing secret (Actions → select the webhook Action → click to reveal), used as UTF-8 bytes.
  • The header can carry several v0 values. Accept the request if any one matches.
  • Check that each candidate is exactly 64 hex characters before decoding: Buffer.from(x, 'hex') silently truncates invalid hex. Hex is case-insensitive.
  • Missing or invalid signature: respond 401. Respond 2xx within 10 seconds on success.

Verification (Node.js)

const crypto = require('crypto');

// rawBody: Buffer of the exact request bytes (express.raw({ type: '*/*' }), not express.json)
function verifySublime(rawBody, signatureHeader, secret) {
  if (!secret || !signatureHeader) return false;
  let timestamp;
  const signatures = [];
  for (const item of signatureHeader.split(',')) {
    const i = item.indexOf('=');
    if (i <= 0) continue;
    const key = item.slice(0, i).trim(), value = item.slice(i + 1).trim();
    if (key === 't') timestamp = value;
    else if (key === 'v0') signatures.push(value);
  }
  if (!/^\d+$/.test(timestamp ?? '') || Number(timestamp) <= 0 || signatures.length === 0) return false;
  // Optional replay window, off by default: Hookdeck retries and replays keep the original timestamp
  const expected = crypto.createHmac('sha256', secret)
    .update(`${timestamp}.`).update(rawBody).digest();
  return signatures.some((sig) =>
    /^[0-9a-fA-F]{64}$/.test(sig) && crypto.timingSafeEqual(Buffer.from(sig, 'hex'), expected));
}

Verification (Python)

import hashlib, hmac, re

def verify_sublime(raw_body: bytes, signature_header, secret: str) -> bool:
    if not secret or not signature_header:
        return False
    timestamp, signatures = None, []
    for item in signature_header.split(","):
        key, sep, value = item.partition("=")
        key, value = key.strip(), value.strip()
        if not sep or not key:
            continue
        if key == "t":
            timestamp = value
        elif key == "v0":
            signatures.append(value)
    if not timestamp or not re.fullmatch(r"[0-9]+", timestamp) or int(timestamp) <= 0 or not signatures:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(re.fullmatch(r"[0-9a-fA-F]{64}", s) and hmac.compare_digest(s.lower().encode(), expected.encode())
               for s in signatures)

Replay window (opt-in)

The examples do not enforce a timestamp window by default. Sublime's own retries arrive with a new timestamp and signature, but Hookdeck retries and replays resend the original request with the original t, so a window would reject late Hookdeck redeliveries. To opt in, set SUBLIME_SECURITY_TIMESTAMP_TOLERANCE_SECONDS to a positive whole number; any other value leaves it off, with a warning at startup. Use the body's id for idempotency instead.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Common Event Types

The body is JSON: { id, api_version, created_at, type, data }. Dispatch on type, which says why the delivery was sent. data holds message, flagged_rules, and triggered_actions.

typeDescription
message.flaggedA message matched a detection rule and the webhook Action fired
groupThe Action fired for a message group
manualA user sent the message to the webhook Action by hand

type_extended may list several reasons at once and adds group_late_arriving. See references/overview.md.

For the full payload reference, see Sublime's webhook documentation.

Important Headers

HeaderDescription
X-Sublime-Signaturet=<unix seconds>,v0=<hex>[,v0=<hex>...]

Environment Variables

SUBLIME_SECURITY_WEBHOOK_SECRET=your_webhook_action_signing_secret   # Actions → webhook Action → click to reveal
SUBLIME_SECURITY_TIMESTAMP_TOLERANCE_SECONDS=0   # optional; a positive value opts in to the replay window

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 sublime-security --path /webhooks/sublime-security

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →