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.
| Field | Type | Required | Notes |
|---|---|---|---|
url | string | Yes | HTTPS only. Rank SSRF-checks the destination (no private IPs, localhost, or cloud metadata) and pins the resolved IP for delivery |
events | string[] | Yes | At least one name from the catalog. Unknown names are 400. ping is not subscribable |
secret | string | No | Min 16 characters. If omitted, Rank generates one and returns it once |
description | string | No | Label, max 200 characters |
team_id | int | No | Own 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
}
| Field | Notes |
|---|---|
rotate_secret | true generates a new secret, returned once (same secret / secret_notice shape as create) |
secret | Supply your own replacement (min 16 characters) instead of rotating |
active | true 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"
}
}
| Field | Type | Notes |
|---|---|---|
id | string | 32 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) |
type | string | A catalog name, or ping |
version | int | Envelope schema. Today 2. Only increases on a breaking change |
created_at | string | ISO 8601 with offset |
pentest_id | int | null | null only on a ping |
data | object | Event-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
| Header | Purpose |
|---|---|
User-Agent | Rank-Webhooks/2.0 |
X-Rank-Event | Event type — route without parsing the body |
X-Rank-Event-Id | Same as envelope id. Deduplicate on this |
X-Rank-Delivery-Id | This attempt. Matches the id in the delivery log |
X-Rank-Delivery-Attempt | 1 on the first send, up to 6 |
X-Rank-Timestamp | Unix seconds when the payload was signed |
X-Rank-Signature | v1= 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.
| Attempt | When |
|---|---|
| 1 | Immediate |
| 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):
| Status | Why it is final |
|---|---|
400, 422 | Your endpoint says the body is malformed |
405 | Method not allowed |
410 | The 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
| Event | Fires when |
|---|---|
vulnerability.created | An agent files a new finding |
vulnerability.validated | A deterministic validator reproduced it |
vulnerability.validation_failed | A validator proved the issue is not present (not “could not reproduce”) |
vulnerability.status_changed | A state change that has no dedicated event |
vulnerability.assigned | The finding is assigned (or unassigned) |
vulnerability.resolved | Status becomes resolved |
vulnerability.reopened | Status returns to open |
vulnerability.retested | A deterministic retest answered whether the fix holds |
vulnerability.retest_sla_breached | The deadline to retest a closure elapsed |
vulnerability.regression_detected | The same finding_fingerprint reappears after it was resolved |
vulnerability.commented | A comment is added |
vulnerability.sla_breached | The remediation deadline for its severity elapsed |
Pentest lifecycle
| Event | Fires when |
|---|---|
pentest.started | The orchestrator starts execution |
pentest.phase_completed | A phase finishes |
pentest.completed | The pentest finishes successfully |
pentest.failed | The pentest ends in error |
pentest.cancelled | The run is cancelled |
pentest.killed | The kill switch stops the run |
pentest.report_sent | A sealed report is sent |
Operational control
| Event | Fires when |
|---|---|
control.scope_violation | Something outside the declared scope was touched |
control.kill_requested | Someone requested an emergency stop |
control.kill_confirmed | The stop was confirmed |
control.approval_requested | An action is waiting for a human decision |
control.approval_decided | That approval was granted or denied |
control.roe_activated | Rules of Engagement become active |
control.roe_validation_failed | The RoE does not validate |
control.window_warning | The 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
| Plan | Webhooks |
|---|---|
| Casual | — |
| Pro | 5 |
| Ultra | 20 |
| Business | 50 |
| Enterprise | Unlimited |
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.