consentera_consent

DPDP (India, Digital Personal Data Protection Act 2023) consent for Flutter.

The app never holds a secret key. Your own backend holds tiq_live_* and proxies to Consentera; this SDK talks to your backend, opens Consentera's hosted notice in an in-app browser, and reads the decision back.

  • Version 2.0.0 · Dart ≥3.5, Flutter ≥3.24

Install

dependencies:
  consentera_consent: ^2.0.0

Register your callback scheme in AndroidManifest.xml (<data android:scheme="yourapp" android:host="consent" />) and in Info.plist (CFBundleURLTypes). It must ALSO be registered server-side in ALLOWED_CALLBACK_APP_SCHEMES, or session create is refused 400.

Quickstart

import 'package:consentera_consent/consentera_consent.dart';

final session = ConsenteraSession(const SessionConfig(
  // YOUR backend's route to Consentera. Required — there is no default server,
  // and a blank or scheme-less value throws ArgumentError('backendBaseUrl').
  backendBaseUrl: 'https://api.yourbank.in/consentera',
  callbackScheme: 'yourapp',
));

// The ONE shape the lifecycle roads accept: an OPEN map keyed by YOUR
// organisation's locked integration key (F015), the same shape and the same
// vocabulary `dataPrincipal` uses on create. A bare string
// (`data_principal_ref`) is refused outright by the API.
const who = PrincipalRef.identifiedBy({'email': 'riya@example.in'});

// 1 — create the session through YOUR backend
final s = await session.createSession(
  noticeInternalName: 'your_notice_code',
  // Keyed by YOUR organisation's locked integration key — the SAME vocabulary
  // the lifecycle roads take. The mobile atom is `mobile` on both.
  dataPrincipal: {'email': 'riya@example.in'},
  dateOfBirth: '1998-04-12', // omit and the person's age is UNKNOWN
);
if (s.warnings.isNotEmpty) debugPrint(s.warnings.join('; ')); // arrive with a 200
if (s.guardianVerification != null) {
  // §9(1): a child, invitation sent to the guardian, NOT yet consented.
}

// 2 — present the hosted notice in the in-app browser
await session.presentConsentSession(s);

// 3 — on resume, check any deep link and then VALIDATE
//     (validate is the only source of consent truth, and it is also what covers
//      the person who simply closed the tab: no callback ever arrives)
final cb = session.parseCallback(incomingUri, expectedState: s.callbackState);
//   The platform returns
//     yourapp://consent/callback?artifact_id=…&pending=1&session_id=…&state=…&status=granted|partial|denied
//   cb.callbackStatus is granted / partial / denied, else unknown (never a
//   grant); cb.pending means the record is still being written, so your
//   backend's read-back may answer 202 + Retry-After. Both are HINTS.
final d = await session.validate(who, 'product_analytics');
if (d.allowed) { /* process */ }

// 4 — withdrawal must be as easy as giving (DPDP §6(4))
await session.withdraw(who, ['product_analytics']);

// 5 — the full rights portal (access, erasure, nomination, grievance), one tap.
//     The same identifier map as everywhere else; the key is the kind.
await session.openPortal({'email': 'riya@example.in'});

// Release the HTTP client the session owns when you are done with it.
session.close();

Re-validate on AppLifecycleState.resumed: Chrome blocks gesture-less custom-scheme redirects, so a person who closes the tab produces no callback at all.

One identifier vocabulary (F015)

dataPrincipal on create and PrincipalRef.identifiedBy on the lifecycle roads are the same open map, keyed by the fields your organisation locked. This SDK does not allow-list them: the admissible set is a per-tenant fact the platform reads at request time, so send what your key defines and let the server answer.

the portal (openPortal) the same map, and the key is the kind: {'mobile': '+91…'} is sent as a mobile reference, never as an email. The portal takes ONE identifier: pass one, or set identifierScheme so the SDK can choose. phone is refused before the wire.
the mobile atom mobile on both roads. phone is refused by name — UNKNOWN_IDENTIFIER_FIELD, whose message says "…use mobile".
pan not a scheme field at all. It is evidence-class and can never be one.
aadhaar the Aadhaar-linked token, never the number. A raw 12-digit value is refused — but the code differs by road: INVALID_IDENTIFIER_FORMAT on the lifecycle roads (F015 folded the old token into it there), and still AADHAAR_RAW_REFUSED on session create. Switch on both, or on the 400 alone.

