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