Integrating Custom Consent Platform

This guide outlines how to implement Transcend Consent & Preference Management on platforms where Transcend does not provide a native SDK (e.g., Smart TVs, consoles, Unity, desktop apps, in‑vehicle infotainment, set‑top boxes, kiosks). You’ll build a lightweight native consent layer on top of Transcend’s APIs and CDN‑hosted configuration so that your app can:

  1. Render a compliant UI (webview, native, or off‑device via QR/link).
  2. Read & write consent to a central store.
  3. Regulate local SDKs/libraries based on consent.

✅ If you do target Web, iOS, or Android, use our standard SDKs and guides instead—they’re faster to adopt and include prebuilt UIs.

Transcend provides first‑class support for:

  • Web (airgap.js) – Our network‑layer CMP for websites.
  • iOS SDK – Native consent APIs and UI helpers for iOS.
  • Android SDK – Native consent APIs and UI helpers for Android.

If your platform isn’t one of the above, follow the rest of this guide to build a custom integration.

You have three patterns to collect consent. Pick one—or combine them:

Pros: Fastest path, benefits from Transcend UI updates, translations, and A/B improvements.

Cons: Requires an embedded browser control; styling is web‑based.

How it works (brief overview)

  • Load a lightweight web page that hosts your consent UI (Transcend’s reference UI or your own).
  • Communicate consent decisions to your native app via a bridge (postMessage/JS‑to‑native callbacks).
  • Persist consent to Transcend Preference Store (see §3).

How it works (in depth)

Host a tiny, trusted HTML page (e.g., consent.html) that the native app opens in a WebView. That page loads only your bundle’s airgap.js. Use a light message bridge to exchange identity, locale, and (optionally) a consent token for authenticated sync.

Example skeleton:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Consent</title>
    <script>
      // Prepare queues so the auto-loaded UI can drain them when ready
      window.transcend = window.transcend || { readyQueue: [] };
      window.airgap = window.airgap || {
        readyQueue: [],
        ready(cb) {
          this.readyQueue.push(cb);
        },
      };

      // Cross-platform host bridge
      // replace based on your platform
      window.__sendToHost = function (type, payload) {
        if (
          window.ReactNativeWebView &&
          typeof window.ReactNativeWebView.postMessage === 'function'
        ) {
          window.ReactNativeWebView.postMessage(
            JSON.stringify({ type, payload }),
          );
        } else if (
          window.webkit &&
          window.webkit.messageHandlers &&
          window.webkit.messageHandlers.transcend
        ) {
          window.webkit.messageHandlers.transcend.postMessage({
            type,
            payload,
          });
        } else if (
          window.chrome &&
          window.chrome.webview &&
          typeof window.chrome.webview.postMessage === 'function'
        ) {
          window.chrome.webview.postMessage({ type, payload });
        } else if (window.parent && window !== window.parent) {
          window.parent.postMessage({ type, payload }, '*');
        }
      };

      // Receive bootstrap/config messages from native host
      window.addEventListener('message', (evt) => {
        const { type, payload } = evt.data || {};
        if (type === 'transcend:bootstrap') {
          // 1) Authenticate SDK with a consent token (if provided)
          if (payload.consentToken) {
            if (window.airgap && typeof window.airgap.ready === 'function') {
              window.airgap.ready(function (ag) {
                if (ag && typeof ag.sync === 'function')
                  ag.sync({ auth: payload.consentToken });
              });
            }
          }

          // 2) Apply locale & identity once UI API is ready
          if (
            window.transcend &&
            typeof window.transcend.ready === 'function'
          ) {
            window.transcend.ready(function (api) {
              if (api && typeof api.setLocale === 'function') {
                api.setLocale(payload.locale);
              }
              if (
                payload.forceOpen === true &&
                api &&
                typeof api.showConsentManager === 'function'
              ) {
                api.showConsentManager({ viewState: 'AcceptOrRejectAll' });
              }
            });
          }
        }
      });
    </script>

    <!-- Load ONLY airgap.js for your bundle; it will load the UI automatically -->
    <script
      src="https://transcend-cdn.com/cm/6efb0409-a0f6-4d3c-90dd-0a03f909dd68/airgap.js"
      defer
    ></script>
  </head>
  <body>
    <div id="root"></div>

    <script>
      // Wire consent events -> host app once UI is ready
      (function waitForAPI() {
        function onReady(api) {
          if (api && typeof api.on === 'function') {
            api.on('consent:changed', function (consent) {
              __sendToHost('consentChanged', consent);
            });
            api.on('consent:saved', function (consent) {
              __sendToHost('consentSaved', consent);
            });
            api.on('ui:closed', function () {
              __sendToHost('uiClosed');
            });
          }
        }
        if (window.transcend && typeof window.transcend.ready === 'function') {
          window.transcend.ready(onReady);
        } else {
          setTimeout(waitForAPI, 50);
        }
      })();
    </script>
  </body>
