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:
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 → 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, 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. The list holds only paths, with no values. |
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. 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;stepsAppliedsays 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" }| verdict | stage: plan | stage: approve |
|---|---|---|
allow | The plan proceeds and the approval card opens. | The gate opens and apply runs. |
deny | Plan 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. |
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. Only
ticketis 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
ticketblock is optional (since 0.25.0):{ "id", "url", "status" }.idis required, at most 128 characters;urlmust behttp(s), at most 2048;statusis your ITSM's own word, at most 64, and is not translated. The approval card showsid, links it whenurlis present, and drawsstatusas 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.
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.
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.
| 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: Administration → Notifications → Hooks → New hook. A restart is not 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. |
| Address | both | http 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. |
| 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 | Three 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 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 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
| Stage | Asked of the ITSM | What deny does |
|---|---|---|
plan | Is there an open change record for this namespace? | Plan DENIED; no approval card is ever created. |
approve | Is that record still approved? | 409; the plan waits. Once the change manager approves, the same card can be pressed again. |
notify | No 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.
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 six operation notifications (three
terminal outcomes, the three gestures of dual consent) 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.