Skip to content

Webhooks

DevSecure sends an HTTPS POST to your endpoint when something happens in the workspace. Events usually arrive within a minute.

EventWhen
scan.completedA scan finished. Includes issue counts by severity. Subscribing needs the `scans:read` scope.
scan.failedA scan failed. Subscribing needs the `scans:read` scope.
issue.createdA scan found an issue whose fingerprint was not seen before in the workspace. Subscribing needs the `issues:read` scope.
issue.status_changedAn issue was resolved, reopened, snoozed, marked false positive or risk-accepted, in the dashboard or through this API. Subscribing needs the `issues:read` scope.
exception_request.decidedAn exception request was approved, denied or cancelled. Subscribing needs the `issues:read` scope.
autofix.completedAn autofix job finished (fixed, low confidence or cannot fix). Subscribing needs the `autofix:read` scope.
autofix.failedAn autofix job failed. Subscribing needs the `autofix:read` scope.
kubernetes.finding.createdThe Kubernetes agent reported a new finding. Subscribing needs the `kubernetes:read` scope.
pentest.target.decidedA pentest target was approved or denied. Subscribing needs the `pentest:read` scope.
pingA test event sent by POST /v1/webhooks/{webhook_id}/test. Subscribing needs the `webhooks:manage` scope.

The API reference shows the exact body of each event. data holds the same objects, with the same fields, as the matching GET endpoint.

Terminal window
curl -s https://api.devsecure.io/v1/webhooks \
-H "Authorization: Bearer $DEVSECURE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/devsecure", "event_types": ["scan.completed", "issue.created"]}'

The response includes the signing secret (whsec_…) once. Store it with your receiver.

  • Managing webhooks needs the webhooks:manage scope. Each event type also needs its read scope, such as issues:read for issue.created, so a webhook never carries data its key could not read.
  • The URL must use https on a public host, with no user name or password in it. Private, loopback and cloud metadata addresses are refused, both when you create the webhook and at every delivery.
  • A workspace can have up to 10 webhooks. They belong to the workspace, not to the key that created them.
  • GET /v1/webhooks/event-types lists the event types and their scopes.
POST /devsecure HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: DevSecure-Webhooks/1.0
X-DevSecure-Event: scan.completed
X-DevSecure-Event-Id: 3f2b9c1e-7a6d-4e5f-9b8a-1c2d3e4f5a6b
X-DevSecure-Delivery: 0d1e2f3a-4b5c-4d6e-8f9a-0b1c2d3e4f5a
X-DevSecure-Webhook-Id: 9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a
X-DevSecure-Signature: t=1790000000,v1=06ae881a…
{"api_version":"v1","created_at":"2026-10-01T12:00:00Z","data":{"scan":{…}},"id":"3f2b9c1e-7a6d-4e5f-9b8a-1c2d3e4f5a6b","type":"scan.completed"}

Always verify X-DevSecure-Signature before trusting a delivery. The header holds a Unix timestamp t and one or more v1 signatures. Each signature is the hex HMAC-SHA256 of "<t>.<raw body>", keyed with your signing secret.

  1. Use the raw request body, byte for byte, before any JSON parsing.
  2. Compute the HMAC with your secret and compare it to each v1 value in constant time. The delivery is genuine if any of them matches.
  3. Reject timestamps more than five minutes from your clock, so an old delivery cannot be replayed.
verify_webhook.py
"""Verify a DevSecure webhook delivery (Python 3.9+, standard library only)."""
import hashlib
import hmac
import time
def verify_devsecure_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
"""True when X-DevSecure-Signature matches the raw request body.
secret: the webhook's signing secret (whsec_...)
header: the X-DevSecure-Signature header, "t=<unix seconds>,v1=<hex>[,v1=<hex>]"
raw_body: the request body exactly as received, before any JSON parsing
"""
try:
pairs = [part.split("=", 1) for part in header.split(",")]
timestamp = int(next(value for key, value in pairs if key == "t"))
signatures = [value for key, value in pairs if key == "v1"]
except (StopIteration, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False # too old (or too far in the future): possibly a replay
signed = str(timestamp).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)

These samples are tested against deliveries signed by DevSecure’s own code.

  • Return any 2xx status within 10 seconds. Do the work afterwards, for example from a queue.
  • Anything else, including a timeout, a TLS error or a redirect, counts as a failure. Redirects are never followed.
  • Failed deliveries are retried after about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. After six failed attempts the delivery is marked failed.
  • Respond 410 Gone to tell DevSecure to turn the webhook off.
  • After 20 failed deliveries in a row, DevSecure turns the webhook off and sets disabled_reason. Turn it back on with PATCH /v1/webhooks/{webhook_id} and {"enabled": true}.
  • An event can arrive more than once, for example after a timeout or a redelivery. Use X-DevSecure-Event-Id (the body’s id) to ignore duplicates.
  • Events can arrive out of order. Use timestamps in data (such as updated_at), or fetch the object from the API, when order matters.
  • POST /v1/webhooks/{webhook_id}/test sends a ping event.
  • GET /v1/webhooks/{webhook_id}/deliveries lists recent deliveries with their status, attempts, response status, error and duration. Your endpoint’s response bodies are never stored.
  • POST /v1/webhooks/{webhook_id}/deliveries/{delivery_id}/redeliver sends a finished delivery again.

POST /v1/webhooks/{webhook_id}/rotate-secret returns a new secret, once. For the next 24 hours every delivery carries two v1 signatures, one for each secret, so you can update your receiver without dropping events.