The four refusals you can switch on: UNKNOWN_IDENTIFIER_FIELD (names the field you sent and the ones that are allowed), IDENTIFIER_REQUIRED (you named nobody), INVALID_IDENTIFIER_FORMAT (right field, wrong value — this is also where a raw Aadhaar number lands on these roads), and SCHEME_NOT_CONFIGURED (this tenant has no locked key yet — an onboarding problem, not a request problem).

Only the wire KEY differs between the two roads: data_principal on create may mint a person, data_principal_identifiers on the lifecycle roads resolves only.

final gate = ConsentGate(session, who);
gate.register(Tracker(
  purposeCode: 'product_analytics',
  name: 'Analytics SDK',
  init: analytics.start,
  revoke: analytics.stopAndClear,
));
await gate.refresh(); // on start and on every resume

init runs at most once per allowed-transition; revoke runs when a purpose stops validating. Validation failures are fail-closed.

What this SDK will not do

It refuses rather than degrading:

Missing It does It will not
in-app browser throws ConsenteraBrowserUnavailable fall back to LaunchMode.externalApplication — the external browser leaves the callback to any app that claims the scheme
a Custom Tabs browser, on Android throws ConsenteraBrowserUnavailable (code: 'NO_BROWSER') let url_launcher fall back to its own WebView, which has no address bar, so the person cannot see whose page is asking
callback whose scheme, host, path or state does not match throws ConsenteraCallbackRejected accept it
a 2xx that is not JSON throws ConsenteraException let a raw FormatException escape

It has no advertising function: no advertising identifier, no IABTCF keys. That is a separate concern and would be a separate package.

Errors, retries, TLS

ConsenteraException carries statusCode, code (the platform's canonical code), platformMessage and requestId.

platformMessage is the platform's own sentence for a refusal, verbatim. Show it to the person, because it says why. It is kept out of message and toString(), as is the rest of the response body, because a body can carry the person's own identifiers and an exception message ends up wherever you log.

try {
  await session.validate(who, 'product_analytics');
} on ConsenteraException catch (e) {
  show(e.platformMessage ?? 'Something went wrong');
  report(e.code, e.statusCode, e.requestId); // for support
}

Android: declare the Custom Tabs query

On Android the SDK presents the hosted pages only in a Custom Tab. It checks for one first, and refuses with NO_BROWSER rather than let url_launcher fall back to its address-bar-less WebView. On Android 11+ that check can see a Custom Tabs browser only if your app's manifest asks to:

<!-- android/app/src/main/AndroidManifest.xml, a child of <manifest> -->
<queries>
  <intent>
    <action android:name="android.support.customtabs.action.CustomTabsService" />
  </intent>
</queries>

Without it, every Android 11+ device refuses. This package is pure Dart, so it cannot merge that entry into your manifest for you.

Mutations carry an Idempotency-Key minted once per operation and reused across retries; 429 and 5xx are retried with full jitter, honouring Retry-After; a 4xx is never retried.

Certificate pinning is the constructor's client: parameter — pass an IOClient over an HttpClient with your own badCertificateCallback, or any http.Client. A client you pass in is yours: close() will not close it.

Option Default
requestTimeout 30s per ATTEMPT
totalTimeout 2× the above ceiling on the whole call including retries
maxAttempts 3 including the first; 1 disables retrying
retryBaseDelay 250ms full-jitter base
onDiagnostic — opt-in sink; the SDK is otherwise SILENT
callbackHost / callbackPath consent / /callback both are CHECKED on the callback

A complete example app ships in example/ (BNB Pay).

Support

Security issues: see SECURITY.md at the repository root. Full integration guide: docs.consentera.in/docs/developer/df-integration/apps/mobile-04-flutter

Libraries

Consentera Consent SDK for Flutter — DPDP Act 2023.