Skip to content

Webhooks — configuration and reliability

Arbitex webhooks push real-time event notifications to an HTTP endpoint of your choice. When a subscribed event occurs — a new conversation, a DLP rule trigger, a quota breach, a compliance bundle state change, or a billing event — Arbitex sends a signed JSON payload to your configured URL within seconds.

Common use cases include feeding Arbitex events into SIEM pipelines, triggering remediation workflows, updating compliance dashboards, and syncing audit records to external systems.

All webhook management endpoints require an admin role. The router prefix is /api/v1/admin/webhooks.


Each webhook registration binds one URL to one or more event types. When a matching event fires, Arbitex delivers an HTTP POST request to your endpoint with:

  • A JSON body describing the event and its payload
  • An X-Arbitex-Signature header containing an HMAC-SHA256 signature you can use to verify authenticity
  • Automatic retry logic on delivery failure

Delivery is best-effort with up to three attempts. See Retry policy for timing details.


Send a POST request to create a new webhook registration.

POST /api/v1/admin/webhooks/

Request body:

{
"name": "My SIEM Integration",
"url": "https://ingest.example.com/arbitex-events",
"events": ["new_conversation", "dlp_trigger"],
"secret": "<your-signing-secret>",
"enabled": true
}

Example:

Terminal window
curl -s -X POST https://api.arbitex.ai/api/v1/admin/webhooks/ \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "My SIEM Integration",
"url": "https://ingest.example.com/arbitex-events",
"events": ["new_conversation", "dlp_trigger"],
"secret": "<your-signing-secret>",
"enabled": true
}'

Field descriptions:

Field Required Description
name Yes Human-readable label for this webhook
url Yes HTTPS endpoint that will receive POST requests
events Yes List of event types to subscribe to — at least one required
secret Yes Signing secret for HMAC-SHA256 signature verification — 8 to 255 characters
enabled No Whether the webhook is active; defaults to true

The response includes the assigned webhook_id, which you use for all subsequent operations on this registration.


Subscribe to any combination of the following event types. Pass them as string values in the events array.

Platform events

Event type When it fires
new_conversation A new conversation is created on the platform
dlp_trigger A DLP policy rule matches content in a conversation
quota_exceeded A user or group reaches or exceeds their configured usage quota
bundle_state_change A compliance bundle transitions to a new state (e.g., active, archived)

Billing events (cloud-0020)

Event type When it fires
quota_exceeded Current monthly request count reaches the plan limit
usage_threshold Usage reaches 80%, 90%, or 100% of the monthly limit
invoice_generated A new invoice is created for the organization

A single webhook can subscribe to multiple event types simultaneously.


Returns all webhook registrations for the tenant.

GET /api/v1/admin/webhooks/
Terminal window
curl -s https://api.arbitex.ai/api/v1/admin/webhooks/ \
-H "Authorization: Bearer $ADMIN_TOKEN"

Returns the webhook registration details along with the last 20 delivery records.

GET /api/v1/admin/webhooks/{webhook_id}
Terminal window
curl -s https://api.arbitex.ai/api/v1/admin/webhooks/wh_01abc123 \
-H "Authorization: Bearer $ADMIN_TOKEN"

Use a PUT request to update any combination of fields. All fields are optional — only the fields you include are changed.

PUT /api/v1/admin/webhooks/{webhook_id}
Terminal window
# Disable a webhook temporarily
curl -s -X PUT https://api.arbitex.ai/api/v1/admin/webhooks/wh_01abc123 \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": false}'
# Update the URL and add an event type
curl -s -X PUT https://api.arbitex.ai/api/v1/admin/webhooks/wh_01abc123 \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://ingest.example.com/arbitex-events-v2",
"events": ["new_conversation", "dlp_trigger", "quota_exceeded"]
}'
# Rotate the signing secret
curl -s -X PUT https://api.arbitex.ai/api/v1/admin/webhooks/wh_01abc123 \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"secret": "<your-new-signing-secret>"}'

Updatable fields: name, url, events, secret, enabled.

Permanently removes the webhook registration and stops all future deliveries.

DELETE /api/v1/admin/webhooks/{webhook_id}
Terminal window
curl -s -X DELETE https://api.arbitex.ai/api/v1/admin/webhooks/wh_01abc123 \
-H "Authorization: Bearer $ADMIN_TOKEN"

