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.checkbefore you send a marketing email to a signed-in user. - Read a visitor's banner categories with
subjects.getbefore 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
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.
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:
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.
| Option | Default | What it does |
|---|---|---|
baseUrl | Required | Backend URL, path included. A query string or fragment is rejected. |
apiKey | None | Sent as Authorization: Bearer. Adds the key-only methods. |
fetch | globalThis.fetch | fetch implementation, for tracing, proxies or tests. |
headers | {} | Headers sent with every request. |
timeoutMs | 10000 | Deadline 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. |
onEvent | None | Called 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:
| Field | On | What it holds |
|---|---|---|
ok | Both | true for success, false for failure |
data | Success | The response body. Dates are Date objects. |
status | Success | HTTP status |
requestId | Success | The x-request-id the call sent |
headers | Success | Response Headers |
error | Failure | A 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 code | When |
|---|---|
INVALID_INPUT | The input or call options failed validation. Nothing was sent. error.issues lists each problem with a path. |
MISSING_API_KEY | The method needs an API key and the client has none. Nothing was sent. |
NETWORK_ERROR | The request failed before a response arrived. |
TIMEOUT | One attempt took longer than timeoutMs. |
ABORTED | Your signal aborted the call. |
UNEXPECTED_RESPONSE | A 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:
To name a method's types elsewhere, use C15tDataOf and C15tErrorCodeOf:
Methods
| Method | Endpoint | API key |
|---|---|---|
consents.check({ externalId, types }) | GET /consents/check | Required |
subjects.identify(id, { externalId, identityProvider, identityToken }) | PATCH /subjects/:id | No, but verifies only with a key or token |
subjects.get(id, { types }) | GET /subjects/:id | No |
subjects.create(input) | POST /subjects | No |
subjects.list({ externalId }) | GET /subjects | Required |
legalDocuments.publish(type, { version, hash, effectiveDate }) | PUT /legal-documents/:type/current | Required |
experiments.summary(id, { from, to, domain }) | GET /experiments/:id/summary | Required |
status() | GET /status | No |
init({ language, country, region, gpc }) | GET /init | No |
manifest({ language, ifNoneMatch }) | GET /manifest | No |
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.
Check consent before sending email
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.
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:
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.
Link a user after sign-in
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.
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.
Verify a link made in the browser
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:
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:
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.
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:
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.
Record a consent
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:
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.
Publish a legal document from CI
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:
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.
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
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:
| Option | What it does |
|---|---|
signal | Aborts the call, including a pending retry. The result is ABORTED. |
timeoutMs | Overrides the client's per-attempt timeout. |
headers | Merged over the client's headers for this call. |
retry | Overrides the client's retry settings, or false for none. Omitted fields keep the client's values. |
requestId | x-request-id to send. Defaults to a random UUID. |
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
initialDelayMsdoubled per retry and capped atmaxDelayMs: under 200 ms, then under 400 ms. - A
Retry-Afterheader, in seconds or as an HTTP date, replaces the backoff. ARetry-Afterlonger thanmaxDelayMsstops retrying and returns the error. - Every attempt sends the same
x-request-id. maxRetriescan 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:
onEvent reports each attempt for logging and metrics. An error it throws is
ignored.
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:
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.