Documentation

Webhooks & delivery

Every decision is already in the response to /v1/events and /v1/decide. Delivery is the optional second copy: KaQuill POSTs the same decision to an endpoint you own, or to Slack.

KaQuill does not render anything and does not send mail. You own the surface. If you want an email to go out, point a webhook at your own transactional provider — your sender reputation and your templates stay yours.

Turning it on

Dashboard → Settings → Delivery. Add a webhook URL, a Slack incoming webhook, or both, then flip the switch. Saving a webhook URL for the first time mints a signing secret and shows it once — copy it then, because nothing reads it back.

URLs must be HTTPS and must resolve to a public address. Every DNS record is checked, not just the first, and the host is re-resolved at send time — a hostname that was public when you saved it can move.

The envelope

One JSON object per fired decision. version is bumped if the shape ever changes incompatibly; treat unknown fields as additive and ignore them.

{
  "type": "decision",
  "version": 1,
  "tenant_id": "tnt_0f3a91c6b2d47e58",
  "user_id": "usr_8812",
  "session_id": "sess_41f0",
  "decision": {
    "action_id": 3,
    "action": "ShowUpgradeCTA",
    "headline": "Still weighing it up?",
    "body": "You've hit the export limit twice this week.",
    "confidence": 0.81
  },
  "telemetry": {
    "logic_path": "AI_TREATMENT",
    "policy_kind": "specialist",
    "policy_version": "9f2c4ab1e7d3",
    "propensity": 0.62
  },
  "decided_at_ms": 1755500000000
}

Fields

FieldTypeNotes
decision.action_idint0 = DoNothing (never delivered), 1–5 otherwise.
decision.actionstringe.g. OfferDiscount20, ShowUpgradeCTA, SendSlackAlert.
decision.headlinestring | nullGenerated copy. Null for SendSlackAlert, which is internal-only by design.
decision.bodystring | nullGenerated copy, same caveat.
decision.confidencefloat | null0–1. The policy's confidence in this call.
telemetry.logic_pathstringWhich branch decided: AI_TREATMENT, HYBRID_CONSENSUS, a rule override, and so on.
telemetry.policy_kindstringspecialist (your model), generalist (cold-start fallback), or rule.
telemetry.policy_versionstring | nullsha256 of the weights file that produced this decision.
telemetry.propensityfloat | nullThe probability this action was sampled with. Keep it — it is what makes IPS/SNIPS/doubly-robust offline evaluation valid on your side too.
decided_at_msintUnix milliseconds at decision time, not delivery time.

Headers

Content-Typeapplication/json
User-AgentKaQuill-Delivery/1
X-KaQuill-Delivery-IdStable per delivery row. Use it to dedupe.
X-KaQuill-Signaturet=<unix>,v1=<hex>. Absent only if no signing secret exists.

Verifying the signature

X-KaQuill-Signature is t=<unix>,v1=<hex>, where the hex is HMAC-SHA256 of the string "<t>.<raw request body>" keyed with your signing secret.

Sign the raw bytes, before any JSON parsing. Parsing and re-serialising changes key order and whitespace, and the signature will never match. This is the single most common integration mistake.

The timestamp is inside the signed string so a captured delivery can't be replayed later. Reject anything outside your own tolerance — five minutes is a reasonable default.

import crypto from "node:crypto";

// Express: you MUST use the raw body, not the parsed object.
// app.post("/kaquill", express.raw({ type: "application/json" }), handler)

const TOLERANCE_SECONDS = 300;

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("=")),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;

  // Reject replays of a captured delivery.
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

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

  // Constant-time — a === comparison leaks the prefix length.
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1 ?? "", "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False

    if abs(time.time() - t) > TOLERANCE_SECONDS:
        return False

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

    return hmac.compare_digest(expected, parts.get("v1", ""))

Delivery semantics

Deliveries go through a durable outbox, so they survive a restart and are not lost if the process that made the decision goes away.

  • At-least-once. A timeout on your side after you already committed will be retried. Dedupe on X-KaQuill-Delivery-Id.
  • Ordering is not guaranteed. A retried delivery can land after a newer one. Use decided_at_ms if order matters to you.
  • Timeout: 8 seconds. Return a 2xx as soon as you've accepted the payload and do the real work asynchronously.
  • Retries: 5 attempts, backing off 30s → 2m → 5m → 15m. Roughly a 20-minute window, which covers a short deploy.
  • Any 2xx is success. The body is ignored.
  • 4xx is permanent — we stop immediately rather than hammering an endpoint that is telling us no. The one exception is 429, which is retried like a 5xx.
  • 5xx and network errors retry until attempts run out, then the delivery is marked dead and left in place for inspection.

What is never delivered

  • DoNothing decisions. Most decisions are DoNothing — that is the product working.
  • Ghost-suppressed decisions. When the policy wanted to intervene but suppression judged the user would convert anyway, the action is rewritten to DoNothing and nothing is sent. The suppression is the point; the margin you keep shows up as Ghost MRR.
  • Test users, matched the same way they are excluded from billing.

Slack

The Slack hook receives one action: SendSlackAlert, the high-intent alert meant for your team rather than for the user. It arrives as Block Kit with a plain-text fallback, so it reads correctly in a push notification.

It is deliberately a separate field from the generic webhook. Pasting a Slack hook into the generic box is the likeliest setup mistake, and it is rejected with a message that says so.

Email

There is no email channel, and that is a design decision rather than a gap. Sending on your behalf would mean owning your sender identity, your templates, your deliverability and your unsubscribe list. Point a webhook at your own ESP instead — you keep all four.

Something here wrong or unclear? It is a young API — tell us.support@kaquill.com