</html>

Native host (platform‑agnostic pseudocode)

function openConsentWebView() {
  const wv = WebView(url = localAsset('consent.html'))

  // 1) Fetch a consent token for the signed-in user from your backend
  //    (see Preference Management docs for creating the JWT token)
  const consentToken = loggedIn ? httpGET('/api/consent-token').token : null

  // 2) Provide identity/locale/prefill (+ token) to the page
  wv.onLoad(() => {
    const payload = {
      subjectId: loggedIn ? user.id : null,
      deviceId: getDeviceId(),
      locale: currentLocale(),
      consentToken,                // <-- passed down to WebView
      forceOpen: true              // optional: programmatically open now
    }
    wv.postMessage({ type: 'transcend:bootstrap', payload })
  })

  // 3) Receive consent events from WebView
  wv.onMessage((msg) => {
    switch (msg.type) {
      case 'consentChanged':
        cacheLocally(msg.payload)
        break
      case 'consentSaved':
        // Option A: WebView already called /sync via airgap (authed by token)
        // Option B: Also forward to backend for auditing/side-effects
        httpPOST('/api/consent-events', { consent: msg.payload, token: consentToken })
        wv.close()
        break
      case 'uiClosed':
        wv.close()
        break
    }
  })

  wv.present()
}

Passing the consent token end‑to‑end

  1. Backend → Native: On login, your backend generates a consent token (JWT) and returns it to the app.
  2. Native → WebView: Inject consentToken via the bootstrap message.
  3. WebView → Airgap: Call airgap.sync({ auth: consentToken }) to authenticate consent writes/reads.
  4. WebView/Native → Backend (optional): Post consentSaved + consentToken to your backend for auditing, triggers, or redundancy.

See Preference Management → Storing Consent Preferences for sample token generation in TypeScript & Python, key management (encryption/signing), and airgap.sync({ auth }) usage.

Pros: Full design control; can match your OS look‑and‑feel; works where WebView is unavailable.

Cons: You own rendering & state; must fetch texts/config from Transcend CDN.

How it works

  • Fetch translation strings and policy/purpose maps from your bundle’s CDN folder.
  • Render native screens (welcome/banner, granular toggles, legal text).
  • Store choices locally & sync to a central store (see §3).
  • Re‑evaluate local SDKs on changes (see §4).

Powering your Native UI from Transcend’s CDN

Your Transcend bundle publishes assets to a CDN path like:

https://transcend-cdn.com/cm/<your-bundle-id>/

Key files:

These files are GET‑only resources generated by your dashboard configuration. Treat their schema as versioned by Transcend; avoid hard‑coding assumptions and prefer tolerant parsing.

Example: Translation Loading (pseudocode)

The following pseudocode can be used to fetch translation files - this allows for messaging and translations to be specified by legal team from Transcend's Admin Dashboard.

function loadTranslations(locale):
  localesToTry = [locale, locale.split('-')[0], 'en']
  for loc in localesToTry:
    url = CDN + '/translations/' + loc + '.json'
    json = httpGet(url, timeout=3s)
    if json.ok:
      return json.body
  return {}

function t(key, vars={}):
  s = translations[key] or key
  return interpolate(s, vars)

Using the messages above loaded from Transcend's CDM, you would render your consent interface using native components. See §3 to learn more about fetching the default consent values.

Pros: Minimal client code; perfect for remotes/gamepads/IVI or restricted UI.

Cons: User completes on a second device; you need a way to return or poll.

Two patterns

  1. Direct Privacy Center link
  • Show a URL/QR to your Privacy Center. Users manage consent on their phone and the device polls your backend for completion.
  • Best UX: use a Preference Access Link (a scoped, time‑limited token) so users land directly on their pre‑populated page with no login. See §3 → Option 3 for token generation and link format.

Generate an access token (REST/GraphQL):

