yeke.io · integration guide

Hook integration

Check an operation with your own system before it runs, or send a notification afterward. This guide covers hook requests, responses, signature verification and failure handling.

Two hook kinds, and where they sit

Validation hooks control an operation. Notification hooks report an event.

Validation (validate)

Asked before the operation is applied, and it can stop it. Your answer decides what happens to the plan.

  • When: while the plan is built, at the moment approval is pressed, or both.
  • You answer: allow / deny / elevate.
  • If unreachable: onFailure decides; the default is deny.

Notification (notify)

Told after the operation was applied, and it stops nothing. The user already has their result.

  • When: after apply finishes, from a durable queue.
  • You answer: any 2xx. The body is not read.
  • If unreachable: retried, then moved to a dead-letter queue.

The path a write request takes (hooks in bold):

  request  →  dry-run  →  guardrail  →  HOOK (stage: plan)
                                            │
                                            ├─ deny     → plan DENIED, no approval card is created
                                            ├─ elevate  → the card demands the resource name typed out
                                            └─ allow    → approval card opens
                                                            │
                                                 user presses approve
                                                            │
                                                         HOOK (stage: approve)
                                                            │
                                            ┌───────────────┴─ deny → 409, plan STAYS pending
                                            │
                                          apply
                                            │
                                         HOOK (stage: notify)  ← queued, asynchronous

A guardrail is not a hook. A rule whose answer is knowable from the shape of the plan alone belongs in a guardrail (“no deletes in this namespace”) and never leaves the process. A hook is for questions only the outside can answer: is there an open change ticket, are we inside a maintenance window, who is on call.

The request: headers and payload

Always POST, always JSON. Redirects are not followed; a 3xx is treated as a failed call.

HeaderAlwaysValue
content-typeYesapplication/json
user-agentYesyeke-core, fixed and unversioned. Your access log should be able to say “this came from YEKE”; putting a version in it would invite you to branch on our release train.
x-yeke-hookYesThe hook id: the name you gave it.
x-yeke-deliveryYesDelivery UUID. This is your idempotency key; it stays the same across retries.
authorizationNoOnly if you configured one. The value is passed verbatim — Bearer …, Basic …, whatever you stored.
x-yeke-signature-256NoOnly if you configured a signing secret. HMAC-SHA256 of the body, hex encoded.

Validation payload

stage carries either plan or approve; the rest of the payload is identical in both.

{
  "schemaVersion": 1,
  "hookId": "change-board",
  "deliveryId": "6f1c9d84-2b77-4a51-9e0e-7a2f1c0d8b33",
  "stage": "plan",
  "plan": {
    "id": "op-01KZGTWSG5WSSR8PDZ35",
    "clusterId": "c-3",
    "source": "ui",
    "createdAt": "2026-08-08T14:02:09.144Z",
    "actor":    { "userId": "u-2", "username": "dana" },
    "identity": { "user": "dana", "groups": ["platform"] },
    "origins": [
      { "method": "PATCH", "path": "/apis/apps/v1/namespaces/production/deployments/api" }
    ],
    "steps": [
      {
        "target": {
          "schemaId": "apps/v1/Deployment", "group": "apps", "version": "v1",
          "kind": "Deployment", "resource": "deployments",
          "namespaced": true, "namespace": "production", "name": "api",
          "subresource": null, "uid": "9c1…", "resourceVersion": "122835"
        },
        "classification": { "severity": "mutating", "flags": ["owner-managed"] },
        "changedPaths": ["/spec/replicas"],
        "dryRun": { "status": "ok" }
      }
    ],
    "policy": {
      "verdict": "allow",
      "approvalLevel": "standard",
      "matches": [
        { "policyId": "production.replica-ceiling", "effect": "allow", "message": null }
      ]
    }
  }
}
FieldValuesMeaning
stageplan · approve · testtest is the Test button in the admin screen; it carries no plan and its verdict has no effect on anything.
sourceui · nl · apiWhich surface produced the request. nl means the chat.
actorobject · nullThe YEKE user. May be null for automated requests.
identityobjectThe Kubernetes identity the request will actually use against the apiserver.
origins[].methodPOST · PATCH · PUT · DELETEThe raw HTTP intent. Read the verb from here.
classification.severitymutating · destructiveBlast class, derived from the shape of the object.
classification.flagsarraycluster-scoped, collection, finalizers, owner-managed, orphan-delete, dry-run-unsupported, irreversible, stream
changedPathsJSON PointersWhich paths will change. The list holds only paths, with no values.
dryRun.statusok · failed · unsupported · skipped · not-applicableWhat the apiserver's dry run said.
policy.verdictallow · deny · elevate · errorYEKE's own guardrail decision, taken before yours.
policy.approvalLevelstandard · elevatedThe approval bar as it stands right now.

