Skip to main content
CMDashCybersecurity Maturity Dashboard
DocsDevelopersWebhook Receivers — Developer & Integration Guide

Webhook Receivers — Developer & Integration Guide

Feature flag: webhook-receivers (must be on for your workspace)

Webhook receivers let vendors push events to CMDash in real time instead of waiting for the 15-minute poll cycle. Lower latency = fresher dashboards = better "real-time XDR" story.


How it works

  1. An Owner or Admin generates a signing secret in CMDash (self-service — see "Tenant onboarding flow" below) and pastes it into the vendor's webhook config. The secret is server-generated, not customer-chosen — CMDash mints 32 random bytes (64 hex chars) and reveals the plaintext exactly once, the same one-time-reveal contract an API key uses. There is deliberately no "set your own secret" form.
  2. Vendor signs each delivery's raw body with HMAC-SHA256 using that secret.
  3. CMDash's receiver (it takes no login token; the HMAC signature IS the authentication):
    • Checks the webhook-receivers feature flag for the tenant (kill-switch).
    • Looks up the encrypted secret for that tenant and provider.
    • Verifies the signature using a timing-safe compare.
    • Extracts the provider's event id and records the event, unique per tenant, provider and event id — retries / duplicate deliveries surface as 409 Conflict with no side effect.
    • Returns 202 Accepted on success.
  4. There is no separate background worker. Immediately after the event is persisted (still inside the same HTTP request, but best-effort — a failure here never fails the webhook response), CMDash resolves the tenant's matching active connection for that provider and enqueues a targeted sync job on the same queue the 15-minute poll already uses. A per-(tenant, connection) Redis debounce (60s window) collapses a burst of webhooks (e.g. GitHub Dependabot re-scanning a monorepo) into a single enqueued job; if Redis is down the debounce fails OPEN (every event enqueues its own job — never silently drops one). The 15-minute poll remains the backstop either way, so a failure to enqueue (logged, not surfaced) degrades to pre-real-time behavior, not to data loss.

Tenant onboarding flow

1. Enable the feature flag (CMDash platform staff, one-time per workspace)

Webhook receivers are switched on per workspace by CMDash platform staff; a workspace cannot self-serve this step. Ask your CMDash contact (or support) to enable webhook-receivers for your workspace.

2. Generate the signing secret — self-service, in-app (tenant OWNER/ADMIN)

This part of the flow is self-service. Once the flag is on, any Owner or Admin can go to Integrations → (the connected provider) → Real-time sync (webhook) and click Generate secret. The receiver URL and a plaintext secret (server- generated — there is no "paste your own secret" form) are shown; the secret is shown exactly once and must be copied into the vendor's webhook config immediately. The same card shows whether a secret is currently configured and, once at least one event has arrived, the most recent delivery's type and time, so you can confirm a vendor's config actually reached CMDash.

Equivalent API call, if you're scripting onboarding instead of clicking through the UI (authenticated as an Owner or Admin of your workspace):

bash
curl -X POST https://app.cmdash.net/api/webhook-receivers/github/rotate \
  -H "Authorization: Bearer <tenant-owner-or-admin-jwt>"
# => { "provider": "GITHUB_SECURITY", "url": "https://.../api/webhooks/github/<tenantId>", "secret": "<64-hex-chars, shown once>" }

Configure at the vendor

GitHub (org-level):

  1. Org Settings → Webhooks → Add webhook
  2. Payload URL: https://app.cmdash.net/api/webhooks/github/<tenantId>
  3. Content type: application/json
  4. Secret: paste the same string you stored in CMDash
  5. Events: select at minimum Secret scanning alerts, Dependabot alerts, Code scanning alerts
  6. Save. GitHub sends a ping event immediately — check integration_webhook_events for a row with event_type = 'ping'.

Jira Cloud (global admin — added S40-M4):

  1. Settings → System → Webhooks → Create
  2. URL: https://app.cmdash.net/api/webhooks/jira/<tenantId>
  3. Secret: paste the same string you stored in CMDash
  4. Events: Issue created / updated / deleted (+ Issue commented if you track remediation SLA in Jira)
  5. Jira's "Secret Token" option enables X-Hub-Signature: sha256=<base64>. Note the base64 format — differs from GitHub which uses hex. Our verifyJira handles this.

ServiceNow (admin — added S40-M4): ServiceNow doesn't sign webhooks natively. You write a Business Rule that computes the HMAC and sets a header:

