πŸ“±

React Native / Expo

Pre-release

There is no compiled React Native/Expo package yet - this page documents a single, complete TypeScript file you copy into your own project. It calls the exact same public REST endpoints the Web SDK and the native Android SDK use, so a consent captured this way lands in the same ConsentRecord table, shows up in your dashboard/exports/re-consent campaigns the same way, and requires nothing reconfigured on the backend.

0
native deps to add
Expo Go
works unmodified, no eject
~1
file to copy

Why a file to copy, not an npm package

This is deliberate for now, not a placeholder for "we haven't gotten to publishing yet." The whole integration is under 150 lines with zero DPDPA-specific native code - publishing it as a versioned package would add an update/dependency-management surface for something this small. If your team would rather npm install a real package instead of copy-pasting, tell us - it's a reasonable ask and we'll prioritise it against demand.


Why this instead of the native Android SDK

The native Android SDK is a Kotlin/Jetpack Compose library - embedding it in an Expo managed-workflow app would mean writing an Expo Module or config plugin, which most React Native teams correctly want to avoid unless they have a specific reason to eject. Since the backend already exposes the same functionality as plain, API-key-authenticated REST endpoints (the exact ones the web SDK calls from a browser), there is no native code required at all - a fetch() call does the job.

This is the recommended path for React Native, not a fallback

If a mobile developer's only reason to reach for the native Android SDK was "we're on React Native and need mobile consent" - stop, and use this page instead. The native SDK exists for teams already writing Kotlin, not as the default mobile answer.


Quick Start

Install the two dependencies
npx expo install @react-native-async-storage/async-storage expo-crypto
# Bare React Native without Expo modules? Any RN-compatible SHA-256 lib
# (e.g. crypto-js) substitutes for expo-crypto - see the comment at the
# top of the file below.
Somewhere in your consent screen
import { fetchNotice, recordConsent, getLastDecision, flushQueue } from './shieldConsent'

useEffect(() => {
  (async () => {
    flushQueue() // retry anything queued from a previous session, offline-first
    const existing = await getLastDecision()
    if (existing) return // already answered on this device - skip the screen

    const notice = await fetchNotice()
    setNotice(notice) // render your own UI from notice.purposes[]
  })()
}, [])

async function onAccept(purposes: Record<string, boolean>) {
  await recordConsent(userIdentifier, purposes, notice.id)
}

There is no bundled Compose-equivalent UI here - notice.purposes[] gives you name/description/required/dataCategories per purpose, and notice.translations/availableLanguages for all 22 languages if you want to localise. Build your own screen from that, the same way any web integrator not using the pre-built widget would.


The full file

Everything above is a consumer of this one file. Copy it as-is into your project (e.g. src/lib/shieldConsent.ts) and replace API_KEY with a real key from Settings β†’ API Keys.

shieldConsent.ts
// shieldConsent.ts
// Copy this file into your project (e.g. src/lib/shieldConsent.ts) and fill
// in API_KEY below. No native module, no config plugin, no eject - this is
// plain TypeScript over fetch(), so it works identically in Expo Go, an EAS
// dev client, and a bare React Native app.
//
// Two dependencies, both near-universal in RN/Expo projects already:
//   npx expo install @react-native-async-storage/async-storage expo-crypto
// (Not using Expo? Swap Crypto.digestStringAsync() below for any SHA-256
// function - e.g. crypto-js's SHA256(str).toString() - the rest is unchanged.)

import AsyncStorage from '@react-native-async-storage/async-storage'
import * as Crypto from 'expo-crypto'

const API_BASE = 'https://api.dpdpashield.in/api/v1'
const API_KEY = 'dpdpa_live_YOUR_KEY_HERE' // Settings -> API Keys in the dashboard

const QUEUE_KEY = 'shield_consent_queue_v1'
const LAST_DECISION_KEY = 'shield_last_decision_v1'

type QueuedDecision = {
  identifierHash: string
  purposes: Record<string, boolean>
  noticeId: string
  language: string
  queuedAt: number
  // Your own internal ID for this user (e.g. your users table primary key) -
  // sent as-is, never hashed, unlike identifierHash. Optional - omit it and
  // nothing changes. See "Choosing an identifier" below.
  externalId?: string
}

/**
 * Same normalisation the backend applies before hashing
 * (apps/api/src/lib/hash.ts): trim, lowercase, SHA-256, lowercase hex.
 * JS's String.prototype.toLowerCase() is NOT locale-sensitive per the
 * ECMAScript spec (unlike Java/Kotlin's default toLowerCase()), so there is
 * no Turkish-'I'-style caveat to worry about here.
 */
export async function hashIdentifier(raw: string): Promise<string> {
  const normalised = raw.trim().toLowerCase()
  return Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, normalised, {
    encoding: Crypto.CryptoEncoding.HEX,
  })
}