The payload contains no object values, and it never will. It carries no manifest, no prior state, no dry-run result and no request body. A hook has to know what will happen, not which value will become what. Even when a Secret is being updated, changedPaths says ["/data/password"], never the password. This is not a configuration choice; it is the boundary of the single function that builds the payload.

Notification payload

stage is always notify. Steps have the same shape as in validation; what differs is event and result.

{
  "schemaVersion": 1,
  "hookId": "itsm-bridge",
  "deliveryId": "b3d0…",
  "stage": "notify",
  "event": { "type": "plan.applied", "ts": "2026-08-08T14:02:14.881Z" },
  "operation": {
    "id": "op-01KZGTWSG5WSSR8PDZ35",
    "clusterId": "c-3",
    "source": "ui",
    "revertOf": null,
    "actor":    { "userId": "u-2", "username": "dana" },
    "identity": { "user": "dana", "groups": ["platform"] },
    "origins": [ { "method": "PATCH", "path": "/apis/apps/v1/…" } ],
    "steps":   [ { "target": { … }, "classification": { … }, "changedPaths": ["/spec/replicas"] } ],
    "result": {
      "state": "APPLIED",
      "stepsApplied": 1,
      "appliedAt": "2026-08-08T14:02:14.702Z",
      "cause": null,
      "failure": null,
      "reason": null
    }
  }
}

event.type can take one of six values — three terminal outcomes and the three gestures of dual consent; it is not a schema-frozen trio. The terminal three:

  • plan.applied — every step was applied.
  • plan.partially_applied — some steps landed; stepsApplied says how many.
  • plan.failed — nothing landed.

On a failed or partial result the cause arrives as a code: result.cause says why the plan stopped as a code with parameters (for example APPLY_STEP_FAILED and the failing step's index), result.failure carries the failing step's failure code and parameters. result.reason carries only the apiserver's own sentence, otherwise null; YEKE's own explanation is never written there (0.49.28).

The three gestures of dual consent (plan.consent_requested, plan.consented, plan.consent_revoked) arrive in the same body shape: since they carry a plan that hasn't reached apply yet, result.cause, result.failure and result.reason are null, and stepsApplied is 0. The full list of the six subscribable events: the Events row in Defining a hook.

If revertOf is set, this operation is the revert of another one and the value is that operation's id; this is the only place that correlation can be made.

The response contract

A validation hook’s response body determines the decision. Notification response bodies are ignored.

HTTP/1.1 200 OK
content-type: application/json

{ "verdict": "deny", "reason": "CHG-0042 is closed — no open change record" }
verdictstage: planstage: approve
allowThe plan proceeds and the approval card opens.The gate opens and apply runs.
denyPlan becomes DENIED and no approval card is ever created. Terminal.409. The plan stays in AWAITING_APPROVAL and nothing is recorded as changed. This is temporary: when the window opens, the same card can be pressed again.
elevateThe approval bar rises: the card demands the resource name typed out.Rejection plus a refresh directive. A frozen plan's bar cannot be changed underneath the user; the pipeline re-runs and elevate is said where it belongs.
  • reason is optional but valuable. It is the sentence the user reads on screen; YEKE does not translate it or substitute its own. It is clipped at 2048 characters.
  • Unknown fields are fine. Only ticket is read (below); everything else is ignored. The response is your schema — an integration that silently started blocking every plan because you added a field would not be acceptable behaviour.
  • The ticket block is optional (since 0.25.0): { "id", "url", "status" }. id is required, at most 128 characters; url must be http(s), at most 2048; status is your ITSM's own word, at most 64, and is not translated. The approval card shows id, links it when url is present, and draws status as a chip. When the block is present it must pass its own schema; if it fails, not just the block but the whole response counts as not understood — it is never dropped silently.
  • The response body cap is 64 KiB. Above that, the answer counts as not understood.
  • Notification bodies are never read. 204 is enough; 2xx means delivered.

“2xx but no verdict” and “unreachable” are different failures and they surface differently. The first (body is not JSON, no verdict, unrecognised value) points the operator at your response format and is not retried; it is a deterministic mismatch. The second (connection, timeout, 5xx) points at the service itself and is retried once.

The approval-time hook and maintenance windows (change freeze, Enterprise) share the same gate point: both return 409 and leave the plan waiting in AWAITING_APPROVAL, the record unchanged. The distinction is in the response's params.source field — a freeze writes it as "change-freeze", a hook denial carries hookId (and reason, if any) instead. The hook is not called during the second approver's consent gesture (E3 dual approval, POST …/operations/:opId/consent) — it only runs at the actual apply moment. Details: Governance.

Verifying the signature

If you configure a signing secret, every request carries x-yeke-signature-256.

The signature is the HMAC-SHA256 of the raw body, hex encoded. Headers are not part of it: a canonical string that also covered headers would require you to re-implement our ordering rules, and on the day the two implementations drifted the signature would fail silently. Replay protection lives inside the body instead: deliveryId is under the signature.

Verify before you parse, and work on the raw bytes: parsing the JSON and re-serialising it changes key order and whitespace, and the signature will not match.

// Node.js — express, raw body
import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/yeke/hook", express.raw({ type: "application/json" }), (req, res) => {
  const expected = createHmac("sha256", process.env.YEKE_HOOK_SECRET)
    .update(req.body)                      // Buffer — unparsed
    .digest("hex");
  const got = req.get("x-yeke-signature-256") ?? "";

  // timingSafeEqual THROWS on unequal lengths; check that first.
  if (got.length !== expected.length ||
      !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
    return res.status(401).json({ error: "bad signature" });
  }

  const body = JSON.parse(req.body.toString("utf8"));
  res.json({ verdict: "allow" });
});
# Python — Flask
import hmac, hashlib, os
from flask import request, jsonify

