Binding a legal document, and what required really does

Embed
Preview of the card
<a href="https://forumulate.com/guides/legal-binding-and-acceptance"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/a126b85b-63cf-4e38-ac04-26e040879e22/social-card.svg?v=3e7ae33eec8b" alt="Binding a legal document, and what required really does" width="600" height="315"></a>

Legal spaces covers the content model: a page in a Legal space is a legal document, the eight declared fields, editions, inline documents, and both acceptance registers. This page answers the two questions that page leaves open — what legal/bind records, and what required does to a reader.

The short answer to the second one is: nothing, until you turn on the space's legal gate. Read on before you rely on it.

Binding a page to a kind

POST /orgs/{orgId}/legal/bind
{
  "kind": "privacy",
  "pageId": "b0662f97-…",
  "required": true
}

Field

kind

1–40 characters. The stable slug the document answers on — terms, privacy, dpa.

pageId

A published page in a Legal space in this organization.

required

Optional, defaults to true.

Answers { "document": { … } }.

It needs the admin token scope

@RequireCapability('org.manage_settings')
@RequireScope('admin')

A read/write token cannot bind, unbind, or set the acceptance secret, even with org.manage_settings. This is not stated anywhere else and it is a real integration blocker: the call answers 403 with a token that looks sufficient. Mint a token with "scopes": ["read", "write", "admin"] for this one.

What it refuses

Status

Message

422

Publish the page before making it a legal document — members cannot accept an unpublished draft.

404

The page is not in this organization.

403

The token lacks the admin scope.

What it records

Two rows:

  1. A legal document row — the kind, the title copied from the page's live revision, the pageId, a position, a version, and required.

  2. An edition row pinning { kind, version, revisionId } — the page revision that this version is.

So binding records a version number and the exact page revision behind it. It records no user, no IP and no timestamp of who bound it beyond the row's updatedAt.

Two side effects worth knowing:

  • Binding clears unlisted. A document you bound is a document you are publishing.

  • Re-pointing a kind at a different page bumps the version, which means everybody re-accepts.

How a new version is triggered

Three paths can increment the document's version. Two of them do it only when the words actually changed:

What you do

Result

Publish a bound page

The version bumps and a new edition is recorded only if the published title or text differs from the edition in force. An identical republish keeps the version, and nobody is asked to accept again

Re-point a kind at a different page

Version bumps.

Edit an inline document's title or body

Version bumps, but only if the title or body actually changed. Sending the same body again is a no-op

Acceptance is then stale automatically, because "accepted" compares the stored acceptance version against the document's current version.

Republishing the same text

Publishing a page whose draft says exactly what the live revision already says succeeds as usual (it clears the draft) and is reported, not refused. The publish response carries unchanged: true:

{ "revision": { "id": "…", "status": "PUBLISHED", "unchanged": true } }

On a bound page that means the legal version is unchanged, no edition is minted, and every reader's existing acceptance still counts. A routine re-import or a habitual "publish" therefore does not re-prompt anybody.

The two checks are not identical. unchanged on the response compares the whole revision, so a changed image or block attribute makes it false. The legal check compares only the title and the words a reader agrees to, so a republish that changes formatting but not wording can answer unchanged: false and still keep the legal version. Neither check ever makes the publish fail.

The page metadata field mustAccept syncs to the row's required. It is a presence check on the metadata payload, so a metadata update that does not mention mustAccept leaves required alone.

required: what it actually does

On its own, required marks a document as one that asks to be accepted, and nothing is withheld. What a reader is kept from until they accept is a separate setting per space, the legal gate, and its default is none.

GET of the acceptance status returns:

{
  "documents": [ { "kind": "privacy", "version": 3, "required": true, "accepted": false }, … ],
  "pending": ["privacy"]
}

pending is the list of kinds that are required and not accepted at the current version. In the member console it drives a dismissible banner, not a modal: a hard modal would mean a mis-published document could lock an organization out of its own console.

How the member prompt asks

The Legal space's theme.legal.gate decides how that prompt asks:

Key

Default

What it does

style

banner

banner is the strip. dialog opens the document at once.

opensOn

changes

What the prompt opens on: the changes since the member's last acceptance, or the full text.

dismissible

on

false removes "Later".

PATCH /orgs/{orgId}/spaces/{legalSpaceId}/theme
  { "legal": { "gate": { "style": "dialog", "opensOn": "changes", "dismissible": false } } }

With dismissible: false the prompt stays on screen until the member accepts. That is why the default can be put off: if a document is published by mistake, every member is asked about it at once.

The public document shape carries required too, so a reader surface can tell a mandatory document from an optional one.

Making a reader accept

Set the space's legal gate (PUT /orgs/{orgId}/spaces/{spaceId}/legal-gate, or the organization default legal.require_acceptance). post withholds posting, commenting, voting and applying; read withholds the space's content; signin withholds the session until the reader accepts. The values, and what happens to a reader who has not signed in, are in Who may read, sign up and sign in to a space. A refused request is a 403 with the code legal.acceptance_required, naming the documents to accept in details.documents.