typescript
// Business Rule: After Insert / Update on Incident table
// Condition: yourFilter
// Advanced script:
(function executeRule(current, previous /*null when async*/) {
  var gsa = new GlideSecureRandomUtilities();
  var payload = JSON.stringify({
    sys_id: current.sys_id.toString(),
    event_type: 'incident.' + (previous ? 'updated' : 'created'),
    record: { /* ...the fields you care about... */ }
  });
  var secret = gs.getProperty('cmdash.webhook.secret');  // configure via sys_properties
  var mac = new GlideSysCrypto().generateMacWithSHA256(secret, payload);
  var hexSig = GlideStringUtil.toHex(mac);  // matches our verifier

  var req = new sn_ws.RESTMessageV2();
  req.setEndpoint('https://app.cmdash.net/api/webhooks/servicenow/' + gs.getProperty('cmdash.tenant_id'));
  req.setHttpMethod('POST');
  req.setRequestHeader('Content-Type', 'application/json');
  req.setRequestHeader('X-CMDash-Signature', hexSig);
  req.setRequestHeader('X-ServiceNow-Event', 'incident.' + (previous ? 'updated' : 'created'));
  req.setRequestBody(payload);
  req.executeAsync();  // async so the Business Rule doesn't block
})(current, previous);

Two sys_properties the admin should create:

  • cmdash.webhook.secret — the shared secret (same value stored in CMDash)
  • cmdash.tenant_id — the CMDash tenant UUID

CrowdStrike Falcon (admin — added S45-R1):

