---
title: Vendor consent
description: Let visitors allow a category such as marketing in a JavaScript app
  and still turn off one vendor in it, with the vendors option on
  createConsentRuntime and a vendor switch you render yourself.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## How vendor consent works

A visitor allows marketing, then switches off X Pixel. Other marketing
scripts load; X Pixel does not. c15t loads a script, network request or
iframe that names a vendor only when both are true:

* Its category is allowed.
* The visitor has not switched its vendor off.

A vendor switch never grants a category. With marketing denied, X Pixel stays
blocked whatever its own switch says. You do not need IAB TCF for this. Under
an `iab` policy, c15t ignores vendor slugs and takes vendor consent from the
TC string instead; see [IAB TCF](./iab).

## Declare the vendors and record a switch

Built-in `@c15t/integrations` helpers already list their vendor by name, so
you only declare a vendor for a script of your own, or to replace a helper's
name and privacy policy.

With `createConsentRuntime`, declare the vendors, then render the switch in
your own UI with the [headless runtime](/docs/frameworks/javascript/headless).
The stock preference dialog that `init()` from `@c15t/browser` mounts lists
vendor switches on its own. This module declares X Pixel, reads whether it
may run, and records a new switch:

```ts title="src/vendor-consent.ts"
import { isVendorAllowed } from 'c15t';
import type { SaveResult, Vendor } from 'c15t';
import type { ConsentRuntime } from 'c15t/runtime';

export const vendors: Vendor[] = [
	{
		category: 'marketing',
		id: 'x-pixel',
		name: 'X Pixel',
		privacyPolicyUrl: 'https://x.com/privacy',
	},
];

// Whether the vendor may run now: it is declared, its category is allowed
// and the visitor has not switched it off. An undeclared id returns false.
export const canVendorRun = function canVendorRun(
	runtime: ConsentRuntime,
	vendorId: string
): boolean {
	return isVendorAllowed(runtime.kernel.getSnapshot(), vendorId);
};

// Record one vendor switch. The empty category input confirms no category;
// `save()` with no input would also confirm every displayed category.
export const saveVendorSwitch = function saveVendorSwitch(
	runtime: ConsentRuntime,
	vendorId: string,
	granted: boolean
): Promise<SaveResult> {
	return runtime.kernel.commands.save({}, { vendors: { [vendorId]: granted } });
};
```

Pass `vendors` to the runtime next to `scripts`:

```ts title="src/consent-runtime.ts (partial)"
import { vendors } from './vendor-consent';

export const runtime = createConsentRuntime({
	mode: hosted({ backendURL: 'https://your-project.inth.app' }),
	scripts,
	vendors,
});
```

`init()` from `@c15t/browser` accepts the same `vendors` option. Its client
exposes the runtime as `client.runtime`, so the functions above work with it
too.

`isVendorAllowed(snapshot, id)` from `c15t` backs `canVendorRun()`.

A vendor reads as allowed only when it is declared, its category is allowed
and the visitor has not switched it off. An id that nothing declares, in
`vendors`, on a script or iframe, or from the backend, reads as not allowed.
A typo such as `x-pixle` or a vendor you forgot to declare never looks like
consent. In development, c15t logs a console warning that names the id.

Call `saveVendorSwitch(runtime, 'x-pixel', false)` from your switch's change
handler. To confirm categories and vendors in one action, pass both to the
kernel's save:

```ts
await runtime.kernel.commands.save({
	marketing: true,
	measurement: true,
	vendors: { 'x-pixel': false },
});
```

