Documentation

Everything you need to embed Conovo.

Six steps to a signed contract, then the concepts and reference behind them. The whole get-started path runs against the sandbox, so you can see a real generated document before thinking about billing or a signing provider.

01

Get a secret key

Create an account, then open API keys in the console and generate one. The full key is shown once and stored hashed — if you lose it, revoke it and make another.

Keys are per-account, not per-business. One key serves every workspace you create. It is a server-side credential: if it ever reaches a browser bundle, revoke it.

.env.local
CONOVO_SECRET_KEY=sk_live_…
02

Register your payload schema

Describe the shape of the records a contract can draw from. Conovo uses it to propose bindings and to reject a binding path that could never resolve. Name the objects whatever your product calls them — a deal, a job, a case, a matter, a policy.

Register it on the Payload schema page. Versions are append-only, so adding a field later never invalidates a template that pinned an earlier version.

payload schema
{
  "org":    { "name": "string", "address": "string" },
  "client": { "fullName": "string", "email": "string" },
  "deal":   {
    "id": "string",
    "name": "string",
    "startDate": "date",
    "total": "money"
  }
}
03

Mint a session

The browser never sees your secret key. Your server exchanges it for a short-lived token scoped to exactly one workspace. Pass your own stable ID for the business as externalRef — Conovo creates the workspace on first use and matches it on every mint after.

app/api/conovo/session/route.ts
import { Conovo } from '@conovo/node'

const conovo = new Conovo({ secretKey: process.env.CONOVO_SECRET_KEY })

export async function POST() {
  const org = await currentOrg()   // however you resolve the signed-in business

  const session = await conovo.sessions.create({
    workspace: { externalRef: org.id, name: org.name },
    user: { id: currentUser.id, role: 'owner' },   // optional, for the audit trail
  })

  // { token, expiresAt } — 15 minutes; re-mint freely
  return Response.json(session)
}
Scope comes from the token, never the request. A session minted for one workspace cannot read another’s templates or contracts, whatever the client sends. You never pass a workspace ID to a component.
04

Mount the provider

Wrap anything that renders Conovo components. The provider caches the token, re-mints it on expiry, and surfaces a locked state if the account lapses — so a billing problem renders as your empty state rather than a stack trace.

app/providers.tsx
'use client'

import { ConovoProvider } from '@conovo/react'
import '@conovo/react/styles.css'

const getSession = () =>
  fetch('/api/conovo/session', { method: 'POST' }).then((r) => r.json())

export function Providers({ children }) {
  return <ConovoProvider getSession={getSession}>{children}</ConovoProvider>
}

Styles are scoped and driven by custom properties. Override them anywhere in your own stylesheet.

your stylesheet
.conovo {
  --cv-accent: #2e42c6;
  --cv-radius: 6px;
  --cv-font: 'Your UI Font', system-ui, sans-serif;
}
05

Set up a template

ContractStudio is upload and review in one component. Your user drops in the document they already send; Conovo proposes fields, formulas, signing parties, repeating tables, and conditional sections, each highlighted in the document. Nothing is saved until they confirm.

app/settings/contracts/page.tsx
import { ContractStudio } from '@conovo/react'

export default function ContractSetup() {
  return (
    <ContractStudio
      onConfirmed={({ fieldCount, autoFillCount }) => {
        toast(`Template ready — ${autoFillCount} of ${fieldCount} fields fill automatically`)
      }}
    />
  )
}
06

Send a contract

Hand SendContract the record the contract is about. Bindings resolve against it, standing defaults fill themselves, and anything left over is asked as a typed question. Your user reviews a real draft PDF before sending.

app/deals/[id]/contract.tsx
import { SendContract } from '@conovo/react'

export function DealContract({ deal, client }) {
  return (
    <SendContract
      subject={deal}
      defaultRecipient={{ name: client.fullName, email: client.email }}
      onSent={(contractId) => router.push(`/deals/${deal.id}/contracts/${contractId}`)}
    />
  )
}
§

Workspaces

A workspace is one of your businesses. It owns templates, standing defaults, and contracts, and it is the unit of isolation — nothing crosses between workspaces.

You never create one explicitly. The first session minted for an externalRef creates it; every mint after matches on that ref and refreshes the display name, so renames on your side follow through automatically.

§

Templates and versions

