Overview

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.

Base URL
https://app.termitude.com/api/public/v1
Version
v1
Format
JSON
5 minutes

Quickstart

  1. 1

    Create a document

    In the dashboard, open Documents → New, give it a slug like terms, and publish your first version.
  2. 2

    Generate an API key

    Go to Settings → API keys and create a key. Store the secret — it's only shown once. Keys start with tmt_.
  3. 3

    Pick your language

    Fetch a document
    bash
    curl https://app.termitude.com/api/public/v1/documents/terms \
      -H "Authorization: Bearer tmt_live_..."
    Record a consent
    bash
    curl -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]"
      }'
Auth

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.

bash
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.

Key handling
  • 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 … or x-api-key: …. Both are equivalent.
Reference

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:

bash
npx openapi-typescript https://app.termitude.com/api/public/v1/openapi -o termitude.d.ts

Loading endpoints from the live OpenAPI spec…

Conventions

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.

bash
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.

Drop-in

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.

html
<!-- 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

AttributePurposeDefault
data-termitude-docDocument slug to render.required
data-termitude-keyWorkspace API key (tmt_…). Use a dedicated key for embeds — it's visible in page source.required
data-termitude-userYour stable user identifier (recorded against the consent).
data-termitude-emailUser email recorded with the consent.
data-termitude-sourceFree-form source app label saved on the ledger entry.location.host

Styling attributes

AttributePurposeDefault
data-termitude-themelight, dark, or auto (follows prefers-color-scheme).light
data-termitude-accentCSS color for the Accept button background.#111827 / #f5f5f5
data-termitude-accent-textCSS color for the Accept button label.#ffffff / #111827
data-termitude-radiusBorder radius in pixels (number, no unit).8
data-termitude-fontCSS font-family value applied to the embed.system-ui stack
data-termitude-max-widthCSS max-width of the container.720px
data-termitude-max-heightCSS max-height of the scrolling document body.400px
data-termitude-unstyledSet to true to skip all default inline styles. Style via the .termitude-* child classes (header, title, version, content, actions, button, status).false

Behavior attributes

AttributePurposeDefault
data-termitude-button-labelText shown on the Accept button.Accept
data-termitude-accepted-labelText shown after a successful acceptance.Accepted
data-termitude-show-titleShow the document name in the header.true
data-termitude-show-versionShow the vN badge next to the title.true
data-termitude-auto-submitWhen 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-initSet 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 when data-termitude-auto-submit="false". Call event.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.

No-code

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.

URLShows
https://app.termitude.com/p/<orgSlug>/<docSlug>The current published version, with workspace branding.
https://app.termitude.com/p/<orgSlug>/<docSlug>/logPublic 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.

Redirect & listen

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

  1. Your backend creates a session with the user identifiers and the document slug.
  2. You redirect the browser to the url returned.
  3. The user accepts or declines on the hosted page.
  4. Termitude records the consent, fires acceptance_session.completed or acceptance_session.rejected to your webhook, and redirects to successUrl?session_id=ats_… or cancelUrl?session_id=ats_….
  5. 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

bash
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)

ts
// 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.

bash
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

statusMeaning
openCreated, waiting for the user. Expires 30 minutes after creation by default.
acceptedUser accepted. A consent record exists; webhook fired.
rejectedUser declined. No consent record; webhook fired with the reason if provided.
expiredUser 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.

json
{
  "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.

Lifecycle

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.

PolicyBehaviourcompliance-status
noneNo action required. The new version is informational only and existing acceptances carry forward.compliant
notifyFire document.reconsent_required and consent.outstanding. Users are informed but not blocked.action_required
forcedRequire 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.

Events

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

HeaderMeaning
x-termitude-eventEvent type, e.g. version.published.
x-termitude-deliveryStable delivery id. Retries reuse this id — dedupe on it.
x-termitude-timestampUnix seconds when the signature was generated.
x-termitude-signaturet=<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.

text
signed_payload = timestamp + "." + raw_request_body
expected       = hex(hmac_sha256(endpoint_secret, signed_payload))
ok             = timing_safe_equal(expected, v1)

Verifier

ts
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

EventDescription
version.publishedA new document version went live.
version.scheduledA version was queued for a future effective date.
consent.recordedA user accepted a document.
consent.reacceptedA user re-accepted after a new version was published.
consent.outstandingRe-consent window opened for existing users.
review.dueA scheduled document review is due.
review.overdueA scheduled document review has passed its due date.
review.completedA reviewer marked a scheduled review as completed.
usage.createdAn audience / distribution usage was added to a document.
usage.updatedAn existing usage was edited (audience, trigger, re-consent rules).
acknowledgement.recordedA user acknowledged a policy (non-consent acknowledgement).
acceptance_session.createdA hosted acceptance session was created.
acceptance_session.completedA user accepted via the hosted page. Authoritative success signal.
acceptance_session.rejectedA user declined via the hosted page.
document.reconsent_requiredA major version was published — listed users must re-consent.
document.unpublishedA published document was unpublished. Public hosted pages and embeds 404; GET /documents/{slug} returns 404.
document.rejectedA user declined via the hosted page (document-level mirror of acceptance_session.rejected).
access_request.createdA user submitted a request for access to a gated document.
access_request.approvedAn admin approved a pending access request.
access_request.declinedAn admin declined a pending access request.
version.approval_requestedA draft version was submitted and is awaiting approver sign-off.
version.approvedAn approver approved a pending version (may auto-publish if all approvals are in).
version.rejectedAn approver rejected a pending version; it returns to draft.
webhook.testSent when you click 'Send test' on an endpoint — useful for verifying signature handling.

Configure endpoints in Settings → Webhooks.

Tamper-evident ledger

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

bash
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.

Bring your own domain

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

dns
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).

What good looks like
  • Status in Settings → Custom domains reads Active within a few minutes of DNS propagation.
  • dig CNAME legal.acme.com +short returns a single hostname on your-domain.
  • curl -I https://legal.acme.com/p/<org>/<doc> returns 200 with 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.
Developer Tools

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.

Install
curl -fsSL https://app.termitude.com/install.sh | sh
Authenticate
export TERMITUDE_API_KEY=tmt_…
Forward
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:

MethodPathPurpose
POST/v1/cli/sessionsOpen a forward session, returns signing secret.
GET/v1/cli/sessions/{id}/eventsLong-poll (≤25s) for queued events.
POST/v1/cli/sessions/{id}/eventsAck a delivery with its local response.
POST/v1/cli/triggerInject a synthetic event into a session.
DELETE/v1/cli/sessions/{id}Revoke a session immediately.
Reference

Errors

Errors are returned as { "error": "<code>", "detail": "<message>"? } with the matching HTTP status. Branch on error, not on the human-readable detail.

StatusCodeMeaning
400invalid_bodyRequest body failed validation. `detail` contains the Zod parse message.
401unauthorizedMissing, malformed, or revoked API key.
404document_not_foundNo document with this slug exists in your workspace.
404version_not_foundThe pinned versionNumber does not exist for this document.
400no_published_versionThe document exists but has no current published version (unpublished or never published).
400invalid_redirect_urlsuccessUrl or cancelUrl is not a valid http(s) URL.
404not_foundGeneric not-found (used by the integrity and session endpoints).
500internal_errorSomething 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.

Ready to ship?

Create your first document and an API key from the dashboard.