When an async analysis job reaches a terminal state, the server can POST an at-least-once webhook to a configured callback URL so consumers don't have to poll to the end. This is the canonical, versioned envelope every consumer conforms to. The feature is off by default (see Configuration).
- One shape for all consumers. There is no per-consumer transformation; consumer-specific
data rides in the
contextfield (a verbatim echo of what the client supplied at submit). - Delivered at terminal. There are no interim
submitted/runningevents — useGET /v1/workflow/async/job/{job_id}for in-progress polling. Delivery is idempotent: a stableevent_idperjob_idlets a consumer safely de-duplicate a redelivery. - Small payloads. Per-stage reports are referenced by
report_url, not inlined; pull the full Markdown viaGET /reports/....
| JobStore state | event_type |
status |
job.result |
job.error |
context |
|---|---|---|---|---|---|
SUCCESS |
job.completed |
completed |
PipelineResult | null |
present |
FAILURE |
job.failed |
failed |
PipelineResult or null |
message | present if a result exists |
INTERRUPTED |
job.failed |
failed |
null |
message | absent |
SUCCESSmeans the workflow returned aPipelineResultwhose outcome iscompleteorpartial. The analysis outcome lives injob.result.outcomeand per-resulterror; partial analysis is stilljob.completed.FAILUREmeans no vulnerability produced a usable result or execution raised before producing a result. A structuredoutcome: failedresult, summary, and context are retained when available. If execution raised first,job.resultisnulland the consumer correlates byjob_id(store it from the submit response).INTERRUPTEDmeans the analysis service restarted before the job completed. It follows the samejob.failedcontract asFAILURE, including correlation byjob_id.
outcome is the authoritative aggregate analysis state:
| value | meaning | legacy success |
|---|---|---|
complete |
Every requested vulnerability produced a usable result. | true |
partial |
At least one requested vulnerability succeeded and at least one did not. | false |
failed |
No requested vulnerability produced a usable result. | false |
Consumers should branch on outcome; success remains for compatibility with existing
clients that only distinguish complete from not-complete.
A flattened per-vuln view for upsert. One object per vulnerability:
| field | notes |
|---|---|
vuln_id |
the submitted identifier (CVE-… or GHSA-…) |
cve_id |
auto-classified from vuln_id |
package_name |
resolved package; null when unresolved |
package_version |
resolved version; null when unresolved |
vex_status |
e.g. not_affected, affected |
vex_justification |
VEX justification, when applicable |
success |
whether analysis for this vuln succeeded |
error |
per-vuln error string, with host paths redacted and length capped |
Consumers must tolerate null package_name/package_version.
Public error strings and result fields are limited to 2,000 characters and redact absolute host
paths. Path-like result fields expose only validated /reports/... routes; unresolved host paths
become null. The separate context object remains a verbatim, type-preserving client echo.
A verbatim, type-preserving echo of the client's callback_context submitted with the job
(POST /v1/workflow/async). Integers stay JSON numbers; the pipeline attaches no meaning to
its keys. Present whenever a structured result exists; absent when execution failed or was
interrupted before producing one.
-
Transport bearer. When an outbound OAuth2 identity is configured, requests carry
Authorization: Bearer <token>(client-credentials grant). The receiver's gateway validates it. Provider-neutral — no identity provider is named in this project's code or config keys. -
HMAC signature. When an HMAC secret is configured, each request carries
X-Webhook-TimestampandX-Webhook-Signature: sha256=<hex>(GitHub/Stripe convention). The signature isHMAC-SHA256(secret, "<timestamp>.<canonical_json>")where the canonical JSON is the exact request body:json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
A consumer can require either, both, or neither.
schema_version allows additive evolution. New fields may be added within 1.x without
breaking consumers; breaking changes bump the major version.
- Retries and dead letters. Transport failures, 5xx responses,
408, and429are retried with bounded exponential backoff and jitter.Retry-Afterhints from429and503responses are honored up to the configured backoff ceiling. When an OAuth token provider is configured,401responses are also retried; all other 4xx responses are dead-lettered immediately. A delivery that exhausts the positiveVULN_ANALYSIS_WEBHOOK_MAX_ROUNDSlimit is also dead-lettered. - At-least-once, restart-durable. A SQLite ownership lease prevents concurrent dispatchers
from sending the same job at the same time. A process can still stop after the receiver accepts
a request but before the acknowledgement is persisted, so consumers must de-duplicate using
the stable
event_id.
All settings are environment variables prefixed VULN_ANALYSIS_WEBHOOK_; the feature is off
unless …_ENABLED=true and …_URL is set. See the README section Pushing job completion to a
consumer (webhooks) and .env.example.
{ "schema_version": "1.0", "event_id": "5f2b0642-…", // stable UUIDv5 of job_id; idempotency key "event_type": "job.completed", // job.completed | job.failed "occurred_at": "2026-07-14T18:03:00Z", // RFC3339 UTC "source": "vuln-analysis", "job": { "job_id": "f4abf109-…", "status": "completed", // completed | failed (terminal only) "image": { "name": "…", "tag": "…", "digest": "sha256:…" }, "error": null, // string on job.failed, else null "result": { /* native PipelineResult: outcome, success, elapsed_seconds, results[], … */ }, "summary": { "vulns": [ /* flattened per-vuln view, see below */ ] } }, "context": { "program_id": 1234, "exception_id": 5678 } // verbatim echo when a result exists }