Grant Exchange API

Exchange a grant_code for a pass_token. This must be done server side to protect your partner secret.

Endpoint

POST https://api.zykay.com/v1/exchange

Request

Headers

HeaderRequiredDescription
Content-TypeYesapplication/json
X-Partner-IDYesYour Partner ID
X-Partner-TimestampYesUnix timestamp in seconds
X-Partner-NonceYesUnique UUID v4 per request
X-Partner-SignatureYesSignature HMAC-SHA256 (base64url)

Body

{
  "grant_code": "g_abc123def456..."
}
FieldKindRequiredDescription
grant_codestringYesGrant code received from the widget (starts with g_)

Signature generation

The HMAC signature protects against replay attacks and guarantees the integrity of the request.

const crypto = require('crypto');
 
function base64url(buffer) {
  return buffer.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}
 
function signRequest(partnerId, partnerSecret, body) {
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomUUID();
  const bodyString = JSON.stringify(body);
 
  // 1. Hash du body (SHA256, base64url)
  const bodyHash = base64url(
    crypto.createHash('sha256').update(bodyString, 'utf8').digest()
  );
 
  // 2. Chaîne canonique
  const canonicalString = `${bodyHash}.${timestamp}.${partnerId}.${nonce}`;
 
  // 3. IMPORTANT: Décoder le secret depuis base64 avant HMAC
  const secretBuffer = Buffer.from(partnerSecret, 'base64');
 
  // 4. Signature HMAC-SHA256 (base64url)
  const signature = base64url(
    crypto.createHmac('sha256', secretBuffer)
      .update(canonicalString)
      .digest()
  );
 
  return {
    headers: {
      'Content-Type': 'application/json',
      'X-Partner-ID': partnerId,
      'X-Partner-Timestamp': timestamp,
      'X-Partner-Nonce': nonce,
      'X-Partner-Signature': signature
    },
    body: bodyString
  };
}
 
// Exemple d'utilisation
const { headers, body } = signRequest(
  'pk_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4',
  'dGVzdF9zZWNyZXRfMzJfYnl0ZXNfbG9uZw==',  // base64-encodé
  { grant_code: 'g_xyz789...' }
);
 
const response = await fetch('https://api.zykay.com/v1/exchange', {
  method: 'POST',
  headers,
  body
});

Answer

Successes (200)

{
  "pass_token": "p_xyz789abc123...",
  "expires_in": 14400,
  "token_type": "Bearer",
  "age_over_18": true,
  "scopes": ["isAdult", "isFrench", "revealNationality"],
  "attributes": {
    "age_over_18": true,
    "is_french": true,
    "nationality": "FRA"
  }
}
FieldKindDescription
pass_tokenstringToken to store for future verification (prefix p_)
expires_inintegerValidity period in seconds (4 hours by default)
token_typestringToken type (Bearer)
age_over_18booleantrue if the user is ≥ 18 years old
scopesstring[]List of requested and verified scopes
attributesobjectVerification attributes (see below)

attributes fields:

FieldKindDescription
age_over_18booleantrue B major
is_malebooleantrue if male (if scope isMale requested)
is_femalebooleantrue if female (if scope isFemale requested)
is_frenchbooleantrue if French nationality (if scope isFrench requested)
is_eubooleantrue if EU citizen (if scope isEU requested)
nationalitystringISO 3166-1 alpha-3 code (ex: "FRA", if scope revealNationality requested)
birth_yearintegerYear of birth (if scope revealBirthYear requested)
nullifierstringUnique identifier per app (if scope isUnique requested)

Error (400/401/403/500)

{
  "error": "GRANT_INVALID",
  "message": "Grant code is invalid or expired"
}
FieldKindDescription
errorstringError code
messagestringReadable error message

Error codes

CodeHTTP StatusDescription
MISSING_HEADERS401One or more required headers are missing
INVALID_PARTNER403Partner ID not recognized
TIMESTAMP_SKEW401The timestamp is off by more than 5 minutes
INVALID_SIGNATURE401HMAC signature does not match
REPLAY_DETECTED401The nonce has already been used
INVALID_REQUEST400Invalid body or missing grant_code
INVALID_GRANT400Invalid grant format
GRANT_INVALID401Grant code nonexistent or expired
INTERNAL_ERROR500Server error