A reader surface renders the acceptance step from two public calls, which need no token:

  • GET /public/orgs/{orgId}/spaces/{spaceId}/legal-gate answers the resolved gate, the documents this reader has not accepted at the current edition (empty when nothing is pending or the gate is none), and acceptor: how much the reader's identity is worth (identified or anonymous), or null when there is nobody to record an acceptance against. The reader's subject itself is never returned.

  • POST /public/orgs/{orgId}/spaces/{spaceId}/legal-gate/{kind}/accept records the acceptance of one document at its current edition and answers { accepted: true, kind, version, verification }. There is no subject in the body: we decide who is reading. A reader we cannot identify is a 401 under require_identity; under cookie a pseudonymous cookie is minted at this moment, never on a status read. A kind the organization does not require is a 404. Accepting twice at the same edition records one row.

These acceptances go into the subject register below with source: 'reader', and because a legal document belongs to the organization, accepting from one space counts in every space. The status call is limited to 60 requests a minute per client and the accept call to 30.

You can also gate in your own application instead: call GET /public/orgs/{orgId}/legal/{kind}/versions for the current version, compare it with what you have recorded for that user, show your own interstitial, and record the acceptance with POST /public/orgs/{orgId}/legal/{kind}/accept.

The two acceptance registers are not the same

They record different evidence, and this matters if somebody asks you to produce proof.

Member register

Subject / embed register

Who

A signed-in member, by user id

Any subject string you supply

Version

yes

yes

Timestamp

yes

yes

IP address

no

yes

User agent

no

yes

Uniqueness

one row per (document, user, version)

duplicates ignored

No IP or user agent is captured for a member acceptance. If your legal advice is that acceptance evidence needs an IP, the member register does not provide it and you should record acceptance through the subject register instead, with your own subject identifier.

The public endpoints

Call

Returns

Rate limit

GET /public/orgs/{orgId}/legal

{ documents, frontDoor }

120/min

GET /public/orgs/{orgId}/legal/{kind}

{ document }, else 404

120/min

GET /public/orgs/{orgId}/legal/{kind}/versions

{ currentVersion, versions: [{version, publishedAt}] }

120/min

POST /public/orgs/{orgId}/legal/{kind}/accept

{ accepted: true, version, verification }

30/min

A public document carries kind, title, body (inline markdown or null), content (a block document or null — exactly one of the two is set), pageId, spaceId, fields, required, version and updatedAt.

Two filters that explain a document "missing" from the index:

  • A document whose page is still a draft is absent entirely. Published means it has a body or a block document; a draft has neither.

  • Internal fields are stripped. reviewBy never reaches the wire.

verification on an acceptance is "signed" when you supplied a valid subjectToken and "unverified" when no acceptance secret is configured. If a secret is configured and the token is missing or wrong, the call is a 403 whose message carries the HMAC recipe — the comparison is constant-time.

The published Legal spaces page says:

Inline documents have no editions: …/versions returns an empty list and …/diff 404s, because there is no pinned revision to compare. Author in a Legal space instead.

That is no longer true. An inline document records an edition on every content change, so …/versions lists its history. Editing an inline document's title or body bumps the version and writes an edition row just as a page-backed document does.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.

Operation

Request

What it does

Shortcuts

LegalPublic_list

GET /public/orgs/{orgId}/legal

Get the legal in an organization (public reader)

client.legal.public.list()

LegalPublic_accept

POST /public/orgs/{orgId}/legal/{kind}/accept

Accept the legal (public reader)

client.legal.public.accept()

LegalPublic_versions

GET /public/orgs/{orgId}/legal/{kind}/versions

List versions in an organization (public reader legal)

client.legal.public.versions()

LegalPublic_edition

GET /public/orgs/{orgId}/legal/{kind}/versions/{edition}

Get the version

client.legal.public.edition()

LegalPublic_diff

GET /public/orgs/{orgId}/legal/{kind}/diff

Get the diff in an organization (public reader)

client.legal.public.diff()

LegalPublic_rss

GET /public/orgs/{orgId}/legal/rss.xml

RSS feed of the legal

client.legal.public.rss()

LegalPublic_atom

GET /public/orgs/{orgId}/legal/atom.xml

Atom feed of the legal

client.legal.public.atom()

LegalPublic_jsonFeed

GET /public/orgs/{orgId}/legal/feed.json

JSON feed of the legal

client.legal.public.jsonFeed()

LegalPublic_trust

GET /public/orgs/{orgId}/legal/trust.json

JSON feed of trust

client.legal.public.trust()

LegalPublic_subprocessors

GET /public/orgs/{orgId}/legal/{kind}/subprocessors

List subprocessors in an organization

client.legal.public.subprocessors()

LegalPublic_get

GET /public/orgs/{orgId}/legal/{kind}

Get the legal

client.legal.public.get()

The Forumulate teamUpdated