How to Send HMAC-Signed Outbound Webhooks to Slack, Teams, PagerDuty, and Jira

Intermediate 25 minutes Security Engineers / SRE Monitoring & Compliance

Configure outbound webhook destinations, verify the HMAC signatures on receivers, and route events with per-team destination maps — including SSRF protection, per-org rate limits, retry/backoff, and DLQ handling.

Two Notification Channels

Chainsaw has two outbound-notification mechanisms:

MechanismUse for
SIEM exportersSplunk / Sentinel / QRadar — see tutorial 58
HMAC outbound webhooksChat platforms, ticketing, custom integrations — Slack, Teams, PagerDuty, Jira, your own receiver

This tutorial covers the second. The same primitive powers the per-team destination map for ActionNotifyOwner (see tutorial 61), so getting it right pays off twice.

Prerequisites

  • Admin role.
  • An incoming-webhook URL from the destination platform (Slack incoming-webhook, Teams connector, PagerDuty Events API, Jira Automation webhook, or your own HTTPS endpoint).

Step 1: Add a Webhook Destination

Open Settings → Webhooks → New:

FieldValue
Nameslack-secops
URLThe incoming-webhook URL
MethodPOST
Content typeapplication/json
HMAC secretAuto-generated 32-byte key — used to sign every request. Save this for the receiver.
Event filterThe events to send. Defaults to violation.blocked, violation.quarantined, bypass.attempted. Add or remove as needed.
Severity floormedium, high, critical — only fire for events at or above this severity
Per-org rate limitThe maximum events per minute Chainsaw will send to this destination. Defaults to 30.

Click Test — Chainsaw fires a synthetic event. The receiver should accept it; the dashboard reports success or the HTTP error received.

Step 2: Verify the HMAC Signature

The webhook payload arrives with two headers:

X-Chainsaw-Signature: sha256=abc123...
X-Chainsaw-Timestamp: 1761969600

The signature is HMAC-SHA256(secret, "<timestamp>.<body>"). The timestamp is signed alongside the body to prevent replay attacks (you should reject any request whose timestamp is more than a few minutes in the past).

A reference verifier in Python:

import hmac
import hashlib
import time

def verify(headers, body, secret):
    sig = headers["X-Chainsaw-Signature"].removeprefix("sha256=")
    ts = headers["X-Chainsaw-Timestamp"]
    
    # Reject stale requests
    if abs(time.time() - int(ts)) > 300:
        raise ValueError("timestamp too old")
    
    expected = hmac.new(
        secret.encode(),
        f"{ts}.{body}".encode(),
        hashlib.sha256
    ).hexdigest()
    
    if not hmac.compare_digest(expected, sig):
        raise ValueError("invalid signature")

For the canonical Slack / Teams / PagerDuty receivers, the platforms verify the signature on their side automatically when configured — you only write a verifier for your own receivers.

Step 3: Per-Team Destination Map

For routing violations to the team that owns the affected manifest path, configure the destination map at Admin → Org settings → Ownership:

OwnerSlack channelPagerDutyEmail
@frontend-team#frontend-secops—frontend-leads@example.com
@platform-team#platform-secopsPD-Schedule-Platform—
@security-team#securityPD-Schedule-Securitysecops@example.com

When ActionNotifyOwner fires (see tutorial 61), Chainsaw resolves the manifest path’s CODEOWNERS team and dispatches to the destinations on the team’s row.

The map is also queryable via API for use by your own automation:

curl -H "Authorization: Bearer $CHAINSAW_TOKEN" \
     https://chain305.com/chainproxy/api/admin/destination-map \
     | jq '.owners["@frontend-team"]'

Step 4: SSRF Protection

Webhooks can be a vector for server-side request forgery — a malicious tenant configures a destination that points at an internal endpoint, and the control plane “helpfully” issues a request that exposes internal data.

Chainsaw’s webhook dispatcher refuses to issue requests to:

  • RFC 1918 private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
  • Loopback addresses (127.0.0.0/8, ::1)
  • Link-local addresses (169.254.0.0/16)
  • IPv4 mapped to IPv6 in any of the above ranges
  • Cloud metadata endpoints (169.254.169.254)

If you need a webhook to land on an internal receiver, the receiver should be exposed via your reverse proxy with a public hostname — that route runs through your normal ACLs.

To override (e.g., for a testing setup where the receiver is on the same private network):

CHAINSAW_WEBHOOK_ALLOW_PRIVATE_NETS=1

Don’t use this in production.

Step 5: Per-Org Rate Limit

The rate limit defaults to 30 events/minute per destination. The limiter is sliding-window — once you hit the limit, new events queue, and at the next window boundary the oldest events drain first.

Set the limit higher for high-volume destinations or lower for chat platforms that throttle aggressively:

PlatformRecommended limit
Slack incoming-webhook30 / min (Slack’s documented limit)
Teams connector60 / min
PagerDuty120 / min
Custom HTTPS receiveras high as your receiver can handle

Step 6: Retry & DLQ

A failed webhook (any 4xx other than 429, any 5xx, network timeout) retries with exponential backoff:

AttemptDelay
1immediate
230s
32m
48m
532m

After 5 failed attempts, the event lands in the dead-letter queue at Settings → Webhooks → Dead-letter queue. From there you can:

  • Inspect the failure reason (HTTP code, body, network error).
  • Edit the destination and replay.
  • Discard if the event is no longer relevant.

The DLQ has a default retention of 30 days — events older than that are automatically discarded.

Step 7: Receiver Patterns

Slack incoming-webhook

The simplest setup. Slack’s incoming-webhook URL accepts a JSON payload directly. Chainsaw maps event fields to Slack blocks automatically — your channel sees a card with the package, version, policy, and action.

Microsoft Teams connector

Teams expects MessageCard or AdaptiveCard JSON. The same automatic mapping applies; the resulting card includes “Investigate” and “Create exception” deep links to the Chainsaw dashboard.

PagerDuty Events API

Use the Events V2 endpoint (https://events.pagerduty.com/v2/enqueue). Chainsaw maps severity → PagerDuty severity, event_id → dedup key, and policy + package → summary.

Jira Automation webhook

The webhook URL goes into a Jira automation rule. Chainsaw posts the event payload; the rule converts it into a ticket. Jira’s automation language reads event.package, event.policy, etc. directly from the JSON.

Custom HTTPS receiver

The most flexible. You receive the JSON envelope, verify the HMAC, do whatever you want. The reference verifier in Step 2 is the starting point.

Step 8: Common Operational Issues

SymptomLikely causeFix
All events landing in DLQBad HMAC secret on receiver, or signature verification rejecting based on stale timestampRe-paste the secret. Confirm the receiver compares timestamps with a reasonable skew.
Some events missingSeverity floor filtering them outLower the severity floor.
Webhook fires but Slack channel sees nothingSlack channel was renamed; webhook URL is staleMint a new incoming-webhook URL in Slack and update the destination.
Rate limit alarmsGenuine event spike, or noisy policyInvestigate the spike via the dashboard; tune the policy that’s flapping.
Internal receiver not reachableSSRF protection refusing the destinationExpose the receiver via a public hostname (right answer) or set CHAINSAW_WEBHOOK_ALLOW_PRIVATE_NETS=1 (lab only).

Verification

  1. Test button on the destination fires a synthetic event that the receiver accepts.
  2. A real violation produces a notification in the destination within seconds.
  3. The HMAC verifier on a custom receiver accepts genuine requests and rejects requests with tampered bodies.
  4. After temporarily breaking the receiver’s network, events queue and then drain when restored.
  5. Five consecutive failures land an event in the DLQ.