Skip to main content
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.
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:
And publishing is its own act, because it is the moment words start governing people:
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.
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:
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. In a Next app, import it from @userkit/nextjs — the same component, and in proxy mode 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:
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:
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.