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.
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.
CONOVO_SECRET_KEY=sk_live_…
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.
{ "org": { "name": "string", "address": "string" }, "client": { "fullName": "string", "email": "string" }, "deal": { "id": "string", "name": "string", "startDate": "date", "total": "money" } }
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.
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) }
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.
'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.
.conovo { --cv-accent: #2e42c6; --cv-radius: 6px; --cv-font: 'Your UI Font', system-ui, sans-serif; }
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.
import { ContractStudio } from '@conovo/react' export default function ContractSetup() { return ( <ContractStudio onConfirmed={({ fieldCount, autoFillCount }) => { toast(`Template ready — ${autoFillCount} of ${fieldCount} fields fill automatically`) }} /> ) }
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.
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.
| Source | Filled from |
|---|---|
| platform_bound | The subject you pass to the component, via a binding path |
| workspace_default | A standing value the business set once |
| per_deal | Asked at send time, as a typed input |
| computed | A stored formula, evaluated in decimal-safe code |
| conditional | A 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.
| Component | Props | What it does |
|---|---|---|
| ContractStudio | onConfirmed? | Upload a document and confirm the proposed fields |
| SendContract | subject, defaultRecipient, onSent? | Generate, review and send one contract |
| BulkSend | — | CSV upload, column mapping, pre-flight and batch send |
| ContractInbox | renderItemActions? | What has been sent and where each one stands |
| ContractTemplates | — | Manage the templates a business has set up |
| StandingTerms | — | Edit 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.
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.
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.
| Status | Means | Do |
|---|---|---|
| 401 | Token missing, expired or malformed | Re-mint a session and retry once |
| 402 | Account not entitled — see the reason | Show your billing message; the provider exposes a locked state |
| 403 | Out of workspace scope | A bug — the token doesn’t own that resource |
| 409 | State conflict, e.g. sending a contract needing attention | Surface the validation issues instead |
| 422 | Understood but impossible, e.g. filling a PDF-sourced template | Read 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.
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.