> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kvelden.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Real-time HTTP delivery of Enclave audit events — configure endpoints, verify signatures, and integrate with SIEMs and automation tools.

## Overview

Enclave can deliver audit events to any HTTP endpoint in real time. Each event is signed with HMAC-SHA256, retried automatically on failure, and carries a stable JSON schema designed for long-term compatibility.

Webhooks are managed at **Organisation → Webhooks**.

***

## Creating an endpoint

1. Navigate to **Organisation → Webhooks**
2. Click **Add endpoint**
3. Enter the HTTPS URL of your receiver
4. Choose which events to subscribe to — or select **All events** (`*`)
5. Copy the generated **signing secret** and store it securely — it is shown once
6. Click **Send test event** to verify connectivity

<Warning>
  Enclave only delivers to HTTPS endpoints. Plain HTTP URLs are rejected at creation time.
</Warning>

***

## Event types

Subscribe to individual event types or use `*` to receive all events.

### Files

| Event type             | Trigger                             |
| ---------------------- | ----------------------------------- |
| `file.uploaded`        | A file is successfully uploaded     |
| `file.downloaded`      | A file is downloaded or viewed      |
| `file.deleted`         | A file is deleted                   |
| `file.shared`          | A file or folder share is created   |
| `file.version_created` | A new version of a file is uploaded |

### Secure rooms

| Event type                 | Trigger                       |
| -------------------------- | ----------------------------- |
| `room.created`             | A Secure Room is created      |
| `room.archived`            | A room is archived            |
| `room.restored`            | An archived room is restored  |
| `room.deleted`             | A room is permanently deleted |
| `room.member_added`        | A user is added to a room     |
| `room.member_removed`      | A user is removed from a room |
| `room.member_role_changed` | A room member's role changes  |

### E-signatures

| Event type            | Trigger                                      |
| --------------------- | -------------------------------------------- |
| `signature.requested` | A signature request is created               |
| `signature.submitted` | A signer submits their signature             |
| `signature.completed` | All signers have signed — document finalised |
| `signature.declined`  | A signer declines to sign                    |
| `signature.cancelled` | The request creator cancels the workflow     |

### Approvals

| Event type           | Trigger                            |
| -------------------- | ---------------------------------- |
| `approval.requested` | A file approval request is created |
| `approval.approved`  | An approver approves the file      |
| `approval.rejected`  | An approver rejects the file       |
| `approval.cancelled` | The request creator cancels        |

### Secrets

| Event type       | Trigger                      |
| ---------------- | ---------------------------- |
| `secret.created` | A secret is stored in a room |
| `secret.viewed`  | A secret's value is revealed |
| `secret.deleted` | A secret is deleted          |

### Security

| Event type             | Trigger                          |
| ---------------------- | -------------------------------- |
| `anomaly.detected`     | A behavioral anomaly alert fires |
| `dlp.file_quarantined` | DLP quarantines a file           |

### Users

| Event type             | Trigger                               |
| ---------------------- | ------------------------------------- |
| `user.invited`         | A user is invited to the organisation |
| `user.invite_accepted` | An invited user accepts and joins     |
| `user.status_changed`  | A user is activated or deactivated    |
| `user.signed_in`       | A user completes authentication       |

***

## Payload format

Every delivery is an HTTP POST with `Content-Type: application/json`.

```json theme={null}
{
  "id": "01926f3a-1c2d-7e4b-a891-0d2e3f4a5b6c",
  "type": "room.deleted",
  "occurred_at": "2026-06-28T14:32:01Z",
  "tenant_id": "11111111-2222-3333-4444-555555555555",
  "actor": {
    "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
    "email": "alice@acme.com",
    "role": "org_admin"
  },
  "data": {
    "resource_type": "room",
    "resource_id": "ffffffff-0000-1111-2222-333333333333",
    "action": "room_deleted",
    "metadata": {
      "room_name": "Project Phoenix",
      "org_unit_id": "44444444-5555-6666-7777-888888888888"
    }
  }
}
```

