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/exchangeRequest
Headers
| Header | Required | Description |
|---|---|---|
Content-Type | Yes | application/json |
X-Partner-ID | Yes | Your Partner ID |
X-Partner-Timestamp | Yes | Unix timestamp in seconds |
X-Partner-Nonce | Yes | Unique UUID v4 per request |
X-Partner-Signature | Yes | Signature HMAC-SHA256 (base64url) |
Body
{
"grant_code": "g_abc123def456..."
}| Field | Kind | Required | Description |
|---|---|---|---|
grant_code | string | Yes | Grant 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"
}
}| Field | Kind | Description |
|---|---|---|
pass_token | string | Token to store for future verification (prefix p_) |
expires_in | integer | Validity period in seconds (4 hours by default) |
token_type | string | Token type (Bearer) |
age_over_18 | boolean | true if the user is ≥ 18 years old |
scopes | string[] | List of requested and verified scopes |
attributes | object | Verification attributes (see below) |
attributes fields:
| Field | Kind | Description |
|---|---|---|
age_over_18 | boolean | true B major |
is_male | boolean | true if male (if scope isMale requested) |
is_female | boolean | true if female (if scope isFemale requested) |
is_french | boolean | true if French nationality (if scope isFrench requested) |
is_eu | boolean | true if EU citizen (if scope isEU requested) |
nationality | string | ISO 3166-1 alpha-3 code (ex: "FRA", if scope revealNationality requested) |
birth_year | integer | Year of birth (if scope revealBirthYear requested) |
nullifier | string | Unique identifier per app (if scope isUnique requested) |
Error (400/401/403/500)
{
"error": "GRANT_INVALID",
"message": "Grant code is invalid or expired"
}| Field | Kind | Description |
|---|---|---|
error | string | Error code |
message | string | Readable error message |
Error codes
| Code | HTTP Status | Description |
|---|---|---|
MISSING_HEADERS | 401 | One or more required headers are missing |
INVALID_PARTNER | 403 | Partner ID not recognized |
TIMESTAMP_SKEW | 401 | The timestamp is off by more than 5 minutes |
INVALID_SIGNATURE | 401 | HMAC signature does not match |
REPLAY_DETECTED | 401 | The nonce has already been used |
INVALID_REQUEST | 400 | Invalid body or missing grant_code |
INVALID_GRANT | 400 | Invalid grant format |
GRANT_INVALID | 401 | Grant code nonexistent or expired |
INTERNAL_ERROR | 500 | Server 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-446655440000Expected 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-446655440000Step 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
| Error | Cause | Solution |
|---|---|---|
INVALID_SIGNATURE with different body hash | partner_id included in the body | Body = {"grant_code":"..."} ONLY |
INVALID_SIGNATURE with same body hash | Secret not decoded from base64 | Buffer.from(secret, 'base64') |
INVALID_SIGNATURE | Wrong order in canonical string | Order: bodyHash.timestamp.partnerId.nonce |
MISSING_HEADERS | Missing or misnamed headers | Use X-Partner-ID (not partner_id) |
404 Not Found | Bad endpoint | URL: 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')).