@app.post("/yeke/hook")
def yeke_hook():
    raw = request.get_data()               # bytes, unparsed
    expected = hmac.new(os.environ["YEKE_HOOK_SECRET"].encode(),
                        raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("x-yeke-signature-256", "")):
        return jsonify(error="bad signature"), 401

    body = request.get_json()
    return jsonify(verdict="allow")

A signing secret must be at least 8 characters. Shorter values are rejected, and the reason is the redaction mask used in diagnostics — a short string matches everywhere and would render the mask useless. Secrets are written once and never read back: a screen can show that a secret exists, never what it is.

Timeouts, retries, fail-closed

Validation and notification hooks use different timeout and retry rules.

ValidationNotification
TimeouttimeoutMs — 500–10000 ms, default 5000. Established fresh for every attempt.
RetriesAt most 2 attempts (one retry), and only for transient failures. Fixed; not configurable.maxAttempts — 1–10. 1 means never retry.
BackoffShort, with jitter: we are inside the window of a human waiting on an approval.Exponential: 60 s base, ×2, 20% jitter, capped at 15 min.
What is retriedNetwork errors, timeouts and 5xx. 4xx and an unparseable body are not retried: those are deterministic misconfiguration, and repeating them only adds latency and queue depth.
On exhaustiononFailure: deny → the plan stops · warn → the plan proceeds and the attempt is recorded.Moved to the dead-letter queue and a hook.delivery_failed event is written to the audit trail.

The default is fail-closed and its price is stated plainly: if a validation hook is configured with onFailure: deny and its endpoint is down, writes stop on that cluster. The counterweights are the 10 s timeout ceiling, warn as a deliberate and named concession, the escape hatch of disabling the hook from the screen, and the fact that the risk is zero when no hook is configured at all. Timeouts and network failures stop the operation too, not just your deny.

Idempotency

x-yeke-delivery stays the same across retries. If your endpoint has side effects (opening a ticket, posting to a channel), store that value and do not repeat the work when the same delivery arrives twice. There is a second trap on the validation side: the same plan is asked twice, once with stage: plan and once with stage: approve, and those are separate deliveries. What ties them together is plan.id.

Defining a hook

From the admin screen: Administration → Notifications → Hooks → New hook. A restart is not required.

