Policy sources

Embed
Preview of the card
<a href="https://forumulate.com/guides/policy-source"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/e577c26b-a6ea-4142-a392-771faccfb22a/social-card.svg?v=3e7ae33eec8b" alt="Policy sources" width="600" height="315"></a>

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

display

checkbox, scroll (scroll to accept), link ("by continuing you agree")

checkbox

required

true, false

true

applies_to

sign_up, sign_in_and_sign_up

sign_up

reaccept

never, next_sign_in, grace_days:N (1–999)

next_sign_in

locale

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 401 means 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/preview

Creating 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

forumulate-id (or atlas-id)

A unique request id. A repeat within 5 minutes is refused.

forumulate-timestamp (or atlas-timestamp)

Unix time in milliseconds. More than 5 minutes from our clock is refused.

forumulate-signature (or atlas-signature)

v1, + base64 of HMAC-SHA256 over {id}.{timestamp}.GET\n{path}.

{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_html is 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_sha256 is 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_markdown instead.

  • ?key= with a key that is not published answers an empty list.

Errors

Status

Meaning

401

Missing or bad signature, a timestamp more than 5 minutes off, or a request id already used.

404

Unknown space, not a Legal space, or nothing connected to it.

429

Too many requests. Wait the Retry-After seconds.

5xx

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

webhook-id

The event id. Retries of one event reuse it: deduplicate on it.

webhook-timestamp

Unix time in seconds.

webhook-signature

v1, + base64 of HMAC-SHA256 over {id}.{timestamp}.{body}. During a rotation, two space-separated values.

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 match

Answer 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

  1. In the Legal space, Settings → Policy sources, choose Atlas, paste the instance id (ins_…) and connect.

  2. Copy the feed URL and the secret.

  3. 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, 429 with Retry-After.

  • Translations beyond locale are 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

PolicySources_view

GET /orgs/{orgId}/spaces/{spaceId}/policy-sources

List a Legal space’s policy sources

client.policySources.view()

PolicySources_preview

GET /orgs/{orgId}/spaces/{spaceId}/policy-sources/preview

Preview a Legal space’s policy feed

client.policySources.preview()

PolicySources_createLink

POST /orgs/{orgId}/spaces/{spaceId}/policy-sources/links

Link a Legal space to a policy consumer

client.policySources.createLink()

PolicySources_updateLink

PATCH /orgs/{orgId}/spaces/{spaceId}/policy-sources/links/{linkId}

Rename a policy source link or change its webhook URL

client.policySources.updateLink()

PolicySources_removeLink

DELETE /orgs/{orgId}/spaces/{spaceId}/policy-sources/links/{linkId}

Remove a policy source link

client.policySources.removeLink()

PolicySources_rotate

POST /orgs/{orgId}/spaces/{spaceId}/policy-sources/links/{linkId}/rotate

Rotate a policy source link’s secret

client.policySources.rotate()

PolicySources_settings

PUT /orgs/{orgId}/spaces/{spaceId}/policy-sources/settings

Turn the public policy feed on or off

client.policySources.settings()

PolicySources_patchSettings

PATCH /orgs/{orgId}/spaces/{spaceId}/policy-sources/settings

Change the public policy feed settings

client.policySources.patchSettings()

PolicySources_fields

GET /orgs/{orgId}/legal/{kind}/policy

Read a legal document’s policy defaults

client.policySources.fields()

PolicySources_putFields

PUT /orgs/{orgId}/legal/{kind}/policy

Replace a legal document’s policy defaults

client.policySources.putFields()

PolicySources_setFields

PATCH /orgs/{orgId}/legal/{kind}/policy

Set a legal document’s policy defaults

client.policySources.setFields()

The Forumulate teamUpdated