Skip to main content
Unlisted page
This page is unlisted. Search engines will not index it, and only users having a direct link can access it.

Universal Consent Developer Guide

This guide is for developers implementing Universal Consent. It explains how to identify a user so their consent preferences can be recognized across your supported experiences — including web, iOS, Android, and React Native — and how to securely authenticate preference updates from client applications.

For an introduction to Universal Consent and available deployment options, see the Overview. For information about the consent lifecycle and compliance considerations, see the Lifecycle & Compliance Guide.

How Authentication Works​

Universal Consent associates a user's consent preferences with a hashed identifier.

Reading an existing consent record requires your API key. Updating a consent record requires an additional signature so DataGrail can verify that the request was authorized by your backend.

Because browsers and mobile applications run in environments where secrets cannot be securely stored, your client application should never have access to the signing secret. Instead, signing happens on your backend:

  1. Generate a shared secret in DataGrail. The secret is used by your backend to sign requests and by DataGrail to verify them. It should never be exposed to the browser or mobile application.

  2. The SDK prepares the request. When a user updates their consent preferences, the SDK calculates the user_hash, generates the timestamp and nonce, and creates the canonical string to sign. It then passes that string to the getSignature() callback that you implement.

  3. Your backend signs the request. Your backend applies HMAC-SHA256 to the string provided by the SDK and returns { signature, keyId }. Your backend does not need to construct the signing payload or generate the nonce or timestamp. Its responsibility is simply to sign the value provided by the SDK.

  4. DataGrail verifies the signature. The SDK includes the signature and associated request values with the consent update. DataGrail uses the corresponding secret identified by keyId to independently calculate the expected signature and verify that the request is valid.

This approach keeps the signing secret out of client applications while allowing Universal Consent to verify that preference updates originated from your authorized backend.

DataGrail defines the signing contract and provides reference implementations below. Signature generation always happens in your backend; DataGrail does not generate the client-side signature or sign requests on your behalf.

Signature Construction​

Universal Consent uses two deterministic values for authentication: the user hash and the signature string. The same inputs must always produce the same values, regardless of the language or platform used to generate them.

User Hash​

Universal Consent creates the user hash from your customer ID, project ID, and normalized user identifier:

user_hash = SHA-256( "{customer_id}:{project_id}:{identifier}" ) // lowercase hex

The SDK computes this value when writing a consent preference. Your backend may also derive the same hash when authorizing the signing request.

The identifier must be normalized before hashing so every SDK and your backend produce the same hash for the same identifier.

Identifier Normalization​

Normalize identifiers in this order:

  1. Apply Unicode NFC normalization.
  2. Trim leading and trailing whitespace.
  3. Convert to lowercase using a fixed, locale-independent method.
  4. Confirm the normalized identifier is not empty.

Use locale-independent lowercase behavior so the same identifier produces the same hash regardless of where your code runs. In Turkish, for example, I lowercases to ı (dotless i) rather than i under the server's default locale, so the same identifier can hash differently depending on where your code runs. The reference implementations below use the appropriate locale-independent method for each supported language — Python's .lower(), Ruby's .downcase, JavaScript's .toLowerCase(), Go's strings.ToLower, and PHP's mb_strtolower do not vary by locale. If you write your own in Java or Kotlin, pass an explicit locale: toLowerCase(Locale.ROOT), never the no-argument form.

Including {customer_id} and {project_id} scopes the resulting hash to a specific DataGrail project. The same identifier used in another customer or project therefore produces a different hash.

Use a Non-PII Identifier

Use a UUID or another opaque account identifier that your application already assigns to the user. Avoid email addresses and phone numbers, even when hashed. Because these values are predictable, someone with a candidate value may be able to hash it and compare the result.

Do Not Hash Empty Identifiers

Reject the request if the identifier is empty after normalization. An empty identifier would cause multiple users to resolve to the same hash for that customer and project.

Signing Write Requests​

The SDK creates the canonical string to sign and passes it to your getSignature() callback. Your backend signs that value using HMAC-SHA256 and returns the signature to the SDK.

provDigest = SHA-256( "{is_explicit}\n{decision_ts}\n{actor_id}" ) // lowercase hex; see defaults below
stringToSign = "{customer_id}:{user_hash}:{timestamp}:{nonce}:{provDigest}" // built by the SDK
signature = HMAC-SHA256( secret, stringToSign ) // lowercase hex — all your backend does

