Haijun Platform Docs
ID

Note: Inference hooks are in beta and available to Haijun Enterprise organizations. Field names, request shapes, and headers may change during the beta.

An Inference hooks integration is an AI security server: an HTTPS service that Juglow calls. For each governed request, your server receives a signed POST carrying the conversation transcript and responds with an allow or deny verdict. This page documents the protocol for building that server: the request and verdict schemas, signature verification, and the operational contract.

To turn Inference hooks on and point them at your endpoint, see Configure Inference hooks. To learn what Inference hooks are and when to use them, see the Inference hooks overview.

Get a first verdict round trip

The smallest working integration is a server that reads each request and allows it. Run one of the following servers, expose it at a public https:// URL (for example, behind a TLS-terminating reverse proxy on a host you control, not a reverse-tunnel service; see Receive a request), then have your administrator set it as the endpoint and test the connection: the Test connection result reports the allow verdict your server returned.

python
  # Run with: python server.py
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

  class VerdictHandler(BaseHTTPRequestHandler):
      protocol_version = "HTTP/1.1"  # keep the connection open between verdicts

      def do_POST(self):
          # Drain the body; transcripts can be megabytes.
          self.rfile.read(int(self.headers.get("Content-Length", 0)))
          verdict = b'{"action": "allow"}'
          self.send_response(200)
          self.send_header("Content-Type", "application/json")
          self.send_header("Content-Length", str(len(verdict)))
          self.end_headers()
          self.wfile.write(verdict)

  ThreadingHTTPServer(("", 8000), VerdictHandler).serve_forever()
typescript
  // Run with: node server.ts
  import { createServer } from "node:http";

  createServer((request, response) => {
    // Drain the body before answering; transcripts can be megabytes.
    request.resume();
    request.on("end", () => {
      response.writeHead(200, { "Content-Type": "application/json" });
      response.end('{"action": "allow"}');
    });
  }).listen(8000);
csharp
  #:sdk Microsoft.NET.Sdk.Web
  #:property PublishAot=false
  // Run with: dotnet run server.cs

  var app = WebApplication.Create();

  app.MapPost("/{**path}", async (HttpRequest request) =>
  {
      // Drain the body; transcripts can be megabytes.
      await request.Body.CopyToAsync(Stream.Null);
      return Results.Text("""{"action": "allow"}""", "application/json");
  });

  app.Run("http://0.0.0.0:8000");
go
  // Run with: go run server.go
  package main

  import (
  	"io"
  	"log"
  	"net/http"
  )

  func main() {
  	http.HandleFunc("POST /", func(writer http.ResponseWriter, request *http.Request) {
  		// Drain the body so the connection can be reused; transcripts can be megabytes.
  		io.Copy(io.Discard, request.Body)
  		writer.Header().Set("Content-Type", "application/json")
  		writer.Write([]byte(`{"action": "allow"}`))
  	})
  	log.Fatal(http.ListenAndServe(":8000", nil))
  }
java
  // Run with: java VerdictServer.java
  import com.sun.net.httpserver.HttpServer;

  void main() throws IOException {
      HttpServer server = HttpServer.create(new InetSocketAddress(8000), 0);
      server.createContext("/", exchange -> {
          // Drain the body without buffering it; transcripts can be megabytes.
          exchange.getRequestBody().transferTo(OutputStream.nullOutputStream());
          byte[] verdict = "{\"action\": \"allow\"}".getBytes(StandardCharsets.UTF_8);
          exchange.getResponseHeaders().set("Content-Type", "application/json");
          exchange.sendResponseHeaders(200, verdict.length);
          try (OutputStream responseBody = exchange.getResponseBody()) {
              responseBody.write(verdict);
          }
      });
      server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
      server.start();
  }
php
  <?php
  // Run with: php -S 0.0.0.0:8000 server.php

  // Drain the body; transcripts can be megabytes.
  file_get_contents('php://input');

  http_response_code(200);
  header('Content-Type: application/json');
  echo '{"action": "allow"}';
