Skip to main content

Astro Consent API

Client API

One runtime per page load

The c15t() integration starts one consent runtime per page load and keeps it across ClientRouter navigation. Every script and island on the page shares it. There is no provider to wrap your components in. c15t/astro/client is how browser code reaches that runtime.

getConsentClient() returns the page's client, or null on the server and before the runtime starts. The runtime starts from a module script, and module scripts run in document order, so a script of yours can run first. Try again on DOMContentLoaded, which fires after every module script has run:

src/components/consent-video.astro
---
import { ConsentDialogLink } from 'c15t/astro/components';
---

<consent-video>
	<div data-video>
		<p>Allow measurement to load this YouTube video.</p>
	</div>
	<ConsentDialogLink>Open privacy settings</ConsentDialogLink>
</consent-video>

<script>
	import { getConsentClient } from 'c15t/astro/client';

	class ConsentVideo extends HTMLElement {
		dispose?: () => void;

		connect = () => {
			const client = getConsentClient();
			const container = this.querySelector('[data-video]');
			if (this.dispose || !client || !container) {
				return;
			}
			const render = () => {
				// Measurement is allowed and the visitor has not switched YouTube
				// off. An undeclared vendor is never allowed, so declare youtube.
				if (!client.isVendorAllowed('youtube')) {
					container.textContent =
						'Allow measurement to load this YouTube video. No video request is sent before permission.';
					return;
				}
				if (container.querySelector('iframe')) {
					return;
				}
				const frame = document.createElement('iframe');
				frame.src = 'https://www.youtube-nocookie.com/embed/czTksCF6X8Y';
				frame.title = 'YouTube video';
				frame.allowFullscreen = true;
				container.replaceChildren(frame);
			};
			render();
			this.dispose = client.subscribe(render);
		};

		connectedCallback() {
			// c15t boots from a module script, which can run after this one.
			document.addEventListener('DOMContentLoaded', this.connect, {
				once: true,
			});
			this.connect();
		}

		disconnectedCallback() {
			document.removeEventListener('DOMContentLoaded', this.connect);
			this.dispose?.();
			this.dispose = undefined;
		}
	}

	if (!customElements.get('consent-video')) {
		customElements.define('consent-video', ConsentVideo);
	}
</script>

The exports that take no client, such as getConsent() and openDialog(), look the client up for you and do nothing before it starts.

Read a permission, not a recorded choice

A consent snapshot holds both. They answer different questions:

FieldAnswersUse it to
effectivePermissionsMay this run now?Load a script, render an embed, send an event
explicitChoiceWhat did the visitor decide?Show the visitor's choice, report consent rates

Under an opt-out policy, effectivePermissions.measurement can be true before the visitor has done anything, while explicitChoice is null. A Global Privacy Control signal can turn a permission off with no recorded choice at all. Gate code on effectivePermissions. See How consent works and the consent state reference.

ExportReturns
getConsentClient()The page's AstroConsentClient, or null
getConsent()The current snapshot, or null before the runtime starts
subscribe(listener)An unsubscribe function. listener receives every new snapshot. Before the runtime starts, it returns a no-op

The client has the same two methods, client.getConsent() and client.subscribe(listener), which never return null.

The snapshot fields you are most likely to read:

FieldHolds
effectivePermissionstrue or false for each category, right now
explicitChoiceThe visitor's recorded decision per category, or null
activeUI'banner', 'dialog' or 'none', and null before the policy resolves
modelThe policy's model: 'opt-in', 'opt-out', 'iab' or 'none'
policyRuleThe matched rule, with its id, prompt and rights
policyPendingtrue while the first /init answer is outstanding
locationCountry and region the backend resolved, or null
translationsThe active language and its translation bundle

A listener runs synchronously on each change. Keep it quick, and read only the fields you need.

When the integration declares vendors, the client also answers per vendor:

MemberReturns
client.getDeclaredVendors()The vendors from vendors, the backend manifest and the slugs on scripts and iframes. Empty under an IAB policy
client.getVendorChoice()The recorded vendor decision, whose denied lists the vendors the visitor switched off, or null
client.isVendorAllowed(id)true when the vendor is declared, its category is allowed and the visitor has not switched it off

isVendorAllowed() returns false for an id nothing declares, such as a typo or a vendor missing from vendors. In development it also logs a console warning that names the id.

Open and close dialogs

MemberDoes
openDialog(kind?, tab?)Opens 'preferences' (default) or 'iab'. For 'iab', tab is 'purposes' or 'vendors'
client.closeDialog()Closes the open dialog. The banner comes back while the policy still owes a choice
preloadDialog()Downloads the dialog without opening it

openDialog() downloads and mounts the dialog island the first time. While the first /init answer is outstanding, it waits for it, then opens nothing if the policy owes no consent interface.

ConsentDialogLink needs no script. For a control you render yourself, call openDialog() from its click handler:

import { openDialog } from 'c15t/astro/client';

