Toss for Craft CMS

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.