The SDK calls your getSignature() callback with a payload and expects { signature, keyId } back:

payload = { stringToSign, customer_id, user_hash, timestamp, nonce, provDigest } // in
return = { signature, keyId } // out
  • secret is the raw bytes of the hex secret from your dashboard (decode the hex string to bytes before signing — do not sign the hex characters).
  • stringToSign is assembled by the SDK from the values it owns; your backend signs it verbatim and does not reconstruct it. The extra payload fields (customer_id, user_hash, timestamp, nonce, provDigest) are supplied so you can authorize the request before signing — not so you can rebuild the string.
  • timestamp is the current Unix time in seconds, generated by the SDK per write. DataGrail rejects timestamps outside a ±5-minute window to bound replay attacks. You neither generate nor return it — the SDK owns it.
  • nonce is a fresh 128-bit value (32 lowercase-hex characters) the SDK generates for each write. It is part of the signed string, so a captured signature is single-use — replaying it with the same nonce is rejected, and changing the nonce breaks the signature. The SDK sends the same value in the X-DG-Nonce header; you do not generate it.
  • provDigest binds the write's decision provenance into the signature, so those fields can't be tampered with in transit. It is computed over three fields, each with a default if the write doesn't set it explicitly: is_explicit (defaults to true), decision_ts (defaults to the request's timestamp), and actor_id (defaults to an empty string). For an ordinary write with no explicit provenance, this resolves to SHA-256("true\n{timestamp}\n"). You neither generate nor return provDigest — the SDK owns it, the same as timestamp and nonce.
Sign the secret's raw bytes

The secret on your dashboard is 64 hex characters, but the HMAC key is the 32 raw bytes those characters decode to — bytes.fromhex(secret) in Python, Buffer.from(secret, 'hex') in Node, hex.DecodeString(secret) in Go. Feeding the hex string straight into the HMAC produces a well-formed signature that DataGrail rejects on every write, with no other symptom to point at the cause. Every helper below decodes the hex first — do the same in anything you write yourself.

The SDK sends the signature alongside these headers. You only produce the signature — the SDK attaches the rest:

HeaderSent onPurpose
X-DG-Api-KeyAll requestsIdentifies your account so the edge can locate the verification secret
X-DG-SignatureWritesThe HMAC you computed
X-DG-TimestampWritesThe Unix seconds value the SDK generated and folded into the signed string
X-DG-Key-IdWritesWhich secret was used, for rotation
X-DG-NonceWritesThe 128-bit (32 lowercase-hex) nonce the SDK generates per write. It is bound into the signature, so each signature is single-use.
X-DG-Idempotency-KeyWrites (optional)A client-chosen key that lets the edge collapse a retried write into a single effect, so a network retry can't double-apply the same change. Not part of the signed string.

Bulk Writes​

The bulk endpoint uses the same authentication model as a single write, but signs one HMAC for the entire batch. Instead of including an individual user_hash in the signed string, the SDK creates a deterministic digest representing the records in the batch.

The same identifier and normalization requirements described above apply to every record in a bulk request.

bulkSignSlot = SHA-256( <"{user_hash}\n{provDigest}" per record, sorted ascending, joined with ","> ) // lowercase hex
bulkProvSentinel = provDigest("true", timestamp, "") // batch-level default provDigest

So the signed string is "{customer_id}:{bulkSignSlot}:{timestamp}:{nonce}:{bulkProvSentinel}". bulkSignSlot folds each record's own user_hash and its provDigest together, so the signature binds both the batch membership and every record's provenance — altering a record's decision_ts, is_explicit, or actor_id changes its provDigest, which changes bulkSignSlot, which invalidates the signature. bulkProvSentinel is the batch-level provDigest computed with the same defaults as a single write (see Signing Write Requests above); it's part of the signed string even though each record also carries its own provenance.

Bulk records go through the same per-category merge as a single write, one record at a time. Importing a record never blindly replaces an existing one: each consent category is merged on its own, and an imported value wins only if it is more recent than what is stored. Re-running an import, or importing an older export, therefore cannot roll back a choice the user has since made — a category whose stored value is newer is left untouched.

To make imported values authoritative — for example when migrating verified historical consent from another CMP — stamp each record with its decision provenance:

