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:
-
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.
-
The SDK prepares the request. When a user updates their consent preferences, the SDK calculates the
user_hash, generates thetimestampandnonce, and creates the canonical string to sign. It then passes that string to thegetSignature()callback that you implement. -
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. -
DataGrail verifies the signature. The SDK includes the signature and associated request values with the consent update. DataGrail uses the corresponding secret identified by
keyIdto 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:
- Apply Unicode NFC normalization.
- Trim leading and trailing whitespace.
- Convert to lowercase using a fixed, locale-independent method.
- 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 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.
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
secretis the raw bytes of the hex secret from your dashboard (decode the hex string to bytes before signing — do not sign the hex characters).stringToSignis 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.timestampis 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.nonceis 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 theX-DG-Nonceheader; you do not generate it.provDigestbinds 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 totrue),decision_ts(defaults to the request'stimestamp), andactor_id(defaults to an empty string). For an ordinary write with no explicit provenance, this resolves toSHA-256("true\n{timestamp}\n"). You neither generate nor returnprovDigest— the SDK owns it, the same astimestampandnonce.
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:
| Header | Sent on | Purpose |
|---|---|---|
X-DG-Api-Key | All requests | Identifies your account so the edge can locate the verification secret |
X-DG-Signature | Writes | The HMAC you computed |
X-DG-Timestamp | Writes | The Unix seconds value the SDK generated and folded into the signed string |
X-DG-Key-Id | Writes | Which secret was used, for rotation |
X-DG-Nonce | Writes | The 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-Key | Writes (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:
| Field | Type | Meaning |
|---|---|---|
decision_ts | Unix seconds | When the user actually made this choice. This is what the merge compares, not when the import runs. |
is_explicit | boolean | Whether this was an explicit user decision (beats a non-explicit/default value at the same timestamp). |
actor_id | string (≤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.
| Field | Value |
|---|---|
customer_id | ac46d8ad-a67a-431f-a5d5-9e3eb922dae7 |
project_id | proj_abc123 |
identifier | 9c3f0b8e-2a41-4d3a-9c2a-2e7a2e6f9b11 (a non-PII account identifier; normalize: lowercase, NFC, trim) |
user_hash | 5a44a2a61144abdca9e21c4ec11703fa30a6c8cc392d68c2b9df25d5ebc196ca |
secret (hex) | 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff |
timestamp | 1750000000 |
nonce | 0f1e2d3c4b5a69788796a5b4c3d2e1f0 |
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 |
signature | 332003ff019788081a0f22ed90772e7d54163ff8e46f4e49f2bd8d22d1d9b9a9 |
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.
| Field | Value |
|---|---|
identifier | " 9C3F0B8E-2A41-4D3A-9C2A-2E7A2E6F9B11 " (leading/trailing spaces, mixed case) |
user_hash | 5a44a2a61144abdca9e21c4ec11703fa30a6c8cc392d68c2b9df25d5ebc196ca |
signature | 332003ff019788081a0f22ed90772e7d54163ff8e46f4e49f2bd8d22d1d9b9a9 (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:
| Field | Value |
|---|---|
identifier | IIDX-9c3f0b8e-2a41-4d3a-9c2a-2e7a2e6f9b11 |
user_hash | d781f318a5feb0caaeb8a473a69e2789caff70ffe0b5b3363582712a267c2e2d |
signature | 72dd487466227a0a566211ca6e079f9ffc5af656b6fc7be0a5c87e40eea554bd (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.
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'
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.
Reading Stored Consent
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:
| Method | What it does | Use it when |
|---|---|---|
fetchUniversalConsent | Returns the stored record with signals reconciled. Local state is untouched. | You want to inspect stored consent without applying it |
rehydrateFromUniversalConsent | Fetches the record and applies it locally, so the banner check and every category read agree with it | You 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.
- Initialize the SDK with your config URL.
- Once you know who the user is, call
rehydrateFromUniversalConsentwith their identifier and your API key. - 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.
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:
| Platform | Signal | Scope |
|---|---|---|
| Web | Global Privacy Control (navigator.globalPrivacyControl) and Do Not Track | Broad — a general do-not-sell/share request |
| iOS | App Tracking Transparency (the "Allow Tracking" prompt) | Narrow — cross-app tracking and the advertising identifier |
| Android | The 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.
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.
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.
Related
- Universal Consent Overview — what Universal Consent is and which deployment option you need
- Universal Consent Lifecycle & Compliance Guide — the non-technical compliance guide
- Mobile App SDKs — the iOS, Android, and React Native SDKs
- Callbacks — reacting to consent changes in your app
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.