Important notes

⚠️

Security: Never expose your Partner Secret on the client side. The call to this endpoint must always be made from your server.

Expiration: Grant codes expire after 5 minutes. Exchange them immediately upon receipt.

Single use: Each grant_code can only be redeemed once. Repeated attempts will return GRANT_INVALID.

HMAC Verification Test

Use these test values ​​to validate your implementation before integrating into production. Compare each step with the expected results.

⚠️

Important: Use these values ​​EXACTLY as directed. A difference of just one character will produce a different signature.

Test values

Partner ID:     pk_test_example_123
Partner Secret: dGVzdF9zZWNyZXRfMzJfYnl0ZXNfbG9uZw==
Grant Code:     g_test_verification_abc123
Timestamp:      1700000000
Nonce:          550e8400-e29b-41d4-a716-446655440000

Expected results at each stage

Step 1 - Request body (ONLY grant_code, NO partner_id):

{"grant_code":"g_test_verification_abc123"}

Step 2 - Body Hash (SHA256, base64url):

bodyHash = "3lQmxO9DcoXgG0iPyG8wVCxpVbw6q-R-v9MOE0_kd98"

Step 3 - Canonical string (format: bodyHash.timestamp.partnerId.nonce):

3lQmxO9DcoXgG0iPyG8wVCxpVbw6q-R-v9MOE0_kd98.1700000000.pk_test_example_123.550e8400-e29b-41d4-a716-446655440000

Step 4 - Decoding the secret (base64 → bytes):

Secret décodé = "test_secret_32_bytes_long" (25 bytes)

Step 5 - Final signature (HMAC-SHA256, base64url):

signature = "IiqKqzxLThjE4anzR2UGycy5JrJKSjjD2m3R81RxsW8"

Node.js validation script

const crypto = require('crypto');
 
function base64url(buffer) {
  return buffer.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}
 
// Valeurs de test
const grantCode = 'g_test_verification_abc123';
const partnerId = 'pk_test_example_123';
const partnerSecret = 'dGVzdF9zZWNyZXRfMzJfYnl0ZXNfbG9uZw==';
const timestamp = '1700000000';
const nonce = '550e8400-e29b-41d4-a716-446655440000';
 
// Étape 1: Corps (UNIQUEMENT grant_code!)
const body = JSON.stringify({ grant_code: grantCode });
console.log('1. Body:', body);
 
// Étape 2: Hash du corps
const bodyHash = base64url(crypto.createHash('sha256').update(body, 'utf8').digest());
console.log('2. Body hash:', bodyHash);
 
// Étape 3: Chaîne canonique
const canonical = `${bodyHash}.${timestamp}.${partnerId}.${nonce}`;
console.log('3. Canonical:', canonical);
 
// Étape 4: Décoder le secret (CRITIQUE!)
const secretBuffer = Buffer.from(partnerSecret, 'base64');
console.log('4. Secret decoded:', secretBuffer.toString(), `(${secretBuffer.length} bytes)`);
 
// Étape 5: Signature
const signature = base64url(crypto.createHmac('sha256', secretBuffer).update(canonical).digest());
console.log('5. Signature:', signature);

Common mistakes

ErrorCauseSolution
INVALID_SIGNATURE with different body hashpartner_id included in the bodyBody = {"grant_code":"..."} ONLY
INVALID_SIGNATURE with same body hashSecret not decoded from base64Buffer.from(secret, 'base64')
INVALID_SIGNATUREWrong order in canonical stringOrder: bodyHash.timestamp.partnerId.nonce
MISSING_HEADERSMissing or misnamed headersUse X-Partner-ID (not partner_id)
404 Not FoundBad endpointURL: https://api.zykay.com/v1/exchange
🚫

Common pitfall: The Partner Secret is base64 encoded. You MUST decode it before using it as an HMAC key. crypto.createHmac('sha256', secret) is FALSE. Use crypto.createHmac('sha256', Buffer.from(secret, 'base64')).