> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Terms and consent

> The documents your customers agree to, the versions of them, and the record of who agreed to which words.

Every product eventually has to answer one question about one person: *did they
agree, and to what exactly?* This is the module that answers it — terms of
service, a privacy policy, a code of conduct, a data processing addendum, and the
acceptance record behind each.

It is deliberately small. A document, its versions, and a row per person per
version. Everything else in this guide is a consequence of one decision:
**an acceptance names a version, never a document.**

## Why the version, and not the document

A record saying "Ana agreed to your terms" is worth nothing on the day the terms
change. It proves that somebody ticked a box next to whatever the text says now,
which is the opposite of what the record is kept for.

So the words live in a **version**, and publishing one freezes it. The body, the
summary, the effective date and the re-acceptance rule all become immutable —
enforced by a database trigger, not by a convention — and correcting a typo means
publishing a new version, exactly as repricing a plan means writing a new price.

The acceptance stores the version's id and its number, along with the moment, the
address the click came from and the browser it was made in.

## Publishing

A document is created with **no text at all**: a name, an address (`slug`) and a
rule about whether agreeing is required. Creating one never puts anything in front
of anybody.

```bash theme={null}
POST /v1/organization/legal?environment=live
{ "slug": "termos-de-uso", "title": "Termos de uso", "enforcement": "required" }
```

The words are a version, written as a draft — there is **one draft at a time** per
document, which is the database's rule rather than the panel's:

```bash theme={null}
POST /v1/organization/legal/{id}/versions
{ "body_markdown": "# Termos de uso\n\n...", "summary": "", "requires_reacceptance": true }
```

And publishing is its own act, because it is the moment words start governing
people:

```bash theme={null}
POST /v1/organization/legal/{id}/versions/{versionId}/publish
{ "effective_at": "2026-03-15T00:00:00-03:00" }
```

`effective_at` may be in the **future**, which is what a thirty-day notice is:
until that date the version before it is still the one in force, and still the one
being offered for acceptance. It may not be in the past — back-dating terms would
assert that somebody was governed by words that did not exist.

Publishing happens exactly once per version. A second call answers `404`, because
a second publish would move the effective date of terms people have already agreed
to.

### Announcing it

`notify: true` on the publish puts an `action` notification in the bell of
everybody the publication puts in front of a checkbox — and only them, computed
with the same floor the card uses. It is off by default, because a version
published to fix a heading must not put a demand in anybody's notifications.

With a future `effective_at`, the announcement **waits for it**. It goes out on
the day the terms start governing, to whoever owes them on that day. Announcing
at publication would reach the people who owe the *previous* version, about
words that govern nobody yet — and the date itself would pass with nothing
happening. A nightly sweep sends it, so the notice lands within a day of the
effective moment rather than at the second.

## Who is asked again, and who is not

`requires_reacceptance` is the field with the largest blast radius here, and it
defaults to **`true`**.

Each document carries a **floor**: the highest version that is in force and
declared a material change. Somebody is in good standing when they accepted any
version at or above it.

* Publish a corrected link with `requires_reacceptance: false` — nobody is asked
  again, and every acceptance on file still counts.
* Publish a new arbitration clause with `requires_reacceptance: true` — everybody
  below the floor becomes pending, in the same statement, with nothing to backfill.

The default is `true` because of what each mistake costs. Asking again when you did
not have to costs a click. Not asking when you should have is a change to the
agreement nobody was told about, which is the entire thing this module exists to
prevent.

## What `required` means, and what it does not

`enforcement: "required"` says a person may not use the product here without
having agreed. UserKit owns no door an account is created at — your product
does — so it never refuses anybody. What it does is **report**: the agreement
is `blocking` rather than merely offered, and every read says so. `GET /v1/contact/me` carries
`pending_agreements`, `GET /v1/contact/legal` carries the whole picture, and each
outstanding entry says `blocking: true`. Nothing refuses an ordinary API call over
an outstanding agreement, and `POST /v1/contact/token` in particular never does.

