Developer access is currently closed. This reference is maintained for approved integrations. Installing the SDK does not grant access to the service.
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 thread stream. A backend consumer should keep one thread subscription, persist its cursor and reduced state, and serve that state to every device.
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.
Thread events
Subscribe to the thread before or after sending. A send receipt carries the cursor for a race-free send-then-subscribe flow:
const receipt = await novo.threads.send(threadId, input, {
expectedAdmissionEpoch,
idempotencyKey: webhookEventId,
});
const stream = await novo.threads.subscribe(threadId, {
lastEventId: receipt.subscribeFrom,
signal: shutdown.signal,
});
for await (const event of stream) {
await persistThreadProjection(threadId, event, stream.lastEventId);
}
Persist expectedAdmissionEpoch with the event when first preparing its send.
Every webhook retry keeps that captured epoch and webhookEventId.
The subscription reconnects through connection rollovers and continues across
engine-triggered runs. Use a finite runs.stream(runId) only when one known run
segment is the complete unit you need.