Skip to main content

Reference

HTTP endpoints

Find your API base URL

All paths are relative to the backend's mount point. With basePath: '/api/c15t', /manifest is available at /api/c15t/manifest. The framework adapters call these endpoints for you. Prefer the adapter unless you are building a custom transport or server integration. For server code, the Node.js SDK wraps these endpoints with typed inputs, results and retries.

Method and pathPurposeAPI key
GET /statusService status and configured version.No
GET /manifestGeo-independent policy configuration, optionally sliced with ?language=en.No
GET /initResolve configuration for this request's location, language and privacy signals.No
POST /sessionsReport an init a host resolved from a cached manifest. Server-to-server only; nothing is stored.No
POST /subjectsRecord a consent submission and its category receipts.No
GET /subjects/:idRead this subject's consent status and merged choices. Optional ?type=a,b filter.No, the subject ID is the capability
PATCH /subjects/:idLink externalId and optional identityProvider. The link counts in reads by external ID only when verified.No
GET /subjects?externalId=...Retrieve subjects verifiably linked to an external identity.Required
GET /consents/check?externalId=...&type=a,bReturn hasConsent and isLatestPolicy for every requested type, from verified links only.Required
GET /experiments/:id/summary?from=...&to=...&domain=...Choices per arm of a banner experiment, split by action and surface, with the median time to decision.Required
PUT /legal-documents/:type/currentPublish a legal document's current version, hash and effective date.Required
GET /spec.jsonDiscover the enabled OpenAPI routes.No

Subject IDs allow public reads of that subject's state. Treat them as private capabilities. Do not expose administrative API keys to make browser requests.

Fetch reusable configuration

curl -i 'http://localhost:3000/api/c15t/manifest?language=en'

/manifest returns the policy configuration and a revision, with an ETag and public cache headers. It contains no visitor's consent choices. A matching If-None-Match returns 304. See caching before putting a CDN in front of the backend.

Resolve a request with /init

curl -i 'http://localhost:3000/api/c15t/init' \
  -H 'x-c15t-country: DE' \
  -H 'Accept-Language: en'

This development request supplies a location explicitly. In production, use the framework adapter and trusted deployment headers. /init resolves location, language and GPC against the manifest. Its policyResolution distinguishes a matched rule from an unmatched or failed resolution. A 200 response alone does not establish that a rule matched.

Query parameters and CORS

A browser on another origin sends /init with no custom request header, so the request needs no CORS OPTIONS preflight before the banner can show. The client's inputs travel in the query string instead:

Query parameterReplaces headerValue
vx-c15t-versionClient package version.
contractx-c15t-policy-contractPolicy contract the client reads. Any other value than 1 gets an unsupported-contract failure.
countryx-c15t-countryCountry override.
regionx-c15t-regionRegion override.
gpcx-c15t-gpcGPC override, 1 or 0. Other values are ignored.
experimentx-c15t-experimentExperiment arm as <id>=<arm>, each part URI-encoded.

The backend reads each parameter first and the header second, so older clients and server-to-server callers that send the headers keep working. Accept-Language stays a header because browsers send it without a preflight. The journey parameters journey, journeyScope and stored also travel in the query string. The backend still reads the c15tJourney, c15tJourneyScope and c15tStored names that 3.0.0-alpha.8 and alpha.9 clients send.

c15t reserves these names on an init URL. If your initURL already carries one, such as /api/consent/init?country=US, c15t replaces it with its own value, so each name appears once. A reserved name c15t does not send on that request passes through, and the backend reads it as that input.

/init answers any origin. A trusted origin gets its own origin back with Access-Control-Allow-Credentials: true, as older clients send /init with cookies. Any other origin gets Access-Control-Allow-Origin: * and no credentials, which is enough for current clients: they send /init without cookies to another origin, and /init reads none. Every other route, including POST /subjects, still answers only origins in trustedOrigins.

The overrides are visitor-controlled in either form. If your edge strips incoming x-c15t-* headers so visitors cannot choose their own policy rule, strip country, region and gpc from /init too.

