Your agreements, in your own code.

A REST API over what Termn tracks: contacts, workspaces, agreements and the conditions they are waiting on, the money moving against them, and the ledger that flattens all of it into what you are owed and owe. Reading covers all of that. Writing is deliberately narrower — your integration syncs records, drafts agreements, and proposes the send; a member of your organization completes the setup and confirms it in the app. 31 calls in 9 groups, typed end to end.

Download the OpenAPI schema Browse it interactively

OpenAPI 3.1 · Version 1.0.0 · Base URL https://termn.ai

Your first call takes a token and a line

Create an organization token in the app under Settings → API and webhooks. It is shown once, so store it before you close the page. Send it as a bearer token on every request.

curl https://termn.ai/api/v1/me \
  -H "Authorization: Bearer $TERMN_TOKEN"

The reply names the organization the token belongs to and the scope it carries. Every other call is scoped to that organization: another organization’s record reads as not found, never as forbidden, so a wrong token never confirms that a record exists.

Every endpoint, by group

19 of the 31 calls are reads. The token column names the ones a read-only token is refused for.

Identity 1 call

Who the calling token belongs to.

CallWhat it doesToken
GET /api/v1/me Describe the calling token Read

Contacts 4 calls

People and entities your organization does business with. Organization-scoped and reusable; agreements reference contacts rather than copying them.

CallWhat it doesToken
GET /api/v1/contacts List contacts Read
POST /api/v1/contacts Create a contact Read and write
GET /api/v1/contacts/{contact_id} Fetch one contact Read
PATCH /api/v1/contacts/{contact_id} Update a contact Read and write

Workspaces 4 calls

One workspace per deal or matter. Agreements live inside a workspace.

CallWhat it doesToken
GET /api/v1/workspaces List workspaces Read
POST /api/v1/workspaces Create a workspace Read and write
GET /api/v1/workspaces/{workspace_id} Fetch one workspace Read
POST /api/v1/workspaces/{workspace_id}/contacts Reference a contact from a workspace Read and write

Agreements 5 calls

Agreements with their ordered stages and gates. Create drafts here; publishing an agreement to participants happens in the Termn app.

CallWhat it doesToken
GET /api/v1/agreements List agreements Read
POST /api/v1/agreements Create a draft agreement Read and write
GET /api/v1/agreements/{agreement_id} Fetch one agreement Read
GET /api/v1/agreements/{agreement_id}/information Answers to an agreement's information requests Read
POST /api/v1/agreements/{agreement_id}/participants Add a participant to an agreement Read and write

Money movement 2 calls

Read-only, deliberately. Authorizing a payment and confirming that one arrived are evidence-gated acts: only receiver-side evidence at recipient-confirmed assurance or better may reconcile a movement, and a sender's acknowledgement never proves receipt. An organization-wide API token is not that evidence, so those transitions are performed by a person in the Termn app and observed here.

CallWhat it doesToken
GET /api/v1/movements List money movements Read
GET /api/v1/movements/{movement_id} Fetch one money movement Read

Ledger 1 call

Every obligation and asset across all workspaces, in one flat list.

CallWhat it doesToken
GET /api/v1/ledger List ledger rows Read

Obligations 2 calls

Read-only, deliberately. One record per promise projected from an agreement, with its own life: scheduled occurrences, declared triggers, standing states to reverify. obligation.* webhooks carry an obligation_id; dereference it here. Declaring a trigger, recording satisfaction, and confirming are evidence-gated acts a person performs in the Termn app and you observe here.

CallWhat it doesToken
GET /api/v1/obligations List obligations Read
GET /api/v1/obligations/{obligation_id} Fetch one obligation Read

Trust funding 9 calls

A workspace can run one trust customer's funding: every asset that has to move, as rows created cheaply before any paperwork exists. Rows here are intent — listing, sorting, parking, and papering contact nobody and authorize nothing; papering produces an ordinary draft agreement. Two acts are deliberately absent: closing a row unfunded moves the completion number, and sending anything is a protected effect — both stay with a member in the Termn app.

