yeke.io · integration guide
Hooks
YEKE can ask your system before an operation is applied, and tell it after. This page is the whole wire: which headers arrive, what the payload contains, what you must answer, how to verify the signature, and what happens when your endpoint does not reply.
Two hook kinds, and where they sit
One is a gate, the other is a messenger. Confusing them is the expensive mistake.
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:
onFailuredecides — the default isdeny.
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 → guardrail → dry-run → 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, asynchronousA 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.
| Header | Always | Value |
|---|---|---|
content-type | Yes | application/json |
user-agent | Yes | yeke-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-hook | Yes | The hook id — the name you gave it. |
x-yeke-delivery | Yes | Delivery UUID. This is your idempotency key; it stays the same across retries. |
authorization | No | Only if you configured one. The value is passed verbatim — Bearer …, Basic …, whatever you stored. |
x-yeke-signature-256 | No | Only 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 }
]
}
}
}| Field | Values | Meaning |
|---|---|---|
stage | plan · approve · test | test is the Test button in the admin screen; it carries no plan and its verdict has no effect on anything. |
source | ui · nl · api | Which surface produced the request. nl means the chat. |
actor | object · null | The YEKE user. May be null for automated requests. |
identity | object | The Kubernetes identity the request will actually use against the apiserver. |
origins[].method | POST · PATCH · PUT · DELETE | The raw HTTP intent. Read the verb from here. |
classification.severity | mutating · destructive | Blast class, derived from the shape of the object. |
classification.flags | array | cluster-scoped, collection, finalizers, owner-managed, orphan-delete, dry-run-unsupported, irreversible, stream |
changedPaths | JSON Pointers | Which paths will change. No values — paths only. |
dryRun.status | ok · failed · unsupported · skipped · not-applicable | What the apiserver's dry run said. |
policy.verdict | allow · deny · elevate · error | YEKE's own guardrail decision, taken before yours. |
policy.approvalLevel | standard · elevated | The approval bar as it stands right now. |
The payload contains no object values, and it never will. No manifest, no
prior state, no dry-run result, 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": "slack-channel",
"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",
"reason": null
}
}
}event.type can take exactly three values, and that limit is frozen in the schema:
plan.applied— every step was applied.plan.partially_applied— some steps landed;stepsAppliedsays how many.plan.failed— nothing landed;result.reasoncarries the apiserver's own sentence.
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
Validation reads your body; notification does not. The vocabulary is three words.
HTTP/1.1 200 OK
content-type: application/json
{ "verdict": "deny", "reason": "CHG-0042 is closed — no open change record" }| verdict | stage: plan | stage: approve |
|---|---|---|
allow | The plan proceeds and the approval card opens. | The gate opens and apply runs. |
deny | Plan becomes DENIED. 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. |
elevate | The 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. |
reasonis 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. Put a
ticketIdin your response; it 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 response body cap is 64 KiB. Above that, the answer counts as not understood.
- Notification bodies are never read.
204is enough;2xxmeans 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.
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
The two kinds have different resilience models, because only one of them has a human waiting.
| Validation | Notification | |
|---|---|---|
| Timeout | timeoutMs — 500–10000 ms, default 5000. Established fresh for every attempt. | |
| Retries | At most 2 attempts (one retry), and only for transient failures. Fixed; not configurable. | maxAttempts — 1–10. 1 means never retry. |
| Backoff | Short, 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 retried | Network 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 exhaustion | onFailure: 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: Admin → Hooks → New hook. No restart required.
| Field | Kind | Note |
|---|---|---|
| Id | both | How the hook is named in the audit trail, in the delivery queue and in the x-yeke-hook header. It cannot be changed later. |
| URL | both | http or https. The host must be on the allow-list (below). |
| Timeout | both | 500–10000 ms. |
| Scope | both | All clusters, or selected clusters. Matching is by cluster id, not name — names can be changed, ids cannot. |
| Stages | validation | plan, approve, or both. Defaults to plan. A hook that only guards a maintenance window can pick approve alone. |
| If unreachable | validation | deny (default, fail-closed) or warn. |
| Events | notification | A choice among the three terminal outcomes. No other audit event can be subscribed to. |
| Delivery attempts | notification | 1–10. |
| Secrets | both | An 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 is explicit —
write it 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.
A working reference endpoint
No dependencies, one file. 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)); }; 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)); }; 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); // ← your ITSM 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)) { seen.add(body.deliveryId); await postToChannel(body.event.type, body.operation); // ← your Slack } res.writeHead(204).end(); // 2xx = delivered return; } json(404, { error: "not found" }); }).listen(8787);
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.
What a hook cannot do
These are mechanisms, not promises in a document. Design your integration around them.
allowdoes 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 the three terminal operation
outcomes are delivered. Asking for something like
auth.logingets 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
3xxis an error: a302would carry the signed body and theauthorizationheader to a host the operator never wrote down. - Your response must be
2xx. A200with a verdict body, or for notifications any2xxat 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.