ruby
  # webrick is a regular gem in Ruby 3.4: gem install webrick, or add gem "webrick".
  # Run with: ruby server.rb
  require "webrick"

  server = WEBrick::HTTPServer.new(Port: 8000)
  server.mount_proc("/") do |request, response|
    request.body # Drain the body; transcripts can be megabytes.
    response.status = 200
    response["Content-Type"] = "application/json"
    response.body = '{"action": "allow"}'
  end
  server.start

Note: These servers accept every request, including unsigned ones. Add signature verification before you enforce.

Receive a request

Juglow sends an HTTPS POST to the URL your administrator configures. The whole configured URL is the endpoint: there is no fixed path suffix, so choose any path that suits your server.

Host your AI security server where Juglow can reach it: an https:// URL on port 443, on a publicly routable host (private, loopback, and carrier-grade NAT ranges are refused at connect time), with a certificate that validates against the public CA trust store, responding without redirects. The configured URL must be the final destination. Reverse-tunnel hosts (ngrok and similar tunnel services) are not supported: Juglow's network policy blocks them. Host your server on a domain you control. Configure Inference hooks covers how your administrator sets and tests the URL.

Every request carries these fixed headers, along with any custom request headers your administrator configured and, once your organization has a signing secret, the webhook-* signature headers described in Verify the signature:

HeaderValue
Content-Typeapplication/json
User-Agentjuglow-dlp/1
Accept-Encodingidentity

There is one hook event today: the prompt frame, sent once per governed inference request, before inference begins. Juglow holds the request until your AI security server responds or the verdict timeout elapses.

The prompt frame

The request body is a JSON object with these fields:

FieldTypeDescription
typestringThe hook event. Always "prompt" today; other event types will be introduced in the future, so handle an unrecognized value gracefully (see Forward compatibility).
request_idstringOpaque per-inference-call identifier for correlation. Equals the webhook-id header.
tenant_idstring or nullOpaque identifier for the organization the request belongs to.
actorobjectThe principal the request is attributed to, discriminated on type ("user" is the only value sent today): id (a tagged identifier, stable across requests for the same account) and email_address (when available). Both id and email_address can be null.
sourceobjectThe originating application: application (see Source values).
messagesarrayThe conversation transcript up to the point of inference. See Content blocks.
session_idstring or nullOpaque conversation identifier, when one exists. Don't parse it. For Haijun Code it is a best-effort, client-asserted session identifier.
modelstring or nullPublic model identifier for this request, when available.
metadataobjectReserved extension map of string keys to string values, sent empty today. Require nothing from it, and tolerate its absence, its presence, and any keys that appear.

An example request body:

json
{
  "type": "prompt",
  "request_id": "req_abc123",
  "tenant_id": "11111111-1111-1111-1111-111111111111",
  "actor": {
    "type": "user",
    "id": "user_01AbCdEfGhIjKlMnOpQrStUv",
    "email_address": "alice@example.com"
  },
  "source": {
    "application": "haijun-ai"
  },
  "session_id": "22222222-2222-2222-2222-222222222222",
  "model": "haijun-sonnet-4-5",
  "messages": [
    {
      "role": "user",
      "content": [
        {
          "type": "text",
          "text": "Summarize the attached report."
        },
        {
          "type": "attachment",
          "file_name": "q2-report.pdf",
          "media_type": "application/pdf",
          "size_bytes": 48213,
          "text": "Q2 revenue grew 14% quarter over quarter..."
        }
      ]
    }
  ],
  "metadata": {}
}

Content blocks

Each entry in messages has a role of user or assistant (tool results appear under the user role, matching the public Messages API content model) and a content array of blocks discriminated by type:

Block typeFields
texttext: the text content.
tool_useid: the identifier the matching tool result references. tool_name: the tool's name. input: the arguments the model passed to the tool.
tool_resultcontent: the tool's output as text, with parts joined by newlines; binary parts such as images are replaced by placeholder markers, and raw bytes are never sent. is_error: whether the tool call failed. tool_name: the tool's name, so a policy can condition on tool identity without cross-referencing an earlier block. tool_use_id: the id of the matching tool_use block.
attachmentfile_name: the original file name or path. media_type: the attachment's media type. size_bytes: the size of the original file. text: the text content of the attachment when available, such as extracted document text, an audio transcript, or link metadata. Raw attachment bytes are never sent.