Vendor scope

x-c15t-vendors is optional. It takes a comma-separated list of the vendor IDs an application ships. On a matched IAB rule the gvl in the response is then the intersection of that list and gvl.vendorIds, so the header can shrink the served list and cannot widen it. See IAB backend configuration.

Every /init and /manifest response carries hosting: 'self-hosted', or 'inth' from Inth's hosted platform. Set it with the instance's hosting option. Backends older than this field omit it.

/init uses Cache-Control: no-store. It can include a signed policySnapshotToken when signing is configured and a policy matched. For request-time framework rendering, prefer a cached manifest and local resolution when the adapter supports it. Do not cache one visitor's resolved /init result as shared configuration.

Report a session

curl -i 'http://localhost:3000/api/c15t/sessions' \
  -H 'Content-Type: application/json' \
  -H 'X-C15T-Version: 3.0.0' \
  -H 'X-C15T-Client-IP: 203.0.113.42' \
  -H 'User-Agent: Mozilla/5.0' \
  -d '{"source":"route","revision":"…","resolution":"matched","policy":{"id":"eu-opt-in","fingerprint":"…","matchedBy":"country","model":"opt-in"},"country":"DE","region":null,"language":"de","gpc":false}'

A framework server that resolves consent from a cached manifest never calls /init, so it reports each resolution here instead. The framework adapters send the report server-to-server after responding to the visitor. The backend applies its ipAddress settings, passes the report to sessions.onReport, logs it, and answers 204. It writes nothing to the database.

The route rejects requests a browser could send. A request with an Origin header, or without the X-C15T-Version header, gets 400. The visitor's user agent travels as User-Agent, and the visitor's address as X-C15T-Client-IP, because a platform in front of the backend rewrites X-Forwarded-For.

The body follows consentSessionReportSchema from @c15t/schema. source is route for an init route and render for a server-rendered page. init is reserved for the backend's own /init, which emits the same report. Prefetches, prerenders and HEAD requests send no report.

A report is one resolution, not one visitor. A page view can produce a render and a route report. When the client sends a journey, the reports and the save's POST /subjects carry the same journey.id (details). Without one, group reports by address and user agent within a time window; that count is approximate.

Serve the script tag

GET /c15t.js serves the hosted script-tag build of the consent banner. A queued config call sets the backend URL, hosted mode and the defaults from script.config. The client resolves the visitor's policy through /init; this route does not inline a manifest.

GET /c15t.headless.js serves the build with no UI. GET /c15t.iab.js serves the optional IAB CMP and preference UI. Both routes keep their queued manifest configuration. Use script.iabPath to change the IAB route's path and script.config.iab to set CMP options.

QueryWhat it does
languageOverride the language used by the hosted /c15t.js client. On headless and IAB routes, slice the inlined manifest's translations to one language.

Cached like /manifest: Cache-Control: public, s-maxage=… and an ETag that answers If-None-Match with 304. @c15t/backend installs @c15t/browser, which provides the bundles; script.bundles overrides their file paths. Set script.enabled: false to remove all three routes.

Save and read choices

The v3 client sends POST /subjects with a domain, policy type, preferences, givenAt timestamp and the per-category receipts confirmed by the action. Let the client construct this payload so effective permissions are not mistaken for an explicit grant. Hydration, a dismissed notice and a GPC signal are not consent submissions.

The response includes subjectId, consentId, appliedPreferences and givenAt. Read GET /subjects/:id to inspect merged category choices. A type filter changes which consent records are returned and how isValid is calculated; it does not filter away the subject's cookie-banner choice state.

When the client declares vendors, a save also carries vendorChoice: the complete per-vendor grant map with one confirmation time. The backend stores it as sent on the consent row, including vendors the client declared in code rather than in the manifest, and returns subjectVendorChoice: the most recent act decides every vendor it names, and a vendor an earlier act decided but the latest one omits keeps that earlier grant. The aggregate's confirmedAt is the time of the oldest decision still in it, so a retained grant is never presented as newer than it is. A map that omits a vendor the manifest declares is stored too: the client saw an older manifest, so its silence about that vendor is not a decision. A vendor no act ever named follows its category. Retrying the same act with a different map is a CONFLICT, like a retry with different receipts, and so is any retry against a stored map that cannot be read. Migration 4-vendor-choice adds the column.