### Fields

| Field                | Type     | Description                                                      |
| -------------------- | -------- | ---------------------------------------------------------------- |
| `id`                 | UUID     | Unique delivery identifier — use for idempotency                 |
| `type`               | string   | Dot-separated event type (e.g. `file.uploaded`)                  |
| `occurred_at`        | ISO 8601 | UTC timestamp when the event occurred                            |
| `tenant_id`          | UUID     | Organisation that generated the event                            |
| `actor`              | object   | User who triggered the event; `null` for system-generated events |
| `data.resource_type` | string   | Type of the affected resource                                    |
| `data.resource_id`   | UUID     | ID of the affected resource                                      |
| `data.action`        | string   | Internal audit action string                                     |
| `data.metadata`      | object   | Event-specific fields (file name, room name, etc.)               |

### Ping event

When you click **Send test event**, Enclave delivers a `ping` type payload:

```json theme={null}
{
  "id": "...",
  "type": "ping",
  "occurred_at": "...",
  "tenant_id": "...",
  "data": {
    "message": "This is a test event from Kvelden Enclave. If you see this, your webhook endpoint is configured correctly."
  }
}
```

***

## Signature verification

Every delivery includes four HTTP headers:

| Header                | Value                                         |
| --------------------- | --------------------------------------------- |
| `X-Enclave-Delivery`  | UUID of this specific delivery                |
| `X-Enclave-Event`     | Event type (e.g. `room.deleted`)              |
| `X-Enclave-Timestamp` | Unix epoch seconds when the delivery was sent |
| `X-Enclave-Signature` | `sha256=<hex>` HMAC-SHA256 signature          |

### How the signature is computed

```
HMAC-SHA256(signingSecret, timestamp + "." + rawBody)
```

The message is the Unix timestamp (from `X-Enclave-Timestamp`) concatenated with a literal `.` and then the **raw JSON body bytes** — computed before any parsing. Always read the raw body before deserialising JSON, or the byte representation may differ.

### Verification examples

<Warning>
  Always use a **timing-safe comparison** when checking signatures. Standard string equality (`==`, `.equals()`) is vulnerable to timing attacks that can leak the expected value byte by byte.
</Warning>

