@consentera/consent-sdk
v2.1.0
Published
Consentera Consent Management SDK for the web — DPDP (India) first, with GDPR, TCF 2.2 and GPP surfaces
Downloads
385
Maintainers
Readme
@consentera/consent-sdk
Consent management for the web, against the Consentera platform. India's DPDP Act first, with TCF 2.2 and GPP surfaces for sites that also need them.
- Consent lifecycle — create a consent session, validate a purpose, update, withdraw, renew, verify the artefact.
- Cookie banner + preference centre — a drop-in surface for cookie consent.
- React — a provider, hooks and a
<ConsentGate>that closes when consent is withdrawn.
npm install @consentera/consent-sdkNode ≥ 20. React ≥ 18 (optional peer, only for @consentera/consent-sdk/react).
Quickstart (10 minutes)
1. Put your secret key on YOUR server, never in the page
A Data Fiduciary credential (tiq_live_… / tiq_test_…) is a secret. Every
consent lifecycle road needs one, and a browser bundle is public, so the browser
talks to your server and your server talks to us.
// app/api/consentera/[...path]/route.ts (Next.js — any server framework works)
export async function POST(req: Request, { params }: { params: { path: string[] } }) {
// CONSENTERA_API_URL is YOUR tenant's API origin — the SDK ships no default.
const upstream = `${process.env.CONSENTERA_API_URL}/api/v1/public/${params.path.join('/')}`;
const res = await fetch(upstream, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.CONSENTERA_API_KEY!, // the secret, server-side only
'X-Tenant-Id': process.env.CONSENTERA_TENANT_ID!,
// pass these through so the SDK's guarantees survive the hop
'Idempotency-Key': req.headers.get('Idempotency-Key') ?? '',
'X-Consentera-SDK': req.headers.get('X-Consentera-SDK') ?? '',
},
body: await req.text(),
});
// expose the request id so SDK errors can carry it
const out = new Response(res.body, { status: res.status });
const rid = res.headers.get('X-Request-Id');
if (rid) out.headers.set('X-Request-Id', rid);
return out;
}2. Point the SDK at your route
import { ConsentEraClient } from '@consentera/consent-sdk';
const ce = new ConsentEraClient({
proxyEndpoint: '/api/consentera', // in a browser this is required
callbackUrl: 'https://your-site.example/consent/done',
});No tenantId behind a proxy: your route supplies the tenant (the
X-Tenant-Id above) and the SDK sends no tenant header through it. With
apiEndpoint (direct, server-side) tenantId is required, and a client
without it is refused with TENANT_ID_REQUIRED.
Putting apiKey here in a browser is refused at construction with
SECRET_KEY_IN_BROWSER. That is deliberate — see The credential model below.
3. Ask for consent
const session = await ce.consent.createSession({
// The identifiers, keyed by YOUR organisation's locked integration key.
// A field outside the key is 400 UNKNOWN_IDENTIFIER_FIELD, and the message
// lists the fields your key allows.
data_principal: { email: '[email protected]' },
notice_internal_name: 'bnb_consent_v2',
age: { date_of_birth: '1998-04-12' }, // the one age signal
});
ce.consent.redirectToConsent(session); // or openConsentPopup(session)createSession adds its own state to your callbackUrl — 32 random bytes,
fresh per session, kept in this browser's session storage — so the return can
be tied to the browser that started it. The platform keeps every parameter
already on callback_url when it builds the return, so the state comes back.
state is therefore reserved: a callbackUrl that already carries one is
refused with CALLBACK_STATE_RESERVED. Pick a different name for your own
parameter. callbackUrl must be absolute (INVALID_CALLBACK_URL otherwise).
openConsentPopup(session) shows the consent page in a dialog on your page
and resolves when the person decides:
const r = await ce.consent.openConsentPopup(session);
if (r.outcome === 'decided') {
// r.status: 'granted' | 'partial' | 'denied'; r.pending: the record is still
// being written (redirect pending=1), so the read-back may answer 202 first
await confirmOnYourServer(r.session_id, r.artifact_id); // the artefact is the record
}
// r.outcome === 'dismissed': the person closed the dialogIt listens for the one message the consent page posts to the page that frames
it, consentera:submitted / consentera:declined, and only from the consent
page's origin and the frame it opened.
The consent page posts that message only when it is framed. A consent
page in its own window or tab (window.open, a redirect) posts nothing. It
sends the person to your callback_url instead, and that is what
redirectToConsent + handleCallback are for. That is why the popup is a
dialog with the page in an iframe, not a browser window. Two further
conditions, both set by the platform:
- the consent page addresses that message to the origin of the session's
callback_url, so your page must be on that origin. The SDK refuses up front withCALLBACK_ORIGIN_MISMATCHrather than wait for a message the browser would drop; - the platform lets only the Allowed Domains of your integration client frame the page.
The message is the consent page's report, not the consent record. Confirm the artefact (step 4) before you act on a grant.
4. Verify the return trip — the artefact, not the URL
// on https://your-site.example/consent/done
// arriving as ?artifact_id=…&pending=1&session_id=…&state=<ours>&status=granted|partial|denied
const result = await ce.consent.handleCallback();
if (result.status === 'completed') {
// the artefact was read from the platform FOR THIS SESSION and names the
// person this browser's session was created for
proceed(result.artifact);
} else {
// 'unverified' | 'denied' | 'pending' | 'error' — result.reason says which and why
askAgain(result.reason);
}handleCallback() is async and returns completed only after confirming
the artefact with the platform. A query string can never produce completed.
What the platform puts on the return URL — and nothing else:
<your callback_url>?artifact_id=<uuid>&pending=1&session_id=<uuid>&status=granted|partial|deniedplus &sig=<hex> only when your integration client holds a callback signing
secret (a hosted consent page's return is never signed). status is one of
exactly three values, granted, partial or denied; the SDK exposes it as
result.claimed_status, and any other value (completed, success, expired,
a missing status) is 'unknown' and is never confirmed — the result is
unverified. pending=1 (result.claimed_pending) means the consent was
recorded and its record is still being written; the platform sets it on
every capture, and it is why the first artefact read may answer 202.
Both are hints, not proof. They are query parameters, and pending is not
signed. Confirm the consent by reading the record back through your backend —
which is what handleCallback() does on the granted/partial road — and read
the artefact's purposes for what was granted: partial means some purposes
were declined.
Statuses: completed (verified; claimed_status says granted or
partial), denied, pending (the consent WAS recorded, the artefact is not
readable yet — see below), error, and unverified (nothing is proven; treat
exactly as "no consent").
The signature
When you register a callback_signing_secret on your m2m client, the platform
signs the callback:
sig = hex( HMAC_SHA256( callback_signing_secret,
session_id + "|" + artifact_id + "|" + status ) )That key is yours and must not be in a browser. So the browser cannot verify
it: point verifyCallbackSignature at your own server, which does.
// browser
const ce = new ConsentEraClient({
tenantId: TENANT,
proxyEndpoint: '/api/consentera',
verifyCallbackSignature: async (p) =>
(await fetch('/api/consentera/verify-callback', { method: 'POST', body: JSON.stringify(p) })).ok,
});
// your server route
import { verifyCallbackSignature } from '@consentera/consent-sdk';
const ok = await verifyCallbackSignature(process.env.CONSENTERA_CALLBACK_SECRET!, params);A callback that carries a sig with no verifier configured is unverified.
A signature nobody checks is not a control, so the SDK will not quietly ignore
one the platform bothered to produce. If you have registered no signing secret,
no sig is sent and the state plus the artefact confirmation are the controls.
The order of checks. The state must come back and must match the one
stored at create. If it is missing or different, the result is unverified and
nothing is read. Only then is status looked at, and only as a hint:
granted/partial → the artefact is read back for this session; denied →
denied; anything else → unverified. The server's challengeNonce is not
part of this: it is the hosted page's own credential, carried on consent_url,
and the return never carries it.
How the artefact is confirmed
The SDK reads GET /consent/artifacts/{artifact_id}?session_id={session_id}
through your proxy. The session_id is what gives the answer its meaning:
| Platform answer | handleCallback() |
|---|---|
| 200 with the artefact | completed — once the artefact's data_principal_id equals the one your session create returned |
| 202 + Retry-After — recorded, still being written | waits Retry-After inside artifactWaitMs (default 15 000), else pending with retryAfterMs |
| 404 ARTIFACT_NOT_FOUND | unverified at once: the id was never issued for this session. A forged artifact_id looks exactly like this |
| anything else | unverified |
Without the session_id the platform answers 404 for a consent whose artefact
is still being written as well as for an id it never issued, which is why the
SDK always sends it.
Why the person is compared. The platform checks session_id only while
the artefact is still being written. Once it exists, the 200 is returned for
any session id (measured on the platform; walk finding F077). The artefact
carries no session id. It does carry data_principal_id, and the session
create returned the same field for the person the session is about, so the
SDK compares the two.
pending is not a failure and not a consent that did not happen: the
decision is recorded. Read it again after retryAfterMs:
const r = await ce.consent.getArtifact(artifactId, { sessionId });
if (r.state === 'pending') setTimeout(retry, r.retryAfterMs); // 202
else use(r.artifact); // 200
// a 404 throws ConsenteraNotFoundError (code ARTIFACT_NOT_FOUND)5. Gate on consent later
if (await ce.consent.isAllowed({ data_principal_identifiers: { email } }, 'product_analytics')) {
loadAnalytics();
}validate() returns the platform's whole answer, including
data_principal_id, the platform's id for the person you asked about. Keep it:
the next call can name the person by { data_principal_id } and send no
identifier at all.
A DENY that is still catching up. Right after the person grants, the
platform can still be applying that grant, and until it has, validate answers
from the state it recorded before, typically a DENY. From platform 936715ad71
the answer says so: applied is false and retry_after gives the seconds to
wait before asking again. That flag is true on every other answer, and on a
platform older than that commit (the field is absent there). A newer refusal
is never pending: it decides at once. Treat an unapplied DENY as "not yet", not
as "no": keep processing off, and ask again. ce.consent.validate(who,
'product_analytics', { retryWhenPending: true }) waits retry_after (1 s if
none was sent, capped at 5 s), asks once more and returns that second answer,
whatever it says. The re-ask is off by default. Bulk results carry both fields
but are not re-asked.
Naming a Data Principal
data_principal_identifiers is an open map keyed by your organisation's own
locked integration key — the same shape data_principal takes on session
create, validated by the same validator. It is not a fixed vocabulary, so this
SDK does not enumerate one and does not allow-list: a tenant keyed on
{customer_id} names people by customer_id, one keyed on {email, mobile}
by those.
One spelling, both roads. The mobile atom is mobile on session create
and on the lifecycle roads. It used to be phone here and mobile there,
folded server-side; F015 removed the fold, so phone is now refused by name
— the refusal even tells you the atom to use.
Only the wire KEY differs between the two roads: create spells the object
data_principal (and may mint a person), the lifecycle roads spell it
data_principal_identifiers (resolve-only, never creates anybody).
The refusals you will meet, all from that one validator:
| code | meaning |
|---|---|
| UNKNOWN_IDENTIFIER_FIELD | a field outside your key, named — and for a vernacular spelling, the atom to use instead |
| IDENTIFIER_REQUIRED | nothing named a person |
| INVALID_IDENTIFIER_FORMAT | a value that cannot be an identifier of its type (a raw 12-digit Aadhaar lives here — send the Aadhaar-linked token) |
| SCHEME_NOT_CONFIGURED | the organisation has not locked how it identifies people yet |
They arrive as err.code on a ConsenteraError with err.kind === 'identity'
('guardian' for the age/guardian family), so you can branch on the class and
still read the platform's exact word.
React
'use client';
import { ConsentEraProvider, useConsentEra, ConsentGate } from '@consentera/consent-sdk/react';
export function App({ children }) {
return (
<ConsentEraProvider config={{ tenantId: TENANT, proxyEndpoint: '/api/consentera' }}>
{children}
</ConsentEraProvider>
);
}
function Marketing({ email }: { email: string }) {
return (
<ConsentGate
who={{ data_principal_identifiers: { email } }}
purposeCode="marketing_email"
fallback={<AskForConsent />}
>
<MarketingContent />
</ConsentGate>
);
}The built bundle carries 'use client', so it works in the Next.js App Router.
<ConsentGate> fails closed — unknown, loading and errored all render the
fallback — and it re-checks when consent changes, so a withdrawal anywhere in
the app closes the gate without a remount.
No hook throws during render. When the provider's configuration is refused
(a secret key in a browser, no endpoint), or there is no provider at all,
useConsentEraClient() returns client: null. useConsentEra() returns
ready: false and null namespaces. error says why, and a
ConsenteraError carries its code. <ConsentGate> renders its fallback. A
configuration mistake therefore closes the gate instead of blanking the page:
const { consent, error } = useConsentEra();
if (!consent) return <p>Consent is unavailable: {error?.message}</p>;The credential model
| credential | where it may live | what it opens |
|---|---|---|
| tiq_live_ / tiq_test_ secret key | your server only | every consent lifecycle road |
| tiq_pub_ site key | a browser bundle | public roads (notice fetch, consent-page config/submit) |
| consent session id + nonce | the consent page | that one session's render/submit |
In a browser, lifecycle roads must go through proxyEndpoint. The SDK
enforces this: apiKey with a window present is refused at construction, and a
df road with no proxy raises SECRET_KEY_IN_BROWSER naming the fix.
If you are certain your code never reaches a browser but a window exists anyway
(a jsdom harness, an SSR shim), unsafeAllowSecretKeyInBrowser: true allows it
and every request warns.
What the platform must guarantee
This is now enforced by route membership plus a fail-closed origin binding,
not by the four legacy permission names — that mechanism (F017, platform PR
#1781) supersedes the earlier finding that a site key authorised zero roads.
For a browser to reach the SDK's public and session roads directly, without a
proxy, the platform guarantees are:
- A site key opens a fixed SET of routes by membership, not by carrying one
of
consent.render / widget.render / session.submit / session.render. Those four permission names are checked by no route (RequireDFPermission("…")grep: 0 hits) and are no longer how access is decided. allowed_domainson the m2m client must be NON-EMPTY. The key is bound to its registered origins and refused fail-closed everywhere else, so a leaked site key works nowhere the DF did not list — but a key with an EMPTYallowed_domainstherefore works nowhere at all. Register your origins, or the browser calls 403 with a correct key.- Render and submit need NO credential.
GET /consent/sessions/{id}/render,POST /consent/sessions/{id}/submitandGET /consent/sessions/{id}/widget-templateauthorise on the session id + its nonce — the SDK'ssessionroad sends no key and noX-Tenant-Id, because the session is the capability. - The DF read roads (
/df/config,/df/purposes,/df/notice/purposes,/df/notice/template) are the SDK'spublicroad: openable by a site key whoseallowed_domainsincludes the calling origin.
What this SDK still enforces on its side: a secret key (tiq_live_ /
tiq_test_) is refused in a browser at construction — that is orthogonal to the
above and does not change. The df lifecycle roads (create session, validate,
withdraw, …) remain server-to-server and go through proxyEndpoint in a browser.
Base URL and path prefixes
apiEndpoint and proxyEndpoint may carry a path prefix
(https://gw.corp.example/consentera); the prefix is kept. Trailing slashes are
trimmed once, when the configuration is read, so https://x/prefix/ and
https://x/prefix send the same request and no request carries //.
Against apiEndpoint the SDK appends /api/v1/public/... (consent roads) and
/api/v1/cookie-consent/... (the cookie banner). That /api/v1 route prefix is
fixed and not configurable. Through proxyEndpoint it appends only the SDK path
(/consent/..., /df/...), so your proxy chooses the upstream path — that is
the way to target a gateway that re-maps /api/v1. The root README, "Base URLs,
gateway prefixes and the fixed route prefix", has the rule for every SDK.
The drop-in notice widget (consentera-notice-widget.js)
Set data-api-base. Without it the widget falls back to deriving the base
from its own src, and that fallback is deprecated. When the src has the
platform's form <API base>/sdk/<file>, everything before /sdk/ is used, so a
gateway prefix is kept. Any other src is ambiguous and only its origin is
used. That is wrong whenever the file is self-hosted or served from a CDN,
because the requests then go to the CDN. The widget logs one console.warn
(set data-api-base; deriving from the script src is deprecated). An inline
copy with neither the attribute nor an http(s) src logs
[consentera-notice] data-api-base is required, emits
consentera:notice-error, and makes no network call.
<div id="consentera-notice"></div>
<script
src="https://cdn.your-site.example/consentera-notice-widget.js"
data-api-base="https://<your Consentera API host>[/<gateway prefix>]"
data-tenant-code="<your tenant code>"
data-slug="<consent page slug>"
></script>A path prefix in data-api-base is kept on every call. The hand-over to the
hosted consent page uses the server's URL exactly as sent when it is absolute;
a relative one is resolved under data-api-base, prefix included.
Reliability
Every request carries a deadline, retries safely, and can be cancelled.
const ce = new ConsentEraClient({
tenantId: TENANT,
proxyEndpoint: '/api/consentera',
timeoutMs: 10_000, // default
retry: { attempts: 3, baseDelayMs: 250, maxDelayMs: 4000 }, // default
});
// one key per logical operation — a double-clicked Save is ONE consent write
await ce.consent.update(id, updates, context, 'btn_save', { idempotencyKey: formSubmissionId });
// cancel from your own code
const ac = new AbortController();
await ce.consent.validate(who, 'analytics', { signal: ac.signal });Retries happen on network failure, 5xx and 429, with exponential backoff and full
jitter, honouring Retry-After. The idempotency key is minted once per logical
operation and reused across every retry of it, so a retry can never write twice.
Errors
import { ConsenteraError } from '@consentera/consent-sdk';
try {
await ce.consent.createSession({ ... });
} catch (err) {
if (err instanceof ConsenteraError) {
err.kind; // 'identity' | 'guardian' | 'rate_limit' | 'auth' | … (closed set)
err.code; // 'UNKNOWN_IDENTIFIER_FIELD' — the platform's canonical code
err.status; // 400
err.requestId; // quote this in a support ticket
err.retryAfterMs;
}
}Switch on kind (closed, exhaustive); read code for the platform's exact word.
When the organisation's plan cannot take a new consent
consent.update() (and grant() / deny(), which call it) throws
ConsenteraPlanLimitError — kind: 'plan_limit', code: 'PLAN_LIMIT_REACHED',
status: 409, reasonCode: 'new_consents_only' — when the change would add a
new (data principal, purpose) grant past the plan's consents_max.
Nothing was recorded, no consent.changed event fires, and the SDK does not
retry it (the same request gets the same answer until the plan changes).
import { ConsenteraPlanLimitError } from '@consentera/consent-sdk';
try {
await ce.consent.grant(principalId, [purposeId], noticeContext);
} catch (err) {
if (err instanceof ConsenteraPlanLimitError) {
// NOT the person's refusal — never store or show it as "denied".
// Leave their existing choices as they were and tell them, neutrally,
// that this organisation can't accept new consents right now
// (err.message is the platform's person-facing sentence).
showNotice(err.message);
return;
}
throw err;
}A denial, a withdrawal, a renewal and a re-grant of a purpose the person already
holds are never refused, and creating a session is never refused. On the hosted
page (collect popup / redirect) the platform shows the person the message
itself and fires no callback, so the popup resolves dismissed when they close
it — never denied.
Privacy defaults
collectContext: 'minimal'by default: platform, device type, browser family, OS family. No UA string, no screen size, no timezone, no page URL, no referrer.'full'adds them, with the page URL's query and fragment removed;'none'sends no context at all.beforeSendgets the last look at every request body. Return it to send, returnnullto refuse (which raises — it never silently sends nothing).- Debug logging never prints a request or response body. The body of a session create is the Data Principal's identifiers.
How the SDK identifies itself
Every request carries the pair agreed across all six Consentera SDKs:
User-Agent: ConsenteraSDK/2.1.0 (<platform>; <runtime>)
X-Consentera-SDK: js/2.1.0ConsenteraSDK/<version> is the form the platform's audit pipeline already
parses. In a browser only the second is sent: User-Agent is a forbidden
fetch header, so the browser drops any attempt to set it — the Node build and
the CLI send both.
new ConsentEraClient({
tenantId: TENANT,
proxyEndpoint: '/api/consentera',
collectContext: 'none',
beforeSend: ({ body }) => redactForYourPolicy(body),
});Content Security Policy
The SDK makes no eval and inserts no <script>. It does inject a <style>
element for the banner and preference centre, so a strict style-src needs
either 'unsafe-inline' or the SDK's styles disabled (bring your own UI).
The CDN build is at dist/consentera-consent.min.js (unpkg/jsdelivr point
there). Pin the version and use the published SRI hash.
Migrating from 1.x
See CHANGELOG.md. In short: handleCallback() is async and
fails closed, a failed consent sync now throws instead of reporting success,
apiKey is refused in a browser, identifiers replaced data_principal_ref,
telemetry is off by default, and the cookie banner (ConsenteraConsent, the
default export) requires apiEndpoint — there is no default server, and a
missing one is refused at construction with ENDPOINT_REQUIRED (script tag:
data-api-endpoint).
Licence
MIT — see LICENSE.