When a delivery attempt fails (non-2xx response, connection error, or 10-second timeout), Arbitex retries up to two additional times using exponential backoff:

Attempt Delay before attempt
1 (initial) Immediate
2 1 second
3 5 seconds
(if 3 fails) Delivery moves to dead letter queue after 30-second wait

The request timeout per attempt is 10 seconds. Connections that do not complete within 10 seconds are treated as failures and trigger the retry sequence.

If all three attempts fail, the delivery record status is set to dead_letter. Dead letter deliveries are not retried automatically. To re-deliver:

  1. Fix the issue at your endpoint (verify URL reachability, check response codes).
  2. Use the delivery log to identify the delivery ID.
  3. Re-trigger the original event (if available) or re-run from your SIEM pipeline using the delivery log payload.

Check the delivery log regularly for dead_letter entries. A pattern of dead letter deliveries indicates a persistent connectivity or endpoint configuration issue.


Every delivery includes an X-Arbitex-Signature header. Verifying this signature confirms the request originated from Arbitex and that the body has not been tampered with in transit.

Header format:

X-Arbitex-Signature: sha256=<hex_digest>

Algorithm: HMAC-SHA256, computed over the raw request body bytes using the secret you provided at webhook creation.

import hmac
import hashlib
def verify_arbitex_signature(raw_body: bytes, secret: str, signature_header: str) -> bool:
"""
Verify an Arbitex webhook signature.
Args:
raw_body: The raw, unmodified request body bytes.
secret: The signing secret configured on the webhook.
signature_header: The value of the X-Arbitex-Signature header.
Returns:
True if the signature is valid, False otherwise.
"""
expected_prefix = "sha256="
if not signature_header.startswith(expected_prefix):
return False
received_digest = signature_header[len(expected_prefix):]
expected_digest = hmac.new(
key=secret.encode("utf-8"),
msg=raw_body,
digestmod=hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected_digest, received_digest)
# Example usage in a Flask handler
from flask import Flask, request, abort
app = Flask(__name__)
# Replace with the secret you registered; load it from your environment or
# secret store rather than committing it to source.
WEBHOOK_SECRET = "<your-signing-secret>"
@app.route("/arbitex-events", methods=["POST"])
def handle_webhook():
signature = request.headers.get("X-Arbitex-Signature", "")
if not verify_arbitex_signature(request.get_data(), WEBHOOK_SECRET, signature):
abort(403)
event = request.get_json()
print(f"Received event: {event['event_type']}")
return "", 200

Always use hmac.compare_digest for the final comparison to prevent timing-based attacks.

Terminal window
# Compute the expected digest from the raw body and secret
BODY='{"event_type":"dlp_trigger","payload":{}}'
SECRET="<your-signing-secret>"
EXPECTED=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
echo "sha256=$EXPECTED"

Compare the output against the X-Arbitex-Signature header value. Both must match exactly (prefix included) for the delivery to be considered authentic.


Send a synthetic webhook_test event to verify that your endpoint is reachable and that your signature verification logic is working correctly.

POST /api/v1/admin/webhooks/{webhook_id}/test
Terminal window
curl -s -X POST https://api.arbitex.ai/api/v1/admin/webhooks/wh_01abc123/test \
-H "Authorization: Bearer $ADMIN_TOKEN"

Response:

{
"success": true,
"status_code": 200,
"error": null
}

If the delivery fails, success is false, status_code reflects the HTTP response code returned by your endpoint (or null on connection failure), and error contains a description of the failure.

Use the test endpoint after initial registration, after rotating the signing secret, and after making infrastructure changes that could affect reachability.


Every delivery attempt is written to the webhook delivery log. The GET /api/v1/admin/webhooks/{webhook_id} response includes a deliveries array containing the last 20 delivery records.

Delivery record fields:

Field Description
id Unique delivery log ID
webhook_id ID of the webhook registration
event_type The event type that triggered this delivery
status Current delivery status (see below)
attempt_count Number of attempts made so far (1–3)
last_attempt_at Timestamp of the most recent attempt
next_retry_at Scheduled time for the next retry, or null if terminal
response_code HTTP status code from the last attempt, or null on connection failure
error_message Error detail from the last failed attempt, or null on success
payload The JSON body sent (truncated to 10,000 characters)