CallWhat it doesToken
GET /api/v1/trusts List trusts Read
POST /api/v1/trusts Record a trust Read and write
GET /api/v1/workspaces/{workspace_id}/funding The funding schedule Read
GET /api/v1/funding-items List funding items Read
POST /api/v1/funding-items Put an asset on the list Read and write
POST /api/v1/funding-items/paper Paper rows into a draft agreement Read and write
GET /api/v1/funding-items/{item_id} Fetch one funding item Read
PATCH /api/v1/funding-items/{item_id} Sort a row: verb, trust, owners, facts Read and write
POST /api/v1/funding-items/{item_id}/park Park a row, or clear its hold Read and write

Proposals 3 calls

The only door to protected effects. A machine credential may propose publishing or cancelling an agreement; a member of the organization reviews the plain-language summary at confirm_url in the Termn app and confirms or declines it there. Nothing executes until they do. Undecided proposals expire after 72 hours.

CallWhat it doesToken
POST /api/v1/proposals Propose a protected effect Read and write
GET /api/v1/proposals List proposals Read
GET /api/v1/proposals/{proposal_id} Fetch one proposal Read

Paths are shown in full: the base URL above plus any row is the URL you call. Every request and response body is typed in the schema, so point a generator at it rather than transcribing fields by hand.

What the API will not do for you

These hold for every call, from every client, on every token.

Confirm that money arrived
Money movements are read-only here, deliberately. A sender reporting that they sent it is stored as their claim; reconciling it takes receiver-side evidence and a person with authority accepting it, and an organization-wide token is neither.
Email your participants
Publishing an agreement and cancelling one never execute from a token. Your integration proposes the effect and gets back a confirmation link; a member reads the plain-language summary in the app and confirms or declines. Undecided proposals expire after 72 hours.
Reach another organization
Every call resolves the token’s organization before it loads anything. A record outside it returns 404 with no hint that it exists, so an id from somewhere else leaks nothing.
Hand back the wire instructions
A payment tells you its state and who confirmed it. The instructions themselves, bank details, tokens, and webhook secrets appear in no response at all.
Create the same record twice
Send an Idempotency-Key header on any POST and a retry replays the original response. The same key with a different body is refused rather than guessed at. Contacts also carry your own external_reference: re-syncing the same reference returns the existing contact instead of a duplicate.
Change quietly
Every call writes an audit row in the transaction it commits: the token, the operation, the objects, the outcome. Your own text and your participants’ details stay out of the logs.

Failures come back in one shape

Every failure is the same envelope with a stable error.code to branch on, a message for a person to read, and a request id to quote to us. Validation failures add a list naming each field. These are the statuses to handle.

StatusWhat it means
401 Missing, malformed, expired, or revoked token.
403 This token is read-only.
404 No such record in your organization.
409 Idempotency-Key reused with a different request.
422 The request did not pass validation.
500 Unexpected failure on our side.

not_found · unauthenticated · forbidden · invalid_request · method_not_allowed · conflict · internal_error

Webhooks: the full contract

Rather than polling, register an endpoint in the app under Settings → API and webhooks. Endpoints must be HTTPS on a public host; redirects are never followed. Delivery is at-least-once, so treat a repeat as normal and dedupe on the event id.

Each event is one JSON object, POSTed with two headers:

POST /your/endpoint
Content-Type: application/json
Termn-Webhook-Timestamp: 1754806002
Termn-Webhook-Signature: t=1754806002,v1=<hex HMAC-SHA-256>

{"id":"…","type":"movement.reconciled","created_at":"…","data":{…}}