# EU infra shown; use https://api.us.transcend.io for US
BACKEND="https://api.transcend.io"
API_KEY="YOUR_API_KEY"

curl -sS -X POST "$BACKEND/graphql" \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $API_KEY" \
  -d '{
    "query": "mutation CreatePrivacyCenterAccessTokens($input: CreatePrivacyCenterAccessTokensInput!) { createPrivacyCenterAccessTokens(input: $input) { nodes { token } } }",
    "variables": {
      "input": {
        "records": [
          {"email":"user@example.com","expiresIn":3600,"scopes":["preferenceManagement"],"subjectType":"customer"}
        ]
      }
    }
  }'

Constructing the link

https://privacy.example.com/login?tokenType=access-link#<ACCESS_TOKEN>

  • The token goes in the URL fragment (#...) so browsers don’t send it to the server.
  • tokenType=access-link tells the Privacy Center to use restricted-scope auth.

(If you don’t use access links yet, link to your generic Privacy Center URL and have the user log in.)

  1. Device‑code flow (OAuth‑style)
  • Device shows a short code + QR verify_uri. User opens on their phone, logs in, completes consent; device polls poll_uri until done.

Backend: start device session (pseudo)

POST /consent/device/start
body: { deviceId }
---
code = randomBase32(8); expiresAt = now()+10m
store({ code, deviceId, status:'PENDING', expiresAt })
return { code,
         verify_uri: "https://privacy.example.com/device?code="+code,
         poll_uri:   "/consent/device/poll?code="+code }

Verify page (phone)

  • Require login or use a Preference Access Link minted server‑side for the identified user.
  • Page loads airgap.js for your bundle.
  • Bootstrap with airgap.sync({ auth: consentToken }) (see §2A).
  • On save: POST /consent/device/complete { code, consent }.

Complete + poll

POST /consent/device/complete
body: { code, consent }
→ persist (via /sync or Preference Store)
→ mark session COMPLETE

GET /consent/device/poll?code=...
→ { status: 'COMPLETE', consent } | { status: 'PENDING' } | { status: 'EXPIRED' }

Device side (pseudo)

const { code, verify_uri, poll_uri } = httpPOST('/consent/device/start', { deviceId })
showQr(verify_uri); showCode(code)
repeat every 3s up to 10m:
  r = httpGET(poll_uri)
  if r.status == 'COMPLETE':
    cache(r.consent); applyGovernance(r.consent); break
  if r.status == 'EXPIRED':
    showMessage('Code expired'); break

You have two common patterns for storing/retrieving consent. Both support anonymous and authenticated users.

When to use: Kiosks, TVs, consoles, head units, or any environment where calling a simple HTTPS API is easiest and you want to avoid backend work. Transcend's web, iOS and Android SDKs use this API - so if you are syncing consent across multiple platforms, client side sync can often reduce code ownership.

APIs

Endpoint: Get consent preferences for a user

GET

/sync
Open in API Reference
  • Auth: Send the consent token JWT in the Authorization header.
  • Query: ?partition=<partition-or-bundle-id>
  • Responses: 200 with consent JSON; 204 if no confirmed consent yet; 400/500 on error.

Endpoint: Set consent preferences for a user

POST

/sync
Open in API Reference
  • Body: { token: <JWT>, partition: <id>, consent: { ... } }
  • Response: 200 with merged/confirmed consent payload.

cURL quickstart

# GET current consent (device → Transcend)
curl -sS -X GET "https://consent.transcend.io/sync?partition=YOUR_PARTITION_ID" \
  -H "Authorization: YOUR_CONSENT_JWT"

# (US infra example)
# curl -sS -X GET "https://consent.us.transcend.io/sync?partition=YOUR_PARTITION_ID" \
#   -H "Authorization: YOUR_CONSENT_JWT"

# POST consent (device → Transcend)
curl -sS -X POST "https://consent.transcend.io/sync" \
  -H "Content-Type: application/json" \
  -d '{
    "token": "YOUR_CONSENT_JWT",
    "partition": "YOUR_PARTITION_ID",
    "consent": {
      "purposes": { "Analytics": true, "Advertising": false },
      "confirmed": true,
      "timestamp": "'"$(date -u +%FT%TZ)"'"
    }
  }'

Device-side flow (pseudocode)

// On app start (or profile switch)
const token = getConsentTokenFromBackendIfLoggedInElseNull()
const partition = PARTITION_OR_BUNDLE