FieldTypeMeaning
decision_tsUnix secondsWhen the user actually made this choice. This is what the merge compares, not when the import runs.
is_explicitbooleanWhether this was an explicit user decision (beats a non-explicit/default value at the same timestamp).
actor_idstring (≤128)Optional provenance tag for auditing.

These are the same three fields that make up each record's provDigest in bulkSignSlot above — the merge compares the fields, and the signature covers them, so an imported record's provenance can't be tampered with in transit either.

When decision_ts is omitted, the imported record counts as decided at the request's X-DG-Timestamp, so a plain import of records with no timestamps behaves as "decided now." A decision_ts in the future is clamped to now + 300s.

Response — { "status": "ok", "imported": <n>, "errors": <n> }. imported counts records applied (a record fully superseded by newer stored values is a successful no-op). errors counts records that could not be applied — most importantly a user_hash already owned by a different customer_id: that cross-tenant collision is rejected and logged, never overwritten. Send at most 1,000 records per request; paginate larger migrations.

Golden Test Vector​

Use the following test vector to verify your signing implementation before integrating with Universal Consent. Given these exact inputs, your implementation should produce the values shown below.

FieldValue
customer_idac46d8ad-a67a-431f-a5d5-9e3eb922dae7
project_idproj_abc123
identifier9c3f0b8e-2a41-4d3a-9c2a-2e7a2e6f9b11 (a non-PII account identifier; normalize: lowercase, NFC, trim)
user_hash5a44a2a61144abdca9e21c4ec11703fa30a6c8cc392d68c2b9df25d5ebc196ca
secret (hex)00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff
timestamp1750000000
nonce0f1e2d3c4b5a69788796a5b4c3d2e1f0
provDigest (defaults: is_explicit=true, decision_ts=timestamp, actor_id="")7a94e1c3751b527b0a18770953aa8895f97f7e9abc4937b7d47bf5990fb98bd2
stringToSign (built by the SDK)ac46d8ad-a67a-431f-a5d5-9e3eb922dae7:5a44a2a61144abdca9e21c4ec11703fa30a6c8cc392d68c2b9df25d5ebc196ca:1750000000:0f1e2d3c4b5a69788796a5b4c3d2e1f0:7a94e1c3751b527b0a18770953aa8895f97f7e9abc4937b7d47bf5990fb98bd2
signature332003ff019788081a0f22ed90772e7d54163ff8e46f4e49f2bd8d22d1d9b9a9

Normalization Vector​

The identifier below is intentionally not normalized. A correct implementation should produce the same user_hash after applying the normalization steps described above.

FieldValue
identifier" 9C3F0B8E-2A41-4D3A-9C2A-2E7A2E6F9B11 " (leading/trailing spaces, mixed case)
user_hash5a44a2a61144abdca9e21c4ec11703fa30a6c8cc392d68c2b9df25d5ebc196ca
signature332003ff019788081a0f22ed90772e7d54163ff8e46f4e49f2bd8d22d1d9b9a9 (same timestamp/nonce/provDigest as the golden vector)

Both vectors must produce the same user_hash. If the messy identifier gives you a different hash, your normalization is missing or incomplete, and that user's consent will not follow them across devices.

Locale Vector​

Use this vector to verify that lowercasing is locale-independent. An identifier starting with a capital I hashes differently on a Turkish-locale server unless your lowercasing is locale-independent:

FieldValue
identifierIIDX-9c3f0b8e-2a41-4d3a-9c2a-2e7a2e6f9b11
user_hashd781f318a5feb0caaeb8a473a69e2789caff70ffe0b5b3363582712a267c2e2d
signature72dd487466227a0a566211ca6e079f9ffc5af656b6fc7be0a5c87e40eea554bd (same timestamp/nonce/provDigest as the golden vector — only user_hash differs)

If you get a different hash here, your lowercasing is locale-sensitive — the I became ı instead of i. Locale-sensitive lowercasing can produce inconsistent hashes for identifiers containing characters such as a capital I.

The user_hash values above are what the iOS SDK's CryptoKit path and the Android SDK's MessageDigest path actually produce, and the signature values are recomputed for the nonce- and provenance-bound string-to-sign shown in each vector — run your helper against these vectors to confirm your wiring.

Reference Signers​

The reference implementations below show how to sign the SDK-provided stringToSign using your backend secret. Each implementation performs the same operation; only the language-specific syntax differs.

