Signed callbacks
Novo signs outbound calls to customer-owned callback surfaces: environments, result handlers, and tool approval hooks.
Run output and lifecycle events are not delivered through a separate webhook
product. They are delivered on the durable run stream. Server-side consumers
should use runs.start plus runs.stream(runId) and persist the stream chunks
they care about.
Signature verification
Every signed request carries a Novo-Signature header:
Novo-Signature: t=1716936000,v1=hex...
t is the Unix timestamp of signing. v1 is
hmac_sha256(secret, "${t}.${rawBody}") in hex.
Verify with the SDK:
import { verifyNovoSignature } from 'novoagents';
const rawBody = await req.text();
const result = verifyNovoSignature({
rawBody,
signatureHeader: req.headers.get('Novo-Signature')!,
secret: process.env.NOVO_CALLBACK_SECRET!,
});
if (!result.valid) return new Response('bad signature', { status: 401 });
verifyNovoSignature checks:
- Header shape:
reason: 'signature_header_invalid'. - Timestamp tolerance:
reason: 'timestamp_outside_tolerance'. - HMAC equality:
reason: 'signature_mismatch'.
Always verify before parsing or acting on a callback body. Do not log the body of an unverified callback.
Request metadata
Signed callbacks include stable headers you can use for routing and audit:
Novo-Signature: t=...,v1=...
Novo-Timestamp: ...
Novo-Request-Id: req_...
Novo-Org-Id: org_...
Novo-Agent-Id: agent_...
Novo-Run-Id: run_...
Specific callback families add their own resource headers, such as
Novo-Environment-Id, Novo-Result-Handler-Id, or
Novo-Tool-Approval-Hook-Id.
Idempotency
Callbacks are retried when a delivery returns non-2xx or times out. Treat
Novo-Request-Id as the idempotency key for the logical callback and make your
handler safe to receive the same request more than once.
Run events
Use the stream for run events:
const receipt = await novo.runs.start({ threadId, input });
let stream = await novo.runs.stream(receipt.runId);
for (;;) {
for await (const event of stream) {
await persistChunk(event, stream.lastEventId);
}
if (stream.reachedTerminal) break;
stream = await stream.reconnect();
}
For the common “just drain until done” case, use
await stream.streamToCompletion().