FieldKindNote
IdbothHow the hook is named in the audit trail, in the delivery queue and in the x-yeke-hook header. It cannot be changed later.
Addressbothhttp or https URL — the host must be on the allow-list (below). For a notification hook, choosing Email for Transport replaces this field with a Recipients field, where the destination is written; delivery goes out through the SMTP relay and the allow-list is never consulted. SMTP relay setup and the email body's language: Alerts. Like the secrets, the address is stored one-way: once saved, the screen shows only its host and fingerprint (last four characters plus a short hash); to change it, paste a new one.
Timeoutboth500–10000 ms.
ScopebothAll clusters, or selected clusters. Matching is by cluster id, not name: names can be changed, ids cannot.
Stagesvalidationplan, approve, or both. Defaults to plan. A hook that only guards a maintenance window can pick approve alone.
If unreachablevalidationdeny (default, fail-closed) or warn.
EventsnotificationThree options: Only alerts and scan reports (default — no operation notification is sent), All operation events (all six; can produce a lot of notifications on busy clusters), or Only the selected events (three terminal outcomes: applied, partially applied, failed; the three gestures of dual consent: requested, given, revoked). Alert notifications (including closure) and the scan report are never chosen here, in any of the three options; their channel comes from Routing and Scheduled scans.
Delivery attemptsnotification1–10.
SecretsbothAn authorization header value and/or a signing secret. Stored one-way.

The URL's host must appear in YEKE_HOOK_ALLOWED_HOSTS. While that list is empty, core will not call any hook address that resolves into internal ranges (loopback, RFC1918, link-local, ULA) — an endpoint on the public internet keeps working, and the only thing cut off is the internal network, which hooks have no business reaching by default. The reason is SSRF: core is what calls your URL, and core sits in a network position with access to your clusters; out of the box the product must not be an internal network scanner drivable from an admin screen. Pointing a hook at an internal host is a legitimate move, and the path for it is explicit. Write the host into the list:

YEKE_HOOK_ALLOWED_HOSTS=itsm.internal.example.com,change-board

When the list is provided, the list wins and the internal-range check does not run at all. The gate runs in two places: when the record is saved and on every call; without the second one, narrowing the list would have no effect and previously saved hooks would quietly keep reaching out. Matching is on the host string and does not include the port: if example.com is listed, every port on that host is open.

When it is missing, the failure is misleading. Even with perfect network reachability every call fails with HOOK_HOST_NOT_ALLOWED, and in a fail-closed setup that looks like “the hook works but always says no”. The cheap diagnosis is to grep the core log for that code.

Chat channels

A third Transport for the notification hook — not the HTTP hook's machine payload, a formatted message meant for a person to read: a bold headline, a status marker, a clickable link.

An HTTP hook's payload is YEKE's own envelope (schemaVersion, hookId, deliveryId, stage…), and wiring that straight into a chat app's webhook does not work: Google Chat, Slack and Teams each read only their own schema and reject any request carrying a field they do not recognize — Google Chat does this with a 400. The chat hook is a separate Transport built for exactly this: the same producer that writes the email body writes the message, and it is wrapped in the provider's own envelope.

  • The list shows the format name, not the address. The webhook address carries a token, so the target column of the Administration → Notifications → Hooks list shows the product name instead: “Google Chat”, “Slack” or “Microsoft Teams”. The Slack format is also spoken by Mattermost and Rocket.Chat.
  • The address cannot be read back once saved. Like the secrets, it is stored one-way: the edit screen shows only its host and fingerprint, and you change it by pressing Replace and pasting a new one. On Google Chat the host is the same for every hook, so the fingerprint is what tells them apart. The address is not written to the audit trail either.
  • There is no authorization header and no signing secret. Those two fields can only be set on an HTTP hook — none of the three providers read them. (Previously they could also be written on an email hook and were never read anywhere; that gap was closed in 0.52.) Switching a hook's Transport away from HTTP deletes any stored secret; the form warns beforehand, and the change lands in the audit trail.
  • Message language is chosen per hook — the same rule as the email hook's language choice.
  • Long messages are truncated, and the payload marks where truncation happened.
  • The provider's host must be on YEKE_HOOK_ALLOWED_HOSTS — the allow-list rule above applies to chat hooks exactly the same way.
  • It is set up from the admin screen only. Administration → Notifications → Hooks → New hook, choose “Chat” for Transport and pick a format, then paste the webhook address; the Test button sends a trial message to the channel. The YAML file hook does not know about chat — it is admin-screen-only.

