Policy acceptance gate
Make your users accept your Terms of Service, Privacy Policy or any other policy before they sign up, and accept each new version before they sign in again, in any app, whatever signs your users in.
The policies are the documents your Legal space publishes. Your app asks Forumulate what a user still has to accept, shows it (with our widget, our hosted page or your own UI), and records the acceptance here. Forumulate keeps the evidence: which exact version each user saw, its content hash, how it was shown and when it was accepted.
It works with Auth0, Clerk, Supabase, Firebase, Auth.js, your own backend and mobile apps. Policy gate recipes has complete code for each. If you sign users in with Atlas, you may not need any of this page: Atlas reads the same policies through the policy source and gates sign-up and sign-in itself.
How it fits together
Publish a policy in a Legal space. Every publish is a new, immutable version with its own
content_url. Editing a published version is impossible, so the evidence of what someone accepted can never change under them.Ask. Before a sign-up or a sign-in, your server (or the widget) calls
statusfor the user and the event. The answer lists each policy withask(show it now) andblocking(do not let the event go ahead until it is accepted).Show. In one of six presentations.
Record. The acceptance is stored with the version, the SHA-256 of the HTML that was shown, the presentation, whether the reader scrolled to the end, and the time on our database clock.
Prove. You get a signed acceptance token. Your server verifies it, offline with your key or online with a call, before it lets the sign-up or sign-in finish.
Quick start
A sign-up form with the widget. The widget holds the form's submit until the box is ticked, then writes the token to a hidden forumulate_policy_token input and submits.
<script src="https://forumulate.com/policy-gate.js" async></script>
<form action="/signup" method="post">
<input name="email" type="email" required />
<forumulate-policy-gate org="ORG_ID" space="LEGAL_SPACE_ID" event="sign_up"></forumulate-policy-gate>
<button>Create account</button>
</form>On your server, verify the token and redeem it for the new account. A sign-up has no user id yet, so the widget hands back a receipt; claiming it records the acceptance against the account you just created.
import { ForumulateClient, verifyAcceptanceToken } from '@forumulate/sdk';
const forumulate = ForumulateClient.fromEnv(); // FORUMULATE_TOKEN: a PAT with policy:accept
export async function createAccount(email: string, policyToken: string): Promise<string> {
const check = await verifyAcceptanceToken(policyToken, {
keys: process.env.FORUMULATE_POLICY_GATE_KEYS, // "pgk_…:pgs_…"
use: 'receipt',
spaceId: process.env.FORUMULATE_LEGAL_SPACE,
requireSatisfied: true,
});
if (!check.valid) throw new Error(`Policies not accepted: ${check.reason}`);
const userId = crypto.randomUUID(); // your own account creation goes here
await forumulate.policies.claim({
orgId: process.env.FORUMULATE_ORG!,
spaceId: process.env.FORUMULATE_LEGAL_SPACE!,
receipt: policyToken,
subject: userId,
email,
});
return userId;
}In React, the same form uses <PolicyGate> from @forumulate/react:
import { PolicyGate } from '@forumulate/react';
export function SignUpForm() {
return (
<form action="/signup" method="post">
<input name="email" type="email" required />
<PolicyGate org="ORG_ID" space="LEGAL_SPACE_ID" event="sign_up" presentation="checkbox" />
<button>Create account</button>
</form>
);
}The six presentations
Each policy has one presentation. The space sets it, per policy or for all of them, and a single call can override it. Every acceptance records which one was used.
Presentation | What the reader sees | Use it when |
|---|---|---|
| "I agree to the Terms of Service and the Privacy Policy" with a box to tick. The titles open the text. | The default for sign-up forms. A deliberate act, recorded as |
| The same line, but clicking the box opens the text. The box ticks only after the reader presses Accept there. | You want every user to have had the text in front of them, without a popup on page load. |
| A blocking dialog on load: the "unskippable popup". With | Re-acceptance at sign-in after a material change, and any policy your counsel wants read rather than linked. |
| The text in a scroll box inside your form, with Accept beneath it. | Short policies, or a single-page flow where a dialog would get in the way. |
| Our hosted acceptance page. You redirect to it with a signed ticket and get a token back on your return URL. | Mobile apps, server-rendered apps with no JavaScript, and auth platforms that can only redirect (an Auth0 Action, a Firebase hook). |
| "By continuing you agree to…": no box. Pressing your own continue button is the acceptance. | Low-risk policies where an implicit agreement is enough. Recorded as |
The policy model's own display word (checkbox, scroll or link, the words the policy source uses) is the starting point when nothing else is set: scroll becomes modal with requireScroll. The acceptance record keeps both your presentation and that display word.
Configuration
Settings live at two levels, the organization and the Legal space. Each field is resolved on its own. A space that sets only presentation for its Terms keeps the org's reaccept for them.
For each behaviour field the first level that says something wins:
the call (an attribute on the widget, a prop, a
presentationoverride);the space's entry for that policy (
policies.tos);the space's
defaults;the org's entry for that policy;
the org's
defaults;what the policy itself declares (
display,required,applies_to,reaccept);the built-in default.
status reports where each answer came from in sources, so you can tell why a policy behaves the way it does.
PUT /orgs/{orgId}/spaces/{spaceId}/policy-gate/config
{
"defaults": { "presentation": "checkbox", "events": ["sign_up", "sign_in"] },
"policies": {
"tos": { "presentation": "modal", "requireScroll": true, "reaccept": "grace_days", "graceDays": 30 },
"marketing": { "required": false, "decline": "allow" }
},
"ui": { "grouping": "sequence", "layout": "sheet", "vars": { "accent": "#4f46e5", "radius": "12px" } },
"embed": { "allowedOrigins": ["https://app.example.com"], "tokenTtlSeconds": 900 },
"page": { "returnUrls": ["https://app.example.com/policy/return", "exampleapp://policy"] }
}PUT replaces the stored config; an absent key inherits. The body is strict: an unknown key is a 422 naming its path, never a 200 that quietly drops it. PUT /orgs/{orgId}/policy-gate/config sets the org level. GET on either answers { stored, org, resolved }, so you can see what is set where.
Every toggle
Behaviour keys go in defaults or in policies.<key>, at either level.
Key | Values | Default | What it does |
|---|---|---|---|
|
|
| How the policy is shown. See the six presentations. |
| boolean |
| Whether accepting is needed to continue. Only a required policy can be |
|
|
| Which events ask for it. Overrides the policy's own |
|
|
| What happens to someone who accepted an older version once a new one takes effect. See Status rules. |
| 1 to 365 |
| Under |
| boolean |
| Accept waits until the reader has scrolled to the end of the text. |
| boolean |
| A re-accept shows the version's summary of changes above the text. |
|
|
| Non-required policies only: whether declining lets the event go ahead. |
| boolean |
| Non-required policies only: whether the dialog can be closed without an answer. |
Gate-wide keys, by the object they sit in:
Object | Key | Values | Default | What it does |
|---|---|---|---|---|
(top level) |
| boolean |
| The whole gate. Off, every status answers with nothing to accept. |
|
|
|
| Several pending policies: one dialog for all of them, or one after another ("1 of 3"). |
|
|
|
| Where a policy title opens: our reader dialog, or its |
|
|
|
| Where the dialog sits. |
|
| boolean |
| The dialog goes full screen on a narrow viewport, whatever |
|
| boolean |
| Start from the space theme's accent, font and radius before |
|
| CSS values by name (see Look) | none | The widget's CSS custom properties, |
|
| a language tag |
| The language the widget speaks when the page does not say. |
|
|
| none | Replace any of the widget's words, for one language or all of them. |
|
| origins, up to 50 |
| The origins the widget may record acceptances from. Another origin gets |
|
| boolean |
| The browser must present a ticket your server signed to name its user. Off, a |
|
| 60 to 86400 |
| How long an acceptance token the widget hands back stays valid. |
|
| absolute URL prefixes (https, or an app scheme), up to 50 |
| Where the hosted page may send the reader back. A return address that matches none is refused, so the page is never an open redirect. |
|
|
|
| Your Forumulate readers on a space with a legal gate: a plain refusal (as before), or the same widget your app users see. |
Look and words
ui.vars takes any of accent, accent-contrast, background, surface, text, muted, border, radius, font, shadow, backdrop, focus and max-width. Each is a plain CSS value: a declaration block, url() or @import is refused, because the widget never makes a third-party request. You can also set them from your own stylesheet:
forumulate-policy-gate {
--fpg-accent: #4f46e5;
--fpg-radius: 12px;
}
forumulate-policy-gate::part(accept) {
font-weight: 600;
}The parts are dialog, content, accept, decline, checkbox, label and summary.
The widget's words are built in for English, German, French, Spanish and Portuguese. Override any of them with ui.labels. The keys are agree, agreeLink, accept, acceptAll, decline, later, close, continue, scrollHint, summaryHeading, dialogTitle, dialogTitleOne, optional, required, openInNewTab, step, pageTitle, pageIntro, error, declinedBlocked and and. {policies} is replaced with the linked titles, {title} with one title, and {n} and {total} with the step count. and is the word that joins the last two titles in {policies} ("Terms and Privacy Policy"), so a translation of agree reads correctly in its own language.
{ "ui": { "labels": { "*": { "agree": "I have read and accept {policies}" }, "de": { "accept": "Einverstanden" } } } }The widget
<script src="https://forumulate.com/policy-gate.js" async> defines the <forumulate-policy-gate> element and the ForumulatePolicies global. On npm it is @forumulate/embed/policy-gate, and in React @forumulate/react.
Attribute | React prop | What it does |
|---|---|---|
|
| Required. The organization and the Legal space. |
|
| The API base. Default: the script's origin + |
|
|
|
|
| The ticket your server signed: who the user is. |
|
| Only when the space turned tickets off. |
|
| One presentation for every policy on this call. |
|
| Only these keys (comma-separated; an array in React). |
|
|
|
|
| Over |
|
| Over |
|
| Over |
|
| The language. |
|
|
|
|
| The hidden input the token is written to. Default |
|
| Recorded as |
|
| Default |
|
| Render a payload you pass (the |
Events are CustomEvents that bubble: ready (the payload), change ({ valid }), accepted ({ token, tokenType, expiresAt, satisfied, pending }), declined ({ key, blocking }) and error ({ code, message }). In React they are onReady, onChange, onAccepted, onDeclined and onError.
The element has .payload, .valid and .token, plus .accept() and .refresh(). With checkbox, link and inline-scroll, call .accept() from your own continue button (in React, through a ref).
Without an element, call it as a function:
// not type-checked: runs in the browser with the widget script loaded
const result = await ForumulatePolicies.require({
org: 'ORG_ID',
space: 'LEGAL_SPACE_ID',
event: 'sign_in',
ticket: ticketFromYourServer,
});
// result.tokenType: 'acceptance', 'receipt', or 'none' (nothing to accept).
// A declined required policy rejects with { code: 'declined', key }.React's usePolicyGate() returns the same require, status and redirect, loads the script once, and refuses to run on the server.
Status rules
status answers for one subject and one event, on the database clock. Each policy gets a reason:
| Meaning | Asked | Blocking |
|---|---|---|---|
| A sign-up: there is no history. | yes | if required |
| The user never accepted any version. | yes | if required |
| A newer version took effect and | yes | if required |
| A newer version took effect, | yes | no |
| The grace period ended. | yes | if required |
| The current version is accepted. | no | no |
| An older version was accepted and | no | no |
| The policy is not asked at this event ( | no | no |
| The only version takes effect later. | no | no |
upcoming names a version with a future effective_at. It can be accepted early, so a user who accepts it now is not asked again when it takes effect.
satisfied is true when nothing is blocking. pending lists the blocking keys and optional the asked, non-required ones.
The acceptance record
Each accepted policy is one row. The table is append-only: there is no update, and a database trigger refuses one.
Field | What it is |
|---|---|
| Your id for the user, opaque to us. |
| SHA-256 of the trimmed, lower-cased email, when you sent one. The email itself is never stored. |
| Which policy, which version. |
| SHA-256 of exactly the HTML that was shown. |
| One of the six. |
|
|
| Whether the reader scrolled to the end of the text. |
|
|
|
|
| Your name for the app ( |
| The language shown. |
| The end user's, when known: the widget's own request, or what your server passed. |
| One id per acceptance action, shared by the policies accepted together. The token's |
| Our database clock. |
Read the log with GET …/policy-gate/acceptances (filter by subject, email, emailHash or key; up to 500 rows a page, with the next page at page.nextCursor), or download it with GET …/policy-gate/acceptances/export?format=csv (or json).
Tokens
Three tokens, all compact JWTs signed with HS256 by a key of the Legal space's keyring. A key has an id (pgk_…) and a secret (pgs_…). Rotate it with POST …/policy-gate/keys/rotate: the secret is shown once, and the previous key keeps verifying for overlapHours (24 by default, up to 168). Keep both in one environment variable while they overlap:
FORUMULATE_POLICY_GATE_KEYS="pgk_new:pgs_…,pgk_old:pgs_…"Token |
| Signed by | What it says |
|---|---|---|---|
Ticket |
| you | "This browser acts for user |
Acceptance |
| Forumulate | " |
Receipt |
| Forumulate | An acceptance with no |
Every token has aud: "forumulate:policy-gate" and a kid header naming its key. Check use. The same key signs tickets, which you hand to the browser, and acceptance tokens, so a verifier that skips use would take a ticket as proof of acceptance. verifyAcceptanceToken checks it.
An acceptance token's claims:
Claim | Meaning |
|---|---|
|
|
| Organization id. |
| Legal space id. |
| Your user id. Absent on a receipt. |
|
|
| Nothing blocking is left. |
| The keys still blocking. |
|
|
| The acceptance batch. |
| Receipts only: the IP, user agent, locale, app and email hash it was given in. |
Verify offline
import { verifyAcceptanceToken } from '@forumulate/sdk';
export async function policiesOk(token: string, userId: string): Promise<boolean> {
const r = await verifyAcceptanceToken(token, {
keys: process.env.FORUMULATE_POLICY_GATE_KEYS,
subject: userId,
spaceId: process.env.FORUMULATE_LEGAL_SPACE,
requireSatisfied: true,
});
if (!r.valid) console.warn('policy token refused:', r.reason);
return r.valid;
}The reason is one of malformed, unsupported_algorithm, unknown_key, bad_signature, expired, not_yet_valid, wrong_audience, wrong_issuer, wrong_use, wrong_subject, wrong_space, wrong_org, not_satisfied or no_keys. Only HS256 is accepted, whatever the token's header says.
The token is a plain JWT, so any JWT library verifies it, in any language, with the secret's UTF-8 bytes as the HMAC key. Check aud, use, iss, exp, sub and spc yourself.
keys also takes secret (one bare secret), jwks (a JSON Web Key Set of oct keys) or jwksUrl (the same set from a private endpoint of yours, such as one in front of a secrets manager). The keys are symmetric, so Forumulate never publishes them.
Verify online
No key on your server: ask us.
import { ForumulateClient } from '@forumulate/sdk';
const forumulate = ForumulateClient.fromEnv();
export async function verifyOnline(token: string, userId: string) {
return forumulate.policies.verify({
orgId: process.env.FORUMULATE_ORG!,
spaceId: process.env.FORUMULATE_LEGAL_SPACE!,
token,
subject: userId,
});
}The hosted page
For apps that can only redirect: a mobile app, a page with no JavaScript, an auth platform's post-login hook.
Add your return address to the space's
page.returnUrls. A prefix is enough (https://app.example.com/policy/), and app schemes such asexampleapp://work.Your server signs a ticket with
returnUrland astateyou will check.Redirect the user to
https://forumulate.com/policy/{orgId}/{spaceId}?ticket=….The page shows every pending policy and records what the user accepts.
The user comes back:
accepted:
{returnUrl}?policy_token=…&policy_token_type=acceptance&state=…;nothing to accept: the same, with a token whose
accis empty andokis true;a required policy declined:
{returnUrl}?policy_error=declined&state=….
Your server checks
stateand verifies the token.
import { policyGatePageUrl, readPolicyReturn, signPolicyTicket, verifyAcceptanceToken } from '@forumulate/sdk';
const orgId = process.env.FORUMULATE_ORG!;
const spaceId = process.env.FORUMULATE_LEGAL_SPACE!;
const keys = process.env.FORUMULATE_POLICY_GATE_KEYS!;
export async function startPolicyPage(userId: string, state: string): Promise<string> {
const ticket = await signPolicyTicket(
{ orgId, spaceId, subject: userId, event: 'sign_in', returnUrl: 'https://app.example.com/policy/return', state },
keys,
);
return policyGatePageUrl({ orgId, spaceId, ticket });
}
export async function finishPolicyPage(url: string, userId: string, expectedState: string): Promise<boolean> {
const back = readPolicyReturn(url);
if (back.state !== expectedState || !back.token) return false;
const r = await verifyAcceptanceToken(back.token, { keys, subject: userId, requireSatisfied: true });
return r.valid;
}If your server holds a PAT but not the gate key, client.policies.ticket(…) mints the ticket for you and answers with the page URL.
Recording from your own UI
Show the policies yourself, then record them from your server. Ask for the content with includeContent, show contentHtml, and send back the contentSha256 you were given: it is the proof of what was shown. A hash that does not match the live version is 409 policy.content_mismatch.
import { ForumulateClient, acceptanceItemsFrom } from '@forumulate/sdk';
const forumulate = ForumulateClient.fromEnv();
const space = { orgId: process.env.FORUMULATE_ORG!, spaceId: process.env.FORUMULATE_LEGAL_SPACE! };
export async function pendingPolicies(userId: string) {
return forumulate.policies.status({ ...space, subject: userId, event: 'sign_in', includeContent: true });
}
export async function recordAccepted(userId: string, ip: string, userAgent: string) {
const status = await forumulate.policies.status({ ...space, subject: userId, event: 'sign_in' });
const { token, satisfied } = await forumulate.policies.accept({
...space,
subject: userId,
event: 'sign_in',
ip,
userAgent,
policies: acceptanceItemsFrom(status, { presentation: 'modal', scrolledToEnd: true }),
});
return { token, satisfied };
}Record only what the user was actually shown and accepted. acceptanceItemsFrom takes every policy the status asks for; pass keys to narrow it.
Accessibility
The widget uses native form controls: the checkbox is a real checkbox with its label, and every action is a button, so it works with a keyboard and a screen reader.
requireScrolldisables Accept until the end of the text is reached, andscrollHintsays why. The scroll box is focusable, so a keyboard user can scroll it.prefers-reduced-motionturns the dialog's animation off.ui.mobileFullscreenkeeps the text readable on a phone.--fpg-focussets the focus ring. Keep it visible.All of the words can be translated or replaced with
ui.labels.
If you build your own UI, keep the same properties: a real label for the checkbox, the text reachable by keyboard, and a visible focus.
Data and data-subject requests
What we store: your opaque subject id, an email hash (never the email), the version and hash of what was shown, how it was shown, the time, and the IP and user agent when known. The policy text is in the immutable version, not copied per user.
Who controls it: you. Forumulate records acceptances for your app as your processor. The records belong to your organization, and tenant isolation is enforced in the database.
Access: filter the log by
subject,emailoremailHash, or export it as CSV or JSON.Erasure:
POST /orgs/{orgId}/policy-gate/subjects/erasewithsubject,emailoremailHashdeletes that person's rows. It is the one exception to append-only. A privacy executor erases and exports them through/privacy/executor/{orgId}/policy-acceptances/eraseand…/export, as part of a data-subject request that spans your stack.Keep evidence before you erase: an acceptance is often your proof of consent. Export it first if your retention policy says to.
Errors
Code | Status | Meaning |
|---|---|---|
| 409 | The |
| 409 | That version is not the current one or an upcoming one. Ask |
| 401 | The ticket did not verify. |
| 403 | The page's origin is not in |
| 422 |
|
| 409 | The receipt was already redeemed. |
Access and scopes
Capability | Grants |
|---|---|
|
|
| record, |
A personal access token for your app's server needs the policy:read scope to check and policy:accept to record. Keep it, and the gate key, on the server.
Related
Policy gate recipes: Auth0, Clerk, Supabase, Firebase, Auth.js, Express and Next.js, mobile, Atlas.
Policy source: the signed feed that lets Atlas or any platform read your policies.
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 SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.
Policy Gate Admin
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the organization’s policy gate defaults |
| |
| Replace the organization’s policy gate defaults |
| |
| Change part of the organization’s policy gate defaults |
| |
| Get a Legal space’s policy gate config |
| |
| Replace a Legal space’s policy gate config |
| |
| Change part of a Legal space’s policy gate config |
| |
| List the policy gate signing keys |
| |
| Rotate the policy gate signing key |
| |
| Revoke a policy gate signing key |
| |
| Erase a subject’s policy acceptances (DSAR) |
|