These examples are intentionally focused on signing. Your application should also authorize the authenticated user before returning a signature.

Each block below shows the reference signer (the three lines that matter) plus a small userHash helper used only for the authorization check described next. Every signer reproduces the golden vector when fed the golden stringToSign.

Authorize the user before signing

Before signing, verify that the authenticated session is authorized to act for the identifier represented by user_hash. Do not sign a request solely because the caller is logged in.

A valid signature tells DataGrail that your backend authorized that specific user_hash and stringToSign combination. Perform your authorization check first, then sign the request only if it succeeds.

// CORRECT — authorize the payload against the session, THEN sign it verbatim.
app.post('/api/consent-signature', requireAuth, (req, res) => {
const { stringToSign, user_hash } = req.body; // the SDK-built payload
const sessionHash = userHash(CUSTOMER_ID, PROJECT_ID, req.session.user.email);
if (user_hash !== sessionHash) return res.sendStatus(403); // never sign for another user
res.json({ signature: signConsent(SECRET, stringToSign), keyId: KEY_ID });
});

If your architecture genuinely requires the client to name the user (for example, an admin acting on someone's behalf), then authorize that specific pairing — confirm the caller is permitted to act for that identifier — before signing.

Node.js​

const crypto = require('crypto');

// The reference signer — this is all your endpoint has to do.
function signConsent(secretHex, stringToSign) {
return crypto.createHmac('sha256', Buffer.from(secretHex, 'hex'))
.update(stringToSign).digest('hex');
}

// Helper for the authorization check above (re-derive the session user's hash).
function userHash(customerId, projectId, identifier) {
const norm = identifier.normalize('NFC').trim().toLowerCase();
return crypto.createHash('sha256')
.update(`${customerId}:${projectId}:${norm}`).digest('hex');
}

// Worked example — reproduces the golden vector
const stringToSign = 'ac46d8ad-a67a-431f-a5d5-9e3eb922dae7:5a44a2a61144abdca9e21c4ec11703fa30a6c8cc392d68c2b9df25d5ebc196ca:1750000000:0f1e2d3c4b5a69788796a5b4c3d2e1f0:7a94e1c3751b527b0a18770953aa8895f97f7e9abc4937b7d47bf5990fb98bd2';
const sig = signConsent('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff', stringToSign);
// sig === '332003ff019788081a0f22ed90772e7d54163ff8e46f4e49f2bd8d22d1d9b9a9'

Python​

import hashlib, hmac, unicodedata

# The reference signer — this is all your endpoint has to do.
def sign_consent(secret_hex, string_to_sign):
return hmac.new(bytes.fromhex(secret_hex), string_to_sign.encode(), hashlib.sha256).hexdigest()

# Helper for the authorization check above (re-derive the session user's hash).
def user_hash(customer_id, project_id, identifier):
norm = unicodedata.normalize('NFC', identifier).strip().lower()
return hashlib.sha256(f"{customer_id}:{project_id}:{norm}".encode()).hexdigest()

# Worked example — reproduces the golden vector
string_to_sign = 'ac46d8ad-a67a-431f-a5d5-9e3eb922dae7:5a44a2a61144abdca9e21c4ec11703fa30a6c8cc392d68c2b9df25d5ebc196ca:1750000000:0f1e2d3c4b5a69788796a5b4c3d2e1f0:7a94e1c3751b527b0a18770953aa8895f97f7e9abc4937b7d47bf5990fb98bd2'
sig = sign_consent('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff', string_to_sign)
# sig == '332003ff019788081a0f22ed90772e7d54163ff8e46f4e49f2bd8d22d1d9b9a9'

Go​

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"

"golang.org/x/text/unicode/norm"
)

// The reference signer — this is all your endpoint has to do.
func signConsent(secretHex, stringToSign string) (string, error) {
secret, err := hex.DecodeString(secretHex)
if err != nil {
return "", fmt.Errorf("malformed secret: %w", err)
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(stringToSign))
return hex.EncodeToString(mac.Sum(nil)), nil
}

// Helper for the authorization check above (re-derive the session user's hash).
func userHash(customerID, projectID, identifier string) string {
n := strings.ToLower(strings.TrimSpace(norm.NFC.String(identifier)))
h := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%s", customerID, projectID, n)))
return hex.EncodeToString(h[:])
}