A 4xx now points at our own payload. This is exactly the failure a chat app produces when it receives the HTTP payload: on a 400 like the one Google Chat returns for a schema mismatch, the on-screen message used to say “the endpoint is down or rejecting the request” for every non-2xx status. On a 4xx it now says the thing to check is the request we sent; on a 5xx or an unreachable endpoint the message is unchanged and still points at the endpoint.

A working reference endpoint

No dependencies, one file. Download it or copy it, run it, point a hook at it.

// node yeke-hook.mjs — :8787/hook (validation) and :8787/notify (notification)
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.YEKE_HOOK_SECRET;
const seen = new Set();                    // idempotency: deliveryId

const read = async (req) => {
  const chunks = [];
  for await (const c of req) chunks.push(c);
  return Buffer.concat(chunks);
};

const signatureOk = (raw, header) => {
  if (!SECRET) return true;                // no secret, no signature
  const expected = createHmac("sha256", SECRET).update(raw).digest("hex");
  const got = header ?? "";
  return got.length === expected.length &&
         timingSafeEqual(Buffer.from(got), Buffer.from(expected));
};

// ← replace with your ITSM. Stub: pretends every namespace has an open change.
const openChangeRecord = async (ns) => true;

// ← replace with your channel. Stub: does nothing.
const postToChannel = async (eventType, operation) => {};

createServer(async (req, res) => {
  const raw = await read(req);
  const json = (code, payload) => {
    res.writeHead(code, { "content-type": "application/json" });
    res.end(JSON.stringify(payload));
  };

  try {
    if (!signatureOk(raw, req.headers["x-yeke-signature-256"])) {
      return json(401, { error: "bad signature" });
    }

    const body = JSON.parse(raw.toString("utf8") || "{}");

    // ── validation ───────────────────────────────────────────────
    if (req.url === "/hook") {
      // The test call carries no plan; it measures reachability.
      if (body.stage === "test") return json(200, { verdict: "allow" });

      // Decide on something ONLY the outside can know.
      const ns = body.plan?.steps?.[0]?.target?.namespace;
      const ticket = await openChangeRecord(ns);

      return ticket
        ? json(200, { verdict: "allow" })
        : json(200, { verdict: "deny",
                      reason: `no open change record for ${ns}` });
    }

    // ── notification ─────────────────────────────────────────────
    if (req.url === "/notify") {
      // The same delivery can arrive twice; the side effect runs once.
      if (!seen.has(body.deliveryId)) {
        await postToChannel(body.event.type, body.operation);
        seen.add(body.deliveryId);           // only after the side effect succeeded
      }
      res.writeHead(204).end();              // 2xx = delivered
      return;
    }

    json(404, { error: "not found" });
  } catch (err) {
    // Your ITSM failing must not become `allow`, and must not kill this process:
    // 503 lets core apply the hook's onFailure setting (deny by default).
    json(503, { error: String(err?.message ?? err) });
  }
}).listen(8787);

The file runs as shipped: the two functions that reach your ITSM and your channel are stubs, every plan is allowed and notifications go nowhere. Those two are the lines you replace; the rest is signature, idempotency and failure behaviour. For a filled-in example see Connecting an ITSM.

Once it is running, the Test button in the admin screen measures in a single call whether the endpoint is up and accepts your credentials. That call's verdict has no effect: even a deny blocks nothing.

Connecting an ITSM

The reference above with its two ITSM lines filled in. The connector is not a product component but an example: download it and adapt it to your installation; there is no licence gate.

1 · First step: the allow-list

An organisation's ITSM lives on the internal network, and while the allow-list is empty core will not reach it. Before defining the hook, write the ITSM host into YEKE_HOOK_ALLOWED_HOSTS; the compose and k8s deployments carry that key. Skip it and the first attempt fails at save time with HOOK_HOST_NOT_ALLOWED, and the fault gets hunted in the ITSM.

2 · Which stage asks which question

StageAsked of the ITSMWhat deny does
planIs there an open change record for this namespace?Plan DENIED; no approval card is ever created.
approveIs that record still approved?409; the plan waits. Once the change manager approves, the same card can be pressed again.
notifyNo question.The operation id is written into the ticket's log.

The same plan is asked in two separate deliveries; plan.id ties them together (Idempotency). Select both stages when defining the hook: with plan alone, an approval withdrawn after the card opened goes unseen.

