Skip to main content

Reference

Node.js SDK

When to use the SDK

@c15t/node-sdk is a typed client for the HTTP endpoints of a c15t backend, hosted on Inth or self-hosted. Use it in server code:

  • Check consents.check before you send a marketing email to a signed-in user.
  • Read a visitor's banner categories with subjects.get before you send a server-side analytics event.
  • Link a visitor's consent records to your user ID after sign-in with subjects.identify.
  • Export everything recorded for one user with subjects.list, for a data access request.
  • Publish a new privacy policy or terms release from CI with legalDocuments.publish.
  • Read per-arm counts for a banner experiment with experiments.summary.

Do not use it in the browser. The framework adapters already call the backend for the banner, and four methods need an API key that must stay on the server.

Install

npm install @c15t/node-sdk@alpha

The client needs Node.js 20.19 or later, or another runtime with fetch, AbortSignal.any and crypto.randomUUID. It is ESM only.

Create a client

Pass the URL where the backend is mounted, path included. The client reads no environment variables, so pass every value explicitly.

src/lib/c15t-client.ts
import { createC15tClient } from '@c15t/node-sdk';

export const c15t = createC15tClient({
	baseUrl: process.env.C15T_API_URL || 'http://localhost:5173/api/self-host',
});

This SvelteKit example reads the URL from its own C15T_API_URL variable and falls back to the demo's local backend. baseUrl keeps its path: with https://app.example.com/api/c15t, status() requests https://app.example.com/api/c15t/status. For Inth, use the URL from your project, such as https://your-project.inth.app.

A client without apiKey has no consents.check, subjects.list, experiments.summary or legalDocuments.publish. Those four endpoints require a key. Create a second client with one where you need them:

src/lib/server/c15t-admin.ts
import { createC15tClient } from '@c15t/node-sdk';

const apiKey = process.env.C15T_API_KEY;
if (!apiKey) throw new Error('Set C15T_API_KEY');

export const c15tAdmin = createC15tClient({
	baseUrl: 'https://app.example.com/api/c15t',
	apiKey,
});

A self-hosted backend accepts the keys listed in its apiKeys option. The client sends the key as Authorization: Bearer <key>. Check that the key is set before you pass it, as above. With a string | undefined key, TypeScript cannot tell which client you get, and the key-only methods are not callable until you narrow it. Called on a client without a key, they return MISSING_API_KEY and send nothing.

OptionDefaultWhat it does
baseUrlRequiredBackend URL, path included. A query string or fragment is rejected.
apiKeyNoneSent as Authorization: Bearer. Adds the key-only methods.
fetchglobalThis.fetchfetch implementation, for tracing, proxies or tests.
headers{}Headers sent with every request.
timeoutMs10000Deadline for one attempt, reading the response body included. An integer from 1 to 2147483647 milliseconds.
retry{ maxRetries: 2, initialDelayMs: 200, maxDelayMs: 5000 }Retry settings, or false to never retry.
onEventNoneCalled for each request, response and retry.

createC15tClient checks every option and throws one C15tConfigurationError listing all the problems, with each in error.issues. It is the only error the SDK throws. Create the client at module scope, so a bad option fails at startup rather than on the first request.

The client refuses an http: URL together with an API key, because the key would travel in the clear. http: with a key is accepted only for localhost, 127.0.0.1 and [::1].

Handle results

Every method resolves to a result object and never rejects. Check ok before reading data:

const result = await c15t.subjects.get(subjectId);

if (!result.ok) {
	switch (result.error.code) {
		case 'NOT_FOUND':
			return null;
		default:
			throw result.error;
	}
}

return result.data.consents;
FieldOnWhat it holds
okBothtrue for success, false for failure
dataSuccessThe response body. Dates are Date objects.
statusSuccessHTTP status
requestIdSuccessThe x-request-id the call sent
headersSuccessResponse Headers
errorFailureA C15tError

error.code is typed per method. subjects.get can fail with NOT_FOUND or DATABASE_ERROR from the backend, plus the client codes every method shares. TypeScript flags a case for a code the method cannot return.

Client codeWhen
INVALID_INPUTThe input or call options failed validation. Nothing was sent. error.issues lists each problem with a path.
MISSING_API_KEYThe method needs an API key and the client has none. Nothing was sent.
NETWORK_ERRORThe request failed before a response arrived.
TIMEOUTOne attempt took longer than timeoutMs.
ABORTEDYour signal aborted the call.
UNEXPECTED_RESPONSEA response the endpoint does not document, such as a proxy's HTML error page or a body without the documented shape. An undocumented backend code is kept in error.serverCode.

Every C15tError also has message, status (when a response arrived), requestId (when something was sent) and retryable. retryable says whether the same call could succeed later; the client has already retried by the time you see it. A STALE_POLICY error carries reason: policy-changed, decision-mismatch or incomplete-inputs. An unrecognized reason from the backend is undefined. error.toJSON() gives a plain object for logs.