// Worked example — reproduces the golden vector:
// stringToSign := "ac46d8ad-a67a-431f-a5d5-9e3eb922dae7:5a44a2a61144abdca9e21c4ec11703fa30a6c8cc392d68c2b9df25d5ebc196ca:1750000000:0f1e2d3c4b5a69788796a5b4c3d2e1f0:7a94e1c3751b527b0a18770953aa8895f97f7e9abc4937b7d47bf5990fb98bd2"
// sig, err := signConsent("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", stringToSign)
// // sig == "332003ff019788081a0f22ed90772e7d54163ff8e46f4e49f2bd8d22d1d9b9a9"

Ruby​

require 'openssl'
require 'digest'

# The reference signer — this is all your endpoint has to do.
def sign_consent(secret_hex, string_to_sign)
OpenSSL::HMAC.hexdigest('SHA256', [secret_hex].pack('H*'), string_to_sign)
end

# Helper for the authorization check above (re-derive the session user's hash).
def user_hash(customer_id, project_id, identifier)
norm = identifier.unicode_normalize(:nfc).strip.downcase
Digest::SHA256.hexdigest("#{customer_id}:#{project_id}:#{norm}")
end

# Worked example — reproduces the golden vector
string_to_sign = 'ac46d8ad-a67a-431f-a5d5-9e3eb922dae7:5a44a2a61144abdca9e21c4ec11703fa30a6c8cc392d68c2b9df25d5ebc196ca:1750000000:0f1e2d3c4b5a69788796a5b4c3d2e1f0:7a94e1c3751b527b0a18770953aa8895f97f7e9abc4937b7d47bf5990fb98bd2'
sig = sign_consent('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff', string_to_sign)
# sig == '332003ff019788081a0f22ed90772e7d54163ff8e46f4e49f2bd8d22d1d9b9a9'

PHP​

<?php
// The reference signer — this is all your endpoint has to do.
function sign_consent($secret_hex, $string_to_sign) {
return hash_hmac('sha256', $string_to_sign, hex2bin($secret_hex));
}

// Helper for the authorization check above (re-derive the session user's hash).
function user_hash($customer_id, $project_id, $identifier) {
$norm = mb_strtolower(trim(normalizer_normalize($identifier, Normalizer::FORM_C)), 'UTF-8');
return hash('sha256', "{$customer_id}:{$project_id}:{$norm}");
}

// Worked example — reproduces the golden vector
$string_to_sign = 'ac46d8ad-a67a-431f-a5d5-9e3eb922dae7:5a44a2a61144abdca9e21c4ec11703fa30a6c8cc392d68c2b9df25d5ebc196ca:1750000000:0f1e2d3c4b5a69788796a5b4c3d2e1f0:7a94e1c3751b527b0a18770953aa8895f97f7e9abc4937b7d47bf5990fb98bd2';
$sig = sign_consent('00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff', $string_to_sign);
// $sig === '332003ff019788081a0f22ed90772e7d54163ff8e46f4e49f2bd8d22d1d9b9a9'
Normalize Consistently, Everywhere

The hash is only stable if every place that computes it — all client SDKs, and your backend when it re-derives the hash for the authorization check — applies the exact same NFC → trim → lowercase steps. If your web and mobile hashes diverge, a user's consent will not follow them across devices. Test both golden vectors everywhere, including the normalization vector — it is the one that fails when a normalization step is missing.

Your userHash helper normalizes for you, but the safest habit is to normalize the identifier once, as early as possible, and hand the already-normalized value to the SDK and to your server-side authorization check. Then no step in the chain can disagree.

Client SDKs​

Once your backend exposes a signing endpoint, configure the SDK's getSignature() callback to call it whenever Universal Consent needs to write a preference.

Each SDK follows the same basic flow: identify the user, retrieve any existing Universal Consent record, and request a backend signature when an explicit preference update needs to be written. The implementation differs slightly by platform, but the signing contract remains the same.

React Native apps use the same SDK contract through DataGrail's React Native wrapper around the native iOS and Android SDKs, so the Swift and Kotlin examples below apply to it as well.