The signature's v1 value is HMAC-SHA-256, keyed with your endpoint's signing secret, over the exact string timestamp + "." + raw request body — the raw bytes, before any re-encoding, since a proxy that re-serializes the JSON breaks the signature. Compare with a constant-time comparison, and refuse timestamps more than five minutes old to close the replay window.

# Python. raw_body must be the exact bytes received — never a re-encoded string.
import hashlib, hmac, time

def verify(secret, timestamp_header, signature_header, raw_body):
    assert isinstance(raw_body, (bytes, bytearray)), "pass the raw request body bytes"
    sent = dict(part.split("=", 1) for part in signature_header.split(","))
    # The timestamp inside the signature is what was signed; require it to match
    # the separate header, then check freshness on it.
    if sent.get("t") != timestamp_header:
        return False
    if abs(time.time() - int(timestamp_header)) >= 300:
        return False
    signed = timestamp_header.encode() + b"." + raw_body   # bytes, not an f-string
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sent.get("v1", ""), expected)

The data object carries ids you look the full record up by — the envelope stays small and stable, and you fetch what you need over the REST API. Every type's data shape:

Event typedata fields
agreement.publishedagreement_id, name
agreement.completedagreement_id, name
signature.completedagreement_id, document_id, participant_id
movement.sender_reportedmovement_id, agreement_id
movement.reconciledmovement_id, agreement_id
movement.exceptionmovement_id, reason
obligation.triggeredobligation_id, name
obligation.satisfiedobligation_id, name
obligation.confirmedobligation_id, name
proposal.createdproposal_id, agreement_id, action
proposal.confirmedproposal_id, agreement_id, action
proposal.declinedproposal_id, agreement_id, action
proposal.expiredproposal_id, agreement_id, action
trust.createdtrust_id, workspace_id
trust.updatedtrust_id, workspace_id
funding_item.addedfunding_item_id, workspace_id, trust_id when assigned
funding_item.sortedfunding_item_id, workspace_id, verb, trust_id when assigned
funding_item.parkedfunding_item_id, workspace_id, waiting_on (nobody means the hold cleared)
funding_item.paperedfunding_item_id, workspace_id, agreement_id
funding_item.closedfunding_item_id, workspace_id, status
funding_item.reopenedfunding_item_id, workspace_id
workspace.activatedworkspace_id, authorization
webhook.testmessage — sent only by the “Send a test event” button; never fires for real activity

Handle unknown types by acknowledging and ignoring them — new ones are added without notice, and the data object only grows fields, never renames them. A non-2xx response or a timeout retries after 1 minute, then 5, then 30, then 2 hours; after the fifth failure the delivery waits for a manual replay from the endpoint's page in the app, where you can also send a test event, pause deliveries, and rotate the signing secret.

Two ways in

Write against the REST API

For scripts, back-office jobs, and your own product.

  1. Generate a client. The schema is OpenAPI 3.1 with every body typed, so your generator writes the models.
  2. Read the ledger. One flat list of everything owed and owing across every workspace, rather than walking agreements.
  3. Retry safely. An idempotency key on each POST makes a repeat harmless.

Connect an MCP client

For agents that work the follow-up rather than call endpoints.

  1. Point it at /mcp. Same token, same organization scope, same audit trail.
  2. Read the conditions. Every tool runs an API operation underneath, so no tool has a power the API lacks.
  3. Hand the send to a person. The agent proposes; a member confirms. How that works.

The API and the schema cost nothing, and drafting through them is free exactly as it is in the app. $149 activates the workspace when you are ready to send. See all pricing.

Four real workflows, end to end

Runnable as-is in bash with curl and jq: export your token, paste, run. Every response is JSON in the shapes the schema declares, and each step captures the ids the next one needs.

Sync a CRM record into a draft, then hand the send to a person

BASE=https://termn.ai/api/v1
AUTH="Authorization: Bearer $TERMN_TOKEN"
JSON="Content-Type: application/json"