document.querySelector('#privacy-link')?.addEventListener('click', () => {
	void openDialog();
});
MemberDoes
client.acceptAll()Without IAB, saves a grant for every category in scope. Under an IAB policy, an optional category is granted only if a listed or custom vendor declares a consent purpose mapped to it after publisher restrictions apply. The CMP sets vendor and purpose consent for declared consent purposes, separate legitimate-interest signals for declared legitimate-interest purposes, and opt-ins for declared special features. The TC string records these signals. See categories under an IAB policy
client.rejectAll()Saves a rejection of everything except necessary, through the CMP under an IAB policy
client.save(consents)Saves the categories you pass, such as { measurement: true }. A vendors map, such as { vendors: { posthog: false } }, saves vendor switches too
client.identify(user)Links the consent record to your own user, as { externalId }

Each applies the choice in the browser at once, closes the open banner or dialog, and returns a promise that settles when the backend has answered. A failed request keeps the choice in the browser and retries later. See when a choice is saved for which surface shows next.

Save only from a visitor's action, such as a button click. Calling acceptAll() or save() when a page loads records a choice the visitor never made. A save that turns off a category that was allowed reloads the page, unless the integration sets reloadOnConsentRevoked: false.

Call identify() after your visitor signs in. externalId is your own user ID. identityProvider, properties and an identityToken that verifies the link are optional.

Run gated inline scripts

activateGatedScripts(snapshot, root?) runs every inline script marked type="text/plain" and data-c15t-category that the snapshot allows, inside root or the whole document. A script that also carries data-c15t-vendor waits until the visitor has not switched that vendor off. It returns the number of scripts it ran.

c15t calls it after every consent change and every ClientRouter navigation. Call it yourself only for markup you insert from your own code:

import { activateGatedScripts, getConsent } from 'c15t/astro/client';

const snapshot = getConsent();
if (snapshot) {
	activateGatedScripts(snapshot, container);
}

See gate an inline script.

Use the page runtime directly

The client exposes two properties for advanced use:

MemberHolds
client.runtimeThe underlying consent runtime. Pass it to a React, Vue or Svelte island. See your own islands
client.optionsThe integration options as the browser received them

client.runtime.kernel.events.on(type, listener) subscribes to individual consent events, such as 'choice:recorded' and 'permissions:changed'. For most sites, the callbacks in the client entrypoint are simpler. See Callbacks.

Do not call client.dispose() in your own code. The page owns the runtime, and a disposed runtime stops every consent surface on the page.

Keep scripts working across ClientRouter

With ClientRouter, module scripts run once per page load, not once per navigation. The consent runtime survives each swap, so getConsentClient() keeps returning the same client and your subscriptions keep firing.

Markup your script changed is replaced on a swap. Render it again on astro:page-load:

import { getConsent, subscribe } from 'c15t/astro/client';

const render = () => {
	const allowed = getConsent()?.effectivePermissions.measurement ?? false;
	document
		.querySelector('#analytics-state')
		?.replaceChildren(allowed ? 'Analytics on' : 'Analytics off');
};

subscribe(render);
document.addEventListener('astro:page-load', render);

A custom element, like the video component above, reconnects on its own when the new page contains it.

createEventDispatcher from @c15t/integrations/events sends your own events to the vendors you registered, and only while their consent allows it. Pass the same scripts and the page's getConsent:

src/analytics-events.ts
import { createEventDispatcher } from '@c15t/integrations/events';
import { getConsentClient } from 'c15t/astro/client';

import consentClient from './c15t.client';

export const trackSearch = function trackSearch(resultCount: number) {
	const client = getConsentClient();
	if (!client) {
		return;
	}
	const events = createEventDispatcher({
		getSnapshot: client.getConsent,
		scripts: consentClient.scripts ?? [],
	});
	events.track('search', { resultCount });
};

Events sent while consent is denied are dropped, not queued. Keep search text and other personal data out of event properties unless you have set up their collection on purpose.

Advanced exports

c15t/astro/client also exports what the integration's page script uses: boot, attachBannerActions, registerDialogAdapter, registerDialogSurface, registerDialogStyles, syncBannerVisibility and syncSurfaceVisibility, plus the attribute names ACTION_ATTRIBUTE, DIALOG_ATTRIBUTE and DIALOG_TAB_ATTRIBUTE. The integration calls them for you. You need them only to run c15t's client without the integration, as a test harness does. The script loader, the network blocker and a consentSource connection come from the integration's page script, so boot() on its own throws when the options configure scripts, networkBlocker or consentSource.

Check the client API

  • Load a page with the video component above in a fresh private window. The placeholder shows, and DevTools Network has no request to youtube-nocookie.com.
  • Allow and deny measurement from Privacy settings. The video appears after you allow it and disappears after you withdraw it, when the page reloads.
  • Navigate with ClientRouter and confirm your script still reacts. Scripts that change markup should listen for astro:page-load.