Webhook & Event Reference
ThreatWeaver has two distinct event surfaces:
- Inbound webhooks β GitHub and GitLab push events that trigger automated AppSec scans
- Outbound webhooks / events β notifications ThreatWeaver sends to external systems when scans complete, findings are created, or risk scores change
- SSE streams β Server-Sent Events for real-time progress updates in the browser
- Internal EventEmitter β decoupled service communication within the backend process
Architecture Overviewβ
Part 1: Inbound Webhooksβ
1.1 GitHub Push Webhookβ
Endpoint: POST /api/webhooks/github
Authentication: HMAC-SHA256 signature in X-Hub-Signature-256 header
Purpose: Triggers an AppSec scan when code is pushed to a monitored repository branch.
Authenticationβ
GitHub signs every webhook payload with HMAC-SHA256 using a shared secret you configure in the GitHub webhook settings. ThreatWeaver validates this signature on every request:
X-Hub-Signature-256: sha256=<hex_digest>
The digest is computed as HMAC-SHA256(webhookSecret, rawRequestBody). ThreatWeaver uses crypto.timingSafeEqual() for comparison to prevent timing attacks.
To configure:
- In ThreatWeaver Admin β Integrations, generate a webhook secret for your target
- In GitHub repository Settings β Webhooks, set the secret to the same value
- Set Content-Type to
application/json - Select events: Push, optionally Pull request
Payload Schemaβ
{
"ref": "refs/heads/main",
"before": "abc123...",
"after": "def456...",
"repository": {
"id": 123456789,
"name": "my-api",
"full_name": "acme-corp/my-api",
"html_url": "https://github.com/acme-corp/my-api",
"clone_url": "https://github.com/acme-corp/my-api.git",
"default_branch": "main"
},
"pusher": {
"name": "dev@acme.com",
"email": "dev@acme.com"
},
"commits": [
{
"id": "def456...",
"message": "fix: patch SQL injection in orders endpoint",
"author": {
"name": "Jane Dev",
"email": "jane@acme.com"
},
"timestamp": "2026-04-05T14:30:00Z",
"added": ["src/routes/orders.ts"],
"modified": ["src/services/db.ts"],
"removed": []
}
],
"head_commit": {
"id": "def456...",
"message": "fix: patch SQL injection in orders endpoint"
}
}
What ThreatWeaver Does With Itβ
- Validates
X-Hub-Signature-256 - Extracts
repository.html_urland looks up a matchingPentestTarget - If a match is found: creates a new
PentestAssessment, starts the scan asynchronously - Returns
200 OKimmediately (scan runs in background) - If no matching target: returns
200 OKsilently (prevents enumeration of registered repos)
Branch Filteringβ
By default, scans are triggered on pushes to any branch. You can restrict to specific branches in the PentestTarget.scanTriggerConfig:
{
"triggerBranches": ["main", "release/*"],
"skipBranches": ["dependabot/*", "renovate/*"]
}
1.2 GitLab Push Webhookβ
Endpoint: POST /api/webhooks/gitlab
Authentication: Plain token in X-Gitlab-Token header, compared with crypto.timingSafeEqual()
Purpose: Same as GitHub β triggers an AppSec scan on push to a monitored repository.
Authenticationβ
GitLab uses a plain secret token (not HMAC). Configure it in:
- ThreatWeaver Admin β Integrations β GitLab targets
- GitLab project Settings β Webhooks β Secret Token
X-Gitlab-Token: your-secret-token-here
Payload Schemaβ
{
"object_kind": "push",
"event_name": "push",
"before": "abc123...",
"after": "def456...",
"ref": "refs/heads/main",
"project": {
"id": 9876,
"name": "my-api",
"web_url": "https://gitlab.com/acme-corp/my-api",
"http_url": "https://gitlab.com/acme-corp/my-api.git",
"default_branch": "main"
},
"user_name": "Jane Dev",
"user_email": "jane@acme.com",
"commits": [
{
"id": "def456...",
"message": "fix: patch IDOR in profile endpoint",
"timestamp": "2026-04-05T14:35:00Z",
"author": {
"name": "Jane Dev",
"email": "jane@acme.com"
},
"added": [],
"modified": ["api/handlers/profile.go"],
"removed": []
}
],
"total_commits_count": 1
}
Differences from GitHubβ
| Feature | GitHub | GitLab |
|---|---|---|
| Auth method | HMAC-SHA256 (X-Hub-Signature-256) | Plain token (X-Gitlab-Token) |
| Event header | X-GitHub-Event: push | X-Gitlab-Event: Push Hook |
| Repo URL field | repository.html_url | project.web_url |
| Pull request event | pull_request | merge_request |
1.3 CI/CD Scan Trigger (API)β
Endpoint: POST /api/ci/scan
Authentication: X-API-Key header β timing-safe comparison
Purpose: Trigger a scan from a CI/CD pipeline (GitHub Actions, GitLab CI, Jenkins, etc.) without browser authentication.
Authenticationβ
Generate a CI API key in ThreatWeaver Admin β API Keys. Keys are scoped per-tenant and per-target.
X-API-Key: tw_ci_<base64url_random_64_bytes>
Request Bodyβ
{
"targetId": "550e8400-e29b-41d4-a716-446655440000",
"branch": "feature/new-auth",
"commitSha": "def456abc...",
"pullRequestUrl": "https://github.com/acme/api/pull/42",
"failOnSeverity": "high",
"timeoutMinutes": 60
}
Responseβ
{
"assessmentId": "cfefcdcb-5633-4dbf-bd47-8216aadae2ef",
"status": "queued",
"statusUrl": "https://api.threatweaver.ai/api/ci/scan/cfefcdcb.../status",
"estimatedDurationMinutes": 45
}
Polling for Completionβ
# Poll status until completed or failed
curl -H "X-API-Key: tw_ci_..." \
https://api.threatweaver.ai/api/ci/scan/cfefcdcb.../status
Status response:
{
"assessmentId": "cfefcdcb-5633-4dbf-bd47-8216aadae2ef",
"status": "completed",
"phase": "completed",
"findingCount": 4,
"criticalCount": 1,
"highCount": 2,
"mediumCount": 1,
"lowCount": 0,
"durationMinutes": 38,
"reportUrl": "https://app.threatweaver.ai/appsec/assessments/cfefcdcb..."
}
Fail-Gate Endpointβ
For CI pipelines that should fail the build on severity threshold:
curl -f -H "X-API-Key: tw_ci_..." \
"https://api.threatweaver.ai/api/ci/scan/cfefcdcb.../fail-gate?severity=high"
# Returns 0 exit code if no high/critical findings
# Returns non-zero if threshold breached (causes CI job to fail)
Fail-gate response:
{
"fail": true,
"reason": "2 high-severity findings found (threshold: high)",
"findingsAboveThreshold": [
{
"id": "abc...",
"type": "SQL_INJECTION",
"severity": "high",
"title": "SQL Injection in /api/orders",
"url": "https://app.threatweaver.ai/appsec/findings/abc..."
}
]
}
Part 2: Outbound Webhooksβ
ThreatWeaver can send outbound webhook notifications to any HTTPS endpoint when key events occur.
2.1 Configuring Outbound Webhooksβ
In ThreatWeaver Admin β Integrations β Outbound Webhooks:
{
"url": "https://your-service.com/threatweaver-events",
"secret": "your-signing-secret",
"events": [
"scan.completed",
"finding.created",
"finding.severity_changed",
"risk_score.changed",
"sync.completed"
],
"targetId": "optional-filter-by-target-uuid"
}
All outbound webhooks are signed with HMAC-SHA256 using the configured secret:
X-ThreatWeaver-Signature: sha256=<hex_digest>
X-ThreatWeaver-Event: scan.completed
X-ThreatWeaver-Delivery: <unique-delivery-uuid>
Content-Type: application/json
2.2 Event: scan.completedβ
Fired when a PentestAssessment reaches status: completed or status: failed.
{
"event": "scan.completed",
"deliveryId": "d7e3f1a2-...",
"timestamp": "2026-04-05T15:45:00Z",
"tenantId": "tenant-uuid-...",
"data": {
"assessmentId": "cfefcdcb-5633-4dbf-bd47-8216aadae2ef",
"targetId": "550e8400-e29b-41d4-a716-446655440000",
"targetName": "Production API",
"targetUrl": "https://api.acme.com",
"status": "completed",
"scanMode": "whitebox",
"durationSeconds": 2280,
"findings": {
"total": 21,
"critical": 2,
"high": 7,
"medium": 8,
"low": 4,
"falsePositives": 9,
"deduplicated": 8
},
"topFindings": [
{
"id": "abc123...",
"type": "SQL_INJECTION",
"severity": "critical",
"title": "SQL Injection in /api/users/search",
"cvssScore": 9.8
},
{
"id": "def456...",
"type": "BOLA",
"severity": "high",
"title": "BOLA on /api/orders/{orderId}",
"cvssScore": 8.1
}
],
"reportUrl": "https://app.threatweaver.ai/appsec/assessments/cfefcdcb...",
"triggeredBy": "webhook:github:push",
"commitSha": "def456abc..."
}
}
2.3 Event: finding.createdβ
Fired for each new validated, non-false-positive finding after a scan completes.
{
"event": "finding.created",
"deliveryId": "e8f4g2b3-...",
"timestamp": "2026-04-05T15:44:58Z",
"tenantId": "tenant-uuid-...",
"data": {
"findingId": "abc123-...",
"assessmentId": "cfefcdcb-...",
"targetId": "550e8400-...",
"targetName": "Production API",
"type": "SQL_INJECTION",
"severity": "critical",
"title": "SQL Injection in /api/users/search",
"description": "A SQL injection vulnerability was detected in the 'q' parameter of the user search endpoint. The input is interpolated directly into a SQL query without parameterization.",
"url": "https://api.acme.com/api/users/search",
"method": "GET",
"cvssScore": 9.8,
"cvssVector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
"cwe": "CWE-89",
"owasp": "A03:2021",
"confidence": 0.96,
"remediation": "Use parameterized queries or prepared statements. Replace: db.query(`SELECT * FROM users WHERE name LIKE '%${q}%'`) with: db.query('SELECT * FROM users WHERE name LIKE $1', [`%${q}%`])",
"findingUrl": "https://app.threatweaver.ai/appsec/findings/abc123..."
}
}
2.4 Event: finding.severity_changedβ
Fired when a finding's severity is manually updated by an analyst.
{
"event": "finding.severity_changed",
"deliveryId": "f9g5h3c4-...",
"timestamp": "2026-04-05T16:10:00Z",
"tenantId": "tenant-uuid-...",
"data": {
"findingId": "abc123-...",
"assessmentId": "cfefcdcb-...",
"targetName": "Production API",
"title": "SQL Injection in /api/users/search",
"previousSeverity": "critical",
"newSeverity": "high",
"changedBy": "analyst@acme.com",
"reason": "Mitigating control: WAF rule blocks this specific injection pattern in production",
"findingUrl": "https://app.threatweaver.ai/appsec/findings/abc123..."
}
}
2.5 Event: risk_score.changedβ
Fired when a tenant's WeaverScore changes by more than the configured threshold (default: 5 points) after a sync or scan.
{
"event": "risk_score.changed",
"deliveryId": "g0h6i4d5-...",
"timestamp": "2026-04-05T17:00:00Z",
"tenantId": "tenant-uuid-...",
"data": {
"previousScore": 72,
"newScore": 58,
"delta": -14,
"direction": "improved",
"trigger": "sync.completed",
"criticalVulnerabilities": 3,
"highVulnerabilities": 14,
"totalAssets": 847,
"dashboardUrl": "https://app.threatweaver.ai/dashboard"
}
}
2.6 Event: sync.completedβ
Fired when a Tenable sync job completes successfully.
{
"event": "sync.completed",
"deliveryId": "h1i7j5e6-...",
"timestamp": "2026-04-05T17:05:00Z",
"tenantId": "tenant-uuid-...",
"data": {
"syncJobId": "sync-job-uuid-...",
"status": "completed",
"durationSeconds": 312,
"newVulnerabilities": 47,
"updatedVulnerabilities": 1203,
"newAssets": 5,
"updatedAssets": 62,
"totalVulnerabilities": 8941,
"totalAssets": 847,
"triggeredBy": "admin@acme.com"
}
}
2.7 Event: scan.failedβ
Fired when a scan fails to complete due to an error (authentication failure, target unreachable, etc.).
{
"event": "scan.failed",
"deliveryId": "i2j8k6f7-...",
"timestamp": "2026-04-05T15:20:00Z",
"tenantId": "tenant-uuid-...",
"data": {
"assessmentId": "cfefcdcb-...",
"targetId": "550e8400-...",
"targetName": "Staging API",
"status": "failed",
"phase": "profiling",
"errorMessage": "Target unreachable: connection refused to https://staging-api.acme.com",
"durationSeconds": 30,
"triggeredBy": "webhook:github:push"
}
}
Part 3: Retry Logic and Failure Handlingβ
Delivery Guaranteesβ
Outbound webhooks use an at-least-once delivery model. ThreatWeaver will retry failed deliveries with exponential backoff.
Retry Scheduleβ
| Attempt | Delay Before Retry |
|---|---|
| 1 (initial) | Immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| After attempt 5 | Mark as failed, alert admin |
Success Criteriaβ
A delivery is considered successful if your endpoint returns any HTTP status in the 2xx range within 30 seconds. Any other response (non-2xx, timeout, connection refused) triggers a retry.
Idempotencyβ
Each delivery has a unique deliveryId (UUID). Use this to deduplicate retried deliveries in your receiver:
// Example: deduplicate using deliveryId
const processed = new Set(); // Use Redis/DB in production
app.post('/threatweaver-events', (req, res) => {
const { deliveryId } = req.body;
if (processed.has(deliveryId)) {
return res.status(200).json({ status: 'already_processed' });
}
processed.add(deliveryId);
// ... handle event
res.status(200).json({ status: 'ok' });
});
Failure Notificationsβ
After all 5 retry attempts are exhausted, ThreatWeaver:
- Marks the webhook delivery as
failedin the audit log - Sends an in-app notification to tenant admins
- Optionally emails the admin email address (if email is configured)
Failed deliveries can be manually replayed from Admin β Integrations β Webhook Delivery Log.
Part 4: Verifying Webhook Signaturesβ
HMAC-SHA256 Verification (Outbound Webhooks)β
All outbound webhooks from ThreatWeaver include:
X-ThreatWeaver-Signature: sha256=<hex_digest>
The digest is: HMAC-SHA256(webhookSecret, rawRequestBody)
Always verify against the raw request body β not a re-serialized JSON object, since JSON key ordering may differ.
Node.js Verification Exampleβ
const crypto = require('crypto');
function verifyThreatWeaverWebhook(rawBody, signatureHeader, secret) {
const expectedSignature = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody) // raw Buffer, not parsed JSON
.digest('hex');
const receivedSignature = signatureHeader || '';
// Timing-safe comparison prevents timing attacks
if (receivedSignature.length !== expectedSignature.length) {
return false;
}
return crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(expectedSignature)
);
}
// Express example
app.post('/threatweaver-events', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-threatweaver-signature'];
const isValid = verifyThreatWeaverWebhook(req.body, signature, process.env.WEBHOOK_SECRET);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
console.log(`Received event: ${event.event}, delivery: ${event.deliveryId}`);
switch (event.event) {
case 'scan.completed':
handleScanCompleted(event.data);
break;
case 'finding.created':
handleFindingCreated(event.data);
break;
default:
console.log(`Unknown event type: ${event.event}`);
}
res.status(200).json({ status: 'ok' });
});
Python Verification Exampleβ
import hashlib
import hmac
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['THREATWEAVER_WEBHOOK_SECRET']
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header or not signature_header.startswith('sha256='):
return False
expected = 'sha256=' + hmac.new(
secret.encode('utf-8'),
raw_body,
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, signature_header)
@app.route('/threatweaver-events', methods=['POST'])
def handle_event():
raw_body = request.get_data()
signature = request.headers.get('X-ThreatWeaver-Signature', '')
if not verify_signature(raw_body, signature, WEBHOOK_SECRET):
return jsonify({'error': 'Invalid signature'}), 401
event = json.loads(raw_body)
delivery_id = event.get('deliveryId')
event_type = event.get('event')
print(f'Received {event_type}, delivery: {delivery_id}')
if event_type == 'scan.completed':
findings = event['data']['findings']
print(f"Scan complete: {findings['critical']} critical, {findings['high']} high")
return jsonify({'status': 'ok'}), 200
GitHub Webhook Verification (for Inbound)β
If you are building a service that processes GitHub's webhook to ThreatWeaver (e.g., forwarding via a proxy), use the same pattern with the GitHub secret:
function verifyGitHubSignature(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signatureHeader || ''),
Buffer.from(expected)
);
}
Part 5: Server-Sent Events (SSE)β
SSE is used for real-time streaming from the backend to the browser during scan execution and Tenable sync.
5.1 Scan Progress SSEβ
Endpoint: GET /api/appsec/assessments/:id/events
Authentication: JWT cookie (same as all authenticated routes)
Content-Type: text/event-stream
The coordinator emits these event types (defined in AssessmentEvent):
| Event Type | When Emitted | Payload Example |
|---|---|---|
phase_change | Phase transitions | { "phase": "attacking", "message": "Starting 56 attack agents" } |
progress | Every 5β10 seconds during scan | { "percent": 42, "phase": "attacking", "eta": 90 } |
agent_start | When an agent begins | { "agentName": "sqlInjection", "endpointCount": 14 } |
agent_complete | When an agent finishes | { "agentName": "sqlInjection", "findingsCreated": 2, "durationMs": 4200 } |
agent_error | If an agent crashes | { "agentName": "graphql", "error": "timeout" } |
finding_new | When a validated finding is saved | { "findingId": "abc...", "type": "SQL_INJECTION", "severity": "critical" } |
chain_discovered | When an exploit chain is found | { "chainId": "xyz...", "severity": "critical", "findingCount": 3 } |
assessment_complete | Scan fully finished | { "status": "completed", "findingCount": 21, "durationSeconds": 2280 } |
assessment_error | Scan failed | { "status": "failed", "error": "Target unreachable" } |
warning | Non-fatal warning | { "message": "WAF detected β probe rate reduced" } |
auth_test_user | Test user authentication | { "userId": "...", "status": "authenticated" } |
resource_bootstrap | Phase 0 resource seeding | { "resourceType": "vehicle", "count": 3 } |
SSE Wire Formatβ
Each event is a standard SSE frame:
data: {"type":"phase_change","timestamp":"2026-04-05T14:31:00Z","data":{"phase":"attacking","message":"Starting 56 attack agents"}}
data: {"type":"finding_new","timestamp":"2026-04-05T14:35:12Z","data":{"findingId":"abc123","type":"SQL_INJECTION","severity":"critical","title":"SQL Injection in /api/users/search"}}
data: {"type":"assessment_complete","timestamp":"2026-04-05T15:09:22Z","data":{"status":"completed","findingCount":21,"durationSeconds":2282}}
JavaScript Consumer Exampleβ
const eventSource = new EventSource(
`/api/appsec/assessments/${assessmentId}/events`,
{ withCredentials: true } // Send JWT cookie
);
eventSource.addEventListener('message', (e) => {
const event = JSON.parse(e.data);
switch (event.type) {
case 'phase_change':
updatePhaseIndicator(event.data.phase);
break;
case 'progress':
updateProgressBar(event.data.percent);
break;
case 'finding_new':
addFindingToList(event.data);
break;
case 'assessment_complete':
eventSource.close();
loadFinalResults(assessmentId);
break;
case 'assessment_error':
eventSource.close();
showError(event.data.error);
break;
}
});
eventSource.addEventListener('error', () => {
// SSE auto-reconnects on error β implement max retry limit
console.warn('SSE connection lost, browser will attempt reconnect');
});
5.2 Sync Progress SSEβ
Endpoint: GET /api/progress/sync
Authentication: JWT cookie
Emits a single event type as sync progresses:
data: {"type":"progress","data":{"status":"running","progress":42,"etaSeconds":180,"processedRecords":21034,"totalRecords":50000}}
data: {"type":"progress","data":{"status":"completed","progress":100,"processedRecords":50000,"newVulnerabilities":47,"newAssets":5}}
setMaxListeners(50) is set on the SSE emitter to support up to 50 concurrent sync progress listeners without Node.js memory leak warnings.
5.3 Scan Management SSEβ
Endpoint: GET /api/scan/stream
Authentication: JWT cookie
Streams real-time updates for the distributed scan sensor management view:
data: {"type":"connected","timestamp":"2026-04-05T14:00:00Z"}
data: {"type":"sensor_online","sensorId":"sensor-abc","hostname":"scanner-01.internal"}
data: {"type":"task_progress","taskId":"task-xyz","percent":60,"agentName":"sqlInjection"}
data: {"type":"sensor_offline","sensorId":"sensor-abc","reason":"heartbeat_timeout"}
Part 6: Internal EventEmitter Patternsβ
Within the backend process, services communicate via Node.js EventEmitter. These are not exposed externally but are documented here for backend engineers.
6.1 Agent Gateway Eventsβ
Source: backend/src/services/scanner/agentGateway.service.ts
| Event | Payload | Description |
|---|---|---|
agent:connected | { agentId, hostname, version } | Sensor established WebSocket connection |
agent:disconnected | { agentId, code, reason } | Sensor disconnected (normal closure) |
agent:offline | { agentId, hostname } | Sensor missed heartbeat threshold |
agent:reconnected | { agentId } | Previously offline sensor reconnected |
agent:mtls:verified | { agentId, certSubject } | mTLS certificate verified successfully |
agent:log | { agentId, level, msg, context } | Log message forwarded from sensor |
finding:received | { agentId, taskId, finding } | Sensor reported a new vulnerability finding |
task:progress | { agentId, taskId, progress, phase, stats } | Task progress update from sensor |
task:completed | { agentId, taskId, summary, durationSeconds } | Sensor completed a task |
task:failed | { agentId, taskId, error, retryable } | Task failed (retryable or terminal) |
task:timeout | { agentId, taskId } | Task exceeded timeout |
6.2 Sync Service Eventsβ
Source: backend/src/services/sync.service.ts
| Event | Payload | Description |
|---|---|---|
progress | statusUpdate | Sync progress update (forwarded to SSE stream) |
6.3 Internal EventEmitter Usage Patternβ
// Emitting (in agentGateway.service.ts)
this.emit('finding:received', {
agentId: agent.agentId,
taskId: savedTask.id,
finding: validatedFinding
});
// Consuming (in pentestCoordinator.service.ts)
agentGateway.on('finding:received', ({ agentId, taskId, finding }) => {
this.handleIncomingFinding(finding);
this.emitToSseClients({ type: 'finding_new', data: finding });
});
Part 7: Security Recommendationsβ
IP Allowlisting for Inbound Webhooksβ
For production deployments, restrict the IP addresses that can send webhooks to ThreatWeaver:
GitHub's IP ranges: Retrieve dynamically from https://api.github.com/meta (the hooks array). As of 2026, GitHub uses the 192.30.252.0/22 and 185.199.108.0/22 ranges but these change β use the API.
GitLab.com's IP ranges: Documented at https://docs.gitlab.com/ee/user/gitlab_com/
Configure these in ThreatWeaver Admin β Security β IP Allowlist, or at your load balancer / WAF.
Outbound Webhook Securityβ
- Use HTTPS only β ThreatWeaver refuses to send webhooks to plain HTTP endpoints.
- Rotate webhook secrets β rotate at least every 90 days via Admin β Integrations.
- Validate signatures β always verify
X-ThreatWeaver-Signaturebefore processing events. - Respond quickly β return 200 within 30 seconds. Move heavy processing to a background queue.
- Log delivery IDs β log
deliveryIdwith each processed event for traceability. - Do not expose secrets in logs β the
X-ThreatWeaver-Signatureheader should never appear in application logs.
Avoiding Webhook Loopsβ
If your webhook receiver triggers a ThreatWeaver scan, and that scan sends a scan.completed webhook to the same receiver, ensure your receiver is idempotent and does not trigger further scans. Use the triggeredBy field in scan events to detect automated triggers:
// Avoid loops: don't re-trigger on webhook-triggered scans
if (event.data.triggeredBy.startsWith('webhook:')) {
return res.status(200).json({ status: 'skipped: already triggered by webhook' });
}