// Fetch existing consent to pre-populate UI
authHeader = token ? { Authorization: token } : null
r = httpGET("https://consent.transcend.io/sync?partition="+partition, headers=authHeader)
if (r.status == 200) state.consent = r.json
if (r.status == 204) state.consent = defaultsFromRegime()

// On Save (user action)
const payload = {
  token,                         // may be null for anonymous device-mode
  partition,
  consent: {
    purposes: { Analytics: true, Advertising: false },
    confirmed: true,
    timestamp: nowISO()
  }
}
res = httpPOST("https://consent.transcend.io/sync", json=payload)
if (res.ok) {
  cache(res.json)               // cache confirmed consent locally
  applyGovernance(res.json)     // see §4
}

Notes & tips

  • The consent token (JWT) is minted by your backend at login and can be injected into the device (see §2A). For anonymous devices, omit the token and use a stable deviceId strategy; later link it to a user ID on login.
  • Handle offline with a local queue + retry/backoff; keep the last confirmed consent for governance.
  • Expect 204 until the user confirms, use your regime defaults to render initial state.
  • Include the correct partition (or bundle ID). Find it under Consent Management → Developer Settings.

When to use: You already have a server, want to enrich with identifiers/metadata when submitting the preference change, want to batch preference updates across customers, or need to set granular preferences instead of just purposes.

Endpoint: Upsert user preferences

PUT

/v1/preferences
Open in API Reference
  • Headers: authorization: Bearer <apiKey>; x-sombra-authorization: Bearer <sombraInternalKey> for self-hosted.
  • Body: { records: [ { partition, timestamp, identifiers[], purposes[], metadata[]?, consentManagement? } ], skipWorkflowTriggers? }
  • Rate limits: 3000 req/min (default). Supports attributes, workflow settings, and IAB strings.

Endpoint: Query user preferences

POST

/v1/preferences/{partition}/query
Open in API Reference

cURL quickstart (server-side)

# Upsert via Preference Store (server-side)
BACKEND="https://multi-tenant.sombra.transcend.io"   # or https://multi-tenant.sombra.transcend.io or self hosted endpoint
API_KEY="YOUR_API_KEY"

curl -sS -X PUT "$BACKEND/v1/preferences" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "records": [{
      "partition": "YOUR_PARTITION_ID",
      "timestamp": "'"$(date -u +%FT%TZ)"'",
      "identifiers": [{ "name": "subjectId", "value": "user_123" }],
      "purposes": [
        { "purpose": "Analytics", "enabled": true },
        { "purpose": "Advertising", "enabled": false }
      ]
    }]
  }'

# Query preferences (server-side)
curl -sS -X POST "$BACKEND/v1/preferences/YOUR_PARTITION_ID/query" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "filter": { "identifiers": [{ "name": "subjectId", "value": "user_123" }] } }'

Backend-centric flow

// From device → backend (after user hits Save)
POST /api/consent
body: { subjectId/deviceId, consent }

// Backend persists to Preference Store
PUT https://<sombra>/v1/preferences
headers: { authorization: "Bearer <apiKey>", x-sombra-authorization?: "Bearer <sombraKey>" }
body: {
  records: [
    {
      partition,
      timestamp: nowISO(),
      identifiers: [{ name: "subjectId", value: subjectId }],
      purposes: [ { purpose: "Analytics", enabled: true }, { purpose: "Advertising", enabled: false } ],
      metadata: [{ key: "source", value: deviceType }]
    }
  ]
}

// Later, device (or service) can ask backend for current consent
POST /api/consent/query { subjectId }
→ backend calls POST /v1/preferences/{partition}/query and returns the record

Interoperability

You can mix both: device writes via /sync (token), backend mirrors to Preference Store for analytics/automation; or device posts to backend which then calls /v1/preferences.

Security

Never expose Admin APIs (/v1/preferences*) directly to clients. Terminate tokens server-side. Keep consent tokens short‑lived and rotate keys regularly.

Transcend supports the ability to discover and tag SDKs used in these native platforms. You can connect consent purposes to each SDK and use the Transcend Admin Dashboard to dynamically change what types of consents are needed for any given SDK. For more information, please review this guide.

Transcend will push the set of SDK, cookie and data flow rules to the following bundle locations:

