Usage
Publishing is a separate act
Compiling is cheap and happens whenever an answer changes. Publishing is deliberate: it writes an immutable version, numbered per policy per site, and that version is what your site serves.
Collapsing the two would mean a half-typed address appearing on a live privacy policy the moment somebody saved a form.
When you publish you can add a note, a summary of what changed, and tick everyone must accept this again. Use that last one only for a change that alters the deal. Prompting on every typo fix teaches people to click through without reading.
Knowing when a policy has gone stale
Because the compiler is a pure function of its inputs, Toss can compare what is published with what would be generated now — and tell you why they differ:
The clause library was updated (2026-08-19.a1b2c3d4 → 2026-11-02.9f8e7d6c). Clause added or revised: privacy.rightsState@1.1.0 The answer to "How long do you keep it" changed.
A text diff shows you that something moved. That shows you what moved it. Both are on the policy's Diff screen, alongside a line-by-line comparison of any two versions.
The kits
Toss → Kits. Each one can be placed automatically or by hand.
Footer links
Links to your published legal pages at the bottom of every page. Only published ones — a footer link to a draft is a 404 in the first place a regulator looks.
Update notices
A bar for a few days after you republish, which then takes itself down. Dismissal is remembered per version, so dismissing April's notice does not suppress May's.
Cookie notice
A plain informational bar. It tells people; it does not ask them. If the consent kit is on, this one stands down — two bars saying different things is not a configuration.
Cookie consent
Non-essential tags are held as data and reach the browser only once their category is granted. Nothing fires before a decision.
Decline is a real button with the same weight as accept. A panel where refusing takes three clicks and accepting takes one is the pattern regulators have been fining people over.
Endorsement disclosures
The FTC wants a material connection disclosed near the claim, not only on a linked page. This kit puts the line in the article itself.
It inserts after the first <h1> or before the last </article>. If your template has neither it
inserts nothing and the audit says so, rather than guessing at a <div>. Turn automatic placement
off and call craft.toss.disclosure() where you want it.
Agreement checkboxes
{{ craft.toss.agreement('terms-of-service') }}
Renders a checkbox whose posted value is signed, so an acceptance record cannot be forged. Toss records it at the end of the request, and only if the response is a redirect — Craft redirects a successful POST and re-renders a failed one, so a registration that failed validation leaves no record, which is right, because nobody registered.
A form that returns JSON rather than redirecting should post to toss/acceptance/record itself.
The acceptance record
Every acceptance binds to one exact version id, never to the policy. A policy changes; what somebody agreed to does not.
That keeps two questions answerable years later:
- What did this customer agree to on the day they signed up? — the version's stored text, as published, not as it now reads.
- Who has not accepted the current terms? — which is what makes a re-acceptance prompt possible.
Toss → Acceptances lists them per policy and exports CSV.
Twig
{# Link and render #}
{{ craft.toss.url('privacy-policy') }}
{{ craft.toss.render('privacy-policy') }}
{{ craft.toss.effectiveDate('terms-of-service')|date('j F Y') }}
{{ craft.toss.version('terms-of-service') }}
{% for item in craft.toss.tableOfContents('privacy-policy') %}
<a href="#{{ item.anchor }}">{{ item.heading }}</a>
{% endfor %}
{# Kits, placed by hand #}
{{ craft.toss.footerLinks() }}
{{ craft.toss.disclosure() }}
{{ craft.toss.agreement('terms-of-service', { label: 'I accept the {policy}' }) }}
{# Your own list markup #}
{% for link in craft.toss.links() %}
<a href="{{ link.url }}">{{ link.label }}</a>
{% endfor %}
{# Consent #}
{% if craft.toss.allows('analytics') %}{# your own tag #}{% endif %}
{# Acceptance #}
{% if not craft.toss.hasAccepted('terms-of-service') %}…{% endif %}
{% for policy in craft.toss.outstanding() %}…{% endfor %}
Gating embeds already in your content
A consent kit that only gates its own inventory leaves the biggest tracker on most sites untouched: a YouTube embed pasted into a rich text field, which sets marketing cookies as soon as the page loads.
{{ entry.body|tossGate('marketing') }}
Every iframe becomes a placeholder until the category is granted, then swaps back in place. Nobody has to edit the content.
Console
craft toss/policies # what exists, and its state
craft toss/policies/compile # rebuild everything, publish nothing
craft toss/policies/check # non-zero if a policy is out of date
craft toss/policies/refresh --publish # republish everything that drifted
craft toss/policies/validate-library # structural check on the clause library
craft toss/consent/scripts # the inventory and what gates each tag
craft toss/consent/bump # ask every visitor again
craft toss/consent/prune
craft toss/consent/prune-acceptances
toss/policies/check belongs in CI. It is the closest thing there is to a test for is our privacy
policy still true, and it is the reason the compiler is pure.