Webhooks

Tenant HTTPS subscriptions for finding, pentest and control events — HMAC verification, retries, delivery history and the event catalog.

All paths are relative to https://api.aleex-rank.ai/api/v2 and authenticate with X-API-Key: rk_... (see REST API). A tenant webhook is an HTTPS POST Rank signs and delivers to a URL you own. One subscription covers every pentest of its owner (you, or a team). Prefer this over the legacy per-pentest hooks.

Webhooks are a different channel from Integrations. A webhook delivers the JSON envelope below for your code to interpret. An integration opens, updates or closes a ticket in Jira, GitHub or Slack and can sync back when that ticket moves. You can use both on the same events.

Subscriptions are gated by plan: Casual none, Pro 5, Ultra 20, Business 50, Enterprise unlimited. The ceiling counts registered subscriptions, active or not. On a team subscription the team’s plan wins. See Teams & tiers.

List and create

GET  /webhooks
POST /webhooks
Content-Type: application/json

GET returns the subscriptions you manage (personal and team). Filter with ?pentest_id= to those that would fire for that pentest.

FieldTypeRequiredNotes
urlstringYesHTTPS only. Rank SSRF-checks the destination (no private IPs, localhost, or cloud metadata) and pins the resolved IP for delivery
eventsstring[]YesAt least one name from the catalog. Unknown names are 400. ping is not subscribable
secretstringNoMin 16 characters. If omitted, Rank generates one and returns it once
descriptionstringNoLabel, max 200 characters
team_idintNoOwn the subscription as this team; omit for a personal hook
{
  "url": "https://hooks.example.com/rank",
  "events": ["vulnerability.validated", "vulnerability.sla_breached", "pentest.completed", "control.kill_requested"],
  "description": "SOC inbox",
  "team_id": 12
}

Response 201:

{
  "success": true,
  "data": {
    "message": "Webhook registered successfully",
    "webhook": {
      "id": 18,
      "pentest_id": null,
      "owner_type": "team",
      "owner_id": 12,
      "url": "https://hooks.example.com/rank",
      "description": "SOC inbox",
      "has_secret": true,
      "events": ["vulnerability.validated", "vulnerability.sla_breached", "pentest.completed", "control.kill_requested"],
      "active": true,
      "consecutive_failures": 0,
      "last_delivery_at": null,
      "disabled_reason": null,
      "created_by": 42,
      "created_at": "2026-08-10 17:04:03",
      "updated_at": "2026-08-10 17:04:03",
      "scope": "team"
    },
    "secret": "generated-or-supplied-secret-value",
    "secret_notice": "Store this secret now; it cannot be retrieved later."
  }
}

secret and secret_notice appear only when Rank generated the secret (or when you rotate it). Listings never return the secret — only has_secret.

The created vs validated choice is the main lever: subscribe to vulnerability.created if you want every finding as soon as an agent files it; subscribe only to vulnerability.validated if you do not want developers paged until a deterministic validator has reproduced the issue. See Vulnerabilities.

Retrieve, update, delete

GET    /webhooks/{id}
PUT    /webhooks/{id}
DELETE /webhooks/{id}

PUT edits destination, events, description, whether the hook is on, and the signing secret.

{
  "url": "https://hooks.example.com/rank",
  "events": ["vulnerability.validated", "pentest.killed"],
  "description": "SOC inbox",
  "active": true,
  "rotate_secret": true
}
FieldNotes
rotate_secrettrue generates a new secret, returned once (same secret / secret_notice shape as create)
secretSupply your own replacement (min 16 characters) instead of rotating
activetrue re-enables a hook Rank disabled after exhausted deliveries or a plan downgrade; it also clears disabled_reason and the failure counter

A listing or GET that shows active: false includes disabled_reason (ten exhausted deliveries in a row, or the subscription no longer fits the plan).

Test ping

POST /webhooks/{id}/test

Queues a signed ping to this subscription only. ping is not in the subscribe catalog and is never published by the event bus. A ping is a single attempt (no retries) and does not count toward the subscription’s health in either direction — you cannot use it to paper over a broken endpoint.

{
  "success": true,
  "data": {
    "message": "Test delivery queued",
    "delivery": {
      "id": 918273,
      "event_id": "8f14e45fceea167a5a36dedd4bea2543",
      "event": "ping",
      "pentest_id": null,
      "target_url": "https://hooks.example.com/rank",
      "status": "pending",
      "attempt": 1,
      "http_status": null
    }
  }
}