Confirming in the studio cuts a new immutable template version. Every contract pins the version it was generated from and stores the exact resolved values used, so a contract signed two years ago can be reproduced byte for byte even after the template has moved on.

Editing a template never rewrites history and never changes a contract already sent.

§

Where values come from

Every field has a source, and they resolve in a fixed order. Understanding this is most of understanding Conovo.

SourceFilled from
platform_boundThe subject you pass to the component, via a binding path
workspace_defaultA standing value the business set once
per_dealAsked at send time, as a typed input
computedA stored formula, evaluated in decimal-safe code
conditionalA section kept or removed based on a stored condition

A binding that misses — the path exists but your payload didn’t carry a value — falls through to being asked at send time rather than failing. Those misses are aggregated into a payload gap report in the console, so you can see which fields would benefit from being added to your schema.

§

Components

All of them require the provider above it and take their workspace scope from the session. None takes an API key.

ComponentPropsWhat it does
ContractStudioonConfirmed?Upload a document and confirm the proposed fields
SendContractsubject, defaultRecipient, onSent?Generate, review and send one contract
BulkSendCSV upload, column mapping, pre-flight and batch send
ContractInboxrenderItemActions?What has been sent and where each one stands
ContractTemplatesManage the templates a business has set up
StandingTermsEdit the values that fill themselves every time
§

Server SDK

@conovo/node has no dependencies and does three things: mints sessions, verifies webhooks, and verifies data-connector requests.

@conovo/node
import { Conovo, ConovoError } from '@conovo/node'

const conovo = new Conovo({ secretKey: process.env.CONOVO_SECRET_KEY })

// mint a workspace-scoped token (15 minutes)
await conovo.sessions.create({ workspace: { externalRef, name } })

// timing-safe HMAC over the raw request body
conovo.webhooks.verify(rawBody, signatureHeader, webhookSecret)   // → boolean
conovo.connector.verify(rawBody, signatureHeader, signingSecret)  // → boolean

// entitlement failures surface as a typed error
try {
  await conovo.sessions.create({ workspace })
} catch (err) {
  if (err instanceof ConovoError && err.reason === 'account_lapsed') {
    // show your own billing message
  }
}
§

Webhooks

Conovo posts status changes as the recipient moves through the signing flow. Verify the signature over the raw body before trusting anything, and return 2xx for events you don’t handle so they aren’t retried.

app/api/conovo/webhook/route.ts
import { Conovo } from '@conovo/node'

const conovo = new Conovo({ secretKey: process.env.CONOVO_SECRET_KEY })

export async function POST(req: Request) {
  const raw = await req.text()              // raw bytes, not the parsed body
  const signature = req.headers.get('conovo-signature') ?? ''

  if (!conovo.webhooks.verify(raw, signature, process.env.CONOVO_WEBHOOK_SECRET)) {
    return new Response('bad signature', { status: 401 })
  }

  const event = JSON.parse(raw)
  switch (event.type) {
    case 'contract.viewed':    break
    case 'contract.signed':    break
    case 'contract.completed': await markSigned(event.data.contractId); break
    case 'contract.declined':  break
  }

  return new Response('ok')
}

Delivery is at-least-once. Handlers must be idempotent — key on event.id if you write on receipt.

§

Errors

Errors are RFC 7807 problem+json. The status tells you the class; the reason field is the machine-readable detail worth branching on.

StatusMeansDo
401Token missing, expired or malformedRe-mint a session and retry once
402Account not entitled — see the reasonShow your billing message; the provider exposes a locked state
403Out of workspace scopeA bug — the token doesn’t own that resource
409State conflict, e.g. sending a contract needing attentionSurface the validation issues instead
422Understood but impossible, e.g. filling a PDF-sourced templateRead the plain-English reason; don’t retry
§

Going live

Swap the sandbox key for a live one, point your webhook at your production URL, and add billing in the console. Nothing else in your code changes.

Two things worth doing before real contracts move: send one to your own inbox and sign it end to end, and confirm your app renders sensibly on a 402 — that is what your users see if a card fails.

Auto-send unlocks on evidence, not on a setting. A template must be reviewed and sent by a human several times before unattended sending is available for it. Until then, requesting auto-send returns a draft and tells you why — build your UI to expect that from day one.

Next

Get a key and try it against test data.

The sandbox generates real documents and fires real webhooks. Nothing binds until you move to a live key.