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 RailADULT_BLIND Rail
Widget authdata-partner-iddata-session-token
Result#grant_code=... → API exchange#attestation=... → local verification
CallbackPOST /v1/exchange (HMAC)No callback — offline verification
AnonymityZYKAY knows the partnerZYKAY 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:

AttributeRequiredDescription
data-session-tokenYesThe token from Step 1
data-success-pathYesWhere to redirect after verification (relative path)
data-localeNofr 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.

StepWhat you write
1. TokengetZykayToken() — ~20 lines (server-side)
2. Widget<script> tag — 5 lines (HTML)
3. ExtractionFragment URL reading — ~10 lines (JavaScript)
4. VerificationverifyAttestation() — ~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