Status values:

Status Description
pending Delivery is in progress (first attempt)
delivered At least one attempt received a 2xx response
failed An attempt failed; a retry is scheduled (next_retry_at is set)
dead_letter All three attempts failed; no further retries will occur

Debugging failed deliveries:

If you see failed or dead_letter status:

  1. Check response_code — a 4xx response from your endpoint indicates a configuration issue (wrong URL, authentication failure).
  2. Check error_message"Request timed out" means your endpoint did not respond within 10 seconds.
  3. Verify the webhook URL is publicly reachable from Arbitex infrastructure.
  4. Verify your endpoint returns a 2xx status code synchronously (do not defer to a background job without responding first).
  5. Verify the signing secret in your webhook registration matches what your endpoint expects.

Alert on failed deliveries:

Configure an alert rule to fire when dead_letter deliveries exceed a threshold:

Terminal window
POST /api/v1/admin/alert-rules
{
"name": "webhook-dead-letter-alert",
"condition": {
"metric": "webhook_dead_letter_count",
"threshold": 5,
"window_minutes": 60
},
"action": {
"type": "email",
"recipients": ["[email protected]"]
}
}

All webhook deliveries use the same envelope format:

{
"event_type": "dlp_trigger",
"payload": {
"request_id": "req_01abc123",
"user_id": "user_01def456",
"rule_id": "finance-pii-block",
"entity_type": "credit_card",
"action_taken": "BLOCK",
"timestamp": "2026-03-10T16:30:00Z"
},
"timestamp": "2026-03-10T16:30:00.123456Z"
}

The outer timestamp is the delivery time. The inner payload.timestamp is the event time. They may differ slightly due to processing latency.

Billing event example:

{
"event_type": "usage_threshold",
"payload": {
"org_id": "org_01abc123",
"threshold": 0.80,
"current_requests": 8000,
"monthly_limit": 10000,
"period": "2026-03"
},
"timestamp": "2026-03-10T16:30:00.123456Z"
}


The delivery service validates each webhook URL before making any HTTP request. URLs that resolve to private, loopback, link-local, or reserved IP ranges are blocked immediately with no retry:

  • 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • 127.0.0.0/8 (localhost)
  • 169.254.0.0/16 (link-local / cloud metadata endpoints such as 169.254.169.254)
  • IPv6 private ranges: ::1, fe80::/10, fc00::/7

Blocked deliveries are recorded with status failed and last_error: "URL blocked by SSRF protection: target resolves to a private, loopback, or reserved IP address". They are not moved to the dead letter queue.


The Cloud Portal has a separate notification surface for infrastructure health events, exposed at:

GET /v1/orgs/{org_id}/notifications

This endpoint returns:

  • Outpost heartbeat health alerts (Outposts not seen for more than 5 minutes)
  • Webhook dead-letter notifications (deliveries that failed after all retries in cloud-connected orgs)

Portal-level notifications are distinct from the platform webhook subscriptions documented above. Platform webhooks fire on conversation/DLP/quota/compliance events; portal notifications surface infrastructure health for operators.


Organizations can override the default retry policy through the Cloud Portal API. The custom policy applies to all webhooks in the organization.

Get current policy:

Terminal window
GET /v1/orgs/{org_id}/webhooks/retry-policy
Authorization: Bearer <org-token>
{
"max_retries": 3,
"backoff_base": 1,
"timeout_seconds": 10,
"is_custom": false
}

Set custom policy:

Terminal window
POST /v1/orgs/{org_id}/webhooks/retry-policy
Authorization: Bearer <org-token>
Content-Type: application/json
{"max_retries": 5, "backoff_base": 2, "timeout_seconds": 30}
Field Range Default Description
max_retries 1–10 3 Maximum delivery attempts
backoff_base 1–60 s 1 Base delay in seconds (doubled each retry: base × 2^attempt)
timeout_seconds 5–120 s 10 HTTP timeout per attempt

Setting a policy replaces all three values — partial updates are not supported. When is_custom is true, the org has overridden the global defaults.


The delivery dashboard at Portal > Webhook Dashboard (/portal/webhook-dashboard) provides real-time visibility into webhook delivery health.

Card Description
Total Deliveries Lifetime count of all delivery attempts
Success Rate (delivered / total) × 100
Failed Count of failed deliveries, including those currently retrying
Dead Letter Count of permanently failed deliveries