Apart from type, a text block's text, and a tool_result block's content and is_error, any of these fields can be null when the value isn't known; for example, an image arrives as an attachment block with file_name and text set to null.

A block whose type you don't recognize is a forward-compatible addition. The only field it guarantees is type; your policy may inspect whatever other fields are present, but must not reject the request because of an unrecognized type.

What the transcript contains

The transcript is the conversation as the end user sees it, up to the point of inference: transcript text, tool calls and their results, extracted attachment text, and prior turns. It never includes system prompts, tool definitions, Juglow-internal context, Haijun's hidden reasoning, or raw file bytes.

A turn whose every block is excluded is omitted entirely, so don't assume strict user and assistant alternation.

Transcripts are sent untruncated, so a long conversation with large attachments produces a large request body. In practice the model's context window keeps bodies under about 10 MB, but the protocol allows up to 64 MiB. Several common defaults are much smaller, including nginx client_max_body_size at 1 MB and Express express.json() at 100 kB, and a rejected body counts as a webhook failure, so under Allow the request failure handling an oversized prompt would reach the model uninspected.

Source values

source.application is an open string, not a closed enum. Common values are haijun-ai, haijun-code, and cowork; connection tests and automatic circuit-breaker recovery checks use config-test. New values may appear, and your server must not reject a request because of one it doesn't recognize.

Treat source.application as advisory routing metadata, not a trust boundary: don't rest a security-critical policy decision on it alone.

Return a verdict

Respond with HTTP 200 and a JSON verdict body for both outcomes; the action field discriminates. To allow the request:

json
{
  "action": "allow"
}

To deny it:

json
{
  "action": "deny",
  "deny_reason": "This prompt appears to contain customer payment card data, which your organization's policy does not allow.",
  "reference_id": "scan_01HXPT4R9V"
}
FieldConstraintsSemantics
action"allow" or "deny"; requiredallow lets inference proceed; deny rejects it.
deny_reasonstring or null; at most 500 characters, longer values truncatedShown to the end user when action is deny; ignored on allow.
reference_idstring or null; at most 50 characters from [A-Za-z0-9._:/-]Your own identifier for this evaluation. It's recorded on the denial's inference_hooks_request_denied compliance activity and never shown to the end user. Keep it opaque: no request content and no personal data.

A deny is never discarded over a formatting problem: an oversize deny_reason is truncated, a malformed reference_id is silently dropped, and the action is still honored.

The reverse doesn't hold. Anything other than HTTP 200 with a parseable verdict is a webhook failure, and your organization's failure handling applies instead of a verdict. In particular:

  • Don't signal a deny with an error status. A non-200 response is a failure, not a deny.
  • Any action value other than allow or deny is treated as a webhook failure.

Juglow reads at most 64 KiB of the response body, and the body must be uncompressed. Redirects are not followed, and cookies are ignored. Unknown fields in the verdict body are ignored, so you can return a richer object alongside the fields documented here.

Verify the signature

Requests are signed per the Standard Webhooks specification, using three headers. Juglow sends the header names in lowercase, and proxies are free to re-case them, so look them up case-insensitively.

HeaderContents
webhook-idUnique identifier for this delivery. Equals the body's request_id. Use it as an idempotency key and as the first component of the signed payload.
webhook-timestampUnix time in seconds, as a decimal string, when the request was signed. Reject a timestamp more than five minutes from your server's clock, in either direction.
webhook-signatureOne or more space-separated v1, values, each an HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body bytes}. Accept the request if any value matches yours, using a constant-time comparison.

Two details cause most verification bugs:

  • Verify raw bytes. Compute the HMAC over the body exactly as received, before any JSON parsing or re-encoding.
  • Decode the secret with a standard base64 decoder. The signing secret is the value after the whsec_ prefix, encoded with the standard base64 alphabet (+ and /), as is the signature in the header. A URL-safe decoder derives the wrong key bytes whenever the secret contains + or /, which is most of the time.