That last one is the reason for the whole position. The token endpoint is the
refresh flow: gating it would mean that publishing new terms signs every one of
your customers out of your product, at once, over a checkbox. Publishing terms has
to mean "everybody is asked", never "everybody is thrown out" — so **what to do
about an outstanding agreement is your product's decision**, made where you can see
what interrupting costs.

## Reading the documents

`GET /v1/legal/{publishable_key}` is public and cacheable, addressed by the key
that already sits in your page. It has to be: your sign-up form renders the link
to the terms before there is any session, and a form that cannot show what it
must show is a form that must not take a sign-up.

```ts theme={null}
const documents = await userkit.legal.listDocuments();
const terms = await userkit.legal.getDocument("termos-de-uso"); // with the markdown
```

Drafts are absent, archived documents are absent, and a version published for next
month is absent until its date. Render the markdown — never hand it to
`innerHTML`; it is prose out of a database drawing on your page.

## Asking, in your own product

The tick at sign-up is on your own form, and your backend records it (below).
After that, `<Agreements />` draws what is outstanding and
**nothing in the ordinary case**, which is what makes it safe to mount
permanently:

```tsx theme={null}
import { Agreements } from "@userkit/react";

<Agreements onAccepted={() => router.refresh()} />;
```

With `position="center"` it is a modal, and then it joins a queue: the center of the
page holds one dialog at a time, and this is **first** in it — the condition you
declared comes before a published note and a survey, which wait their turn. The
order, and how to change it, are in [Surveys](/en/guides/surveys).

In a Next app, import it from `@userkit/nextjs` — the same component, and in
[proxy mode](/en/customer-auth/session-tokens) both the read and the acceptance are
forwarded by the handlers, with the session in an httpOnly cookie on your own origin.
`useLegalDocuments` crosses with it and still needs no session: the public document
route is on the allowlist without `authenticated`, because your sign-up form has
to draw the link before there is a session.

It reads the outstanding list off the session state rather than polling, so a
version you publish this morning appears without a request of its own. Optional
documents are left alone unless you ask for them (`includeOptional`) — an optional
document is an offer, and interrupting somebody with one would make the word mean
nothing.

Driving it yourself is two calls:

```ts theme={null}
const agreements = await userkit.legal.listAgreements();
await userkit.legal.accept(agreements[0].version_id);
```

Agreeing twice is agreeing once. Both calls answer `200` and `recorded` says which
one wrote the row; the timestamp and the origin stay the **first** agreement's. A
version that stopped being in force answers `404`, which is how a page left open
overnight fails — re-read rather than retry.

## Recording the acceptance from your backend

The sign-up form is yours, so the box is ticked in your app and your backend
tells us:

```bash theme={null}
POST /v1/contacts/{id}/legal-acceptances
{ "version_id": "…" }
```

The row is marked `source: api`, permanently. These are the acceptances whose
evidence is yours rather than ours, and a question about how you know somebody
agreed has a different answer for them.

## The record

`GET /v1/organization/legal/{id}/acceptances` is the evidence list: who agreed, to
which version, when, from where, and through which door.

An acceptance **survives the erasure of the person it names**. That is deliberate:
it is your record of the lawful basis you processed somebody on, and a person
exercising their right to erasure does not thereby un-agree to the terms they were
served under. What the erasure takes is the pair that is about the person rather
than about the agreement — the address and the user agent — leaving ids, a version
number and a moment. The list shows `contact: null` for those rows, which is what
the record is supposed to look like afterwards.

For the same reason, **a document that ever published anything cannot be deleted**.
Withdrawing terms is `PATCH { "archived": true }`: it leaves the public document
and stops being asked of anybody, and every acceptance behind it stays.

## Events

`legal_document.published` fires once per version and is deliverable to your
webhook endpoints — invalidate the cached terms page on your marketing site, send
the notice, re-open the gate in your own product. `legal_agreement.accepted` fires
on the **first** acceptance of a version by a person, which is the moment a gate
elsewhere can open.

The draft's own facts (`legal_document.updated`) stay in: an unpublished version is
a legal position you have not taken yet, and deliverability is a one-way door.