Throw instead with unwrap

unwrap(result) returns data or throws the C15tError. Use it where a failure should stop the surrounding work, such as a job with its own retry. isC15tError(error, ...codes) checks a caught value and narrows its code:

import { isC15tError, unwrap } from '@c15t/node-sdk';

try {
	const { subjects } = unwrap(await c15tAdmin.subjects.list({ externalId }));
	await writeExport(subjects);
} catch (error) {
	if (isC15tError(error, 'UNAUTHORIZED')) {
		// error.code is 'UNAUTHORIZED': the key is wrong or revoked
	}
	throw error;
}

To name a method's types elsewhere, use C15tDataOf and C15tErrorCodeOf:

import type { C15tDataOf, C15tErrorCodeOf } from '@c15t/node-sdk';

type Subject = C15tDataOf<typeof c15t.subjects.get>;
type SubjectError = C15tErrorCodeOf<typeof c15t.subjects.get>;

Methods

MethodEndpointAPI key
consents.check({ externalId, types })GET /consents/checkRequired
subjects.identify(id, { externalId, identityProvider, identityToken })PATCH /subjects/:idNo, but verifies only with a key or token
subjects.get(id, { types })GET /subjects/:idNo
subjects.create(input)POST /subjectsNo
subjects.list({ externalId })GET /subjectsRequired
legalDocuments.publish(type, { version, hash, effectiveDate })PUT /legal-documents/:type/currentRequired
experiments.summary(id, { from, to, domain })GET /experiments/:id/summaryRequired
status()GET /statusNo
init({ language, country, region, gpc })GET /initNo
manifest({ language, ifNoneMatch })GET /manifestNo

Every method also takes call options as its last argument. createIdentityToken is a separate export, not a client method; see Verify a link made in the browser. The examples below use the c15t and c15tAdmin clients from Create a client.

consents.check reports, for each requested policy type, whether the user has a consent record of that type and whether one of those records is for the current policy version. The user is your own ID. Only subjects with a verified link to it count, and the method needs an API key.

const result = await c15tAdmin.consents.check({
	externalId: user.id,
	types: ['marketing_communications'],
});

if (result.ok && result.data.results.marketing_communications.hasConsent) {
	await sendNewsletter(user);
}

results has a key for every type you request, so results.marketing_communications needs no ?. A type with no record, or one the backend does not know, comes back as { hasConsent: false, isLatestPolicy: false }. A response missing a requested type or containing non-boolean consent flags returns UNEXPECTED_RESPONSE.

hasConsent means a record of that type exists. The check does not read the record's preferences, so record a marketing_communications consent only when the user opts in. Treat a failed check as no consent.

types must be a non-empty array. Each type is one entry; a type containing a comma returns INVALID_INPUT.

Gate a server-side event on a banner category

consents.check reports hasConsent: true for cookie_banner after a reject too, because a reject is also a recorded choice. To send a server-side analytics event only when the visitor allowed measurement, read the category from subjects.get:

const result = await c15t.subjects.get(subjectId);
const measurement = result.ok
	? result.data.subjectChoice?.categories.measurement
	: undefined;

if (measurement?.value === true) {
	await trackPurchase(order);
}

subjectChoice.categories holds the latest receipt for measurement, marketing, functionality and experience, each with value and confirmedAt. A category with no receipt is absent. subjectChoice is null when the subject has no usable choice.

subjects.identify links a subject, the ID the c15t client stores in the visitor's browser, to your user ID. After a verified link, consents.check and subjects.list find the subject's records by your ID.

const result = await c15tAdmin.subjects.identify(subjectId, {
	externalId: user.id,
	identityProvider: 'auth0',
});

A client with an API key verifies every link it makes. Call subjects.identify this way when your server handles sign-in and the frontend sends the subject ID with the request. identityProvider is optional; the backend stores external without it. data.subject holds id, externalId and identityProvider. An unknown subject ID returns NOT_FOUND. Linking the same user again changes nothing.

Without a key or an identity token the link is stored but unverified, and consents.check and subjects.list ignore it. It also can't replace a verified link; that returns IDENTITY_CONFLICT.

The browser adapters link the subject themselves when you call their identify() after sign-in. To verify that link, sign an identity token on your server and pass it to identify(). Set the same key as the backend's identityToken.signingKey:

src/lib/server/identity.ts
import { createIdentityToken } from '@c15t/node-sdk';

const signingKey = process.env.C15T_IDENTITY_SIGNING_KEY;
if (!signingKey) throw new Error('Set C15T_IDENTITY_SIGNING_KEY');