# 1. The customer, keyed by your CRM id — re-running this never duplicates.
CONTACT=$(curl -s "$BASE/contacts" -H "$AUTH" -H "Idempotency-Key: sync-cust-42" -H "$JSON" \
  -d '{"display_name":"Brightline Inc","kind":"corporation",
       "email":"legal@brightline.example","external_reference":"crm-42"}' | jq -r .id)

# 2. A workspace for the deal, and a draft inside it.
WORKSPACE=$(curl -s "$BASE/workspaces" -H "$AUTH" -H "Idempotency-Key: ws-42" -H "$JSON" \
  -d '{"name":"Brightline onboarding"}' | jq -r .id)
AGREEMENT=$(curl -s "$BASE/agreements" -H "$AUTH" -H "Idempotency-Key: agr-42" -H "$JSON" \
  -d "{\"workspace_id\":\"$WORKSPACE\",\"name\":\"Data processing agreement\"}" | jq -r .id)

# 3. Attach a signer, then read readiness. proposal_ready:true means propose;
#    any other blocker means a person finishes setup at the draft's app_url
#    (documents and steps are configured there, not over the API).
curl -s "$BASE/agreements/$AGREEMENT/participants" -H "$AUTH" \
  -H "Idempotency-Key: part-42" -H "$JSON" \
  -d "{\"contact_id\":\"$CONTACT\",\"roles\":[\"signer\"]}" > /dev/null
curl -s "$BASE/agreements/$AGREEMENT" -H "$AUTH" \
  | jq '{proposal_ready, publish_blockers, app_url}'

# 4. Propose. Send the response's confirm_url to the person who decides, then
#    poll GET $BASE/proposals/<id> for state and the decision note.
curl -s "$BASE/proposals" -H "$AUTH" -H "Idempotency-Key: prop-42" -H "$JSON" \
  -d "{\"action\":\"publish_agreement\",\"agreement_id\":\"$AGREEMENT\",
       \"note\":\"Q3 onboarding, approved by legal\"}" | jq '{state, confirm_url}'

Run a trust’s funding from your book of record

BASE=https://termn.ai/api/v1
AUTH="Authorization: Bearer $TERMN_TOKEN"
JSON="Content-Type: application/json"

# 1. The people, keyed by your own ids — re-running any step here never duplicates.
SETTLOR=$(curl -s "$BASE/contacts" -H "$AUTH" -H "Idempotency-Key: tc-c-90" -H "$JSON" \
  -d '{"display_name":"Margaret Hartwell","email":"margaret@example.com",
       "external_reference":"tc-person-90"}' | jq -r .id)

# 2. One workspace per client or matter, then the trust — a Contact plus a
#    funding record. Your reference makes the create an upsert (200 on repeat).
WORKSPACE=$(curl -s "$BASE/workspaces" -H "$AUTH" -H "Idempotency-Key: tc-ws-90" -H "$JSON" \
  -d '{"name":"Hartwell estate plan"}' | jq -r .id)
TRUST=$(curl -s "$BASE/trusts" -H "$AUTH" -H "Idempotency-Key: tc-t-90" -H "$JSON" \
  -d "{\"workspace_id\":\"$WORKSPACE\",
       \"formal_name\":\"Hartwell Family Trust dated 3 March 2019\",
       \"settlor_contact_ids\":[\"$SETTLOR\"],\"trustee_contact_ids\":[\"$SETTLOR\"],
       \"external_reference\":\"tc-trust-881\"}" | jq -r .id)

# 3. Every asset that should move, listed cheaply — a label is enough, and a
#    row contacts nobody. Sort each one when you know more: the verb, the kind,
#    and the kind-shaped facts. `missing_facts` tells you what's still needed.
ITEM=$(curl -s "$BASE/funding-items" -H "$AUTH" -H "Idempotency-Key: tc-i-17" -H "$JSON" \
  -d "{\"workspace_id\":\"$WORKSPACE\",\"trust_id\":\"$TRUST\",
       \"label\":\"the Cypress Lane house\",\"asset_kind\":\"real_property\",
       \"external_reference\":\"tc-asset-17\"}" | jq -r .id)