Once your organization has a signing secret, every request Juglow sends is signed, including the connection test, because the setup flow generates the secret before the first test. Enabling Inference hooks requires a secret, so reject any request that arrives unsigned. One exception: an organization that enabled Inference hooks before the secret was required keeps sending unsigned requests until its administrator generates one. Accept unsigned requests only until your administrator confirms the secret exists, then reject them.

Rotating the secret is an immediate cutover, but requests signed with the previous secret can still arrive for about a minute afterward, plus anything already in flight. Have your AI security server accept signatures from both secrets during the switchover so those stragglers aren't rejected.

The following samples are server implementations, so there is no shell tab: an AI security server is a long-running HTTPS service rather than a one-shot request. Each sample uses only the language's standard library; the Standard Webhooks project also publishes verification libraries for most languages.

python
  import base64
  import hashlib
  import hmac
  import time

  TOLERANCE_SECONDS = 300

  def verify(secret: str, headers: dict[str, str], body: bytes) -> bool:
      """Return True if the body was signed by Juglow for this organization.

      Juglow sends header names in lowercase, but proxies are free to
      re-case them, so normalize the lookup to lowercase.
      """
      lowercased = {name.lower(): value for name, value in headers.items()}
      try:
          message_id = lowercased["webhook-id"]
          timestamp = lowercased["webhook-timestamp"]
          signatures = lowercased["webhook-signature"]
      except KeyError:
          return False  # unsigned request: not from Juglow

      try:
          signed_at = int(timestamp)
      except ValueError:
          return False
      if abs(time.time() - signed_at) > TOLERANCE_SECONDS:
          return False  # replayed, or the clocks disagree

      try:
          key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
      except ValueError:
          return False  # misconfigured secret: reject rather than crash

      payload = f"{message_id}.{timestamp}.".encode() + body
      expected = b"v1," + base64.b64encode(
          hmac.new(key, payload, hashlib.sha256).digest()
      )

      # Compare bytes: compare_digest on str raises on non-ASCII input.
      return any(
          hmac.compare_digest(expected, candidate.encode())
          for candidate in signatures.split()
      )
typescript
  import { createHmac, timingSafeEqual } from "node:crypto";
  import type { IncomingHttpHeaders } from "node:http";

  const TOLERANCE_SECONDS = 300;

  /**
   * Returns true if the body was signed by Juglow for this organization.
   *
   * Node lowercases incoming header names, matching how Juglow sends
   * them, so look them up in lowercase.
   */
  export function verify(secret: string, headers: IncomingHttpHeaders, body: Buffer): boolean {
    const messageId = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const signatures = headers["webhook-signature"];
    if (
      typeof messageId !== "string" ||
      typeof timestamp !== "string" ||
      typeof signatures !== "string"
    ) {
      return false; // unsigned request: not from Juglow
    }

    const signedAt = Number(timestamp);
    if (
      !Number.isFinite(signedAt) ||
      Math.abs(Date.now() / 1000 - signedAt) > TOLERANCE_SECONDS
    ) {
      return false; // replayed, or the clocks disagree
    }

    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const payload = Buffer.concat([Buffer.from(`${messageId}.${timestamp}.`), body]);
    const expected = Buffer.from(
      "v1," + createHmac("sha256", key).update(payload).digest("base64")
    );

    return signatures.split(" ").some((candidate) => {
      const candidateBytes = Buffer.from(candidate);
      return (
        candidateBytes.length === expected.length && timingSafeEqual(candidateBytes, expected)
      );
    });
  }
