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
- 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.
- Vendor signs each delivery's raw body with HMAC-SHA256 using that secret.
- CMDash's receiver (it takes no login token; the HMAC signature IS the authentication):
- Checks the
webhook-receiversfeature 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 Conflictwith no side effect. - Returns
202 Acceptedon success.
- Checks the
- 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):
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):
- Org Settings → Webhooks → Add webhook
- Payload URL:
https://app.cmdash.net/api/webhooks/github/<tenantId> - Content type:
application/json - Secret: paste the same string you stored in CMDash
- Events: select at minimum Secret scanning alerts, Dependabot alerts, Code scanning alerts
- Save. GitHub sends a
pingevent immediately — checkintegration_webhook_eventsfor a row withevent_type = 'ping'.
Jira Cloud (global admin — added S40-M4):
- Settings → System → Webhooks → Create
- URL:
https://app.cmdash.net/api/webhooks/jira/<tenantId> - Secret: paste the same string you stored in CMDash
- Events: Issue created / updated / deleted (+ Issue commented if you track remediation SLA in Jira)
- Jira's "Secret Token" option enables
X-Hub-Signature: sha256=<base64>. Note the base64 format — differs from GitHub which uses hex. OurverifyJirahandles 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:
// 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.
-
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):
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>" } -
In your relay (Foundry function / SIEM Connector forwarder / Lambda), POST each Falcon event to:
https://app.cmdash.net/api/webhooks/crowdstrike/<tenantId>with these headers:
Content-Type: application/jsonX-Cs-Signature: <hex>— HMAC-SHA256 of the raw request body using the shared secretX-Cs-Eventtype: <event-type>— e.g.DetectionSummaryEvent,IncidentSummaryEvent(optional; falls back topayload.metadata.eventType)X-Cs-Eventid: <unique-id>— your relay's per-event UUID (optional; the receiver derives one frommetadata.eventCreationTime + metadata.eventType, or hashes the body, when omitted)
-
Sample Falcon detection payload (typical CrowdStrike event envelope):
{ "metadata": { "customerIDString": "<falcon-cid>", "offset": 17048, "eventType": "DetectionSummaryEvent", "eventCreationTime": 1713520000000, "version": "1.0" }, "event": { "DetectName": "NGAV - Suspicious Process", "Severity": 70, "DeviceId": "abc123", "DetectDescription": "..." } } -
Sample relay snippet (Node):
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):
- Workflow → Event Hooks → Create Event Hook
- Endpoint URL:
https://app.cmdash.net/api/webhooks/okta/<tenantId> - Authentication:
- Field:
Authorization - Secret: the value CMDash generated for this tenant, pasted verbatim
- Field:
- Subscribe to:
user.lifecycle.*,user.mfa.factor.*,group.user_membership.* - Preview + Verify to complete the one-time challenge handshake. Okta issues a
GETto the same URL carryingx-okta-verification-challenge; CMDash answers{"verification": "<value>"}fromGET /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
| Provider | Header | Value format |
|---|---|---|
| GitHub | X-Hub-Signature-256 | sha256=<hex-digest> |
| Okta | Authorization | the configured secret, verbatim (not a body signature — see #498) |
| Jira | X-Hub-Signature | sha256=<hex-digest> (same convention as GitHub; the base64 form is accepted for one deprecation release — #510) |
| ServiceNow | X-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-Deliveryheader — a UUID per delivery. - Okta:
data.events[0].uuidin the payload. - Jira:
X-Atlassian-Webhook-Identifierheader. - ServiceNow: the changed record's
sys_id(orpayload.record.sys_id). - CrowdStrike (relay):
X-Cs-Eventidheader from the relay, with a SHA256 ofmetadata.eventCreationTime + metadata.eventTypeas 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_secretis never readable plaintext except on decrypt inside the service. - RLS on both tables — even though the write path runs pre-auth, the
tenant_idon 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_eventshas no TTL today; rows stick around indefinitely. TheDataRetentionPolicysystem (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
Hostheader 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:
# 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:
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"