export function identityTokenFor(userId: string) {
	return createIdentityToken({ externalId: userId }, { signingKey });
}

Render the token into the signed-in page and pass it to identify(). A user passed when creating the client only links a subject that the next save creates, so call identify() for a visitor who consented before signing in:

await identify({ externalId: user.id, identityToken });

The token expires after ttlSeconds, one hour by default, so mint one per page load. If you pass identityProvider, the browser must send the same one. A failed token makes identify() return IDENTITY_TOKEN_INVALID, but never blocks the first consent save; that link just stays unverified. See verify identity links for the full rules.

Export a user's records

subjects.list returns every subject verifiably linked to one of your user IDs, each with its consents. It needs an API key.

const result = await c15tAdmin.subjects.list({ externalId: user.id });

if (result.ok) {
	for (const subject of result.data.subjects) {
		console.log(subject.id, subject.createdAt.toISOString());
		for (const consent of subject.consents) {
			console.log(consent.type, consent.givenAt, consent.preferences);
		}
	}
}

createdAt, givenAt and policyEffectiveDate are Date objects. Each consent also has isLatestPolicy, policyVersion and policyHash where they apply.

Read one subject

subjects.get reads one subject by ID. Pass types to return only consents of those policy types:

const result = await c15t.subjects.get(subjectId, { types: ['cookie_banner'] });

data holds subject, consents, isValid and subjectChoice, the latest category choices across the subject's cookie-banner consents. The filter changes which consent records come back and how isValid is calculated. It does not filter subjectChoice. Anyone with a subject ID can read it, so treat subject IDs as private.

subjects.create records a consent and creates the subject if it does not exist. Leave cookie-banner saves to the browser client, which builds the receipts for each category. Use this method for consents your server collects, such as terms accepted in a sign-up form:

const result = await c15t.subjects.create({
	type: 'terms_and_conditions',
	subjectId,
	domain: 'app.example.com',
	givenAt: new Date(),
	externalSubjectId: user.id,
	policyHash: termsHash,
});

subjectId uses the sub_ format that generateSubjectId() from c15t produces. givenAt takes a Date or epoch milliseconds. type decides which other fields apply: cookie_banner requires preferences, and legal-document types accept policyHash, policyId or documentSnapshotToken. Sending the same consent again returns the existing record. data holds subjectId, consentId, givenAt and appliedPreferences.

legalDocuments.publish stores a document release as the current version, so new consents of that type record it. Run it from the job that ships the document, with an API key:

scripts/publish-privacy-policy.ts
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';

import { createC15tClient, unwrap } from '@c15t/node-sdk';

const apiKey = process.env.C15T_API_KEY;
if (!apiKey) throw new Error('Set C15T_API_KEY');

const c15t = createC15tClient({
	baseUrl: 'https://app.example.com/api/c15t',
	apiKey,
});

const document = await readFile('legal/privacy-policy.md');
const hash = `sha256:${createHash('sha256').update(document).digest('hex')}`;

const { policy } = unwrap(
	await c15t.legalDocuments.publish('privacy_policy', {
		version: '2026-10-01',
		hash,
		effectiveDate: '2026-10-01T00:00:00.000Z',
	})
);

console.log(`Published ${policy.type} ${policy.version} as ${policy.id}`);

type is privacy_policy, dpa or terms_and_conditions, or one of them with a suffix such as terms_and_conditions_b2b. effectiveDate takes a Date or an ISO 8601 string. Impossible calendar dates such as 2026-02-30 return INVALID_INPUT before publishing. Publishing the same hash again with the same version and date changes nothing, so a rerun job is safe. The same hash with a different version or date returns CONFLICT. See legal-document snapshots for signing the document a user saw.

Read an experiment summary

experiments.summary counts choices per arm of a banner experiment. It needs an API key.

const result = await c15tAdmin.experiments.summary('banner-shape', {
	from: '2026-09-01',
	to: '2026-09-30',
});

if (result.ok) {
	for (const arm of result.data.arms) {
		console.log(arm.arm, arm.choices, arm.byAction.accept_all);
	}
}

from and to take a Date or an ISO 8601 string. A date without a time covers the whole day in UTC, so the example covers all of September. domain narrows to one domain. The response's own from and to are strings, or null when unbounded. Read the summary from your backend explains each field.

Status, init and manifest

const status = await c15t.status();
// status.data: { version, timestamp, client }

const init = await c15t.init({ country: 'DE', language: 'de', gpc: true });
// Sent as x-c15t-country, Accept-Language and x-c15t-gpc

const manifest = await c15t.manifest({ language: 'en', ifNoneMatch: etag });
if (manifest.ok && manifest.data.status === 'modified') {
	cache.set(manifest.data.manifest, manifest.data.etag);
}