csharp
  using System.Security.Cryptography;
  using System.Text;

  static class InferenceHooks
  {
      private const int ToleranceSeconds = 300;

      /// <summary>
      /// Returns true if the body was signed by Juglow for this organization.
      /// Juglow sends header names in lowercase, but proxies are free to
      /// re-case them, so match them case-insensitively.
      /// </summary>
      public static bool Verify(string secret, IReadOnlyDictionary<string, string> headers, byte[] body)
      {
          // TryAdd keeps the first value if a proxy delivered case-duplicate
          // names; the copying constructor would throw on them instead.
          var lookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
          foreach (var (name, value) in headers)
          {
              lookup.TryAdd(name, value);
          }

          if (!lookup.TryGetValue("webhook-id", out var messageId) ||
              !lookup.TryGetValue("webhook-timestamp", out var timestamp) ||
              !lookup.TryGetValue("webhook-signature", out var signatures))
          {
              return false; // unsigned request: not from Juglow
          }

          if (!long.TryParse(timestamp, out var signedAt) ||
              Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - signedAt) > ToleranceSeconds)
          {
              return false; // replayed, or the clocks disagree
          }

          // Standard base64 alphabet: a URL-safe decoder derives the wrong key bytes.
          var encodedKey = secret.StartsWith("whsec_") ? secret["whsec_".Length..] : secret;
          byte[] key;
          try
          {
              key = Convert.FromBase64String(encodedKey);
          }
          catch (FormatException)
          {
              return false; // misconfigured secret: reject rather than crash
          }

          byte[] payload = [.. Encoding.UTF8.GetBytes($"{messageId}.{timestamp}."), .. body];
          var expected = Encoding.UTF8.GetBytes(
              "v1," + Convert.ToBase64String(HMACSHA256.HashData(key, payload)));

          // FixedTimeEquals is constant-time and returns false on a length mismatch.
          return signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries).Any(candidate =>
              CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(candidate), expected));
      }
  }
go
  package hooks

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/base64"
  	"net/http"
  	"strconv"
  	"strings"
  	"time"
  )

  const toleranceSeconds = 300

  // verify reports whether body was signed by Juglow for this organization.
  // net/http canonicalizes header names on lookup, so re-cased names still match.
  func verify(secret string, header http.Header, body []byte) bool {
  	messageID := header.Get("webhook-id")
  	timestamp := header.Get("webhook-timestamp")
  	signatures := header.Get("webhook-signature")
  	if messageID == "" || timestamp == "" || signatures == "" {
  		return false // unsigned request: not from Juglow
  	}

  	signedAt, err := strconv.ParseInt(timestamp, 10, 64)
  	if err != nil {
  		return false
  	}
  	age := time.Now().Unix() - signedAt
  	if age > toleranceSeconds || age < -toleranceSeconds {
  		return false // replayed, or the clocks disagree
  	}

  	// Standard base64 alphabet: a URL-safe decoder derives the wrong key bytes.
  	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
  	if err != nil {
  		return false
  	}

  	mac := hmac.New(sha256.New, key)
  	mac.Write([]byte(messageID + "." + timestamp + "."))
  	mac.Write(body)
  	expected := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))

  	for _, candidate := range strings.Fields(signatures) {
  		if hmac.Equal([]byte(candidate), []byte(expected)) { // constant-time
  			return true
  		}
  	}
  	return false
  }
