Skip to content

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.


  • API reference — full endpoint reference
  • Error catalog — error codes referenced in webhook delivery failures
  • AuditProof storage operational guide — pairs with cdp.generated for storage pipelines