Policy sources
Your legal team writes and publishes the Terms of Service and the Privacy Policy in a Legal space. A policy source lets any app's sign-up and sign-in flow ask its users to accept exactly those published versions — Atlas out of the box, and Auth0, Clerk, Supabase, Firebase, NextAuth or your own backend with a few lines of code. Publish a new version in Forumulate and every connected app knows within a minute.
What Forumulate guarantees, whichever platform reads it:
Only published versions are sent. Drafts never leave Forumulate.
A published version never changes. Any change to the text — a new edition, an editorial amendment, a re-dated upcoming edition — is a new version with a new number. The old one keeps its number and its text.
Every version has a permanent address (
content_url) that serves exactly that text, for ever, on your own domain if the Legal space has one.Scheduled versions are announced ahead. A version with a future effective date is in the feed (and the webhook) from the moment it is published, so the consumer can start asking on the day.
The protocol is Forumulate policy feed v1: one signed GET that lists the policies and their live versions, and a signed webhook when they change.
What is a policy
Every published document in the Legal space whose key fits [a-z0-9_-]{1,40} is a policy. The policy's key is the document's kind (terms, privacy, dpa: the slug its page is published at, or the kind it was bound as).
A space's feed lists the documents whose page lives in that Legal space. Legal documents are unique per organization, so a key is unique within a space too. A document that is unlisted, unbound, unpublished or deleted leaves the feed, and consumers receive policy.retired.
Each policy carries defaults the consumer starts from and may override:
Field | Values | Default |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
| a BCP 47 tag, sent on each version | not sent |
Set them in the studio (Policy sources → Published policies) or by API:
PATCH /orgs/{orgId}/legal/{kind}/policy
{ "display": "scroll", "appliesTo": "sign_in_and_sign_up", "reaccept": "grace_days:14" }null restores a default. GET /orgs/{orgId}/legal/{kind}/policy answers the values as set and as resolved. Changing a default is not a new version: the text did not change.
Versions
A version is labelled <edition>.<n>: 3.0 is edition 3 as first published, 3.1 the next text edition 3 carried (an editorial amendment, or an upcoming edition re-published before its date). Labels are never reused.
The feed lists the version in force and, when one is scheduled, the upcoming version. Older versions are not listed but keep answering at their content_url.
Connecting
In the Legal space, open Settings → Policy sources, choose Atlas or Another app, and connect. Forumulate shows the feed URL and generates a shared secret, shown once with a copy button. Each connection has its own secret, so development and production can be connected separately.
Rotate issues a new secret; the old one keeps working for 24 hours, so you can update the other side without a failed fetch.
Remove stops the secret at once.
The card shows when the consumer last fetched the feed and what it got, and whether webhooks reach it. A webhook refused with
401means the other side does not hold this secret; the card says so.
By API (org.manage_settings, the capability that binds legal documents):
GET /orgs/{orgId}/spaces/{spaceId}/policy-sources
POST /orgs/{orgId}/spaces/{spaceId}/policy-sources/links
{ "consumer": "atlas", "instanceId": "ins_2b…", "label": "Production" }
{ "consumer": "generic", "label": "My app", "webhookUrl": "https://app.example.com/hooks/policies" }
PATCH /orgs/{orgId}/spaces/{spaceId}/policy-sources/links/{linkId}
POST /orgs/{orgId}/spaces/{spaceId}/policy-sources/links/{linkId}/rotate
DELETE /orgs/{orgId}/spaces/{spaceId}/policy-sources/links/{linkId}
PUT /orgs/{orgId}/spaces/{spaceId}/policy-sources/settings { "publicFeed": true }
GET /orgs/{orgId}/spaces/{spaceId}/policy-sources/previewCreating and rotating answer { "link": {…}, "secret": "whsec_…" }; nothing else ever returns the secret.
The feed
GET https://forumulate.com/api/policy-feed/spaces/{spaceId}/policies
GET https://forumulate.com/api/atlas/spaces/{spaceId}/policies (the same feed, at the address Atlas documents)
GET …/policies?key=terms (one policy)Signing a request
Send three headers. The forumulate-* names and the atlas-* names are interchangeable:
Header | Value |
|---|---|
| A unique request id. A repeat within 5 minutes is refused. |
| Unix time in milliseconds. More than 5 minutes from our clock is refused. |
|
|
{path} is the path and query exactly as you request it, including /api on forumulate.com: /api/policy-feed/spaces/7c1e…/policies?key=terms.
The secret is whsec_ + base64 of 32 bytes. The HMAC key is the base64-decoded bytes after whsec_ (the Standard Webhooks convention, so standard libraries take it as it is).
During a rotation you may send several signatures separated by spaces (v1,abc… v1,def…); one match is enough.
import { createHmac, randomUUID } from 'node:crypto';
async function fetchPolicies(feedUrl, secret, key) {
const url = new URL(feedUrl);
if (key) url.searchParams.set('key', key);
const id = randomUUID();
const ts = String(Date.now());
const hmacKey = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const sig = createHmac('sha256', hmacKey)
.update(`${id}.${ts}.GET\n${url.pathname}${url.search}`)
.digest('base64');
const res = await fetch(url, {
headers: { 'forumulate-id': id, 'forumulate-timestamp': ts, 'forumulate-signature': `v1,${sig}` },
});
if (!res.ok) throw new Error(`policy feed: ${res.status}`);
return res.json();
}import base64, hashlib, hmac, time, uuid, urllib.parse, requests
def fetch_policies(feed_url, secret, key=None):
url = feed_url + (f"?key={urllib.parse.quote(key)}" if key else "")
parts = urllib.parse.urlsplit(url)
path = parts.path + (f"?{parts.query}" if parts.query else "")
rid, ts = str(uuid.uuid4()), str(int(time.time() * 1000))
k = base64.b64decode(secret.removeprefix("whsec_"))
sig = base64.b64encode(hmac.new(k, f"{rid}.{ts}.GET\n{path}".encode(), hashlib.sha256).digest()).decode()
r = requests.get(url, headers={"forumulate-id": rid, "forumulate-timestamp": ts,
"forumulate-signature": f"v1,{sig}"})
r.raise_for_status()
return r.json()The response
{
"object": "policy_list",
"policies": [
{
"key": "terms",
"title": "Terms of Service",
"display": "scroll",
"required": true,
"applies_to": "sign_in_and_sign_up",
"reaccept": "next_sign_in",
"versions": [
{
"version": "4.0",
"published_at": "2026-10-24T09:00:00.000Z",
"effective_at": "2026-11-01T00:00:00.000Z",
"content_html": "<h2 id=\"acceptance\">1. Acceptance</h2><p>…</p>",
"content_url": "https://legal.example.com/terms/v/4.0",
"content_sha256": "9f2c…",
"summary_of_changes": "Section 4 now covers AI features."
},
{
"version": "3.1",
"published_at": "2026-06-03T12:30:00.000Z",
"effective_at": "2026-06-01T09:00:00.000Z",
"content_html": "<h2 id=\"acceptance\">1. Acceptance</h2><p>…</p>",
"content_url": "https://legal.example.com/terms/v/3.1",
"content_sha256": "41ab…"
}
]
}
]
}content_htmlis the rendered body, no site chrome: headings, paragraphs, lists, links, tables, emphasis, code. It is rendered once, when the version is published, and stored, so the same version always returns the same bytes;content_sha256is their sha256. Sanitize it to your own allow-list before showing it, as you would any HTML.A version over 512 KB of HTML is sent as
content_markdowninstead.?key=with a key that is not published answers an empty list.
Errors
Status | Meaning |
|---|---|
| Missing or bad signature, a timestamp more than 5 minutes off, or a request id already used. |
| Unknown space, not a Legal space, or nothing connected to it. |
| Too many requests. Wait the |
| Retry with backoff and keep serving the last list you have. |
The public read (no secret)
For apps that would rather not hold a secret, a space can also serve the same list unsigned: Policy sources → Public read, or PUT …/policy-sources/settings { "publicFeed": true }. It is off by default.
GET https://forumulate.com/api/public/spaces/{spaceId}/policies[?key=terms]It is cached at the edge for a few minutes and purged whenever a version is published or retired. Anyone can read it, so use it only for policies that are public anyway (they usually are), and verify content_sha256 if you store the text.
Exact versions: content_url
content_url serves exactly that version, for ever. Read it off the feed rather than building it: it is on the Legal space's own domain when it has one (https://legal.example.com/terms/v/4.0), and on ours otherwise (https://forumulate.com/legal/acme/terms/v/4.0).
It renders the stored content_html (the bytes the feed sent) and never redirects, even after the document is amended, superseded or retired. The same data is available as JSON at GET /api/public/orgs/{orgId}/legal/{kind}/releases/{version}. (A bare number, /v/4, is the reader's archive of edition 4, which shows its latest amendment.)
The webhook
When a version is published or scheduled, every connection with a webhook target gets:
{ "type": "policy.published", "key": "terms", "version": "4.0", "occurred_at": "2026-10-24T09:00:00.000Z" }and when a policy leaves the feed:
{ "type": "policy.retired", "key": "terms", "occurred_at": "2026-10-25T08:12:44.120Z" }The webhook carries no content: fetch …/policies?key=terms to get it.
It is a POST signed with the connection's secret using Standard Webhooks headers:
Header | Value |
|---|---|
| The event id. Retries of one event reuse it: deduplicate on it. |
| Unix time in seconds. |
|
|
Any Standard Webhooks library verifies it:
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.FORUMULATE_POLICY_SECRET); // the whsec_… value
const event = wh.verify(rawBody, request.headers); // throws when it does not matchAnswer 2xx quickly. A 5xx, 429 or no answer is retried with backoff (30 seconds, doubling, then hourly) for up to 24 hours. A 401 stops the retries and marks the connection unhealthy in the studio: it means the secret does not match.
Where it goes:
Atlas:
https://api.atlasauth.net/v1/webhooks/policies/{instance_id}unless you set your own URL on the connection.Another app: the https URL you give the connection. Addresses on private networks are refused. Without a URL the connection simply polls the feed.
Atlas
In the Legal space, Settings → Policy sources, choose Atlas, paste the instance id (
ins_…) and connect.Copy the feed URL and the secret.
In the Atlas dashboard, open Policies → Policy source and paste both (or
PUT /v1/instance/policy_source). Test connection fetches the feed; the studio card then shows the fetch.
Atlas snapshots each version and its hash, shows the policy the way display says (its admins can override display, required and applies_to), records each user's acceptance, and asks again by its re-accept rule when a new version's effective_at passes. Forumulate does not store those acceptances; Atlas is the record.
Other platforms
Each recipe reads the feed with fetchPolicies from Signing a request, caches the list (refresh it on the webhook, or every few minutes), and stores per user the key, version and content_sha256 they accepted. "Owes" below means: for each policy where required is true and that applies_to the moment, the user's accepted version is not the one in force (the version whose effective_at has passed), subject to your reaccept rule.
Auth0
Use a post-login Action. When the user owes a policy, send them to your acceptance page with api.redirect.sendUserTo(...), and on return record the accepted version in app_metadata. Fetch the feed from the Action with the secret stored as an Action secret, or read the public feed.
Clerk
Gate after sign-in: in your app's middleware or a server component, compare the user's accepted versions (kept in publicMetadata, written from your server with the Clerk backend SDK) with the feed, and redirect to your acceptance page while they owe one.
Supabase
Keep acceptances in a table (user_id, key, version, sha256, accepted_at). Gate in your app after getUser(), or in a custom access-token Auth Hook that adds an owes_policies claim your RLS policies and client check.
Firebase Authentication
Use blocking functions: beforeUserCreated to require the sign-up policies, and beforeUserSignedIn for sign_in_and_sign_up policies. Throw an HttpsError the client turns into the acceptance screen, and record acceptances in Firestore.
NextAuth (Auth.js)
In the signIn callback, return a redirect to your acceptance page while the user owes a policy; record the acceptance in your database adapter.
Your own backend
Do the same in your session middleware. The webhook tells you when to refresh your cached list; the feed is the only source of content.
Limits
120 signed requests per minute per connection, 600 per space; past that,
429withRetry-After.Translations beyond
localeare out of scope: one document is one policy.Forumulate does not record your users' acceptances; your platform 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 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 Sources
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| List a Legal space’s policy sources |
| |
| Preview a Legal space’s policy feed |
| |
| Link a Legal space to a policy consumer |
| |
| Rename a policy source link or change its webhook URL |
| |
| Remove a policy source link |
| |
| Rotate a policy source link’s secret |
| |
| Turn the public policy feed on or off |
| |
| Change the public policy feed settings |
| |
| Read a legal document’s policy defaults |
| |
| Replace a legal document’s policy defaults |
| |
| Set a legal document’s policy defaults |
|