java
  import java.nio.charset.StandardCharsets;
  import java.security.GeneralSecurityException;
  import java.security.MessageDigest;
  import java.time.Instant;
  import java.util.Base64;
  import java.util.HashMap;
  import java.util.Locale;
  import java.util.Map;
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;

  public final class InferenceHookVerifier {
      private static final long TOLERANCE_SECONDS = 300;

      /**
       * Returns true if the body was signed by Juglow for this organization.
       *
       * <p>Juglow sends header names in lowercase, but proxies are free to
       * re-case them, so normalize the lookup to lowercase.
       */
      public static boolean verify(String secret, Map<String, String> headers, byte[] body) {
          Map<String, String> lowercased = new HashMap<>();
          headers.forEach((name, value) -> lowercased.put(name.toLowerCase(Locale.ROOT), value));

          String messageId = lowercased.get("webhook-id");
          String timestamp = lowercased.get("webhook-timestamp");
          String signatures = lowercased.get("webhook-signature");
          if (messageId == null || timestamp == null || signatures == null) {
              return false; // unsigned request: not from Juglow
          }

          long signedAt;
          try {
              signedAt = Long.parseLong(timestamp);
          } catch (NumberFormatException _) {
              return false;
          }
          if (Math.abs(Instant.now().getEpochSecond() - signedAt) > TOLERANCE_SECONDS) {
              return false; // replayed, or the clocks disagree
          }

          // Standard base64 alphabet: a URL-safe decoder derives the wrong key bytes.
          byte[] key;
          try {
              key = Base64.getDecoder().decode(
                      secret.startsWith("whsec_") ? secret.substring("whsec_".length()) : secret);
          } catch (IllegalArgumentException _) {
              return false; // misconfigured secret: reject rather than crash
          }

          byte[] expected;
          try {
              Mac mac = Mac.getInstance("HmacSHA256");
              mac.init(new SecretKeySpec(key, "HmacSHA256"));
              mac.update((messageId + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
              expected = ("v1," + Base64.getEncoder().encodeToString(mac.doFinal(body)))
                      .getBytes(StandardCharsets.UTF_8);
          } catch (GeneralSecurityException impossible) {
              // Every JVM ships HmacSHA256, so this never fires at runtime.
              throw new IllegalStateException(impossible);
          }

          for (String candidate : signatures.split(" ")) {
              if (MessageDigest.isEqual(candidate.getBytes(StandardCharsets.UTF_8), expected)) {
                  return true; // MessageDigest.isEqual is constant-time
              }
          }
          return false;
      }
  }
php
  const TOLERANCE_SECONDS = 300;

  /**
   * Returns true if the body was signed by Juglow for this organization.
   *
   * Juglow sends header names in lowercase, but proxies are free to
   * re-case them, so normalize the lookup to lowercase.
   */
  function verify(string $secret, array $headers, string $body): bool
  {
      $lowercased = array_change_key_case($headers, CASE_LOWER);
      $messageId = $lowercased['webhook-id'] ?? null;
      $timestamp = $lowercased['webhook-timestamp'] ?? null;
      $signatures = $lowercased['webhook-signature'] ?? null;
      if ($messageId === null || $timestamp === null || $signatures === null) {
          return false; // unsigned request: not from Juglow
      }

      $signedAt = filter_var($timestamp, FILTER_VALIDATE_INT);
      if ($signedAt === false || abs(time() - $signedAt) > TOLERANCE_SECONDS) {
          return false; // replayed, or the clocks disagree
      }

      // Standard base64 alphabet: a URL-safe decoder derives the wrong key bytes.
      $encodedKey = str_starts_with($secret, 'whsec_') ? substr($secret, strlen('whsec_')) : $secret;
      $key = base64_decode($encodedKey, strict: true);
      if ($key === false) {
          return false;
      }

      $payload = "{$messageId}.{$timestamp}." . $body;
      $expected = 'v1,' . base64_encode(hash_hmac('sha256', $payload, $key, binary: true));

      foreach (explode(' ', $signatures) as $candidate) {
          if (hash_equals($expected, $candidate)) { // constant-time
              return true;
          }
      }
      return false;
  }
ruby
  # base64 is a bundled gem in Ruby 3.4: Bundler-managed apps add gem "base64".
  require "base64"
  require "openssl"

  TOLERANCE_SECONDS = 300

  # Returns true if the body was signed by Juglow for this organization.
  #
  # Juglow sends header names in lowercase, but proxies are free to
  # re-case them, so normalize the lookup to lowercase.
  def verify(secret, headers, body)
    lowercased = headers.transform_keys(&:downcase)
    message_id = lowercased["webhook-id"]
    timestamp = lowercased["webhook-timestamp"]
    signatures = lowercased["webhook-signature"]
    if message_id.nil? || timestamp.nil? || signatures.nil?
      return false # unsigned request: not from Juglow
    end

    signed_at = Integer(timestamp, exception: false)
    if signed_at.nil? || (Time.now.to_i - signed_at).abs > TOLERANCE_SECONDS
      return false # replayed, or the clocks disagree
    end

    # Standard base64 alphabet: a URL-safe decoder derives the wrong key bytes.
    begin
      key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
    rescue ArgumentError
      return false # misconfigured secret: reject rather than crash
    end

    # Feed the body separately so its encoding never has to match the prefix's.
    hmac = OpenSSL::HMAC.new(key, "SHA256")
    hmac.update("#{message_id}.#{timestamp}.")
    hmac.update(body)
    expected = "v1," + Base64.strict_encode64(hmac.digest)

    signatures.split(" ").any? do |candidate|
      # fixed_length_secure_compare raises on a length mismatch, so screen lengths first.
      candidate.bytesize == expected.bytesize &&
        OpenSSL.fixed_length_secure_compare(candidate, expected)
    end
  end

Operational semantics

Timeout and retry

Your administrator sets a verdict timeout between 1 and 10,000ms (5,000ms by default). The budget covers the entire exchange: connection, TLS handshake, request, and response.

Juglow retries exactly once, after a 100ms delay, and only when the connection attempt fails. The retry shares the same timeout budget and carries the same webhook-id and the same signature. Once your AI security server has responded, the exchange is never retried.

Webhook failures

Timeouts, non-200 statuses (redirects included), unparseable or oversized response bodies, and unreachable endpoints are all webhook failures. A webhook failure never becomes a deny; instead, your organization's failure handling setting decides whether the affected request is blocked or proceeds without inspection.

Circuit breaker

Sustained webhook failures attributable to your AI security server trip a circuit breaker that stops enforcement: Juglow stops contacting your server, and failure handling applies to every request.

Starting 10 minutes after the trip, Juglow checks whether your server has recovered: at most about once per minute it sends your server the same synthetic test request that Test connection sends (source.application is config-test), signed like any other request and carrying no user content. Respond to it normally. A valid verdict, allow or deny, resets the breaker and enforcement resumes; a webhook failure leaves the breaker tripped, and the checks continue. An administrator can also reset the breaker at any time, and administrator configuration changes stop the automatic checks; see Circuit breaker.

Each trip is recorded as an inference_hooks_circuit_breaker_tripped activity in the Activity Feed, one activity per trip. While the breaker is tripped, no per-request Inference hooks activities are recorded, so the trip activity is the feed's only record of the tripped window.

Latency

Enforcement adds your AI security server's round trip to the latency of every governed request in your organization. Keep the verdict fast, and load-test your server before rolling it out to a large organization.

Source IP addresses

Requests to your AI security server originate from 160.79.106.0/24, part of Juglow's published outbound IP ranges. Allowlist that block, not the inbound ranges on the same page, which don't cover it. Allowlisting narrows your server's exposure, but it is not a substitute for signature verification: the block carries Juglow egress traffic beyond Inference hooks.

Forward compatibility

The protocol grows without breaking correctly written servers. Your server must ignore:

  • Unknown top-level fields on the prompt frame.
  • Unknown keys in metadata.
  • New source.application values.
  • New actor.type values. actor is a union discriminated on type, and "user" is the only kind sent today; a future kind guarantees only that type is present.
  • Content blocks with an unrecognized type.

Never reject a request because of an unrecognized block type or field; read the fields you know and skip the rest.

Other hook event types will be introduced in the future. A new event type is an addition your server can't handle by skipping a field: the request still needs a verdict. When the top-level type is a value you don't recognize, return an allow verdict rather than an error status; an error response is a webhook failure, and sustained failures trip the circuit breaker.

Design your integration

A production AI security server makes a few design choices beyond the wire protocol.

Deduplicate on webhook-id. The webhook-id header is unique per delivery and equals the body's request_id, and a connection-failure retry reuses it, so it works as an idempotency key. If you record verdicts, key the records on it.

Record verdicts and join denials. Store each verdict you return along with its reference_id. Every denial is recorded as an inference_hooks_request_denied compliance activity carrying the reference_id your server returned, so you can join denials in the Activity Feed to the matching records in your own system.

Archive with an always-allow server. To capture transcripts in real time without policing them, return {"action": "allow"} unconditionally and persist the frame after responding. This is a push-based alternative to polling the Compliance API, and answering before you persist keeps your round trip out of the user's critical path.

Write deny_reason for the end user. The text you return is what the user sees when their request is blocked, truncated at 500 characters. Tell them what to change, such as which kind of content to remove, rather than emitting a scanner code that only your team can interpret.

Next steps

  • Configure Inference hooks — Enable Inference hooks, connect and test your endpoint, and control enforcement, failure handling, and rollout.
On this page
Get a first verdict round tripReceive a requestThe prompt frameContent blocksWhat the transcript containsSource valuesReturn a verdictVerify the signatureOperational semanticsTimeout and retryWebhook failuresCircuit breakerLatencySource IP addressesForward compatibilityDesign your integrationNext steps