<CodeGroup>
  ```python Python theme={null}
  import hmac
  import hashlib
  import time

  def verify_enclave_webhook(
      secret: str,
      headers: dict,
      raw_body: bytes,
      max_age_seconds: int = 300,
  ) -> bool:
      ts  = headers.get("X-Enclave-Timestamp", "")
      sig = headers.get("X-Enclave-Signature", "")  # "sha256=<hex>"

      # Reject replays older than max_age_seconds (default 5 minutes).
      try:
          if abs(time.time() - int(ts)) > max_age_seconds:
              return False
      except ValueError:
          return False

      message  = f"{ts}.".encode() + raw_body
      expected = "sha256=" + hmac.new(
          secret.encode(), message, hashlib.sha256
      ).hexdigest()

      return hmac.compare_digest(expected, sig)
  ```

  ```typescript Node.js theme={null}
  import crypto from "crypto";

  function verifyEnclaveWebhook(
    secret: string,
    headers: Record<string, string>,
    rawBody: Buffer,
    maxAgeSeconds = 300,
  ): boolean {
    const ts  = headers["x-enclave-timestamp"] ?? "";
    const sig = headers["x-enclave-signature"] ?? "";  // "sha256=<hex>"

    // Reject replays older than maxAgeSeconds (default 5 minutes).
    if (Math.abs(Date.now() / 1000 - Number(ts)) > maxAgeSeconds) return false;

    const message  = `${ts}.${rawBody.toString()}`;
    const expected = "sha256=" + crypto
      .createHmac("sha256", secret)
      .update(message)
      .digest("hex");

    // timingSafeEqual requires equal-length buffers.
    const a = Buffer.from(expected);
    const b = Buffer.from(sig);
    if (a.length !== b.length) return false;
    return crypto.timingSafeEqual(a, b);
  }
  ```

  ```go Go theme={null}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"fmt"
  	"math"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  func verifyEnclaveWebhook(secret string, r *http.Request, rawBody []byte) bool {
  	ts  := r.Header.Get("X-Enclave-Timestamp")
  	sig := r.Header.Get("X-Enclave-Signature") // "sha256=<hex>"

  	// Reject replays older than 5 minutes.
  	epoch, err := strconv.ParseInt(ts, 10, 64)
  	if err != nil {
  		return false
  	}
  	if math.Abs(float64(time.Now().Unix()-epoch)) > 300 {
  		return false
  	}

  	message := ts + "." + string(rawBody)
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(message))
  	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

  	return hmac.Equal([]byte(expected), []byte(sig))
  }
  ```

  ```java Java theme={null}
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.nio.charset.StandardCharsets;
  import java.security.MessageDigest;
  import java.time.Instant;

  public class EnclaveWebhookVerifier {

      public static boolean verify(
              String secret,
              String timestamp,
              String signature,   // "sha256=<hex>"
              byte[] rawBody
      ) throws Exception {
          // Reject replays older than 5 minutes.
          long epoch = Long.parseLong(timestamp);
          if (Math.abs(Instant.now().getEpochSecond() - epoch) > 300) {
              return false;
          }

          String message = timestamp + "." + new String(rawBody, StandardCharsets.UTF_8);
          Mac mac = Mac.getInstance("HmacSHA256");
          mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
          byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));

          String expected = "sha256=" + bytesToHex(hash);

          // Constant-time comparison.
          return MessageDigest.isEqual(
              expected.getBytes(StandardCharsets.UTF_8),
              signature.getBytes(StandardCharsets.UTF_8)
          );
      }

      private static String bytesToHex(byte[] bytes) {
          StringBuilder sb = new StringBuilder(bytes.length * 2);
          for (byte b : bytes) sb.append(String.format("%02x", b));
          return sb.toString();
      }
  }
  ```

  ```csharp C# theme={null}
  using System;
  using System.Security.Cryptography;
  using System.Text;

  public static class EnclaveWebhookVerifier
  {
      public static bool Verify(
          string secret,
          string timestamp,
          string signature,   // "sha256=<hex>"
          byte[] rawBody,
          int maxAgeSeconds = 300)
      {
          // Reject replays older than maxAgeSeconds.
          if (!long.TryParse(timestamp, out long epoch)) return false;
          var age = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - epoch);
          if (age > maxAgeSeconds) return false;

          var message  = Encoding.UTF8.GetBytes($"{timestamp}.{Encoding.UTF8.GetString(rawBody)}");
          var keyBytes = Encoding.UTF8.GetBytes(secret);

          using var hmac = new HMACSHA256(keyBytes);
          var hash     = hmac.ComputeHash(message);
          var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();

          // CryptographicOperations.FixedTimeEquals prevents timing attacks.
          return CryptographicOperations.FixedTimeEquals(
              Encoding.UTF8.GetBytes(expected),
              Encoding.UTF8.GetBytes(signature)
          );
      }
  }
  ```

  ```php PHP theme={null}
  <?php

  function verifyEnclaveWebhook(
      string $secret,
      string $timestamp,
      string $signature,   // "sha256=<hex>"
      string $rawBody,
      int    $maxAgeSeconds = 300
  ): bool {
      // Reject replays older than $maxAgeSeconds.
      if (abs(time() - (int) $timestamp) > $maxAgeSeconds) {
          return false;
      }

      $message  = $timestamp . '.' . $rawBody;
      $expected = 'sha256=' . hash_hmac('sha256', $message, $secret);

      // hash_equals is constant-time.
      return hash_equals($expected, $signature);
  }
  ```
</CodeGroup>

### Framework integration examples

