Adult Sites (ADULT_BLIND)
Integration for adult sites using the ADULT_BLIND compliance rail — double anonymization, no grant_code exchange.
Concept
The ADULT_BLIND rail is designed for adult sites that need age verification without ZYKAY being able to identify the site operator. The flow is different from the STANDARD rail:
| STANDARD Rail | ADULT_BLIND Rail | |
|---|---|---|
| Widget auth | data-partner-id | data-session-token |
| Result | #grant_code=... → API exchange | #attestation=... → local verification |
| Callback | POST /v1/exchange (HMAC) | No callback — offline verification |
| Anonymity | ZYKAY knows the partner | ZYKAY never sees partner_id in the verifier path |
Access to the ADULT_BLIND rail is configured by the ZYKAY team during onboarding. Contact tech@zykay.com to enable this mode for your account.
Flow overview
Your Server ZYKAY User's Phone
| | |
|-- 1. Get token (HMAC) ---->| |
|<---- session token --------| |
| | |
|-- 2. Embed widget -------->| |
| (token in script tag) |---- QR / deep link -------->|
| | Face ID |
| |<--- proof complete ---------|
| | |
|<--- 3. attestation --------| |
| | |
|-- 4. Verify signature ---->| (local, no API call) |
| | |
| User is verified ✓ | |Step 1: Get a Session Token (Server-Side)
One API call from your backend. The token is valid for 5 minutes.
<?php
function getZykayToken(): ?string {
$partnerId = 'pk_live_yoursite_xxxxxxxx'; // your partner ID
$secret = file_get_contents('/path/to/secret'); // your base64 secret
$body = json_encode([
'scopes' => ['isAdult'],
'origin' => 'https://yoursite.com' // must match your domain
]);
// HMAC signature
$timestamp = time();
$nonce = bin2hex(random_bytes(16));
$bodyHash = rtrim(strtr(base64_encode(hash('sha256', $body, true)), '+/', '-_'), '=');
$canonical = "{$bodyHash}.{$timestamp}.{$partnerId}.{$nonce}";
$signature = rtrim(strtr(base64_encode(
hash_hmac('sha256', $canonical, base64_decode($secret), true)
), '+/', '-_'), '=');
$ch = curl_init('https://app.zykay.com/api/billing/session');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
"x-partner-id: {$partnerId}",
"x-partner-timestamp: {$timestamp}",
"x-partner-nonce: {$nonce}",
"x-partner-signature: {$signature}",
],
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 201) return null;
$data = json_decode($response, true);
return $data['token'] ?? null;
}
$sessionToken = getZykayToken();
?>Call getZykayToken() on every page load. Do not cache the token across pages — it expires after 5 minutes.
Step 2: Embed the Widget (Frontend)
Add this to your HTML. That's it — no proxy, no CORS config, no extra JavaScript.
<!-- ZYKAY Age Verification Widget -->
<div id="zykay-widget"></div>
<script
src="https://widget-app.zykay.com/v4/loader.min.js"
data-session-token="<?= htmlspecialchars($sessionToken) ?>"
data-success-path="/verified"
data-locale="en">
</script>What happens automatically:
- Widget shows a QR code (desktop) or "Verify" button (mobile)
- User scans QR → opens ZYKAY app → Face ID (~2 seconds)
- Widget detects completion → redirects to
/verified#attestation=eyJ...
Available attributes:
| Attribute | Required | Description |
|---|---|---|
data-session-token | Yes | The token from Step 1 |
data-success-path | Yes | Where to redirect after verification (relative path) |
data-locale | No | fr or en (default: fr) |
The ADULT_BLIND widget uses data-session-token instead of data-partner-id. Do not mix the two attributes.
Step 3: Receive the Attestation (Result Page)
After verification, the user is redirected to your data-success-path with the attestation in the URL fragment:
https://yoursite.com/verified#attestation=eyJhbGciOiJFZERTQSIsImtpZCI6ImFrLTIwMjYtMDIifQ...Extract it with JavaScript and send it to your backend:
<!-- verified.php (or your result page) -->
<script>
const params = new URLSearchParams(location.hash.slice(1));
const attestation = params.get('attestation');
if (attestation) {
fetch('/api/check-age', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attestation }),
}).then(r => r.json()).then(result => {
if (result.verified) {
// User is verified — set session cookie, show content, etc.
document.getElementById('status').textContent = 'Age verified ✓';
}
});
}
</script>Step 4: Verify the Attestation Signature (Server-Side)
The attestation is a signed JWS (Ed25519). Verify the signature locally — no API call needed.
<?php
function verifyAttestation(string $attestation): ?array {
// 1. Fetch ZYKAY's public key (cache for 1 hour)
$jwks = json_decode(file_get_contents('https://app.zykay.com/api/billing/attestation-keys'), true);
$key = $jwks['keys'][0];
$publicKey = sodium_base642bin($key['x'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
// 2. Split JWS: header.payload.signature
$parts = explode('.', $attestation);
if (count($parts) !== 3) return null;
// 3. Verify Ed25519 signature
$message = $parts[0] . '.' . $parts[1];
$signature = sodium_base642bin($parts[2], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
if (!sodium_crypto_sign_verify_detached($signature, $message, $publicKey)) {
return null; // invalid signature
}
// 4. Decode payload
$payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
// 5. Check expiry
if ($payload['exp'] < time()) return null;
// 6. Check scope (bit 0 = isAdult)
$isAdult = ($payload['scope_mask'] & 1) === 1;
return [
'is_adult' => $isAdult,
'scope_mask' => $payload['scope_mask'],
'nullifier' => $payload['nullifier'], // unique user ID (privacy-safe)
];
}
// Usage in your /api/check-age endpoint:
$result = verifyAttestation($_POST['attestation']);
if ($result && $result['is_adult']) {
$_SESSION['age_verified'] = true;
echo json_encode(['verified' => true]);
} else {
echo json_encode(['verified' => false]);
}Attestation verification is offline — no API call to ZYKAY is needed. Only the JWKS public key fetch needs to happen (and be cached).
Summary
No proxies. No CORS configuration. No widget deployment. No exchange endpoints.
| Step | What you write |
|---|---|
| 1. Token | getZykayToken() — ~20 lines (server-side) |
| 2. Widget | <script> tag — 5 lines (HTML) |
| 3. Extraction | Fragment URL reading — ~10 lines (JavaScript) |
| 4. Verification | verifyAttestation() — ~20 lines (server-side) |
What ZYKAY handles for you:
- QR codes, deep links, polling
- Face ID verification
- Attestation signing
- CORS, WebSocket, all transport
FAQ
Do I need to set up CORS?
No. The widget loader talks to widget-app.zykay.com which has CORS configured for all origins.
Do I need a webhook endpoint? No. The result comes via URL fragment redirect, not webhook.
What if the token expires (5 min)?
Generate a new one on page load. Each page load should call getZykayToken() fresh.
Can I cache the public key? Yes, cache the JWKS response for up to 1 hour. The key rotates infrequently (every few months).
What's the nullifier?
A privacy-safe unique identifier. The same user always produces the same nullifier on your site, but a different nullifier on other sites. Use it for deduplication (prevent one person verifying twice) without tracking users across sites.
How long does verification take?
- First time: ~15 seconds (EUDI wallet + Face ID)
- Returning user: ~2 seconds (Face ID only — instant login)
References
- Billing Session API — full documentation for
POST /api/billing/session - Error Codes — ADULT_BLIND-specific error codes
- Rate Limits — billing session endpoint limits