Termitude API
The Termitude API lets you publish policy and legal documents, fetch the current published version, and record user consent — all backed by a tamper-evident ledger. Every request is versioned, every acceptance is hashed against the exact text the user saw, and every change is auditable.
Quickstart
- 1
Create a document
In the dashboard, open Documents → New, give it a slug liketerms, and publish your first version. - 2
Generate an API key
Go to Settings → API keys and create a key. Store the secret — it's only shown once. Keys start withtmt_. - 3
Pick your language
Fetch a documentbashcurl https://app.termitude.com/api/public/v1/documents/terms \ -H "Authorization: Bearer tmt_live_..."Record a consentbashcurl -X POST https://app.termitude.com/api/public/v1/consent \ -H "Authorization: Bearer tmt_live_..." \ -H "Content-Type: application/json" \ -d '{ "documentSlug": "terms", "userExternalId": "user_123", "userEmail": "[email protected]" }'
Authentication
Every request uses a workspace API key. Pass it either as a bearer token or via the x-api-key header. Keys are scoped to one workspace, hashed at rest, and shown only once at creation.
curl https://app.termitude.com/api/public/v1/documents \
-H "Authorization: Bearer $TERMITUDE_API_KEY"
# or
curl https://app.termitude.com/api/public/v1/documents -H "x-api-key: $TERMITUDE_API_KEY"Either Authorization: Bearer … or x-api-key: … works on every endpoint. Keys are scoped to one workspace, hashed at rest, and shown only once at creation.
- Every key looks like
tmt_…. There is no separate test / live prefix — create a dedicated workspace if you want a sandbox. - Keys are shown once at creation and hashed at rest. Store the secret in your secret manager; rotate from Settings → API keys.
- Treat every key as a server-side secret. The embed widget accepts the same key in
data-termitude-key— it will be visible in page source, so create a dedicated key for the embed and revoke it independently if it leaks. - All public endpoints accept the key as
Authorization: Bearer …orx-api-key: …. Both are equivalent.
API Reference
The machine-readable spec lives at https://app.termitude.com/api/public/v1/openapi. Feed it to openapi-typescript, openapi-generator, or any other generator to produce a typed client in your language:
npx openapi-typescript https://app.termitude.com/api/public/v1/openapi -o termitude.d.tsLoading endpoints from the live OpenAPI spec…
Pagination & CORS
Pagination
List endpoints use cursor-free offset pagination: pass ?limit= (1–500, default 100) and ?offset= (default 0). Responses include the echoed limit, offset, and a total count so you can render a "showing X of Y" UI without a second call. Only GET /documents paginates today — single-resource GETs always return the full payload.?status= accepts draft | in_review | published | archived. ?category= accepts terms_of_service, privacy_policy, dpa, cookie_policy, acceptable_use, ai_usage, sla, security_policy, addendum, custom (or any custom category id you've created). Unknown filter values return an empty list, not an error.
curl "https://app.termitude.com/api/public/v1/documents?status=published&limit=50&offset=100" \
-H "Authorization: Bearer $TERMITUDE_API_KEY"
# => { "documents": [...], "total": 412, "limit": 50, "offset": 100 }CORS
Every /api/public/v1/* endpoint sends Access-Control-Allow-Origin: * and answers preflight requests, so the API is safe to call directly from a browser. Callers from a browser context should use a key dedicated to that surface — see the Authentication warning above.
Embeds
Drop a single tag into any page — React, Vue, Svelte, Angular, or plain HTML — to render the current published version of a document and capture acceptance. Both options are framework-agnostic: one ships our widget script, the other isolates the document inside an iframe.
<!-- Minimal: document slug + workspace API key -->
<div data-termitude-doc="terms-of-service"
data-termitude-key="tmt_…"
data-termitude-user="user_123"
data-termitude-email="[email protected]"
data-termitude-source="checkout"
style="max-width:640px;border:1px solid #d4d4d8;border-radius:12px;padding:20px;font-family:Inter,system-ui,sans-serif"></div>
<script async src="https://app.termitude.com/widget/v1/termitude.js"></script>
<script>
// Fires once the user clicks Accept. Payload includes consentId + versionNumber.
document.addEventListener('termitude:accepted', function (e) {
console.log('consent recorded', e.detail);
});
</script>Identity attributes
| Attribute | Purpose | Default |
|---|---|---|
| data-termitude-doc | Document slug to render. | required |
| data-termitude-key | Workspace API key (tmt_…). Use a dedicated key for embeds — it's visible in page source. | required |
| data-termitude-user | Your stable user identifier (recorded against the consent). | — |
| data-termitude-email | User email recorded with the consent. | — |
| data-termitude-source | Free-form source app label saved on the ledger entry. | location.host |
Styling attributes
| Attribute | Purpose | Default |
|---|---|---|
| data-termitude-theme | light, dark, or auto (follows prefers-color-scheme). | light |
| data-termitude-accent | CSS color for the Accept button background. | #111827 / #f5f5f5 |
| data-termitude-accent-text | CSS color for the Accept button label. | #ffffff / #111827 |
| data-termitude-radius | Border radius in pixels (number, no unit). | 8 |
| data-termitude-font | CSS font-family value applied to the embed. | system-ui stack |
| data-termitude-max-width | CSS max-width of the container. | 720px |
| data-termitude-max-height | CSS max-height of the scrolling document body. | 400px |
| data-termitude-unstyled | Set to true to skip all default inline styles. Style via the .termitude-* child classes (header, title, version, content, actions, button, status). | false |
Behavior attributes
| Attribute | Purpose | Default |
|---|---|---|
| data-termitude-button-label | Text shown on the Accept button. | Accept |
| data-termitude-accepted-label | Text shown after a successful acceptance. | Accepted |
| data-termitude-show-title | Show the document name in the header. | true |
| data-termitude-show-version | Show the vN badge next to the title. | true |
| data-termitude-auto-submit | When false, button clicks emit a cancelable termitude:accept-click event (with detail.submit()) instead of posting immediately — use to add your own confirmation step. | true |
| data-termitude-auto-init | Set on the <script> tag itself. When false, the widget will not scan the DOM on load — call window.Termitude.init() or window.Termitude.mount(el) yourself (SPA navigation, late-mounted modals). | true |
Events
termitude:loaded— fired on the host after the document fetch resolves.event.detail={ document, version }.termitude:accept-click— fired only whendata-termitude-auto-submit="false". Callevent.detail.submit()to POST the consent.termitude:accepted— fired after the consent is recorded.event.detail= consent payload (id, version, timestamp).
Include the script once per page; it auto-mounts every matching [data-termitude-doc] element. For SPAs that render embeds after load, re-run window.Termitude.init() after navigation or call window.Termitude.mount(el) on the specific host.
Hosted Pages
Every published document is reachable at a stable, shareable URL — no embed, no API call required. Link to it from your footer, email signatures, marketing pages, or customer comms.
| URL | Shows |
|---|---|
| https://app.termitude.com/p/<orgSlug>/<docSlug> | The current published version, with workspace branding. |
| https://app.termitude.com/p/<orgSlug>/<docSlug>/log | Public change log: every version, effective date, and AI summary. |
| https://app.termitude.com/p/<publicId> | Unguessable short link for one specific document (copy from Share & embed). |
| https://app.termitude.com/embed/<publicId> | Iframe-friendly render of the same document (no chrome). |
Hosted pages render with your custom domain when configured (see Custom Domains). They do not capture consent — use the embed widget or hosted acceptance flow when you need an acceptance record.
Hosted Acceptance
Instead of rendering the document yourself, redirect the user to a Termitude-hosted page. We display the current published version (or the AI change summary for a re-consent), capture Accept / Decline, store the consent record with evidence, and redirect the user back to your app. The authoritative outcome arrives via webhook; the browser redirect and the polling endpoint are conveniences.
Flow
- Your backend creates a session with the user identifiers and the document slug.
- You redirect the browser to the
urlreturned. - The user accepts or declines on the hosted page.
- Termitude records the consent, fires
acceptance_session.completedoracceptance_session.rejectedto your webhook, and redirects tosuccessUrl?session_id=ats_…orcancelUrl?session_id=ats_…. - On the return route, your app either trusts the webhook (recommended) or polls
GET /acceptance-sessions/{sessionId}for the current status.
Re-consent
When you publish a major version, Termitude fires document.reconsent_requiredwith the list of users whose latest acceptance is now stale. Create a /reconsent-sessionsfor each affected user — the hosted page shows the diff and the AI change summary, and the recorded consent is tagged method=forced_reaccept.
1. Create a session
curl -X POST https://app.termitude.com/api/public/v1/acceptance-sessions \
-H "Authorization: Bearer $TERMITUDE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"documentSlug": "terms-of-service",
"user": { "id": "user_123", "email": "[email protected]", "name": "Ada Lovelace" },
"successUrl": "https://app.example.com/onboarding/accepted",
"cancelUrl": "https://app.example.com/onboarding/declined",
"metadata": { "plan": "pro" }
}'
# => { "sessionId": "ats_…", "url": "https://app.termitude.com/accept/…", "expiresAt": "…" }2a. Listen for the webhook (recommended)
// Receives the authoritative outcome — survives closed tabs and network blips.
app.post("/webhooks/termitude", express.raw({ type: "application/json" }), (req, res) => {
verifyTermitude(req.body.toString("utf8"), req.header("x-termitude-signature")!, process.env.TERMITUDE_WEBHOOK_SECRET!);
const event = JSON.parse(req.body.toString("utf8"));
switch (event.type) {
case "acceptance_session.completed":
// event.data: { session_id, kind, status, consent_id, document_id, version_id,
// user_external_id, user_email, customer_id, account_id,
// method, source_app, accepted_at }
markUserAccepted(event.data.user_external_id, event.data.version_id);
break;
case "acceptance_session.rejected":
// event.data: { session_id, kind, document_id, version_id,
// user_external_id, user_email, customer_id, account_id,
// reason, rejected_at }
markUserDeclined(event.data.user_external_id, event.data.reason);
break;
case "document.reconsent_required":
// event.data: { document_id, new_version_id, new_version_number, classification,
// reconsent_policy, reconsent_deadline, effective_date, affected_user_count }
enqueueReconsentEmails(event.data.document_id, event.data.new_version_id);
break;
case "document.unpublished":
// event.data: { document_id, previous_version_id, reason }
invalidateCachedTerms(event.data.document_id);
break;
}
res.status(200).end();
});2b. Poll the session (fallback / return-page UX)
On the successUrl / cancelUrl route, the query string includes ?session_id=ats_…. Hit the polling endpoint to render the right confirmation UI without waiting on your own webhook plumbing.
curl https://app.termitude.com/api/public/v1/acceptance-sessions/ats_… \
-H "Authorization: Bearer $TERMITUDE_API_KEY"
# => {
# "sessionId": "ats_…",
# "kind": "acceptance", // acceptance | reconsent
# "status": "accepted", // open | accepted | rejected | expired
# "document_id": "…",
# "version_id": "…",
# "accepted_consent_id": "…", // null until accepted
# "rejected_reason": null,
# "user_external_id": "user_123",
# "user_email": "[email protected]",
# "metadata": { "plan": "pro" },
# "success_url": "…", "cancel_url": "…",
# "expires_at": "…", "completed_at": "…", "created_at": "…"
# }Session lifecycle
| status | Meaning |
|---|---|
| open | Created, waiting for the user. Expires 30 minutes after creation by default. |
| accepted | User accepted. A consent record exists; webhook fired. |
| rejected | User declined. No consent record; webhook fired with the reason if provided. |
| expired | User did not act in time. Create a new session to retry. |
Trust the webhook as the source of truth — sessions can complete after the user closes the redirect tab. Treat polling results as advisory until the matching webhook arrives, and dedupe on x-termitude-delivery.
Theming
The hosted page inherits your workspace branding (set under Settings → Brands): accent color, button text color, background, surface, body / muted text, border, font, corner radius, logo, and the “Powered by Termitude” toggle. Pass a theme object on session creation to override any subset for that single session — anything you omit falls back to the workspace value, and anything the workspace doesn't set falls back to the Termitude default.
{
"documentSlug": "terms-of-service",
"user": { "id": "user_123", "email": "[email protected]" },
"successUrl": "https://app.example.com/terms/accepted",
"cancelUrl": "https://app.example.com/terms/cancelled",
"theme": {
"accentColor": "#6366f1",
"buttonTextColor": "#ffffff",
"background": "#f8fafc",
"surface": "#ffffff",
"textColor": "#0f172a",
"mutedTextColor": "#64748b",
"borderColor": "#e2e8f0",
"font": "sans",
"radius": 12,
"logoUrl": "https://cdn.example.com/logo.svg",
"showPoweredBy": false
}
}Colors are hex (#rgb or #rrggbb).font is one of system, sans, serif, mono.radius is 0–24 pixels. Unknown or malformed fields are dropped silently.
Re-consent Policies
When you publish a new major version, prior acceptances no longer cover the current text. Termitude lets you decide, per document, how aggressively to chase those users. Configure the policy on the document under Settings → Re-consent; the value flows through to webhooks and the /compliance-status endpoint so your app can enforce it.
| Policy | Behaviour | compliance-status |
|---|---|---|
| none | No action required. The new version is informational only and existing acceptances carry forward. | compliant |
| notify | Fire document.reconsent_required and consent.outstanding. Users are informed but not blocked. | action_required |
| forced | Require re-acceptance. compliance-status returns blocked: true and the user must re-accept (e.g. via a /acceptance-sessions flow) before they can proceed. | blocked |
Every consent-status / compliance-status item carries both reconsent_policy (as configured) and effective_policy (after deadline escalation). Branch on effective_policy when gating access. The recorded consent from a re-consent session is tagged method=forced_reaccept regardless of policy. Minor versions never trigger re-consent; the existing acceptance carries forward.
Webhooks
Subscribe to events to sync versions or consent records into your own systems. Every request is signed with HMAC-SHA256 — verify the signature before trusting any payload.
Request headers
| Header | Meaning |
|---|---|
| x-termitude-event | Event type, e.g. version.published. |
| x-termitude-delivery | Stable delivery id. Retries reuse this id — dedupe on it. |
| x-termitude-timestamp | Unix seconds when the signature was generated. |
| x-termitude-signature | t=<timestamp>,v1=<hex_sha256>. Verify v1. |
Signature algorithm
Compute HMAC-SHA256 over the timestamp and the raw request body, joined with a literal dot. Compare against the v1 value with a timing-safe comparison. Reject any request where |now − timestamp| > 300 seconds to prevent replay. Use the raw body bytes — re-stringifying the JSON will change whitespace and break verification.
signed_payload = timestamp + "." + raw_request_body
expected = hex(hmac_sha256(endpoint_secret, signed_payload))
ok = timing_safe_equal(expected, v1)Verifier
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyTermitude(rawBody: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
const v1 = parts.v1;
if (!t || !v1) throw new Error("malformed signature");
if (Math.abs(Date.now() / 1000 - t) > 300) throw new Error("stale timestamp");
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error("bad signature");
}
// Express:
app.post("/webhooks/termitude", express.raw({ type: "application/json" }), (req, res) => {
try {
verifyTermitude(req.body.toString("utf8"), req.header("x-termitude-signature")!, process.env.TERMITUDE_WEBHOOK_SECRET!);
} catch { return res.status(401).end(); }
const event = JSON.parse(req.body.toString("utf8"));
// … handle event, dedupe on req.header("x-termitude-delivery")
res.status(200).end();
});Event types
| Event | Description |
|---|---|
| version.published | A new document version went live. |
| version.scheduled | A version was queued for a future effective date. |
| consent.recorded | A user accepted a document. |
| consent.reaccepted | A user re-accepted after a new version was published. |
| consent.outstanding | Re-consent window opened for existing users. |
| review.due | A scheduled document review is due. |
| review.overdue | A scheduled document review has passed its due date. |
| review.completed | A reviewer marked a scheduled review as completed. |
| usage.created | An audience / distribution usage was added to a document. |
| usage.updated | An existing usage was edited (audience, trigger, re-consent rules). |
| acknowledgement.recorded | A user acknowledged a policy (non-consent acknowledgement). |
| acceptance_session.created | A hosted acceptance session was created. |
| acceptance_session.completed | A user accepted via the hosted page. Authoritative success signal. |
| acceptance_session.rejected | A user declined via the hosted page. |
| document.reconsent_required | A major version was published — listed users must re-consent. |
| document.unpublished | A published document was unpublished. Public hosted pages and embeds 404; GET /documents/{slug} returns 404. |
| document.rejected | A user declined via the hosted page (document-level mirror of acceptance_session.rejected). |
| access_request.created | A user submitted a request for access to a gated document. |
| access_request.approved | An admin approved a pending access request. |
| access_request.declined | An admin declined a pending access request. |
| version.approval_requested | A draft version was submitted and is awaiting approver sign-off. |
| version.approved | An approver approved a pending version (may auto-publish if all approvals are in). |
| version.rejected | An approver rejected a pending version; it returns to draft. |
| webhook.test | Sent when you click 'Send test' on an endpoint — useful for verifying signature handling. |
Configure endpoints in Settings → Webhooks.
Integrity & Evidence
Every published version is hashed at publish time. The hash chains to the previous version's hash, forming a tamper-evident ledger: if any byte of any past version changes, the chain breaks and the integrity endpoint surfaces it. Every consent record stores the hash of the text the user actually saw, so an evidence pack can prove which text was accepted, not just that "the user accepted Terms".
How the hash is computed
expected_hash = sha256(prev_version_hash || canonical_content). The first published version of a document uses an empty prev_version_hash. hash_version is returned so future format changes can be migrated explicitly.
Verifying a version
curl https://app.termitude.com/api/public/v1/documents/terms/versions/<versionId>/integrity \
-H "Authorization: Bearer $TERMITUDE_API_KEY"
# => {
# "version_id": "…",
# "hash_version": 1,
# "self_matches": true, // stored_hash == sha256(prev || content)
# "chain_matches": true, // prev_version_hash matches the previous version's stored hash
# "expected_hash": "…",
# "stored_hash": "…",
# "prev_version_hash": "…",
# "prev_version_exists": true,
# "integrity_break_acknowledged": false,
# "integrity_break_reason": null,
# "ok": true
# }ok: false with integrity_break_acknowledged: true means an admin explicitly accepted a break (e.g. a legally-required redaction); the reason is human-readable. ok: falsewithout acknowledgement means the chain is silently broken — open a support ticket.
Evidence per consent
When you POST to /consent with an acceptedText field, the server records a SHA-256 of the text. Combined with the version's own content hash, this is the evidence chain you'd produce in a dispute: text seen → consent row → version hash → ledger.
Custom Domains
Point your own domain (e.g. legal.acme.com) at Termitude. We issue and renew TLS certificates automatically via Let's Encrypt — you set DNS once and never touch it again.
DNS records to add
CNAME legal legal.<your-domain>.
CNAME _acme-challenge.legal legal.<your-domain>.<dcv-target>.dcv.cloudflare.com.The exact <dcv-target> value is shown in Settings → Custom domains when you add the hostname. The second record lets us answer ACME challenges on your behalf — without it, you'd have to add a fresh TXT record every renewal (~60-90 days).
- Status in Settings → Custom domains reads Active within a few minutes of DNS propagation.
dig CNAME legal.acme.com +shortreturns a single hostname on your-domain.curl -I https://legal.acme.com/p/<org>/<doc>returns200with a valid TLS certificate.- Hosted pages, the embed widget, and acceptance-session redirect URLs all serve on your domain — no Termitude-branded host appears in the address bar.
Local testing with the Termitude CLI
Webhooks need a publicly reachable URL, which makes them awkward to test against localhost. The termitude CLI fixes that the same way Stripe's CLI does: it opens an authenticated long-poll session against the Termitude API, receives every event the API would have sent to a registered endpoint, signs each payload with a per-session secret, and POSTs it to your local server. No tunnel, no public URL, no inbound ports.
curl -fsSL https://app.termitude.com/install.sh | sh
export TERMITUDE_API_KEY=tmt_…
termitude listen --forward-to http://localhost:3000/api/termitude/webhooks
Signing secret
On first listen the CLI prints a one-time whsec_… secret. To keep the same secret across restarts (so you don't have to reconfigure your verifier each time), pass --secret <value> or set TERMITUDE_WEBHOOK_SECRET — any string works, no whsec_ prefix required. The CLI signs every forwarded payload locally with the same scheme as production webhooks (t=…,v1=hex(hmac_sha256(secret, "{ts}.{rawBody}"))) and sets the header x-termitude-signature. Verify it in your dev server exactly the way you verify production webhooks; see Webhooks for copy-paste verifiers in TypeScript, Python, Go, Ruby, and PHP. Forwarded requests also carry x-termitude-forwarded: true so you can branch on dev vs prod traffic if you want.
Triggering test events
Run termitude trigger <event.type> --session <id> in a second terminal to push a canned event through the session — handy for exercising error branches without creating real consents or publishing real versions.
Filtering
Subscribe to a subset with --events version.published,consent.recorded. Sessions auto-expire after 7 days; rerun listen to start a fresh one.
REST surface
The CLI is a thin client over four endpoints. Anything the CLI does, you can script directly with an API key:
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/cli/sessions | Open a forward session, returns signing secret. |
| GET | /v1/cli/sessions/{id}/events | Long-poll (≤25s) for queued events. |
| POST | /v1/cli/sessions/{id}/events | Ack a delivery with its local response. |
| POST | /v1/cli/trigger | Inject a synthetic event into a session. |
| DELETE | /v1/cli/sessions/{id} | Revoke a session immediately. |
Errors
Errors are returned as { "error": "<code>", "detail": "<message>"? } with the matching HTTP status. Branch on error, not on the human-readable detail.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_body | Request body failed validation. `detail` contains the Zod parse message. |
| 401 | unauthorized | Missing, malformed, or revoked API key. |
| 404 | document_not_found | No document with this slug exists in your workspace. |
| 404 | version_not_found | The pinned versionNumber does not exist for this document. |
| 400 | no_published_version | The document exists but has no current published version (unpublished or never published). |
| 400 | invalid_redirect_url | successUrl or cancelUrl is not a valid http(s) URL. |
| 404 | not_found | Generic not-found (used by the integrity and session endpoints). |
| 500 | internal_error | Something went wrong on our side. Safe to retry with backoff. |
The batch endpoint returns 207 Multi-Status when some — but not all — items in an acceptances[] array fail; inspect the per-item results array. The API does not currently rate-limit by token or return 429 / 403; future changes will be announced here before rollout.