Consume the Verdict Webhook
Three worked consumers of the workspace event stream: a LangGraph thread that interrupts and is resumed by a delivery, an OpenAI Agents SDK run that serializes at its approval point, and a Jira issue opened on revise or stop. These are recipes to copy into your own service, not packages RateLoop maintains for you.
The package examples below describe the unreleased @rateloop/agents 0.2.0 API. The last registry check found 0.1.0, whose exports cannot run these recipes. Check the public version before installing.
Until 0.2.0 is publicly released, connect an agent through workspace MCP or consume signed events through webhooks in your own service. This application repository is private; a source checkout is not a public installation path.
The delivery contract
A workspace owner or admin registers an endpoint while signed in. The signing secret is returned once, at creation, and never again.
POST /api/account/workspaces/{workspaceId}/assurance/event-streams
{ "url": "https://example.com/hooks/rateloop",
"eventTypes": ["ai.rateloop.review.decision_needed", "ai.rateloop.review.verdict"] }
201 { "endpointId": "whe_…", "eventTypes": [...], "signingSecret": "rlwhsec_…", "url": "…" }
GET /api/account/workspaces/{workspaceId}/assurance/event-streams
DELETE /api/account/workspaces/{workspaceId}/assurance/event-streams/{endpointId}The list never returns a signing secret. The delete disables an endpoint and keeps its row, so the URL stays claimed and cannot be registered again — see Webhooks for what that means when a secret is lost.
The URL must be HTTPS, must carry no username, password, or fragment, and must not resolve to a private or local address. In production it must use the standard HTTPS port. RateLoop resolves the hostname once per attempt and pins the resolved address for the connection, so a rebind between validation and delivery cannot redirect it.
Every attempt carries the same five headers.
content-type | Always the CloudEvents JSON media type. |
rateloop-signature | The v1 prefix followed by the hex HMAC. |
rateloop-timestamp | Unix seconds, signed together with the body. |
rateloop-delivery-id | Stable per delivery across every retry. |
rateloop-event-id | Stable per event, shared by every subscribed endpoint. |
The signature covers the timestamp and the raw body, joined by a single dot.
rateloop-signature: v1=<hex>
where <hex> = HMAC_SHA256(signingSecret, "<rateloop-timestamp>.<raw request body>")RateLoop makes at most eight attempts in total, waiting 30 seconds after the first failure and then doubling to a ceiling of one hour. Every attempt reuses the delivery ID; deduplicate on it. Failed delivery does not change the review or sign-off.
What the two events carry
One event marks the moment named reviewers can answer. The other marks the moment a workspace owner recorded go, revise, or stop against the evidence packet.
ai.rateloop.review.decision_needed
subject the opportunity ID
data.lifecycle.state "pending"
data.evidenceReference.kind "gate_transition" (no packet exists yet)
ai.rateloop.review.verdict
subject the assurance run ID
data.lifecycle.state "go" | "revise" | "stop"
data.lifecycle.reasonCodes ["client_signoff"] plus "decision_note_recorded"
data.evidenceReference.kind "decision_packet"
data.packetHash sha256:… digest of the packet the owner signed offThe envelope is CloudEvents 1.0. A verdict delivery looks like this.
{
"specversion": "1.0",
"id": "aev_…",
"source": "urn:rateloop:assurance:workspace:ws_…",
"type": "ai.rateloop.review.verdict",
"subject": "run_…",
"time": "2026-08-11T09:14:02.000Z",
"datacontenttype": "application/json",
"dataschema": "urn:rateloop:schema:assurance-event:v2",
"rateloopevidencekind": "decision_packet",
"rateloopevidencedigest": "sha256:…",
"ratelooppackethash": "sha256:…",
"rateloopchainhash": "sha256:…",
"data": {
"schemaVersion": "rateloop.assurance-event.v2",
"evidenceReference": { "kind": "decision_packet", "digest": "sha256:…" },
"packetHash": "sha256:…",
"lifecycle": { "state": "revise", "reasonCodes": ["client_signoff", "decision_note_recorded"] },
"evidenceChain": { "eventHash": "sha256:…", "previousHash": "sha256:…", "sequence": 412 },
"ocsf": { "class_uid": 2003, "…": "… the full OCSF compliance finding" }
}
}The written reason never leaves RateLoop. A reason code states that a note exists; the text of it stays in the packet, behind the reader's own sign-in. Anything built on this event links back to the decision instead of quoting it.
Verify the signature first
Read the raw bytes, not a re-serialized object: the HMAC covers the exact body that was sent. Compare in constant time, and reject a timestamp outside a short window so a captured delivery cannot be replayed later. All three recipes import this one module.
// rateloopWebhook.ts
import { createHmac, timingSafeEqual } from "node:crypto";
const REPLAY_WINDOW_SECONDS = 300;
export type RateLoopEvent = {
id: string;
source: string;
type: string;
subject: string;
time: string;
data: {
packetHash?: string;
evidenceReference: { kind: "decision_packet" | "gate_transition"; digest: string };
lifecycle?: { state: string; reasonCodes: string[] };
};
};
export function verifyRateLoopDelivery(headers: Headers, rawBody: string, secret: string): RateLoopEvent {
const timestamp = headers.get("rateloop-timestamp") ?? "";
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > REPLAY_WINDOW_SECONDS) {
throw new Error("RateLoop delivery timestamp is outside the replay window.");
}
const expected = Buffer.from(`v1=${createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")}`);
const received = Buffer.from(headers.get("rateloop-signature") ?? "");
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
throw new Error("RateLoop delivery signature does not verify.");
}
return JSON.parse(rawBody) as RateLoopEvent;
}Claim the delivery ID against a unique index before doing any work, so a retry cannot repeat it, and release the claim if the work fails, so the retry can pick it up.
// The same entry point for every recipe below.
export async function POST(request: Request) {
const rawBody = await request.text();
let event: RateLoopEvent;
try {
event = verifyRateLoopDelivery(request.headers, rawBody, process.env.RATELOOP_WEBHOOK_SECRET!);
} catch {
return new Response("invalid signature", { status: 401 });
}
const deliveryId = request.headers.get("rateloop-delivery-id")!;
// INSERT … ON CONFLICT DO NOTHING against a unique delivery_id column.
if (!(await deliveries.claim(deliveryId))) return new Response(null, { status: 204 });
try {
await handle(event);
} catch {
await deliveries.release(deliveryId);
return new Response("handler failed", { status: 500 });
}
return new Response(null, { status: 204 });
}LangGraph
The node opens the review, records which thread is waiting on the opportunity, and interrupts. It never holds the framework call open for the human response window.
// reviewGate.ts — LangGraph's interrupt is injected, which is how the shipped
// adapter takes it and keeps this node testable without the graph runtime.
import {
beginRateLoopFrameworkApproval,
interruptForRateLoopApproval,
refreshRateLoopFrameworkApproval,
} from "@rateloop/agents";
import { driver, threads } from "./rateloop"; // driver wraps the workspace MCP review operations
export function createReviewGate(interrupt: (value: unknown) => unknown) {
return async function reviewGate(state: GraphState) {
// LangGraph restarts this node on resume, so evaluate and prepare must be idempotent.
const gate = await beginRateLoopFrameworkApproval({
driver,
evaluation: { agentId: state.agentId, candidate: state.draft },
preparation: { question: state.question },
});
if (gate.action === "interrupt") await threads.bind(gate.pending.opportunityId, state.threadId);
const outcome = interruptForRateLoopApproval(gate, interrupt);
if (outcome.action === "cancelled") return { settled: true, released: false };
const settled =
outcome.action === "resume_requested"
? await refreshRateLoopFrameworkApproval({ driver, pending: outcome.pending })
: outcome;
if (settled.action === "block") throw new Error(`RateLoop blocked the output: ${settled.reason}`);
if (settled.action === "release") return { settled: true, released: true };
return { settled: false }; // still pending: route back in and interrupt again
};
}One module owns the framework. It imports LangGraph, compiles the graph with a durable checkpointer and a conditional edge back into the gate for as long as the state is unsettled, and exports one resume helper. The delivery then calls that helper.
// graph.ts — the only module that imports @langchain/langgraph
// const graph = new StateGraph(annotation)
// .addNode("reviewGate", createReviewGate(interrupt))
// .addEdge(START, "reviewGate")
// .addConditionalEdges("reviewGate", s => (s.settled ? END : "reviewGate"))
// .compile({ checkpointer });
//
// export const resumeThread = (threadId: string) =>
// graph.invoke(new Command({ resume: { action: "resume" } }), {
// configurable: { thread_id: threadId },
// });
// handle(event) for the LangGraph recipe
const RESUME_TYPES = new Set([
"ai.rateloop.review.completed",
"ai.rateloop.review.failed",
"ai.rateloop.review.expired",
"ai.rateloop.review.inconclusive",
"ai.rateloop.review.cancelled",
"ai.rateloop.gate.blocked",
]);
async function handle(event: RateLoopEvent) {
if (!RESUME_TYPES.has(event.type)) return;
const threadId = await threads.lookup(event.subject); // subject is the opportunity ID
if (threadId) await resumeThread(threadId);
}The resume value is a wake-up, not an authority. It means only that RateLoop should be checked again: the node performs one bounded refresh, and the output is released only once the driver has verified signed terminal evidence bound to that exact output commitment and policy binding. A forged delivery therefore releases nothing — at worst it wastes a refresh. The same reasoning applies to the human-in-the-loop middleware, which decides on its own approve or reject payload; have its handler call the refresh and act on the returned gate rather than on the payload.
OpenAI Agents SDK
The adapter answers a tool's approval callback and stores a RateLoop checkpoint by tool-call ID. Serialize that store beside the SDK run state, and index both by the opportunity so a delivery can find the run again.
// agent.ts
import { Agent, RunState, run, tool } from "@openai/agents";
import { createOpenAiAgentsApprovalAdapter } from "@rateloop/agents";
import { begin, refresh, store } from "./rateloop";
const approval = createOpenAiAgentsApprovalAdapter({ begin, refresh, store });
export const agent = new Agent({
name: "Editor",
tools: [
tool({
name: "publish_draft",
description: "Publish the reviewed draft.",
parameters: publishParameters,
needsApproval: approval.needsApproval,
execute: async ({ draft }) => publish(draft),
}),
],
});
const result = await run(agent, input);
for (const interruption of result.interruptions ?? []) {
const toolCallId = interruption.rawItem.callId;
const stored = await store.load(toolCallId);
if (!stored) continue;
await runs.save({
opportunityId: stored.pending.opportunityId,
toolCallId,
serializedState: JSON.stringify(result.state),
});
}// handle(event) for the OpenAI Agents recipe, with RESUME_TYPES as above
async function handle(event: RateLoopEvent) {
if (!RESUME_TYPES.has(event.type)) return;
const record = await runs.byOpportunity(event.subject);
if (!record) return;
// SDK approval alone never satisfies RateLoop. This re-reads the gate and
// returns true only for a release backed by signed terminal evidence.
if (!(await approval.readyToApproveSdkInterruption(record.toolCallId))) return;
const state = await RunState.fromString(agent, record.serializedState);
const interruption = state.getInterruptions().find(item => item.rawItem.callId === record.toolCallId);
if (!interruption) return;
state.approve(interruption);
const resumed = await run(agent, state);
await runs.save({ ...record, serializedState: JSON.stringify(resumed.state) });
}Pin the versions. In the Python SDK the per-tool approval callback only became reliable around release 0.8, so pin at least that version there. The RateLoop adapter itself is TypeScript only: a Python host calls the workspace API directly and applies the same rule, which is to refresh before approving and to treat a blocked or cancelled terminal as a refusal to release.
Jira issue on revise or stop
This recipe consumes the verdict itself. A go opens nothing; revise and stop each open an issue that points back at the decision. Credentials are per customer — the site URL, an account email, an API token, the project key, and optionally a custom field for the packet digest. Store them per workspace and encrypted, as you would any other outbound credential.
// handle(event) for the Jira recipe
const ISSUE_TYPE = { revise: "Task", stop: "Bug" } as const;
async function handle(event: RateLoopEvent) {
if (event.type !== "ai.rateloop.review.verdict") return;
const state = event.data.lifecycle?.state;
if (state !== "revise" && state !== "stop") return;
const jira = await credentials.forWorkspace(event.source); // urn:rateloop:assurance:workspace:…
const runUrl = `${process.env.RATELOOP_APP_URL}/agents/results?run=${encodeURIComponent(event.subject)}`;
const noted = event.data.lifecycle?.reasonCodes.includes("decision_note_recorded") ?? false;
const response = await fetch(`${jira.site}/rest/api/3/issue`, {
method: "POST",
headers: {
authorization: `Basic ${Buffer.from(`${jira.email}:${jira.apiToken}`).toString("base64")}`,
"content-type": "application/json",
},
body: JSON.stringify({
fields: {
project: { key: jira.projectKey },
issuetype: { name: ISSUE_TYPE[state] },
summary: `RateLoop ${state}: assurance run ${event.subject}`,
labels: ["rateloop", `rateloop-${state}`],
// The packet digest and a link. The reviewers' and owner's written
// words are deliberately absent: the event does not carry them, and
// a Jira project is a wider audience than the review.
description: {
type: "doc",
version: 1,
content: [
{
type: "paragraph",
content: [
{ type: "text", text: `Sign-off recorded ${state} against packet ${event.data.packetHash}. ` },
{
type: "text",
text: "Open the decision in RateLoop",
marks: [{ type: "link", attrs: { href: runUrl } }],
},
{ type: "text", text: noted ? ". A written reason was recorded and is readable there." : "." },
],
},
],
},
...(jira.packetHashField && event.data.packetHash
? { [jira.packetHashField]: event.data.packetHash }
: {}),
},
}),
});
if (!response.ok) throw new Error(`Jira rejected the issue: HTTP ${response.status}`);
}Throwing here is deliberate. The shared entry point releases the claim, RateLoop retries, and a Jira outage costs a delay rather than a lost issue.
If you only want to be told
A Teams or Slack channel needs none of this. RateLoop posts the same two events into a channel configured in the workspace; Teams & Slack has the setup for both. Those channel URLs are bearer secrets and carry no signature a receiver could check, which is exactly why a workflow that has to trust what it received uses the signed stream instead.
Read the webhook reference for registration and the delivery rules, the Evidence reference for verification boundaries, or Agents & MCP for the current hosted review path.