init resolves the banner for one visitor. Pass country, region, language and gpc from the request you are handling; otherwise the backend sees your server's location. manifest returns { status: 'modified', manifest, etag }, or { status: 'not-modified', etag } when ifNoneMatch matches the current manifest. A response missing the required manifest fields or using an unsupported schema version or branding value returns UNEXPECTED_RESPONSE. Prefer the framework adapters for rendering the banner; see data fetching.

Call options

Every method takes these as its last argument:

OptionWhat it does
signalAborts the call, including a pending retry. The result is ABORTED.
timeoutMsOverrides the client's per-attempt timeout.
headersMerged over the client's headers for this call.
retryOverrides the client's retry settings, or false for none. Omitted fields keep the client's values.
requestIdx-request-id to send. Defaults to a random UUID.
const result = await c15t.consents.check(
	{ externalId: user.id, types: ['marketing_communications'] },
	{
		requestId: request.headers.get('x-request-id') ?? undefined,
		timeoutMs: 2000,
	}
);

Pass the ID of the request you are handling as requestId to match your logs with the backend's. The client owns Accept, Authorization, Content-Type, x-request-id, x-c15t-version and x-c15t-policy-contract; a header you pass with one of those names is replaced. An invalid call option returns INVALID_INPUT instead of throwing. Request bodies that cannot be serialized as JSON, such as metadata containing a bigint or circular reference, also return INVALID_INPUT before sending.

Retries and timeouts

The client retries a call when the attempt fails with NETWORK_ERROR, TIMEOUT, or HTTP 408, 429, 500, 502, 503 or 504. It retries only idempotent endpoints, and every current endpoint is one: the backend returns the existing record when it receives the same consent, link or release twice.

  • With the defaults, a call makes up to 3 attempts.
  • Each wait is random, up to initialDelayMs doubled per retry and capped at maxDelayMs: under 200 ms, then under 400 ms.
  • A Retry-After header, in seconds or as an HTTP date, replaces the backoff. A Retry-After longer than maxDelayMs stops retrying and returns the error.
  • Every attempt sends the same x-request-id.
  • maxRetries can be 0 to 10.

initialDelayMs and maxDelayMs must be integers from 0 to 2147483647 milliseconds. timeoutMs must be an integer from 1 to 2147483647. Larger values can overflow Node.js timers to 1 ms, so the client rejects them.

timeoutMs applies to each attempt, reading the response body included. The default worst case is three 10-second attempts plus the waits. To cap the whole call, pass a signal; the result is then ABORTED:

const result = await c15t.consents.check(
	{ externalId: user.id, types: ['marketing_communications'] },
	{ signal: AbortSignal.timeout(3000) }
);

onEvent reports each attempt for logging and metrics. An error it throws is ignored.

const c15t = createC15tClient({
	baseUrl: 'https://app.example.com/api/c15t',
	onEvent: (event) => {
		if (event.type === 'retry') {
			logger.warn('c15t retry', {
				path: event.path,
				attempt: event.attempt,
				delayMs: event.delayMs,
				code: event.error.code,
				requestId: event.requestId,
			});
		}
	},
});

request events carry method, path, requestId and attempt, counted from 0. response events add status and durationMs.

Test code that uses the client

@c15t/node-sdk/testing exports createMockC15tClient, ok and err. The mock is a full client whose methods call the handlers you pass. Handlers are typed from the client, so wrong data, or an error code the method cannot return, is a type error. A method without a handler rejects with an error that names it, so a test fails when the code calls something unexpected.

Write the code under test to take the client as a parameter:

src/newsletter.ts
import type { C15tClient } from '@c15t/node-sdk';

export async function canEmail(c15t: C15tClient, userId: string) {
	const result = await c15t.consents.check({
		externalId: userId,
		types: ['marketing_communications'],
	});
	return result.ok && result.data.results.marketing_communications.hasConsent;
}
src/newsletter.test.ts
import { createMockC15tClient, err, ok } from '@c15t/node-sdk/testing';
import { expect, test } from 'vitest';

import { canEmail } from './newsletter';

test('emails a user who opted in', async () => {
	const c15t = createMockC15tClient({
		consents: {
			check: () =>
				ok({
					results: {
						marketing_communications: {
							hasConsent: true,
							isLatestPolicy: true,
						},
					},
				}),
		},
	});
	expect(await canEmail(c15t, 'user_123')).toBe(true);
});

test('does not email when the check fails', async () => {
	const c15t = createMockC15tClient({
		consents: { check: () => err('DATABASE_ERROR') },
	});
	expect(await canEmail(c15t, 'user_123')).toBe(false);
});

ok(data, { status, requestId, headers }) and err(code, { message, status, reason, retryable, issues }) take optional fields for the rest of the result.

Upgrade from v2

The v2 c15tClient() and its flat methods are gone. See Upgrade from v2 for the mapping.