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
Create this route once and forget it. You never call it yourself. The provider in step 04 calls it automatically whenever a Conovo component needs a token: on first render for a signed-in user, and again each time the 15-minute token expires. There is nothing to run when a business signs up or logs in.
What it does: the browser never sees your secret key, so your server exchanges it for a short-lived token scoped to exactly one workspace: whichever business is signed in right now. Pass your own stable ID for them as externalRef. Conovo creates the workspace on the first mint and matches it on every mint after, so there is no provisioning step.
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. Two are setup surfaces a business visits occasionally (Studio, StandingTerms); the rest are the everyday surfaces where contracts get sent and tracked.
| Component | Where it goes in your product | What your user does there |
|---|---|---|
| ContractStudio | A “contract templates” settings page, used once per template, not per deal | Uploads the contract they already use (DOCX/PDF); AI proposes the fillable fields, formulas and signer roles; they review, edit and confirm. Includes the optional document check-up. onConfirmed? |
| ContractTemplates | Same settings page, beside the Studio | Sees every template they’ve confirmed and its status |
| SendContract | Your project / job / booking detail page, wherever a “send contract” button belongs | The per-deal panel: values auto-fill from the subject record you pass, they fill what’s left, preview the finished PDF, and send it for signature. subject, defaultRecipient, onSent?, notify?. Set notify="platform" and the signing vendor emails nobody, so you send the request yourself, from your own domain, to your own SignContract page |
| BulkSend | Behind a “bulk send” action, for users with lists (renewals, a season’s clients) | Uploads a CSV; AI proposes the column mapping, they confirm it; a pre-flight table shows every row’s issues before anything sends; then a live batch with pause/resume |
| ContractInbox | A “contracts” page or tab in your app | Tracks everything sent and where each one stands (viewed, signed, completed), with the document itself. renderItemActions? adds your own per-row buttons |
| SignContract | A recipient-facing page, if you want signing to happen inside your product | The counterparty signs without leaving your domain; falls back to the provider’s hosted signing link automatically when embedding is refused. Recipients sent with a mobile number pass an SMS identity check first, recorded in the audit trail. Build it as a stable /sign/[contractId] route: it resolves the live signing link on every request, so if the sender corrects the contract before it’s signed, the same link keeps working |
| StandingTerms | Your settings area | Edits the values that pre-fill every contract, such as their own address, rates and standard terms, so repeat fields never get retyped |
Implementation prompts
One self-contained prompt per surface, written for a coding agent. Each carries the exact import, the real prop types, the prerequisite, the constraints that are easy to get wrong, and something to verify at the end. Paste it into your agent and review the diff. They are generated from the same source we maintain alongside the SDK, so a prop named here exists.
Foundation
Build this first — every component needs it.
The foundation every other component requires.
Setup surfaces
Visited occasionally, when a business adds or changes a contract.
Upload a contract, confirm the AI's field mapping, save a template.
Adopt a starter template the platform published.
The templates a business has set up, and what each one fills.
Set-once values that fill into every future contract.
Everyday surfaces
Where contracts actually get sent, signed, and tracked.
Generate a filled contract for one deal and send it for signature.
Your own signing page — the recipient signs on your domain.
Signing when the account uses an external signing vendor.
Every contract sent, and where each one stands.
Send the same contract to many recipients from a CSV.
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 } }
Coding agents (MCP)
Most of this integration can be done by your coding agent. @conovo/mcp is an MCP server that gives Claude Code, Cursor, or any MCP client direct tools over your account: point it at your repo, say “integrate Conovo”, and it can read your data model, build and register the payload schema, mint sandbox sessions, drive test contracts through the real API, wire up webhooks, and read the request inspector when a call fails.
claude mcp add conovo -e CONOVO_SECRET_KEY=sk_test_… -- npx -y @conovo/mcp
Any MCP client works. The server is npx -y @conovo/mcp with CONOVO_SECRET_KEY in its environment. Use an sk_test_ key while integrating: every session it mints is test mode end to end, so nothing your agent does can bind or bill.
The tools: register_payload_schema / infer_payload_schema / get_payload_gaps (the schema loop), create_sandbox_session + get_api_reference (drive the /v1 API for real), list_requests / get_held_contracts / list_events (debugging), and get_webhook / set_webhook / test_webhook.
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 // Everyone has signed — but the executed file may not exist YET. case 'contract.completed': await markSigned(event.data.contractId); break // The signed PDF and certificate now exist. Fetch them here, not above. case 'contract.executed': await store(event.data.contractId, event.data.executionHash) 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. a source file that can’t be filled | 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.