On-demand test and redeliver calls are throttled (20 per subscription and per account per hour).

Deliveries

GET  /webhooks/{id}/deliveries
GET  /webhooks/{id}/deliveries/{deliveryId}
POST /webhooks/{id}/deliveries/{deliveryId}/redeliver

The list is paginated (page, per_page) and filterable with ?status=pending|delivered|dead. Delivery history is retained 30 days.

{
  "success": true,
  "data": {
    "items": [
      {
        "id": 918273,
        "event_id": "8f14e45fceea167a5a36dedd4bea2543",
        "event": "vulnerability.validated",
        "pentest_id": 412,
        "target_url": "https://hooks.example.com/rank",
        "status": "delivered",
        "attempt": 1,
        "max_attempts": 6,
        "http_status": 200,
        "error": null,
        "duration_ms": 84,
        "created_at": "2026-08-10 17:04:03",
        "delivered_at": "2026-08-10 17:04:03"
      }
    ],
    "pagination": {"total": 1, "count": 1, "per_page": 20, "current_page": 1, "total_pages": 1}
  }
}

GET …/deliveries/{deliveryId} includes the request body Rank sent and an excerpt of the response — use it when a signature does not match. POST …/redeliver resends that same envelope with the same X-Rank-Event-Id. The subscription must be active; a delivery that is still pending cannot be requeued.

Envelope (version 2)

Every delivery is an HTTP POST whose JSON body has this shape, regardless of event:

{
  "id": "8f14e45fceea167a5a36dedd4bea2543",
  "type": "vulnerability.validated",
  "version": 2,
  "created_at": "2026-08-10T17:04:03+00:00",
  "pentest_id": 412,
  "data": {
    "vulnerability_id": 9871,
    "triggered_by": 42,
    "validation_status": "validated"
  }
}
FieldTypeNotes
idstring32 hex characters. Shared across every subscriber of the same fact — three subscriptions to the same event all receive this id. Deduplicate on it (or on X-Rank-Event-Id)
typestringA catalog name, or ping
versionintEnvelope schema. Today 2. Only increases on a breaking change
created_atstringISO 8601 with offset
pentest_idint | nullnull only on a ping
dataobjectEvent-specific. Not a closed schema — new keys can appear without bumping version. Read what you need and ignore the rest

Finding events typically carry vulnerability_id and triggered_by; status changes add old_status / new_status. An event published because your tracker moved a ticket also includes origin and origin_integration_id so you can tell a Jira close from a close in Rank (and so Rank does not echo that event back to the integration that caused it).

Delivery headers

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Rank-Webhooks/2.0
X-Rank-Event: vulnerability.validated
X-Rank-Event-Id: 8f14e45fceea167a5a36dedd4bea2543
X-Rank-Delivery-Id: 918273
X-Rank-Delivery-Attempt: 1
X-Rank-Timestamp: 1786633443
X-Rank-Signature: v1=3f9a...c17b
HeaderPurpose
User-AgentRank-Webhooks/2.0
X-Rank-EventEvent type — route without parsing the body
X-Rank-Event-IdSame as envelope id. Deduplicate on this
X-Rank-Delivery-IdThis attempt. Matches the id in the delivery log
X-Rank-Delivery-Attempt1 on the first send, up to 6
X-Rank-TimestampUnix seconds when the payload was signed
X-Rank-Signaturev1= plus HMAC-SHA256 hex of {timestamp}.{raw_body}

v1 is the signature format version, not the envelope version (the envelope is 2). One fact delivered to three subscriptions shares X-Rank-Event-Id and has three distinct X-Rank-Delivery-Id values.

Verify the signature

The signature is HMAC-SHA256 of {timestamp}.{raw_body} keyed by your secret — not the body alone. That is what rejects a captured request replayed later. Sign the raw bytes you received; re-serializing parsed JSON will not match. Compare in constant time. Reject timestamps older than about five minutes.

The Python SDK helper:

import rank

rank.verify_signature(raw_body, headers, secret)

It raises rank.SignatureVerificationError if the timestamp is missing, stale, or the HMAC does not match.

Equivalent HMAC:

import hashlib
import hmac
import time