These files are GET‑only resources generated by your dashboard configuration. Treat their schema as versioned by Transcend; avoid hard‑coding assumptions and prefer tolerant parsing.

Run automated scans on your repo/build files to inventory third‑party SDKs. Configure a CI job to keep this list fresh.

  • Point scans at package manifests (e.g., Gradle, CocoaPods, NPM, SwiftPM, Python, Flutter/Dart, Ruby, PHP, etc.).
  • If your build file type isn’t supported yet, contact Transcend Support—new formats can often be added quickly.

See the Transcend CLI documentation more instructions on running SDK scans.

Note: You can also manually add SDKs directly within the Transcend product.

Fetch your published services list from native/config.json. Each service has mapped purposes. Compare against the current consent object to decide whether to initialize.

function getConsentSdkStatus(consent):
  cfg = loadConsentConfig()                 // contains services[] and purposeMap
  activeRegime = resolveActiveRegime(deviceContext)
  purposes = cfg.purposeMap[activeRegime.id]
  // Build a quick lookup for current user choices
  granted = set([p.key for p in consent.purposes if p.value == 'granted'])

  result = {}
  for svc in cfg.services:
    required = set(svc.purposes) & set([p.key for p in purposes])
    isAllowed = required.every(k => k in granted)
    result[svc.key] = { allowed: isAllowed, requiredPurposes: required }
  return result

function initializeThirdParties(status):
  for (name, entry) in status:
    if entry.allowed:
      initSdk(name)
    else:
      skipSdk(name)

Governing pattern

  • Wrap each third‑party init behind a small adapter (e.g., if (sdkStatus['analytics'].allowed) initAnalytics()), and re‑run on consent changes.
  • For embedded platforms without dynamic module loading, guard code paths or no‑op adapters on disallowed SDKs.

The following JSON shapes are illustrative and may evolve. Always parse defensively and prefer keys over fixed positions.

translations/<locale>.json (abridged)

{
  "consent.title": "Privacy Choices",
  "consent.acceptAll": "Accept all",
  "consent.rejectAll": "Reject all",
  "consent.save": "Save",
  "purpose.analytics": "Analytics",
  "purpose.ads": "Advertising"
}

native/config.json (abridged)

{
  "consentServices": {
    "services": [
      {
        "name": "Snap Ads",
        "tcfId": 455,
        "sdks": [
          {
            "id": "SnapAds",
            "purposes": ["Advertising", "Analytics"]
          }
        ]
      },
      {
        "name": "Facebook Ads",
        "sdks": [
          {
            "id": "fbsdk",
            "purposes": ["Advertising"]
          }
        ]
      }
    ]
  },
  "consentApplications": ["My App"]
}

Consent object from client side API

{
  "confirmed": true,
  "purposes": {
    // purposes can be custom
    "Analytic": true,
    "Advertising": false
  },
  "timestamp": "2023-05-11T19:32:31.707Z"
}

Consent object from server side API

{
  "nodes": [
    {
      "identifiers": [
        {
          "name": "email",
          "value": "no-track@example.com"
        },
        {
          "name": "phone",
          "value": "+11234567890"
        }
      ],
      "partition": "ee1a0845-694e-4820-9d51-50c7d0a23467",
      "timestamp": "2023-04-11T15:09:28.403Z",
      "purposes": [
        {
          "purpose": "Advertising",
          "enabled": true
        },
        {
          "purpose": "Analytics",
          "enabled": true
        },
        {
          "purpose": "ProductUpdates",
          "enabled": true,
          "preferences": [
            {
              "topic": "Frequency",
              "choice": {
                "selectValue": "Weekly"
              }
            },
            {
              "topic": "Channel",
              "choice": {
                "selectValues": ["Email", "Sms"]
              }
            },
            {
              "topic": "GoDigital",
              "choice": {
                "booleanValue": true
              }
            }
          ]
        }
      ],
      "consentManagement": {
        "airgapVersion": null,
        "usp": null,
        "gpp": null,
        "tcf": null
      },
      "system": {
        "updatedAt": "2023-06-13T08:02:21.793Z",
        "decryptionStatus": "DECRYPTED"
      },
      "metadata": [
        {
          "key": "version",
          "value": "1.0.0"
        },
        {
          "key": "confirmationTimestamp",
          "value": "2023-06-13T07:03:12.621Z"
        }
      ],
      "metadataTimestamp": "2023-06-13T08:02:21.793Z"
    }
  ]
}