/** Fetches the tenant's published notice - purposes, 22-language translations,
 *  Rule 3 disclosures (contactEmail, complaintText, withdrawalUrl), etc. */
export async function fetchNotice(noticeId?: string) {
  const url = new URL(`${API_BASE}/consent/public-notice`)
  url.searchParams.set('apiKey', API_KEY)
  if (noticeId) url.searchParams.set('noticeId', noticeId)

  const res = await fetch(url.toString())
  if (!res.ok) throw new Error(`fetchNotice failed: ${res.status}`)
  return (await res.json()).data
}

async function sendConsentRecord(decision: QueuedDecision): Promise<boolean> {
  try {
    const res = await fetch(`${API_BASE}/consent/sdk-record`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        apiKey: API_KEY,
        identifierHash: decision.identifierHash,
        purposes: decision.purposes,
        noticeId: decision.noticeId,
        language: decision.language,
        ...(decision.externalId ? { externalId: decision.externalId } : {}),
      }),
    })
    return res.ok
  } catch {
    return false // no network right now - caller falls back to the offline queue
  }
}

async function enqueue(decision: QueuedDecision) {
  const raw = await AsyncStorage.getItem(QUEUE_KEY)
  const queue: QueuedDecision[] = raw ? JSON.parse(raw) : []
  queue.push(decision)
  await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue))
}

/**
 * Records the user's decision. The identifier is hashed on-device and the
 * raw value never leaves this function - only identifierHash is sent or
 * stored anywhere. Local-first: the decision is written to AsyncStorage
 * before the network call, so a purpose-gate check reading
 * getLastDecision() sees it immediately regardless of connectivity.
 *
 * externalId is YOUR own internal ID for this user (e.g. your users table
 * primary key) - never derived here, sent as-is since it isn't PII we need
 * to protect, unlike identifier. Optional and independent of identifier -
 * pass it to correlate this record back to your own database later via
 * GET /consent/by-external-id/:externalId, without re-hashing lookups.
 */
export async function recordConsent(
  identifier: string,
  purposes: Record<string, boolean>,
  noticeId: string,
  language: string = 'EN',
  externalId?: string,
): Promise<{ sent: boolean; queued: boolean }> {
  const decision: QueuedDecision = {
    identifierHash: await hashIdentifier(identifier),
    purposes,
    noticeId,
    language,
    queuedAt: Date.now(),
    ...(externalId ? { externalId } : {}),
  }

  await AsyncStorage.setItem(LAST_DECISION_KEY, JSON.stringify(decision))

  const sent = await sendConsentRecord(decision)
  if (!sent) await enqueue(decision)
  return { sent, queued: !sent }
}

/** Call this on app foreground and/or on a connectivity-regained event
 *  (e.g. NetInfo.addEventListener from @react-native-community/netinfo). */
export async function flushQueue(): Promise<{ sent: number; remaining: number }> {
  const raw = await AsyncStorage.getItem(QUEUE_KEY)
  const queue: QueuedDecision[] = raw ? JSON.parse(raw) : []
  const remaining: QueuedDecision[] = []
  let sent = 0

  for (const decision of queue) {
    if (await sendConsentRecord(decision)) sent++
    else remaining.push(decision)
  }

  await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(remaining))
  return { sent, remaining: remaining.length }
}

/** Reads the last decision recorded on this device - use this to decide
 *  whether to show the consent screen at all on a cold start. */
export async function getLastDecision(): Promise<QueuedDecision | null> {
  const raw = await AsyncStorage.getItem(LAST_DECISION_KEY)
  return raw ? JSON.parse(raw) : null
}

/** email MUST be a real email address - the backend validates it as one
 *  and rejects anything else. See "Withdrawal" below if your users aren't
 *  identified by email. */
export async function withdrawConsent(email: string, purposeIds: string[] = [], reason?: string) {
  const res = await fetch(`${API_BASE}/consent/withdraw-public`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ apiKey: API_KEY, email, purposeIds, withdrawalReason: reason }),
  })
  if (!res.ok) {
    const body = await res.json().catch(() => null)
    throw new Error(body?.error?.message ?? `withdrawConsent failed: ${res.status}`)
  }
  return (await res.json()).data
}

Choosing an identifier

The first argument to recordConsent(identifier, ...) is whatever uniquely identifies this person in your system, in the clear - it is hashed on-device with the exact SHA-256 normalisation the backend uses (trim + lowercase) before it ever touches the network, and the raw value is never sent or persisted.

Google Sign-In / Firebase Auth

The signed-in email is exactly the right identifier - no extra work, just pass it straight in.

Phone-number auth

The verified phone number (E.164 format, e.g. +919812345678) works the same way.

Your own account system

A user ID or account email both work - use whichever one is stable across the user's sessions on this device.

Anonymous / pre-login

