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:

  1. Header shape: reason: 'signature_header_invalid'.
  2. Timestamp tolerance: reason: 'timestamp_outside_tolerance'.
  3. 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.