3 · No destructive operation without a ticket

The blast class is in the payload (classification.severity); the decision is yours. The whole rule in the example:

const destructive = steps.some((s) => s.classification?.severity === "destructive");
const ticket = await openChangeFor(ns);
if (!ticket) {
  return destructive
    ? json(200, { verdict: "deny", reason: `no open change record for ${ns}` })
    : json(200, { verdict: "allow" });
}

4 · Identity and status on the card

reason lands on the approval card verbatim; that is where the approver reads which change authorised the operation. The example's pattern is "<ref> (<status>) — <title>":

{ "verdict": "allow", "reason": "C-000123 (approved) — production namespace maintenance" }

Since 0.25.0 the response can also carry a typed ticket block; the card shows the id, links it when a URL was given, and draws the status as a chip (response contract). reason still lands verbatim.

5 · The operation id on the ticket after apply

The notification payload carries operation.id; the example writes it into the ticket's log. On a retry deliveryId stays the same; the example keeps it in a set and adds it only after the write succeeded — an ITSM that is down at that moment loses nothing, the retry writes again. YEKE never changes the ticket's state: closing it is the change manager's job.

6 · Never answer allow when the ITSM fails

ITSM unreachable, login rejected, answer not in the expected shape — all of them are 503. Core counts that as “unreachable”, retries once, and on exhaustion applies the hook's onFailure setting (default deny). An endpoint that answers allow on failure has removed the ticket gate at exactly the moment it was needed.

7 · Example: iTop

yeke-hook-itop.mjs — one file, no dependencies, Node 22+. Written against iTop's REST/JSON interface (a json_data form field, auth_token, an application-level code) and carries all six points above. Configuration is by environment:

ITOP_URL=https://itsm.internal.example.com/itop
ITOP_TOKEN=…                                  # or ITOP_USER + ITOP_PASSWORD
ITOP_CLASS=NormalChange                       # default
ITOP_APPROVED_STATES=approved,implemented     # default
YEKE_HOOK_SECRET=…                            # the signing secret you gave the hook
node yeke-hook-itop.mjs

The one function that finds the ticket from the target is openChangeFor(ns): the example relies on the convention that the change title names the namespace. Write your own mapping there (a CI link, a custom field, a tag); the hook payload has no ticket field.

Order: allow-list → download and run the file → enter its URL under Administration → Notifications → Hooks, stages plan and approve → Test (sends core/check_credentials to iTop) → build a destructive plan and read reason on the card.

This example was measured against a fake iTop and, on 04.09.2026, against a real iTop 3.2.2; the same day it passed an end-to-end live acceptance through YEKE (destructive plan → change shown on the card, unapproved change at approval time → 409, approve in the ITSM → apply on the same card → object deleted, operation id in the ITSM log, ITSM down → 503 → deny) (five scenarios: connectivity test, open change at plan time → allow, not-yet-approved change at approval time → deny, destructive step without a ticket → deny, non-destructive → allow). What was measured is the shape of the protocol and the verdicts in eleven scenarios. The OQL syntax, the ref/status/title field names and the log field (private_log) may differ in your installation; the lines that were not verified are listed at the top of the file.

What a hook cannot do

Configure your integration within the following limits.

  • allow does not skip human approval. Even while you are saying yes, the approval card opens and waits for a person to press it. A hook adds gates; it never removes one.
  • A hook cannot modify the plan. The response vocabulary is three words. Correcting a replica count, adding a step, changing a target — none of it is possible, and there is no field for it.
  • The full audit trail cannot be subscribed to. Only six operation notifications (three terminal outcomes, the three gestures of dual consent) are delivered. Asking for something like auth.login gets the record rejected, and the rejection names the limit. Exporting the whole trail is a separate capability with different guarantees (continuous stream, gap detection, signed format).
  • You never see object values. The payload discipline above is a boundary, not a setting.
  • Redirects are not followed. A 3xx is an error: a 302 would carry the signed body and the authorization header to a host the operator never wrote down.
  • Your response must be 2xx. A 200 with a verdict body, or for notifications any 2xx at all.

Want to try it before you build it?

There is a simulator for watching the chain behave before you connect your own system: a fake endpoint that plays the external service, and a panel that shows the raw body of every incoming request. That panel is where you can verify the “no object values” claim above with your own eyes.