<CodeGroup>
  ```python FastAPI / Flask (Python) theme={null}
  from fastapi import Request, HTTPException

  @app.post("/webhook")
  async def receive_webhook(request: Request):
      raw_body = await request.body()
      headers  = dict(request.headers)

      if not verify_enclave_webhook(SIGNING_SECRET, headers, raw_body):
          raise HTTPException(status_code=401, detail="Invalid signature")

      event = await request.json()
      print(f"Received {event['type']} for tenant {event['tenant_id']}")
      return {"ok": True}
  ```

  ```typescript Express (Node.js) theme={null}
  import express from "express";

  app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
    // express.raw() gives you a Buffer — do NOT use express.json() here
    if (!verifyEnclaveWebhook(process.env.ENCLAVE_SECRET!, req.headers as any, req.body)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(req.body.toString());
    console.log(`Received ${event.type} for tenant ${event.tenant_id}`);
    res.status(200).json({ ok: true });
  });
  ```

  ```go Go (net/http) theme={null}
  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      rawBody, err := io.ReadAll(r.Body)
      if err != nil || !verifyEnclaveWebhook(signingSecret, r, rawBody) {
          http.Error(w, "Invalid signature", http.StatusUnauthorized)
          return
      }

      var event map[string]any
      if err := json.Unmarshal(rawBody, &event); err != nil {
          http.Error(w, "Bad JSON", http.StatusBadRequest)
          return
      }

      fmt.Printf("Received %s\n", event["type"])
      w.WriteHeader(http.StatusOK)
  }
  ```
</CodeGroup>

<Note>
  **Read the raw body first.** Parse JSON only after signature verification is complete. Some frameworks (e.g. Express with `express.json()`) consume the body stream before you can read raw bytes — use `express.raw()` or equivalent middleware instead.
</Note>

***

## Delivery and retries

Enclave considers a delivery successful when the endpoint responds with any `2xx` HTTP status code within **10 seconds**. Any other outcome (non-2xx, timeout, connection error) schedules a retry.

| Attempt | Timing                     |
| ------- | -------------------------- |
| 1       | Immediate                  |
| 2       | 30 seconds after attempt 1 |
| 3       | 5 minutes after attempt 2  |
| 4       | 30 minutes after attempt 3 |
| 5       | 2 hours after attempt 4    |

After 5 failed attempts the delivery is permanently marked **Failed**. Failed deliveries are visible in the **Delivery log** tab for each endpoint so you can inspect the response body and HTTP status from each attempt.

### Idempotency

Each event produces a unique `id` (the delivery UUID). If your endpoint receives the same `id` more than once — which can happen during retries — you can safely deduplicate by storing processed IDs.

***

## SIEM integration

Webhooks are the recommended path for streaming Enclave events into a SIEM:

1. Configure a SIEM HTTP Event Collector or Data Input with an HTTPS endpoint
2. Add the endpoint in **Organisation → Webhooks**
3. Subscribe to `*` (all events) or specific event categories
4. Use your SIEM's built-in signature validation to verify deliveries

Tested integrations:

* Splunk HTTP Event Collector
* Elastic / OpenSearch Logstash HTTP input
* Microsoft Sentinel Logic App HTTP trigger
* Datadog Log Management
* Sumo Logic HTTP Logs source

For on-premise deployments where outbound HTTPS is restricted, use the **SIEM Forwarder** option in **Organisation → SIEM Settings** to stream via syslog (RFC 5424) or CEF over UDP/TCP to a local collector.

***

## Managing endpoints

From **Organisation → Webhooks** you can:

* **Pause** an endpoint — deliveries are dropped while paused (not queued)
* **Rotate the signing secret** — the old secret stops working immediately; update your receiver before rotating
* **View the delivery log** — per-endpoint history of every delivery attempt with HTTP status, response body, and retry schedule
* **Delete an endpoint** — removes all future deliveries; historical delivery log is retained

### Rotating a secret

1. Click **Rotate secret** on the endpoint
2. Copy the new secret
3. Update your receiver to accept the new secret
4. Confirm rotation

After confirming, all new deliveries are signed with the new secret. In-flight retries for deliveries that were already queued will still use the old secret.