CrowdStrike's native Event Streams API uses OAuth2 client-credentials with bearer-token auth in the streaming connection itself (not HMAC) and is out of scope for this receiver — CMDash's outbound CrowdStrike sync adapter already covers that path. This webhook receiver exists for the tenant-side relay pattern: the customer runs Foundry, the SIEM Connector, EventBridge, or a Lambda that signs Falcon events with a customer-controlled HMAC and forwards them to CMDash. Use this when you want sub-minute latency on detection events without polling.

  1. Generate the shared secret (self-service — see "Tenant onboarding flow" above; UI or API, same server-generated one-time-reveal secret, no "bring your own" option):

    bash
    curl -X POST https://app.cmdash.net/api/webhook-receivers/crowdstrike/rotate \
      -H "Authorization: Bearer <tenant-owner-or-admin-jwt>"
    # => { "provider": "CROWDSTRIKE", "url": "...", "secret": "<64-hex-chars, shown once>" }
    
  2. In your relay (Foundry function / SIEM Connector forwarder / Lambda), POST each Falcon event to:

    code
    https://app.cmdash.net/api/webhooks/crowdstrike/<tenantId>
    

    with these headers:

    • Content-Type: application/json
    • X-Cs-Signature: <hex> — HMAC-SHA256 of the raw request body using the shared secret
    • X-Cs-Eventtype: <event-type> — e.g. DetectionSummaryEvent, IncidentSummaryEvent (optional; falls back to payload.metadata.eventType)
    • X-Cs-Eventid: <unique-id> — your relay's per-event UUID (optional; the receiver derives one from metadata.eventCreationTime + metadata.eventType, or hashes the body, when omitted)
  3. Sample Falcon detection payload (typical CrowdStrike event envelope):

    json
    {
      "metadata": {
        "customerIDString": "<falcon-cid>",
        "offset": 17048,
        "eventType": "DetectionSummaryEvent",
        "eventCreationTime": 1713520000000,
        "version": "1.0"
      },
      "event": {
        "DetectName": "NGAV - Suspicious Process",
        "Severity": 70,
        "DeviceId": "abc123",
        "DetectDescription": "..."
      }
    }
    
  4. Sample relay snippet (Node):

    typescript
    const crypto = require('crypto');
    const SECRET = process.env.CMDASH_WEBHOOK_SECRET;
    const TENANT = process.env.CMDASH_TENANT_ID;
    
    async function forwardToCMDash(falconEvent) {
      const body = JSON.stringify(falconEvent);
      const sig  = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
      await fetch(`https://app.cmdash.net/api/webhooks/crowdstrike/${TENANT}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Cs-Signature': sig,
          'X-Cs-Eventtype': falconEvent.metadata.eventType,
          'X-Cs-Eventid':   `${falconEvent.metadata.customerIDString}:${falconEvent.metadata.offset}`,
        },
        body,
      });
    }
    

Okta (tenant-level Event Hook):

  1. Workflow → Event Hooks → Create Event Hook
  2. Endpoint URL: https://app.cmdash.net/api/webhooks/okta/<tenantId>
  3. Authentication:
    • Field: Authorization
    • Secret: the value CMDash generated for this tenant, pasted verbatim
  4. Subscribe to: user.lifecycle.*, user.mfa.factor.*, group.user_membership.*
  5. Preview + Verify to complete the one-time challenge handshake. Okta issues a GET to the same URL carrying x-okta-verification-challenge; CMDash answers {"verification": "<value>"} from GET /api/webhooks/okta/<tenantId>.

#498. Okta Event Hooks do not sign the request body. The authentication field/value pair configured above is a static shared credential that Okta sends verbatim on every delivery — there is no HMAC in the Event Hook product at all. CMDash used to expect Authorization: HMACSHA256 <base64-of-body-hmac>, which no Okta tenant can produce, so every delivery 401'd and the hook could not even be saved (there was no GET verification route). Both halves are fixed; if you configured an Okta hook against the old instructions, re-enter the CMDash secret as the plain authentication secret.


Signature verification schemes

ProviderHeaderValue format
GitHubX-Hub-Signature-256sha256=<hex-digest>
OktaAuthorizationthe configured secret, verbatim (not a body signature — see #498)
JiraX-Hub-Signaturesha256=<hex-digest> (same convention as GitHub; the base64 form is accepted for one deprecation release — #510)
ServiceNowX-CMDash-Signature<hex-digest> (our convention; see GlideScript snippet below)
CrowdStrike Falcon (relay)X-Cs-Signature<hex-digest> (HMAC-SHA256 over raw body; relay-side signing — native Event Streams uses OAuth and is handled by the sync adapter, not this receiver)

At-least-once delivery + dedup

Every production-grade webhook provider will occasionally retry deliveries — either because the first attempt timed out, or because their acknowledgement parsing was flaky, or because someone in their ops rolled back a bad deploy. We accept the reality and dedup on provider event-id.

  • GitHub: X-GitHub-Delivery header — a UUID per delivery.
  • Okta: data.events[0].uuid in the payload.
  • Jira: X-Atlassian-Webhook-Identifier header.
  • ServiceNow: the changed record's sys_id (or payload.record.sys_id).
  • CrowdStrike (relay): X-Cs-Eventid header from the relay, with a SHA256 of metadata.eventCreationTime + metadata.eventType as fallback.
  • Provider-specific but always globally unique per event.

Dedup is enforced by the (tenantId, provider, event_id) unique index on integration_webhook_events. A retry with the same event-id surfaces as P2002 at the Prisma layer, which we translate to 409 Conflict. This satisfies the vendor's "we got a non-5xx response, stop retrying" condition.


Security properties

  • HMAC-SHA256 with timing-safe compare — prevents timing-based signature forgery.
  • Feature flag gate — kill-switch without a code deploy.
  • Encrypted secret at rest — AES-256-GCM, same envelope as the LDAP bind credentials (S27 pattern). integration_webhook_secrets.encrypted_secret is never readable plaintext except on decrypt inside the service.
  • RLS on both tables — even though the write path runs pre-auth, the tenant_id on every row means queries from the authenticated admin surface respect tenant isolation.
  • No secrets in logs — the service logs tenant=X provider=Y event_type=Z event_id=W; never the payload contents, never the secret.
  • Raw-body preservation — CMDash keeps the exact raw request bytes before any middleware parses the body, and HMAC verification runs against those raw bytes.

Operational considerations

  • Rate limits: Okta limits event hooks to ~1000/min. GitHub has no hard limit but an unhealthy receiver gets disabled automatically after sustained failures. The AdapterRateLimiter (S31-B) is NOT in the inbound webhook path — this is receive-only; we don't originate traffic to the provider during webhook handling.
  • Backpressure: the receiver writes to Postgres synchronously, so sustained spike = database load. A future improvement (tracked in architecture/09-known-debt.md) is to move the receive into BullMQ so the HTTP transaction returns in < 10ms and processing happens asynchronously.
  • Retention: integration_webhook_events has no TTL today; rows stick around indefinitely. The DataRetentionPolicy system (S23) should be extended to this table in a future sprint.

Anti-patterns

  • ❌ Don't authenticate webhook routes with JWT. The provider doesn't have a JWT; the HMAC signature is the auth.
  • ❌ Don't do heavy processing in the HTTP transaction. The controller persists the event, best-effort enqueues a targeted sync job (cheap: a debounce check plus one enqueue call, not the sync itself), and returns 202. The actual sync work (calling out to the vendor, writing posture/risk/integration-data updates) runs in the existing async worker the 15-minute poll already uses — never inline in this request.
  • ❌ Don't log the raw payload. Payloads can contain PII / secrets from the vendor side. Persist to the DB, log a short summary.
  • ❌ Don't rely on Host header matching. The tenant identifier comes from the URL path, not from any vendor-supplied header.

Testing locally

Use a tunnel (ngrok, cloudflared) to expose a dev instance:

bash
# Terminal 1
npm run dev  # starts API on :3001
# Terminal 2
cloudflared tunnel --url http://localhost:3001
# paste the public URL into GitHub/Okta's webhook config

Or, replay a captured payload with the right signature:

bash
BODY='{"action":"opened","number":42}'
SECRET='your-webhook-secret-16chars-minimum'
SIG="sha256=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST http://localhost:3001/api/webhooks/github/<tenantId> \
  -H "X-Hub-Signature-256: $SIG" \
  -H "X-GitHub-Event: pull_request" \
  -H "X-GitHub-Delivery: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d "$BODY"

This page is the product manual, published from the repository at docs/developers/webhook-receivers.md. Customers read the same text inside CMDash under Help. Last updated October 9, 2026.

Webhook Receivers — Developer & Integration Guide — CMDash Docs