Webhooks¶
Audience: Engineers wiring Nanorix events into their existing systems — alerting, ticket queues, audit pipelines, compliance-automation platforms (Vanta, Drata). Time to read: 8 minutes. Prerequisites: A Nanorix API key and an HTTPS endpoint capable of receiving POST requests.
Nanorix posts a JSON event to your endpoint whenever something happens that you've subscribed to: a capsule destroys, an AuditProof is emitted, an egress rule blocks a request, a key rotates. Each request is HMAC-SHA256 signed with your endpoint's secret so you can verify the payload originated from Nanorix and was not modified in transit.
Event-type catalog¶
| Event | Fires when | Typical use |
|---|---|---|
capsule.created |
A capsule is created | Inventory; concurrent-capsule monitoring |
capsule.executed |
A capsule finishes an execution | Run telemetry; per-execution accounting |
capsule.destroyed |
A capsule is destroyed (success or timeout) | Lifecycle telemetry |
capsule.destroyed_no_proof |
A capsule is destroyed without an AuditProof being emitted | Evidence-gap alerting |
capsule.destroyed_via_console |
A capsule is destroyed by an operator action in the Console | Operator-action audit |
capsule.signing_failed |
AuditProof signing failed for a destroyed capsule | Evidence-pipeline alerting |
capsule.failed |
A capsule terminates with a non-zero exit code | Alerting; retry logic in your orchestrator |
capsule.timeout |
A capsule hits max_lifetime_seconds |
TTL-tuning signal |
capsule.expiring |
A capsule is approaching its TTL ceiling (warning) | Proactive cleanup |
cdp.generated |
An AuditProof is generated at capsule destroy | Storage pipelines (S3 Object Lock, etc.) |
cdp.verified |
A stateless verify call succeeds for one of your AuditProofs | Compliance-automation triggers |
audit_proof.emitted |
Customer-facing-brand alias for cdp.generated (fires alongside) |
Vanta / Drata / SIEM platforms |
egress.blocked |
A capsule attempted egress denied by allowlist | Anomaly detection; drift alerting |
egress.rule_changed |
An egress profile or rule was modified | Change-management evidence |
usage.threshold |
Tier quota crosses a threshold (50%, 75%, 90%, 100%) | Cost monitoring |
key.created |
A new API key is created | Key-rotation pipelines |
key.revoked |
An API key is revoked | Key-rotation pipelines |
key.dot |
Signing-key lifecycle event | Key-lifecycle audit |
capsulefile.created |
A new Capsulefile is finalized | Build-system telemetry |
capsulefile.finalized |
A draft Capsulefile is finalized and becomes immutable | Build-system telemetry; release gating |
capsulefile.updated |
A Capsulefile is modified (rare; finalized Capsulefiles are immutable) | Audit |
capsulefile.deleted |
A Capsulefile is deleted | Cleanup audit |
You subscribe to a specific subset of events, or pass ["*"] to subscribe to all current and future events. Forever-Standard discipline: existing event types are permanent; new events arrive as additive entries to this catalog.
Registering an endpoint¶
from nanorix import Client
client = Client() # auto-loads NANORIX_API_KEY
endpoint = client.webhooks.create(
url="https://hooks.your-org.com/nanorix",
events=[
"capsule.destroyed",
"audit_proof.emitted",
"egress.blocked",
],
description="Production audit pipeline",
)
# Save endpoint["secret"] securely — it is shown ONCE on create.
print(endpoint["id"], endpoint["secret"])
import { NanorixClient } from "@nanorix/sdk";
const client = new NanorixClient();
const endpoint = await client.webhooks.create({
url: "https://hooks.your-org.com/nanorix",
events: ["capsule.destroyed", "audit_proof.emitted", "egress.blocked"],
description: "Production audit pipeline",
});
// endpoint.secret is shown ONCE
console.log(endpoint.id, endpoint.secret);
curl -X POST https://api.nanorix.io/v1/webhooks \
-H "Authorization: Bearer nrx_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.your-org.com/nanorix",
"events": ["capsule.destroyed", "audit_proof.emitted"],
"description": "Production audit pipeline"
}'
The response includes a secret that is shown exactly once — store it securely (your KMS, your secret manager, an env var sourced from a sealed store). Nanorix retains only a hash for the lifetime of the endpoint.
The webhook URL must use HTTPS and cannot target localhost or private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, link-local, loopback).
Receiving an event¶
Every request includes:
| Header | Meaning |
|---|---|
X-Nanorix-Signature |
HMAC-SHA256 signature of <timestamp>.<body> using your endpoint secret, prefixed sha256= |
X-Nanorix-Timestamp |
Unix seconds; reject if outside a 5-minute window to prevent replay |
X-Nanorix-Event |
The event type, e.g. capsule.destroyed |
X-Nanorix-Delivery-Id |
Unique per delivery; use for idempotency in your handler |
Content-Type |
application/json |
Body shape:
{
"id": "evt_018f5b8e-3a9c-4c4d-9e7e-d4a8f7b2c10e",
"type": "capsule.destroyed",
"created_at": "2026-05-08T14:00:00Z",
"data": {
"capsule_id": "cap_...",
"destroyed_at": "2026-05-08T14:00:00Z",
"exit_code": 0,
"duration_ms": 4218,
"audit_proof_id": "prf_..."
}
}
The data object is event-specific. The shape is stable per Forever-Standard discipline — fields are additive, never removed or renamed.
HMAC signature verification¶
The signature header value is sha256=<hex>, computed over the message <timestamp>.<body>. Verify in constant time and reject on any failure or stale timestamp.
Python¶
import hashlib
import hmac
import time
def verify_webhook(payload: bytes, signature: str, secret: str, timestamp: int) -> bool:
"""Verify a Nanorix webhook signature."""
if not signature.startswith("sha256="):
return False
# 5-minute replay window
now = int(time.time())
if abs(now - timestamp) > 300:
return False
expected = signature[len("sha256="):]
message = f"{timestamp}.{payload.decode('utf-8')}"
computed = hmac.new(
secret.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(computed, expected)
The Python SDK ships this helper as Webhook.verify_signature(...):
from nanorix.resources.webhooks import Webhook
from flask import request, abort
@app.route("/nanorix", methods=["POST"])
def receive():
signature = request.headers["X-Nanorix-Signature"]
timestamp = int(request.headers["X-Nanorix-Timestamp"])
if not Webhook.verify_signature(request.data, signature, MY_SECRET, timestamp):
abort(401)
handle(request.json)
return "", 200
TypeScript¶
import { createHmac, timingSafeEqual } from "crypto";
function verifyWebhook(
payload: Buffer,
signature: string,
secret: string,
timestamp: number,
): boolean {
if (!signature.startsWith("sha256=")) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) return false;
const expected = signature.slice("sha256=".length);
const message = `${timestamp}.${payload.toString("utf-8")}`;
const computed = createHmac("sha256", secret).update(message).digest("hex");
const a = Buffer.from(computed, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
The TypeScript SDK ships this helper as a static on the Webhooks resource class: Webhooks.verifySignature(...), with the same shape.
curl (one-off verification)¶
TIMESTAMP=1715173200
BODY='{"id":"evt_...","type":"capsule.destroyed","data":{...}}'
SECRET="whsec_..."
EXPECTED=$(printf '%s.%s' "$TIMESTAMP" "$BODY" | \
openssl dgst -sha256 -hmac "$SECRET" -hex | \
awk '{print $2}')
# Compare against X-Nanorix-Signature stripped of "sha256=" prefix
echo "sha256=$EXPECTED"
Retry policy¶
If your endpoint returns a 4xx or 5xx, or fails to respond within 10 seconds, Nanorix retries with exponential backoff:
| Attempt | Delay after the prior attempt |
|---|---|
| 1 (original) | n/a |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
Five retries follow the initial attempt, spanning a total recovery window of roughly 3 hours. After the final failed attempt, the event is moved to a dead-letter queue and the endpoint is marked unhealthy (visible in the webhook list endpoint). You can replay DLQ events from the Console, and DLQ inspection and replay are live API surface: GET /v1/webhooks/dlq lists dead-lettered deliveries and POST /v1/webhooks/dlq/{id}/replay re-sends one.
A 2xx response (any status from 200 through 299) is considered success. A 410 Gone signals "permanently delete this endpoint" and stops further retries plus disables the endpoint.
Idempotency expectations¶
Your endpoint MUST be idempotent. Nanorix may deliver the same event more than once under several conditions:
- Network failure between Nanorix and your endpoint (Nanorix retries; your endpoint may have processed the original)
- Your endpoint returned 5xx but actually processed the event
- Operator-initiated DLQ replay
Use the X-Nanorix-Delivery-Id header (or the body's id field) as the idempotency key. Common patterns:
# Postgres-backed dedup
def handle(event_id: str, body: dict):
inserted = db.execute(
"INSERT INTO processed_events (event_id) VALUES (%s) "
"ON CONFLICT (event_id) DO NOTHING RETURNING event_id",
(event_id,),
).fetchone()
if not inserted:
return # duplicate; already processed
process(body)
// Redis-backed dedup with 30-day TTL
async function handle(eventId: string, body: object) {
const ok = await redis.set(`evt:${eventId}`, "1", "NX", "EX", 30 * 86400);
if (ok !== "OK") return; // duplicate
await process(body);
}
Listing, updating, deleting endpoints¶
# List all endpoints
client.webhooks.list()
# Get one
client.webhooks.get("wh_...")
# Update (rotate URL, change subscriptions, enable/disable)
client.webhooks.update(
"wh_...",
url="https://hooks.your-org.com/nanorix-v2",
events=["capsule.destroyed", "audit_proof.emitted"],
active=True,
)
# Delete
client.webhooks.delete("wh_...")
Update operations DO NOT rotate the secret. Rotate the signing secret in place with POST /v1/webhooks/{id}/rotate-secret, then update your handler with the new secret.
Troubleshooting¶
Signature mismatch¶
Symptom: verify_webhook returns False; HTTP 401 from your handler.
Likely causes:
1. The body bytes you're hashing have been re-serialized. Frameworks that parse the JSON before passing to your handler often change byte-level representation. Always hash the raw request body bytes, not a re-serialized form.
2. The secret in your handler is from a different endpoint or has been mutated (a stray newline, encoding change). Verify the secret matches what the endpoint create response returned.
3. The timestamp you passed differs from X-Nanorix-Timestamp. The signature is over <timestamp>.<body> — using the wrong timestamp produces the wrong signature.
Replay rejection¶
Symptom: signature is correct but abs(now - timestamp) > 300 returns true and your handler rejects.
Likely causes: 1. Your server's clock has drifted. Run NTP. 2. The webhook was sitting in a queue (yours or an upstream proxy) for >5 minutes. Either widen your replay window (with caution) or fix the queue lag.
5xx loop¶
Symptom: Nanorix retries the same event multiple times and your endpoint keeps returning 5xx.
Likely causes: 1. Your handler raises on a malformed payload it doesn't recognize. Treat unknown event types as success (return 2xx) and log them; new events arrive over time per Forever-Standard discipline. 2. Your downstream system (database, queue) is degraded. Returning 5xx is correct here — Nanorix's retry backoff will allow your system to recover. After 5 retries spanning ~3 hours, the event lands in DLQ.
DLQ recovery¶
If events have landed in DLQ, list them via the Console or GET /v1/webhooks/dlq. Replay flushes them through the retry pipeline once more. After replay, marking your endpoint healthy again resumes normal delivery.
What webhooks are not¶
Webhooks are not the primary distribution channel for AuditProofs. Use client.audit_proofs.get(capsule_id) to retrieve the full proof for storage; the cdp.generated / audit_proof.emitted event carries identifiers and metadata, not the full proof body.
Webhooks are not for low-latency synchronous control. They fire asynchronously after the underlying event commits. If your application logic depends on synchronous knowledge of capsule state, poll GET /v1/capsules/<id> or rely on the SDK's capsule.run() blocking call.
Related¶
- API reference — full endpoint reference
- Error catalog — error codes referenced in webhook delivery failures
- AuditProof storage operational guide — pairs with
cdp.generatedfor storage pipelines