If a stored Universal Consent record already exists, the SDK returns that record without creating a new write. If no stored record exists, the SDK continues with the applicable local state and waits for an explicit user choice before writing a new preference. (Writes merge per category rather than replacing the whole record, but reading first is still what lets a device converge on the user's full cross-device state.) A failed read never blocks the write — someone who just answered the banner still needs their choice saved.

Web​

The web banner exposes setUserIdentifier on window.DG_BANNER_API:

window.DG_BANNER_API.setUserIdentifier(currentUser.accountId, {
apiKey: 'dg_live_your_api_key',
getSignature: async ({ stringToSign, user_hash }) => {
// The SDK built stringToSign (and owns the nonce + timestamp inside it).
// Call YOUR backend, which authorizes user_hash against the session cookie
// and then HMACs stringToSign with the reference signer above.
const res = await fetch('/api/consent-signature', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ stringToSign, user_hash }),
});
return res.json(); // { signature, keyId }
},
});

The banner hashes the identifier with the native Web Crypto API and puts that user_hash into the stringToSign. Your endpoint should hash the session's identifier the same way (using the userHash helper) so the authorization check matches.

On the web only, you can omit getSignature and setUserIdentifier becomes read-only — it rehydrates the stored record onto the device but cannot persist changes. Supply it whenever you need writes. On iOS and Android getSignature is required; use rehydrateFromUniversalConsent (below) when you only want to read.

iOS (Swift)​

DataGrailConsent.shared.setUserIdentifier(
currentUser.accountId,
apiKey: "dg_live_your_api_key",
getSignature: { payload, completion in
// payload.stringToSign is built by the SDK; your backend only HMACs it
// (after authorizing payload.userHash against the session).
MyBackend.fetchConsentSignature(stringToSign: payload.stringToSign, userHash: payload.userHash) { result in
switch result {
case .success(let s):
completion(.success(UniversalConsentSignature(
signature: s.signature,
keyId: s.keyId
)))
case .failure:
completion(.failure(.networkError("could not fetch signature")))
}
}
}
) { result in
// Result<Void, ConsentError>
}

The iOS SDK hashes the identifier with CryptoKit's SHA256. The getSignature closure receives the SDK-built payload (stringToSign, userHash, timestamp, nonce) and a completion handler that you call with a UniversalConsentSignature (signature, keyId) or a ConsentError. Your backend HMACs payload.stringToSign — it does not assemble it.

Android (Kotlin)​

DataGrailConsent.getInstance().setUserIdentifier(
identifier = currentUser.accountId,
apiKey = "dg_live_your_api_key",
getSignature = { payload ->
// Suspend provider — called by the SDK when it needs a signature.
// payload.stringToSign is built by the SDK; your backend only HMACs it
// (after authorizing payload.userHash against the session).
val s = myBackend.fetchConsentSignature(payload.stringToSign, payload.userHash)
UniversalConsentSignature(
signature = s.signature,
keyId = s.keyId,
)
},
) { result ->
// Result<Unit>
}

The Android SDK hashes the identifier with java.security.MessageDigest. The provider is a suspend function receiving the SDK-built payload (stringToSign, userHash, timestamp, nonce). Your backend HMACs payload.stringToSign; it does not assemble it.

Writing a preference is only one part of the integration. On subsequent visits or sessions, retrieve the user's stored Universal Consent record so the local experience can reflect their existing preferences. The mobile SDKs expose two read methods for this, and they differ in whether they change anything locally:

MethodWhat it doesUse it when
fetchUniversalConsentReturns the stored record with signals reconciled. Local state is untouched.You want to inspect stored consent without applying it
rehydrateFromUniversalConsentFetches the record and applies it locally, so the banner check and every category read agree with itYou know who the user is and want their cross-device consent to take effect

Reads only need your API key — there is no signature to compute, so no getSignature callback is involved.

Call rehydrate before you check the banner. The SDK cannot do this for you at startup: initialization takes only a config URL, so the SDK has nothing to hash until you tell it who the user is. Rehydrate as soon as you know the identifier, and check whether the banner is needed after it returns.

  1. Initialize the SDK with your config URL.
  2. Once you know who the user is, call rehydrateFromUniversalConsent with their identifier and your API key.
  3. After it completes, check whether consent is still needed and show the banner only if it is.

On iOS:

DataGrailConsent.shared.rehydrateFromUniversalConsent(
currentUser.accountId,
apiKey: "dg_live_your_api_key"
) { result in
// result is Result<Bool, ConsentError>; true means a stored record was applied.
if DataGrailConsent.shared.shouldDisplayBanner() {
// Show the banner — no stored consent applies to this user yet.
}
}