Generate a random UUID once, store it locally, and use that until the user signs in - do not use a device or install ID that could be used to fingerprint the device beyond this purpose.

One data principal switching identifiers mid-lifecycle (e.g. anonymous UUID β†’ real email after login) is recorded as two separate consent histories on the backend - there is no automatic merge. If that matters to you, call recordConsent() again with the real identifier the moment it becomes available, so the authoritative record is under the identifier you'll actually use for withdrawal/rights requests later.


Offline-first, by construction

recordConsent() writes the decision to AsyncStorage before attempting the network call - so getLastDecision() reflects what the user chose the instant the function returns, regardless of connectivity. If the network call fails (no signal, backend briefly down), the decision is queued in AsyncStorage rather than lost, and flushQueue() retries it on your schedule - call it on app foreground and/or a connectivity-regained callback (e.g. from @react-native-community/netinfo).

This is simpler than the Android SDK's queue, deliberately

The native SDK's OfflineConsentQueue has exponential backoff and a max-attempts drop rule because it runs unattended for the app's whole lifetime. This file retries everything, every call, with no backoff - fine for a queue that's realistically a handful of items deep. Add backoff yourself if your app queues heavily (e.g. bulk-importing existing users), but most integrations won't need to.


Withdrawal

withdrawConsent(email, purposeIds?) calls the same public withdrawal endpoint the web SDK's widget uses.

This endpoint requires a real email address - not a hash, not any identifier

The backend validates email as an actual email format and rejects anything else with a 400. If your app identifies users by email (Google Sign-In, most auth providers) this just works. If your identifier is a phone number, a UUID, or anything else that isn't an email, self-serve withdrawal via this endpoint isn't available yet - point those users at your tenant's Data Rights Portal instead (https://dpdpashield.in/rights/[your-tenant-slug]), which handles every identifier type and every rights request category, not just withdrawal.

Omit purposeIds (or pass an empty array, the default) to withdraw every purpose; pass specific purpose UUIDs for a partial withdrawal.


Origin enforcement - and why there's no fingerprint here

The native Android SDK identifies itself with an app-signing-certificate fingerprint, computed from PackageManager - something no equivalent exists for in JavaScript, and something several teams deliberately don't want an app collecting at all. This integration doesn't send one, and doesn't need to.

Same convention as an unregistered web domain

A request with no Origin/Referer header and no X-App-Identity header is treated the same way a browser request with an unrecognised origin is: allowed through, as long as the API key you're using has zero entries in its domain allowlist (Settings β†’ API Keys β†’ Domains). Use a dedicated API key for this mobile integration and leave its allowlist empty - there is nothing to register, lock down, or fingerprint. If that key's allowlist is ever populated for an unrelated reason (e.g. someone adds a website domain to the same key), requests without an Origin header will start failing with ORIGIN_MISSING - keep the mobile integration on its own key to avoid that.


Backend endpoints used

The same three public, API-key-authenticated endpoints the Web SDK calls from a browser - no mobile-specific route exists or is needed.

GET/api/v1/consent/public-notice
ParameterTypeRequiredDescription
apiKeystringRequiredQuery parameter, not a header.
noticeIdstringOptionalUUID. Omit to get the most recently published notice.

Called by fetchNotice(). Returns purposes, all 22-language translations, and the Rule 3 disclosure fields (contactEmail,complaintText,withdrawalUrl).

POST/api/v1/consent/sdk-record
ParameterTypeRequiredDescription
apiKeystringRequiredBody field.
identifierHashstringRequired64-char lowercase hex SHA-256 - what recordConsent() sends, never a raw identifier.
purposesobjectRequiredMap of purpose UUID -> boolean.
noticeIdstringRequiredFrom the notice returned by public-notice.
languagestringOptionalISO language code shown to the user, e.g. "EN", "HI".
externalIdstringOptionalYour own internal ID for this user (e.g. your users table primary key) - sent as-is, never hashed. Independent of identifierHash - correlates this record back to your own database via GET /consent/by-external-id/:externalId.

Called by recordConsent(), retried by flushQueue() if it fails offline. Records the ConsentAuditLog channel as WEB_FORM - the MOBILE_APP channel is reserved for requests carrying a verified X-App-Identity header, which this integration deliberately does not send (see Origin enforcement above).

POST/api/v1/consent/withdraw-public
ParameterTypeRequiredDescription
apiKeystringRequiredBody field.
emailstringRequiredMust be a valid email address - see Withdrawal above.
purposeIdsstring[]OptionalOmit or pass [] to withdraw every purpose.
withdrawalReasonstringOptionalMax 500 characters.

Rate-limited to 5 requests/hour/IP on the backend. Called by withdrawConsent().


Common issues

Need help integrating, or want a real npm package instead?

This integration is pre-release - we'll walk through it directly, or scope a published package if that's what your team needs.

Email hello@dpdpashield.in β†’