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
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.
19 of the 31 calls are reads. The token column names the ones a read-only token is refused for.
Who the calling token belongs to.
| Call | What it does | Token |
|---|---|---|
| GET /api/v1/me | Describe the calling token | Read |
People and entities your organization does business with. Organization-scoped and reusable; agreements reference contacts rather than copying them.
| Call | What it does | Token |
|---|---|---|
| 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 |
One workspace per deal or matter. Agreements live inside a workspace.
| Call | What it does | Token |
|---|---|---|
| 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 with their ordered stages and gates. Create drafts here; publishing an agreement to participants happens in the Termn app.
| Call | What it does | Token |
|---|---|---|
| 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 |
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.
| Call | What it does | Token |
|---|---|---|
| GET /api/v1/movements | List money movements | Read |
| GET /api/v1/movements/{movement_id} | Fetch one money movement | Read |
Every obligation and asset across all workspaces, in one flat list.
| Call | What it does | Token |
|---|---|---|
| GET /api/v1/ledger | List ledger rows | Read |
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.
| Call | What it does | Token |
|---|---|---|
| GET /api/v1/obligations | List obligations | Read |
| GET /api/v1/obligations/{obligation_id} | Fetch one obligation | Read |
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.
| Call | What it does | Token |
|---|---|---|
| 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 |
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.
| Call | What it does | Token |
|---|---|---|
| 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.
These hold for every call, from every client, on every token.
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.
| Status | What 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
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 type | data fields |
|---|---|
| agreement.published | agreement_id, name |
| agreement.completed | agreement_id, name |
| signature.completed | agreement_id, document_id, participant_id |
| movement.sender_reported | movement_id, agreement_id |
| movement.reconciled | movement_id, agreement_id |
| movement.exception | movement_id, reason |
| obligation.triggered | obligation_id, name |
| obligation.satisfied | obligation_id, name |
| obligation.confirmed | obligation_id, name |
| proposal.created | proposal_id, agreement_id, action |
| proposal.confirmed | proposal_id, agreement_id, action |
| proposal.declined | proposal_id, agreement_id, action |
| proposal.expired | proposal_id, agreement_id, action |
| trust.created | trust_id, workspace_id |
| trust.updated | trust_id, workspace_id |
| funding_item.added | funding_item_id, workspace_id, trust_id when assigned |
| funding_item.sorted | funding_item_id, workspace_id, verb, trust_id when assigned |
| funding_item.parked | funding_item_id, workspace_id, waiting_on (nobody means the hold cleared) |
| funding_item.papered | funding_item_id, workspace_id, agreement_id |
| funding_item.closed | funding_item_id, workspace_id, status |
| funding_item.reopened | funding_item_id, workspace_id |
| workspace.activated | workspace_id, authorization |
| webhook.test | message — 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.
For scripts, back-office jobs, and your own product.
For agents that work the follow-up rather than call endpoints.
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.
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.
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}'
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}'
# 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.
# 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"
Your first workspace is free: one live workspace, unlimited agreements inside it, no card.
Close your first deal freeRather talk it through first? Contact us at sales@termn.ai.