def verify_rank_webhook(raw_body: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
    timestamp = headers.get("X-Rank-Timestamp") or headers.get("x-rank-timestamp") or ""
    signature = headers.get("X-Rank-Signature") or headers.get("x-rank-signature") or ""

    if not timestamp.isdigit() or not signature.startswith("v1="):
        return False
    if abs(time.time() - int(timestamp)) > tolerance:
        return False

    message = timestamp.encode() + b"." + raw_body
    expected = "v1=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Respond 2xx in under 10 seconds and do heavy work asynchronously. A slow 2xx is retried even if your handler already did the work.

Retries and health

A 2xx in under 10 seconds is success. Anything else — 3xx, 4xx, 5xx, timeout, DNS failure — is a failure and is retried, except the four codes below.

AttemptWhen
1Immediate
2+30 s
3+2 min
4+10 min
5+1 h
6+6 h

Six attempts over a little under 8 hours, with up to 15% jitter. A ping is not retried.

These responses stop retries on the first attempt (the body will not change):

StatusWhy it is final
400, 422Your endpoint says the body is malformed
405Method not allowed
410The destination no longer exists

Everything else is retried for the full backoff, including 401, 403, 404, 409 and 429. A stopped delivery of this kind does not count against the subscription’s health.

Auto-disable. Ten consecutive exhausted deliveries (ten events that used all six attempts) deactivate the subscription, set disabled_reason, and email the owner. One successful delivery resets the counter. Reactivate with PUT {"active": true}.

Event catalog

Names are append-only. The subject tells you which identifier data carries: finding events include vulnerability_id; pentest and control events carry pentest_id.

Finding lifecycle

EventFires when
vulnerability.createdAn agent files a new finding
vulnerability.validatedA deterministic validator reproduced it
vulnerability.validation_failedA validator proved the issue is not present (not “could not reproduce”)
vulnerability.status_changedA state change that has no dedicated event
vulnerability.assignedThe finding is assigned (or unassigned)
vulnerability.resolvedStatus becomes resolved
vulnerability.reopenedStatus returns to open
vulnerability.retestedA deterministic retest answered whether the fix holds
vulnerability.retest_sla_breachedThe deadline to retest a closure elapsed
vulnerability.regression_detectedThe same finding_fingerprint reappears after it was resolved
vulnerability.commentedA comment is added
vulnerability.sla_breachedThe remediation deadline for its severity elapsed

Pentest lifecycle

EventFires when
pentest.startedThe orchestrator starts execution
pentest.phase_completedA phase finishes
pentest.completedThe pentest finishes successfully
pentest.failedThe pentest ends in error
pentest.cancelledThe run is cancelled
pentest.killedThe kill switch stops the run
pentest.report_sentA sealed report is sent

Operational control

EventFires when
control.scope_violationSomething outside the declared scope was touched
control.kill_requestedSomeone requested an emergency stop
control.kill_confirmedThe stop was confirmed
control.approval_requestedAn action is waiting for a human decision
control.approval_decidedThat approval was granted or denied
control.roe_activatedRules of Engagement become active
control.roe_validation_failedThe RoE does not validate
control.window_warningThe authorised testing window is about to close

RoE escalation contacts receive a narrower subset automatically (kill_requested, kill_confirmed, roe_validation_failed, window_warning) without a webhook subscription. Configure those contacts on the RoE, not here.

Plan limits

PlanWebhooks
Casual—
Pro5
Ultra20
Business50
EnterpriseUnlimited

A downgrade that no longer fits the count deactivates surplus subscriptions (newest first among the active ones) and records the reason in disabled_reason. Casual has no webhooks; the reason says so explicitly.

Legacy per-pentest webhooks

These still work unchanged. A subscription created here listens to that pentest only, so you must re-register on every run. Prefer tenant webhooks above.

GET    /pentests/{id}/webhooks
POST   /pentests/{id}/webhooks
DELETE /pentests/{id}/webhooks/{hookId}
{
  "url": "https://ci.example.com/hooks/rank",
  "secret": "a-long-random-secret-value",
  "events": ["vulnerability.resolved", "vulnerability.status_changed"]
}

Secrets are never returned in listings. Create still requires HTTPS and at least one catalog event.

For pulling findings on demand rather than receiving pushes, pair webhooks with the Vulnerabilities API. To file tickets instead of handling HTTP yourself, use Integrations.