On Android:

DataGrailConsent.getInstance().rehydrateFromUniversalConsent(
identifier = currentUser.accountId,
apiKey = "dg_live_your_api_key",
) { result ->
// Result<Boolean>; true means a stored record was applied.
if (DataGrailConsent.getInstance().needsConsent()) {
// Show the banner — no stored consent applies to this user yet.
}
}

Both SDKs read the device's tracking signal for you and fire your consent-change callback when a record is applied, so tags gated on that callback pick up the rehydrated state without extra wiring.

When a stored record exists, it takes precedence over local state. If the record and the device disagree, the record is authoritative in both directions — it is a real choice this same identifier made elsewhere, so it applies even when it is the less permissive of the two. Privacy signals are applied on top of the result and can only turn categories off.

No Stored Record​

When no record exists for a user, the API responds with a not_found status and the SDK reports no record — nil on iOS, null on Android, and false from rehydrateFromUniversalConsent. Nothing is written locally and the banner still shows.

No stored record is not an opt-out

A no record response means Universal Consent does not have a stored preference for that identifier. Do not interpret it as an opt-out, affirmative consent, or error. Continue with your normal first-visit or no-preference experience.

A read can also return no record when Universal Consent is switched off for your account, so handle the miss path as a normal outcome rather than an error.

Privacy Signals​

A stored consent record is one input into the user's effective privacy state. Platforms may also expose privacy signals that indicate a broader or more specific privacy choice. Universal Consent makes these signals available alongside the stored consent record so your implementation can account for both:

PlatformSignalScope
WebGlobal Privacy Control (navigator.globalPrivacyControl) and Do Not TrackBroad — a general do-not-sell/share request
iOSApp Tracking Transparency (the "Allow Tracking" prompt)Narrow — cross-app tracking and the advertising identifier
AndroidThe advertising ID opt-out ("Delete advertising ID")Narrow — ad personalization and the advertising ID

The mobile SDKs use the platform-specific privacy signals listed above rather than a native GPC signal from the operating system. On the web, whether GPC and Do Not Track are honored — and which trackers they disable — is configured per policy in Consent Policies.

Privacy signals are suppressive only. A signal can make a stored preference more restrictive, but it cannot make it more permissive. For example, if the stored consent record does not allow a category, a device-level signal cannot enable it.

The Universal Consent API returns the stored preferences together with privacy signals known to DataGrail. It does not calculate an effective consent state for your application. Your implementation remains responsible for applying those inputs when determining which processing or tracking is allowed.

The iOS and Android SDKs read the device's tracking setting for you — there is no signal parameter to pass. When the user has opted out of tracking, the SDK suppresses non-essential categories both in what it writes to the Universal Consent store and in what it applies from a record it reads back, keeping essential categories intact. Because a mobile device has no GPC of its own, a GPC recorded on the web reaches the app only through the stored record's gpc field. On the web, the banner applies the signal before firing any tags.

Key Rotation​

Every signature carries a keyId (sent as the X-DG-Key-Id header), which identifies which secret signed the request.

Plan key rotation as a coordinated cutover

Each signature identifies the signing secret through keyId. When you rotate a key, the previous key is no longer accepted for new signatures, and there is no overlap window between keys. Coordinate the backend and client change to avoid temporary verification failures during the transition.

Only deploy a new keyId after your backend is ready to sign requests with the corresponding secret.

Limited Mode​

Some static-site and client-only implementations do not have a backend where a signing secret can be securely stored. For these implementations, Universal Consent supports a limited mode that authenticates writes using the API key and applies stricter per-user-hash limits.

Security considerations for limited mode

Limited mode provides less write assurance than HMAC-signed requests because DataGrail cannot verify that an authenticated customer backend authorized each update. Use limited mode only when backend signing is not available and the capability has been enabled for your DataGrail account.

DataGrail does not provide a hosted signing service. If your implementation later adds a backend, the recommended path is to implement getSignature() and use HMAC-signed writes.

 

Need help?
If you have any questions, please reach out to your dedicated Account Manager or contact us at support@datagrail.io.

Disclaimer: The information contained in this message does not constitute as legal advice. We would advise seeking professional counsel before acting on or interpreting any material.