Webhooks
Subscribe an HTTPS endpoint to project events and receive signed POST deliveries. Every payload carries an HMAC-SHA256 over timestamp.body so the receiver can verify origin and reject replays.
Event Envelope
Every webhook delivery posts a JSON envelope with the following
canonical shape. The data object varies by event type
and carries the event-specific payload.
{
"id": "evt_a1b2c3d4e5f6a7b8c9d0e1f2",
"object": "event",
"type": "batch.completed",
"created_at": 1709000100,
"data": {
"...": "event-specific payload"
}
}
| Field | Type | Description |
|---|---|---|
id |
string | Unique event identifier prefixed with evt_. Use for deduplication. |
object |
string | Always the literal string event. |
type |
string | Event type identifier (see Event Types). |
created_at |
integer | Unix timestamp (seconds since epoch) when the event was emitted. |
data |
object | Event-specific payload. Schema depends on type. |
Budget alert deliveries (see
Budget Alert Webhooks) use a
legacy non-enveloped payload shape and do not include the
id, object, or type fields
documented here.
Event Types
Subscribe your webhook endpoints to any combination of the
following event types. Types are grouped by family for
readability; subscriptions are made against the full
dotted identifier (for example exec.completed).
| Event Type | Description |
|---|---|
| exec.* XEM execution lifecycle and approval chain | |
exec.dispatched |
A XEM tool invocation was dispatched to an execution agent. |
exec.completed |
A XEM tool invocation completed successfully. |
exec.failed |
A XEM tool invocation failed with a non-zero exit code or transport error. |
exec.timeout |
A XEM tool invocation exceeded its declared timeout_ms. |
exec.ambiguous |
A XEM tool invocation target was ambiguous and needs operator resolution. |
exec.approval_requested |
An approval request was enqueued for a destructive invocation. |
exec.approved |
An approval request was approved by a privileged user. |
exec.rejected |
An approval request was explicitly rejected by a privileged user. |
exec.approval_escalated |
An approval request was escalated to the next contact in the escalation chain. |
exec.approval_timed_out |
An approval request exceeded its timeout without a decision. |
exec.agent_enrolled |
A new XEM execution agent completed enrollment and its capability manifest was accepted. |
exec.agent_offline |
A XEM execution agent transitioned to offline after missing the lease-renewal deadline. |
exec.discovery_pending |
A XEM discovery request was accepted and is awaiting operator action. |
exec.workspace_created |
An operational workspace was created. |
exec.workspace_budget_exhausted |
A workspace consumed its inference-token budget for the current window. |
| agent.* Agent lifecycle management | |
agent.created |
A new agent record was created via the management API. |
agent.updated |
An existing agent record was updated via the management API. |
agent.deleted |
An agent was soft-deleted via the management API. |
agent.enrolled |
An agent finished enrollment and is eligible to receive dispatches. |
agent.key_rotated |
An agent's CURVE transport keypair rotated and the new public key was persisted. |
| join_key.* Join key lifecycle | |
join_key.created |
A new join key was minted via the management API. |
join_key.revoked |
An existing join key was revoked. |
| batch.* Batch jobs | |
batch.completed |
A batch job completed successfully. |
batch.failed |
A batch job failed. |
batch.cancelled |
A batch job was cancelled. |
| response.* Responses API | |
response.completed |
A response completed successfully. |
response.failed |
A response failed. |
| file.* Files API | |
file.uploaded |
A file was uploaded. |
file.deleted |
A file was deleted. |
| conversation.* Conversations | |
conversation.created |
A conversation was created. |
conversation.deleted |
A conversation was deleted. |
| ox.alert.* Observability | |
ox.alert.raised |
A learning invariant was violated. Raised at critical severity. |
API Endpoints
All paths are relative to your endpoint base URL:
https://api.xerotier.ai/proj_ABC123/ENDPOINT_SLUG
| Method | Path | Description |
|---|---|---|
| POST | /v1/webhooks | Create a webhook endpoint |
| GET | /v1/webhooks | List webhook endpoints |
| GET | /v1/webhooks/{webhook_id} | Get a webhook endpoint |
| PUT | /v1/webhooks/{webhook_id} | Update a webhook endpoint |
| DELETE | /v1/webhooks/{webhook_id} | Delete a webhook endpoint |
| POST | /v1/webhooks/{webhook_id}/test | Send a test delivery |
| GET | /v1/webhooks/{webhook_id}/deliveries | List delivery history |
Create Webhook
POST /v1/webhooks
| Parameter | Type | Description |
|---|---|---|
| urlrequired | string | HTTPS URL to receive webhook deliveries. Must use the https:// scheme. |
| eventsrequired | array | Array of event type strings to subscribe to. At least one event is required. |
| descriptionoptional | string | Human-readable description of the webhook endpoint. |
| metadataoptional | object | Up to 16 key-value pairs of custom metadata. |
curl -X POST https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks \
-H "Authorization: Bearer xero_my-project_abc123" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhook",
"events": ["batch.completed", "response.completed"],
"description": "Production webhook",
"metadata": {"env": "production"}
}'
Response
{
"id": "00000000-1111-0000-1111-000000000000",
"object": "webhook_endpoint",
"url": "https://example.com/webhook",
"description": "Production webhook",
"secret": "whsec_a1b2c3d4e5f6...",
"events": ["batch.completed", "response.completed"],
"is_active": true,
"metadata": {"env": "production"},
"created_at": 1709000000,
"updated_at": 1709000000
}
The secret field is returned only in the creation response. Store it securely, it cannot be retrieved again. You will need it to verify webhook signatures.
List Webhooks
GET /v1/webhooks
Returns a paginated list of webhook endpoints. Use limit and after for pagination.
curl https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks?limit=10 \
-H "Authorization: Bearer xero_my-project_abc123"
Get Webhook
GET /v1/webhooks/{webhook_id}
curl https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/00000000-1111-0000-1111-000000000000 \
-H "Authorization: Bearer xero_my-project_abc123"
Update Webhook
PUT /v1/webhooks/{webhook_id}
All fields are optional. Only provided fields are updated.
| Parameter | Type | Description |
|---|---|---|
| urloptional | string | Updated HTTPS URL. |
| eventsoptional | array | Updated event subscriptions. Must be non-empty if provided. |
| descriptionoptional | string | Updated description. |
| is_activeoptional | boolean | Enable or disable the webhook endpoint. |
| metadataoptional | object | Updated metadata (replaces existing metadata). |
curl -X PUT https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/00000000-1111-0000-1111-000000000000 \
-H "Authorization: Bearer xero_my-project_abc123" \
-H "Content-Type: application/json" \
-d '{
"events": ["batch.completed", "batch.failed", "response.completed"],
"is_active": true
}'
Delete Webhook
DELETE /v1/webhooks/{webhook_id}
Permanently deletes the webhook endpoint and all associated delivery
records. Returns 200 OK with a JSON deletion confirmation
body (not 204 No Content).
curl -X DELETE https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/00000000-1111-0000-1111-000000000000 \
-H "Authorization: Bearer xero_my-project_abc123"
Test Webhook
POST /v1/webhooks/{webhook_id}/test
Sends a test delivery to the webhook endpoint with a synthetic payload. Returns the HTTP status and response body from your endpoint.
curl -X POST https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/00000000-1111-0000-1111-000000000000/test \
-H "Authorization: Bearer xero_my-project_abc123"
Response
{
"success": true,
"http_status": 200,
"response_body": "OK",
"error_message": null
}
List Deliveries
GET /v1/webhooks/{webhook_id}/deliveries
Returns a paginated list of delivery attempts for the webhook endpoint.
curl https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/00000000-1111-0000-1111-000000000000/deliveries?limit=20 \
-H "Authorization: Bearer xero_my-project_abc123"
Response
{
"object": "list",
"data": [
{
"id": "del_abc123",
"object": "webhook_delivery",
"event_type": "batch.completed",
"http_status": 200,
"response_body": "OK",
"status": "delivered",
"attempt_count": 1,
"created_at": 1709000100
}
],
"has_more": false
}
Client Examples
The following examples show how to manage webhooks using Python and Node.js. All examples assume you have set your API key and base URL.
Create Webhook
import requests
headers = {"Authorization": "Bearer xero_my-project_abc123"}
# Create webhook
response = requests.post(
"https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks",
headers=headers,
json={
"url": "https://example.com/webhook",
"events": ["response.completed", "batch.completed"]
}
)
webhook = response.json()
const headers = {
"Authorization": "Bearer xero_my-project_abc123",
"Content-Type": "application/json"
};
// Create webhook
const response = await fetch(
"https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks",
{
method: "POST",
headers,
body: JSON.stringify({
url: "https://example.com/webhook",
events: ["response.completed", "batch.completed"]
})
}
);
const webhook = await response.json();
List Webhooks
import requests
headers = {"Authorization": "Bearer xero_my-project_abc123"}
response = requests.get(
"https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks",
headers=headers
)
webhooks = response.json()
const response = await fetch(
"https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks",
{
headers: {
"Authorization": "Bearer xero_my-project_abc123"
}
}
);
const webhooks = await response.json();
Update Webhook
import requests
headers = {"Authorization": "Bearer xero_my-project_abc123"}
webhook_id = "00000000-1111-0000-1111-000000000000"
response = requests.put(
f"https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/{webhook_id}",
headers=headers,
json={
"events": ["batch.completed", "batch.failed", "response.completed"],
"is_active": True
}
)
updated = response.json()
const webhookId = "00000000-1111-0000-1111-000000000000";
const response = await fetch(
`https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/${webhookId}`,
{
method: "PUT",
headers: {
"Authorization": "Bearer xero_my-project_abc123",
"Content-Type": "application/json"
},
body: JSON.stringify({
events: ["batch.completed", "batch.failed", "response.completed"],
is_active: true
})
}
);
const updated = await response.json();
Delete Webhook
import requests
headers = {"Authorization": "Bearer xero_my-project_abc123"}
webhook_id = "00000000-1111-0000-1111-000000000000"
response = requests.delete(
f"https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/{webhook_id}",
headers=headers
)
# Returns 200 OK with a deletion confirmation body on success
const webhookId = "00000000-1111-0000-1111-000000000000";
const response = await fetch(
`https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/${webhookId}`,
{
method: "DELETE",
headers: {
"Authorization": "Bearer xero_my-project_abc123"
}
}
);
// Returns 200 OK with a deletion confirmation body on success
Test Webhook
import requests
headers = {"Authorization": "Bearer xero_my-project_abc123"}
webhook_id = "00000000-1111-0000-1111-000000000000"
response = requests.post(
f"https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/{webhook_id}/test",
headers=headers
)
result = response.json()
print(f"Test delivery: success={result['success']}, status={result['http_status']}")
const webhookId = "00000000-1111-0000-1111-000000000000";
const response = await fetch(
`https://api.xerotier.ai/proj_ABC123/my-endpoint/v1/webhooks/${webhookId}/test`,
{
method: "POST",
headers: {
"Authorization": "Bearer xero_my-project_abc123"
}
}
);
const result = await response.json();
console.log(`Test delivery: success=${result.success}, status=${result.http_status}`);
Security
HMAC-SHA256 Payload Signing
Every webhook delivery includes a signature header so you can verify the payload was sent by Xerotier and has not been tampered with.
The following headers are included with every delivery:
| Header | Description |
|---|---|
X-Webhook-ID |
Unique identifier for this delivery. Use for deduplication. |
X-Webhook-Timestamp |
Unix timestamp (seconds) when the delivery was sent. |
X-Webhook-Signature |
HMAC-SHA256 signature in the format sha256=HEX_DIGEST. |
Signature Computation
The signature is computed as:
HMAC-SHA256(key=secret, message="{timestamp}.{payload}")
Where timestamp is the value of the X-Webhook-Timestamp
header and payload is the raw JSON request body. The result is
hex-encoded and prefixed with sha256=.
HTTPS Only
Webhook URLs must use the https:// scheme. HTTP URLs and
private/internal network addresses are rejected to prevent SSRF attacks.
Retry Policy
Failed deliveries (non-2xx responses or network errors) are retried with a fixed exponential backoff schedule. The initial delivery is attempt 1; each subsequent retry is scheduled relative to the preceding failure:
| Retry | Delay After Previous Failure |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 15 minutes |
| 4th retry | 1 hour |
| 5th retry | 4 hours |
A delivery may be attempted up to 6 times total (1 initial attempt plus up to 5 retries). After the 5th retry fails, the delivery is marked as permanently failed. The total retry window spans approximately 5 hours and 21 minutes from the initial failure.
Your endpoint should respond with a 2xx status code within 30 seconds, which is the per-attempt delivery timeout enforced by the router.
Limits
| Limit | Value |
|---|---|
| Webhook endpoints per project | 20 |
| Metadata key-value pairs per endpoint | 16 |
| Delivery response timeout | 30 seconds |
| Test deliveries per webhook per hour | 10 |
| URL scheme | https:// only, private network addresses rejected |
Budget Alert Webhooks
Budget alerts can also send HTTP POST notifications to a configured webhook URL when spending thresholds are reached. See Budget Alerts for full configuration details.
Webhook Payload
Budget-alert deliveries use a legacy non-enveloped payload shape that
predates the canonical event envelope and
does not include the id/object/type
fields. When a budget threshold is triggered, the webhook receives:
{
"eventType": "budget.threshold_reached",
"alertId": "uuid",
"alertName": "Monthly API Budget",
"projectId": "uuid",
"projectName": "My Project",
"threshold": 75,
"currentSpendCents": 7500,
"budgetAmountCents": 10000,
"spendPercentage": 75.0,
"timestamp": "2026-01-15T10:30:00Z"
}
Signature Verification
Python
import hashlib
import hmac
import time
def verify_webhook(payload_body, secret, signature_header, timestamp_header):
"""Verify the HMAC-SHA256 signature of a webhook delivery."""
# Check timestamp is within 5 minutes to prevent replay attacks
timestamp = int(timestamp_header)
if abs(time.time() - timestamp) > 300:
raise ValueError("Timestamp too old")
# Compute expected signature
message = f"{timestamp}.{payload_body}".encode("utf-8")
expected = hmac.new(
secret.encode("utf-8"), message, hashlib.sha256
).hexdigest()
expected_sig = f"sha256={expected}"
if not hmac.compare_digest(expected_sig, signature_header):
raise ValueError("Invalid signature")
return True
# Usage in a Flask handler:
# verify_webhook(
# request.data.decode("utf-8"),
# "whsec_your_secret",
# request.headers["X-Webhook-Signature"],
# request.headers["X-Webhook-Timestamp"]
# )
Node.js
import crypto from "crypto";
function verifyWebhook(payloadBody, secret, signatureHeader, timestampHeader) {
// Check timestamp is within 5 minutes to prevent replay attacks
const timestamp = parseInt(timestampHeader, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
throw new Error("Timestamp too old");
}
// Compute expected signature
const message = `${timestamp}.${payloadBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(message)
.digest("hex");
const expectedSig = `sha256=${expected}`;
if (!crypto.timingSafeEqual(
Buffer.from(expectedSig),
Buffer.from(signatureHeader)
)) {
throw new Error("Invalid signature");
}
return true;
}
// Usage in an Express handler:
// app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
// verifyWebhook(
// req.body.toString(),
// "whsec_your_secret",
// req.headers["x-webhook-signature"],
// req.headers["x-webhook-timestamp"]
// );
// res.sendStatus(200);
// });
curl (manual check)
# Given a payload file and the webhook secret:
TIMESTAMP="1709000100"
PAYLOAD=$(cat payload.json)
SECRET="whsec_your_secret"
# Compute the expected signature
EXPECTED=$(printf '%s.%s' "$TIMESTAMP" "$PAYLOAD" | \
openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
echo "sha256=$EXPECTED"
# Compare with the X-Webhook-Signature header value