Skip to main content
API reference

Webhooks

When an intelligence product is delivered (or fails), EU Optikos POSTs a signed JSON payload to your configured endpoints. Manage them at /account/webhooks (Institutional).

Event catalogue (V1)

EventFired when
product.deliveredA scheduled product was generated and delivered.
product.failedA scheduled product run failed.

Payload envelope

Every delivery shares this envelope; data is event-specific.

{
  "event": "product.delivered",
  "delivery_id": "5b1c…-uuid",
  "timestamp": "2026-05-20T08:30:00+00:00",
  "organization_id": 123,
  "api_version": "v1",
  "data": {
    "product_id": "…",
    "product_type": "risk_assessment",
    "region_nuts_code": "DE300",
    "title": "…",
    "notification_id": 1000,
    "action_url": "/inbox"
  }
}

Headers

HeaderValue
X-Optikos-Signaturesha256=<hex> · HMAC-SHA256(secret, raw body)
X-Optikos-EventEvent name (route on this without parsing the body)
X-Optikos-DeliveryUUID: idempotency key; identical on retries of the same delivery
X-Optikos-TimestampISO-8601 UTC: reject if it skews > 5 min (anti-replay)

Verifying a delivery

Compute sha256=HMAC-SHA256(secret, raw_body) over the raw request body bytes exactly as received (don't re-serialise the JSON), compare it constant-time against X-Optikos-Signature, and reject payloads whose X-Optikos-Timestamp is more than 5 minutes old.

Python
import hmac, hashlib, time
from datetime import datetime

SECRET = b"whsec_…"  # the secret shown once at creation

def verify(raw_body: bytes, sig_header: str, ts_header: str) -> bool:
    # 1. Anti-replay: reject if the timestamp skews > 5 minutes.
    ts = datetime.fromisoformat(ts_header).timestamp()
    if abs(time.time() - ts) > 300:
        return False
    # 2. Recompute over the RAW body bytes exactly as received.
    expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig_header)
Node.js
const crypto = require("crypto");
const SECRET = "whsec_…";

function verify(rawBody, sigHeader, tsHeader) {
  const ts = Date.parse(tsHeader) / 1000;            // anti-replay
  if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
  const expected =
    "sha256=" + crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader));
}
Shell (openssl)
# Recompute the expected signature for a captured payload:
printf '%s' "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -r \
  | sed 's/ .*/ /; s/^/sha256=/'
# Compare (constant-time) against the X-Optikos-Signature header.

Retries & circuit-breaker

  • Respond 2xx to acknowledge. 5xx, timeouts, and 429 are retried with exponential backoff up to the webhook's max_retries.
  • 4xx (other than 429) is treated as a permanent rejection, not retried.
  • After several consecutive failures the webhook is auto-paused (circuit-breaker). Fix your receiver, then unpause it from /account/webhooks.
  • Deliveries are at-least-once · dedupe on X-Optikos-Delivery.