To stage vendor switches next to category switches and save them together,
use a [preference draft](/docs/frameworks/javascript/headless#build-a-preference-form-with-a-draft):
`draft.setVendor(id, granted)` stages one, and `draft.reset()` drops them.
`runtime.kernel.getSnapshot().vendors?.declared` lists the declared vendors,
and `vendorChoice` holds the recorded decision.

## Vendor fields

|Field|Required|Behavior|
|--|--|--|
|`id`|Yes|Lowercase slug of up to 64 characters: letters, digits, `.`, `_` and `-`.|
|`name`|Yes|Name shown in the preference dialog.|
|`category`|Yes|A category, or a condition such as `{ or: ['measurement', 'marketing'] }`.|
|`privacyPolicyUrl`|Yes|Link shown next to the vendor.|
|`description`, `legalName`, `homepageUrl`|No|Extra detail shown on the vendor card.|
|`disabled`|No|List the vendor without a switch. A stored denial for it no longer applies.|

The `id` must match the `vendor` slug on the script. Every `@c15t/integrations`
helper sets `vendor` to its script ID, so `xPixel()` is `x-pixel`, `gtag()`
is `gtag` and `cloudflareZaraz()` is `cloudflare-zaraz`. Each
[vendor guide](/docs/integrations/overview) names its slug.

You don't need to declare a helper's vendor. Each helper also sets
`vendorDetails` on its script: the vendor's name, privacy policy, homepage
and legal entity. The dialog lists the vendor with its own switch from
those. Declare the vendor in `vendors` to replace them, for example to link
your own data processing notice, or when you self-host a tool such as
Matomo, Umami, Plausible or PostHog and the vendor's privacy policy doesn't
cover your install. A declaration replaces `vendorDetails` as a whole and
doesn't merge with it.

Your own scripts can set `vendorDetails` too. A script with a `vendor` slug
and no `name` and `privacyPolicyUrl` from any source still loads with its
category, but the dialog has no switch for it. In development, c15t logs a
console warning that names the slug. Production builds skip the warning.

A self-hosted backend can declare vendors too. `/init` returns them and c15t
merges them with the vendors in code. When both declare the same `id`, the
code declaration wins.

## Gate scripts, requests and iframes by vendor

Put the vendor slug on each target:

|Target|Field|
|--|--|
|Script configuration|`vendor: 'x-pixel'`|
|Network blocker rule|`vendor: 'x-pixel'`|
|Iframe blocker|`data-vendor="x-pixel"` on the `<iframe>`|

An iframe with `data-vendor` and no `data-category` is gated on the vendor
alone. A script with `alwaysLoad` still loads when its vendor is off. Its
callbacks receive `info.vendor.granted`, so the SDK can stop itself. The
Google Consent Mode, RudderStack and Cloudflare Zaraz helpers do this for
you. While their vendor is off, they send every optional category as denied.

[Iframe blocker](/docs/frameworks/javascript/modules/iframe-blocker) and
[network blocker](/docs/frameworks/javascript/modules/network-blocker) cover
those targets.

## What c15t stores

c15t stores only the vendors a visitor switched off, and sends the full
vendor map to the backend with the consent record. Under an opt-out policy,
every vendor starts on. Turning a vendor off that was on reloads the page,
the same as withdrawing a category, so code the vendor already ran stops.

## What switching a vendor off does not do

* It does not delete cookies the vendor already set.
  [Clear on revocation](./clear-on-revocation) does not run, because the
  category stays allowed. Delete the vendor's cookies from the script's
  `onConsentChange` when `info.vendor.granted` is `false`.
* It does not expire. The switch stays off until the visitor changes it or
  uses Accept All or Reject All, even across a policy change.
* Adding a vendor does not ask returning visitors again. A new vendor starts
  on inside an allowed category. Change the policy's `copyRevision` if a new
  vendor should prompt again.

## Verify vendor consent

Test the production build in a private window with DevTools Network open and
filtered to `ads-twitter.com`:

1. Register `xPixel()` and declare `x-pixel`. Allow marketing. `uwt.js`
   loads.
2. Switch X Pixel off in your control. c15t reloads the page. After the reload
   there is no `uwt.js` request, and other marketing scripts still load.
3. Reload again. `canVendorRun(runtime, 'x-pixel')` still returns `false`.
4. Record Accept all with `runtime.kernel.commands.save('all')`. `uwt.js`
   loads again.