Success rate colour thresholds: ≥ 90% green, ≥ 70% amber, < 70% red.

Column Description
Timestamp When the delivery was first queued
Webhook ID Target webhook
Event Type The event type that triggered the delivery
Status pending, delivered, failed, dead_letter, or retrying
Code HTTP response code from the target
Attempts Format: N/max
Error Truncated error message (40 characters)
Action Retry button for dead_letter entries

The dashboard auto-refreshes every 30 seconds.

Terminal window
GET /v1/orgs/{org_id}/webhooks/delivery-stats
Authorization: Bearer <org-token>
{
"webhooks": [
{
"webhook_id": "3f8a1b2c-...",
"total_deliveries": 1250,
"success_count": 1200,
"failure_count": 47,
"dead_letter_count": 3,
"success_rate_pct": 96.0,
"last_delivery_at": "2026-03-15T12:00:00Z"
}
],
"totals": {
"delivered": 1200,
"failed": 47,
"dead_letter": 3,
"pending": 0,
"retrying": 0,
"total": 1250
}
}

Dead-lettered deliveries can be re-queued programmatically:

Terminal window
# Non-scoped retry
POST /v1/orgs/{org_id}/webhooks/deliveries/{delivery_id}/retry
# Webhook-scoped retry (enforces IDOR protection at org and webhook level)
POST /v1/orgs/{org_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/retry

Both endpoints: verify status: "dead_letter", reset to pending, clear next_retry_at and error_message, and re-enter the normal retry cycle. HTTP 409 is returned if the delivery is not in dead_letter status.


HMAC signature verification — Node.js and Go

Section titled “HMAC signature verification — Node.js and Go”
const crypto = require("crypto");
function verifyWebhook(bodyBuffer, secret, signature) {
const expected = crypto
.createHmac("sha256", secret)
.update(bodyBuffer)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "utf-8"),
Buffer.from(signature, "utf-8")
);
}
// Express handler — use express.raw() to get raw body bytes
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-webhook-signature"] || "";
if (!verifyWebhook(req.body, process.env.WEBHOOK_SECRET, signature)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body);
res.sendStatus(200);
});
func verifyWebhook(body []byte, secret, signature string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
Mistake Fix
Verifying against re-serialized JSON Always verify against the raw request body bytes as received
Using string comparison instead of constant-time Use hmac.compare_digest (Python), crypto.timingSafeEqual (Node), or hmac.Equal (Go)
Encoding the secret incorrectly The secret is UTF-8 encoded
Forgetting to handle missing header Check for empty or missing signature header before comparing

Outpost instances can emit scan.complete events independently of the platform webhook system. Configure via environment variables:

Variable Description
WEBHOOK_EMIT_ENABLED Enable outpost webhook emission (true/false)
WEBHOOK_EMIT_URL Target URL for scan completion events
WEBHOOK_EMIT_SECRET HMAC-SHA256 signing secret

The outpost emitter uses the same retry policy (3 attempts, exponential backoff from 1 second) and the same HMAC-SHA256 signing scheme as the platform.


All Cloud API endpoints required a Bearer token with webhook:write scope. Base URL was https://cloud.arbitex.ai/v1/orgs/{org_id}.

Method Path Description
GET /webhooks List webhooks (augmented with delivery stats)
POST /webhooks Create webhook
PUT /webhooks/{webhook_id} Update webhook
DELETE /webhooks/{webhook_id} Delete webhook
POST /webhooks/{webhook_id}/test Send test event
GET /webhooks/{webhook_id}/health Probe target URL (HEAD request, 10 s timeout)
GET /webhooks/deliveries List all deliveries (query: status, start_time, end_time, limit, offset)
GET /webhooks/{webhook_id}/deliveries Per-webhook deliveries (paginated)
POST /webhooks/deliveries/{delivery_id}/retry Retry dead-lettered delivery
POST /webhooks/{webhook_id}/deliveries/{delivery_id}/retry Webhook-scoped retry
GET /webhooks/dead-letters Dead letter archive (last 100)
GET /webhooks/retry-policy Get retry policy
POST /webhooks/retry-policy Set custom retry policy
GET /webhooks/delivery-stats Aggregate delivery statistics