Late saves

A browser that can't deliver a save keeps it and sends it again on the next page load or when it comes back online, for up to 7 days. The replay carries the original givenAt (the click time) and the policySnapshotToken from the /init the visitor saw. Tokens expire after ttlSeconds (30 minutes by default), so a replay often arrives with an expired token.

With signing configured, the backend accepts such a save when all of these hold:

  • The token's signature, issuer and tenant audience verify.
  • The token was valid at givenAt: givenAt is between the token's issue time and its expiry, with 10 minutes of slack for the visitor's clock.
  • The request arrives no later than policySnapshot.replayWindowSeconds after the token expired (7 days by default).
  • The manifest still has the policy the token names under the same fingerprint.

The record keeps givenAt as sent, createdAt is when the backend received it, and runtimePolicySource is snapshot_token_replayed instead of snapshot_token. The request's wide event carries the same value as consent.decisionSource. A save that arrived before its token expired is unchanged. Retrying a save that already landed returns the existing record.

The backend refuses these, and the c15t client drops them from its replay queue instead of retrying:

ResponseWhen
409 POLICY_SNAPSHOT_EXPIREDgivenAt is outside the token's lifetime, or the request arrived after the replay window
409 POLICY_SNAPSHOT_INVALIDThe token doesn't verify, or its claims are incomplete
422 STALE_POLICY, reason policy-changedThe policy the token names is no longer in the manifest under that fingerprint. The visitor saw the old text, so the choice is not recorded against the new one.
409 CONFLICTThis act was already recorded with different receipts, purposes or vendor grants. The earlier record stands.
409 SUBJECT_CONFLICTAnother tenant on the same database already holds the save's subjectId. The client generates a new subject ID and sends the choice once under it; the save is dropped only if that also fails.

A refused save stays recorded in the visitor's browser only. givenAt is supplied by the client. A late save can only claim a time inside the lifetime of a token this backend signed, for a policy that is still current, but within that range the backend can't tell a genuine click time from a chosen one.

The backend does not store GPC as a standing opt-out. /init reads Sec-GPC on each request, and the client applies the signal live in the browser.

GET /subjects?externalId= and GET /consents/check count a subject only when its link to the external ID was verified: the POST /subjects or PATCH /subjects/:id that made it carried an API key, or an identityToken signed with identityToken.signingKey. Unverified links are stored but skipped by those reads.

Mint tokens with createIdentityToken. From another language, sign an HS256 JWT with sub set to the external ID, aud c15t-identity, iss c15t, an exp, and optionally idp matching identityProvider.

ResponseCause
401 IDENTITY_TOKEN_INVALIDPATCH only. The token doesn't verify, has expired, or names another external ID or provider. Nothing changes.
409 IDENTITY_CONFLICTPATCH only. The subject's current link is verified and this request isn't, so it can't replace it. Sending the same identity again succeeds.

POST /subjects never refuses a consent because of its token: a bad token leaves the link unverified, and the token only applies when the save creates the subject. Verify an existing subject with PATCH /subjects/:id.

Make an administrative request

Use a server environment variable containing a configured API key:

curl 'http://localhost:3000/api/c15t/subjects?externalId=customer-123' \
  -H "Authorization: Bearer $C15T_API_KEY"

From Node.js, subjects.list in the Node.js SDK makes the same request.

Missing or invalid keys return 401 on protected routes. Invalid input returns an error body containing message and cause.code; individual validation paths can return 400 or 422. Inspect both the HTTP status and error code. Database errors return a generic failure to the client, with details available through server logging.

The OpenAPI document lists available routes and security declarations. Use the published @c15t/schema contracts and client adapters for complete payload types; the generated document does not describe every field accepted by each handler.