curl -s -X PATCH "$BASE/funding-items/$ITEM" -H "$AUTH" -H "$JSON" \
  -d '{"verb":"retitle","detail":{"county":"Marin"}}' | jq '{phase, missing_facts}'

# 4. The schedule is the honest denominator: `summary.done` counts only
#    confirmed transfers and explicitly closed rows — a claim never moves it.
curl -s "$BASE/workspaces/$WORKSPACE/funding" -H "$AUTH" | jq .summary

# 5. Paper ready rows into a draft, then propose the send. A member confirms
#    at confirm_url; you hear the decision on the proposal.* webhooks, and
#    every later state change lands as trust.*, funding_item.*, movement.*,
#    and obligation.* events — no polling loop required.
AGREEMENT=$(curl -s "$BASE/funding-items/paper" -H "$AUTH" \
  -H "Idempotency-Key: tc-p-17" -H "$JSON" \
  -d "{\"item_ids\":[\"$ITEM\"]}" | jq -r .id)
curl -s "$BASE/proposals" -H "$AUTH" -H "Idempotency-Key: tc-pr-17" -H "$JSON" \
  -d "{\"action\":\"publish_agreement\",\"agreement_id\":\"$AGREEMENT\"}" \
  | jq '{state, confirm_url}'

Export the ledger to a warehouse

# Page through every open row; unknown filters fail with a 422 rather than
# silently widening the export. `agreement_id`, `workspace_id`, and `product`
# narrow it; `amount_minor` is the arithmetic-safe field.
curl -s "https://termn.ai/api/v1/ledger?include_done=false&limit=200&offset=0" \
  -H "Authorization: Bearer $TERMN_READONLY_TOKEN"
# Repeat, adding 200 to offset, while `pagination.has_more` is true.

React to a settled payment

# Verify the signature (above), then branch on the event type.
# movement.reconciled → the money provably arrived; fetch the movement for
# `confirmed_by`, `confirmed_at`, and `confirmation_basis`.
curl -s https://termn.ai/api/v1/movements/$MOVEMENT_ID \
  -H "Authorization: Bearer $TERMN_READONLY_TOKEN"

Common questions

Does the API cover everything the app does?
Reading covers the resources the API projects: contacts, workspaces, agreements with the conditions they are waiting on, money movements, and the ledger, all typed. Receiving bank instructions, participant links, and webhook secrets are deliberately absent from every response. Writing is narrower still: contacts, workspaces, draft agreements, and participants you create outright; documents, steps, and payments are configured by a person in the app, and sending or confirming money are acts a person has to stand behind.
How do I get a token?
In the app, under Settings → API and webhooks. A token belongs to one organization, carries read-only or read-and-write access, and expires in ninety days. It is shown once at creation, so store it before you close the page.
Can I generate a client from the schema?
That is what it is for. The document at /openapi.json is OpenAPI 3.1 with every request and response typed, so the usual generators produce a client without hand-written models. Point your tooling at the file directly.
Why can’t the API confirm a payment or send an agreement?
Both are acts a person has to stand behind. Confirming that money arrived needs receiver-side evidence, which an organization-wide token is not, and publishing an agreement emails real people. The API proposes; a member confirms in the app, and the proposal records who did.
What happens if I retry a request?
Send an Idempotency-Key header on any POST and a repeat replays the original response instead of creating a second record. Reusing the same key with a different body is refused rather than guessed at.
Is there an SDK?
Not yet. Generated clients cover most of what an SDK would. If you would rather we published one for your language, write to sales@termn.ai and say which.

Finish what the agreement started

Your first workspace is free: one live workspace, unlimited agreements inside it, no card.

Close your first deal free

Rather talk it through first? Contact us at sales@termn.ai.