Skip to main content
Version: Local Β· In Progress

Webhook & Event Reference

ThreatWeaver has two distinct event surfaces:

  1. Inbound webhooks β€” GitHub and GitLab push events that trigger automated AppSec scans
  2. Outbound webhooks / events β€” notifications ThreatWeaver sends to external systems when scans complete, findings are created, or risk scores change
  3. SSE streams β€” Server-Sent Events for real-time progress updates in the browser
  4. 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:

  1. In ThreatWeaver Admin β†’ Integrations, generate a webhook secret for your target
  2. In GitHub repository Settings β†’ Webhooks, set the secret to the same value
  3. Set Content-Type to application/json
  4. 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​

  1. Validates X-Hub-Signature-256
  2. Extracts repository.html_url and looks up a matching PentestTarget
  3. If a match is found: creates a new PentestAssessment, starts the scan asynchronously
  4. Returns 200 OK immediately (scan runs in background)
  5. If no matching target: returns 200 OK silently (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:

  1. ThreatWeaver Admin β†’ Integrations β†’ GitLab targets
  2. 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​

FeatureGitHubGitLab
Auth methodHMAC-SHA256 (X-Hub-Signature-256)Plain token (X-Gitlab-Token)
Event headerX-GitHub-Event: pushX-Gitlab-Event: Push Hook
Repo URL fieldrepository.html_urlproject.web_url
Pull request eventpull_requestmerge_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​

AttemptDelay Before Retry
1 (initial)Immediate
230 seconds
32 minutes
410 minutes
51 hour
After attempt 5Mark 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:

  1. Marks the webhook delivery as failed in the audit log
  2. Sends an in-app notification to tenant admins
  3. 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 TypeWhen EmittedPayload Example
phase_changePhase transitions{ "phase": "attacking", "message": "Starting 56 attack agents" }
progressEvery 5–10 seconds during scan{ "percent": 42, "phase": "attacking", "eta": 90 }
agent_startWhen an agent begins{ "agentName": "sqlInjection", "endpointCount": 14 }
agent_completeWhen an agent finishes{ "agentName": "sqlInjection", "findingsCreated": 2, "durationMs": 4200 }
agent_errorIf an agent crashes{ "agentName": "graphql", "error": "timeout" }
finding_newWhen a validated finding is saved{ "findingId": "abc...", "type": "SQL_INJECTION", "severity": "critical" }
chain_discoveredWhen an exploit chain is found{ "chainId": "xyz...", "severity": "critical", "findingCount": 3 }
assessment_completeScan fully finished{ "status": "completed", "findingCount": 21, "durationSeconds": 2280 }
assessment_errorScan failed{ "status": "failed", "error": "Target unreachable" }
warningNon-fatal warning{ "message": "WAF detected β€” probe rate reduced" }
auth_test_userTest user authentication{ "userId": "...", "status": "authenticated" }
resource_bootstrapPhase 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

EventPayloadDescription
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

EventPayloadDescription
progressstatusUpdateSync 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​

  1. Use HTTPS only β€” ThreatWeaver refuses to send webhooks to plain HTTP endpoints.
  2. Rotate webhook secrets β€” rotate at least every 90 days via Admin β†’ Integrations.
  3. Validate signatures β€” always verify X-ThreatWeaver-Signature before processing events.
  4. Respond quickly β€” return 200 within 30 seconds. Move heavy processing to a background queue.
  5. Log delivery IDs β€” log deliveryId with each processed event for traceability.
  6. Do not expose secrets in logs β€” the X-ThreatWeaver-Signature header 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' });
}