API Integration
Client API Reference
The client API at content-api.vanilla.nl serves published content to mobile apps. All requests require HMAC-SHA256 authentication. Content responses are encrypted with AES-256-GCM.
This document covers everything needed to build a client that fetches content from the API.
Authentication (HMAC-SHA256)
Every request must include signed headers. The server validates the signature, timestamp, and nonce before processing.
Required headers
| Header | Format | Description |
|---|---|---|
Vanilla-Timestamp | Unix seconds (integer string) | Current time. Must be within ±300s of server time. |
Vanilla-Nonce | UUID v4 | Unique per request. Rejected if reused within 5 minutes. |
Vanilla-App-Version | Semver string | App version (e.g., 1.2.3). Used for snapshot resolution. |
Vanilla-App-Build | String | Build number (e.g., 42). |
Vanilla-App-OS | String | Platform identifier: iOS or Android. |
Vanilla-App-Language | Locale code | Requested language (e.g., en, de). Lowercase. |
Vanilla-Signature | Hex string | HMAC-SHA256 of the canonical string. |
You must also include at least one of:
| Header | Format | Description |
|---|---|---|
Vanilla-App-Id | UUID | Direct app ID (if known). |
Vanilla-App-Bundle | Bundle ID string | iOS/Android bundle ID (e.g., com.psycatgames.truthordare). |
Optional:
| Header | Format | Description |
|---|---|---|
Vanilla-Snapshot-Id | UUID | Request a specific snapshot instead of version resolution. |
Building the canonical string
The signature is computed over a canonical string with four sections separated by newlines:
METHOD\n
PATH\n
QUERY\n
HEADERS
- METHOD – HTTP method, uppercase (
GET) - PATH – URL path without query string (
/v1/categories) - QUERY – Query string with parameters sorted by key, URL-encoded. Empty string if no query params.
- HEADERS – All
vanilla-*headers asname:valuepairs, one per line, sorted alphabetically by header name (lowercase)
Header canonicalization rules:
- Header names are lowercase
- Format:
header-name:value(no space after colon) - Include all
vanilla-*headers present in the request exceptvanilla-signature - Sort alphabetically by header name
Example canonical string:
GET
/v1/categories
vanilla-app-build:42
vanilla-app-bundle:com.psycatgames.truthordare
vanilla-app-language:en
vanilla-app-os:ios
vanilla-app-version:1.2.3
vanilla-nonce:a1b2c3d4-e5f6-47a8-9b0c-1d2e3f4a5b6c
vanilla-timestamp:1708700400
Note the empty line for the query string (no query params on /v1/categories).
Computing the signature
signature = HMAC-SHA256(secret, canonical_string).hexdigest()
- Key: The shared HMAC secret (configured per environment)
- Message: The canonical string above
- Output: Lowercase hex digest
Validation rules
| Check | Threshold | Error code |
|---|---|---|
| Timestamp drift | ±300 seconds | INVALID_TIMESTAMP |
| Nonce reuse | 5-minute TTL in Redis | NONCE_REPLAY |
| Signature mismatch | – | INVALID_SIGNATURE |
| Missing headers | – | MISSING_HEADERS |
Encryption (AES-256-GCM)
The /v1/categories and /v1/statements endpoints return encrypted binary responses. The /v1/app endpoint returns unencrypted JSON.
Parameters
| Parameter | Value |
|---|---|
| Algorithm | AES-256-GCM |
| Key length | 32 bytes |
| Key source | First 32 bytes of HMAC secret (zero-padded if secret is shorter) |
| IV length | 12 bytes (randomly generated per response) |
| Auth tag length | 16 bytes |
Response format
- Body:
encrypted_data + auth_tag(binary) Content-Type:application/octet-streamVanilla-Enc-Nonce: Base64-encoded IV (this is the encryption IV, not the request nonce)
Decryption pseudocode
key = hmac_secret[0:32] // first 32 bytes, zero-pad if shorter
iv = base64_decode(response.headers["Vanilla-Enc-Nonce"])
body = response.body // raw bytes
ciphertext = body[0 : length - 16]
auth_tag = body[length - 16 : length]
plaintext = AES-256-GCM.decrypt(
key: key,
iv: iv,
ciphertext: ciphertext,
auth_tag: auth_tag
)
data = JSON.parse(plaintext)
Endpoints
GET /v1/app
Returns app metadata and schema definition. Not encrypted – returns JSON directly.
Response:
{
"app": {
"id": "uuid",
"name": "Truth or Dare",
"bundleIds": ["com.psycatgames.truthordare"],
"contentSchemaType": {
"id": 1,
"name": "Truth or Dare",
"structure": {
"parts": ["main"],
"join": " "
}
}
}
}
GET /v1/categories
Returns all categories with their statement ID lists. Encrypted.
Decrypted response:
{
"categories": [
{
"id": "uuid",
"name": "party-classics",
"isPaid": false,
"isActive": true,
"isExplicit": false,
"hiddenOnIOS": false,
"hiddenOnAndroid": false,
"emoji": "🎉",
"designerMetadata": {},
"gameMetadata": {},
"statements": ["stmt-id-1", "stmt-id-2", "stmt-id-3"],
"localizedTexts": [
{
"title": "Party Classics",
"description": "The best questions for your next party"
}
]
}
]
}
Notes:
statementsis a flat array of statement IDs belonging to this categorylocalizedTextscontains only the resolved locale (not all languages)- The
localefield is stripped fromlocalizedTextsto save bandwidth
GET /v1/statements
Returns paginated statements for a category. Encrypted.
Query parameters:
| Parameter | Required | Default | Description |
|---|---|---|---|
categoryId | Yes | – | UUID of the category |
page | No | 1 | Page number (minimum: 1) |
Page size: 50 statements per page (fixed).
Decrypted response:
{
"statements": [
{
"id": "abc123",
"mode": "classic",
"tags": ["party", "fun"],
"isActive": true,
"hiddenOnIOS": false,
"hiddenOnAndroid": false,
"gameMetadata": {},
"contentMetadata": {},
"designMetadata": {},
"parts": [
{
"partName": "title",
"text": "Would you rather"
},
{
"partName": "text1",
"text": "eat a spider"
},
{
"partName": "text2",
"text": "drink spoiled milk"
}
]
}
],
"pagination": {
"page": 1,
"pageSize": 50,
"totalItems": 150,
"totalPages": 3,
"hasNext": true,
"hasPrevious": false
},
"category": {
"id": "uuid",
"name": "party-classics",
"title": "Party Classics",
"description": "The best questions for your next party"
}
}
Notes:
partscontains only the resolved locale (not all languages). Thelocalefield is stripped.- Use the schema definition from
/v1/appto reconstruct full text from parts using the join string.
Version resolution
When the client sends its app version, the API resolves which snapshot to serve:
- Exact match – if a version mapping exists for the exact requested version, use that snapshot
- Highest ≤ requested – if no exact match, find the highest mapped version that is less than or equal to the requested version (semver comparison)
- 404 – if no version is ≤ the requested version, return
SNAPSHOT_NOT_FOUND
Example: If version mappings exist for 1.0.0, 1.2.0, and 2.0.0:
- Request 1.2.0 → gets 1.2.0 (exact)
- Request 1.5.0 → gets 1.2.0 (highest ≤ 1.5.0)
- Request 2.1.0 → gets 2.0.0 (highest ≤ 2.1.0)
- Request 0.9.0 → 404 (nothing ≤ 0.9.0)
Pre-release versions (e.g., 1.0.0-alpha) have lower precedence than their release counterpart. Build metadata (e.g., 1.0.0+build42) is ignored in comparison.
The resolved version is returned in the X-Resolved-Version response header.
Locale fallback
For each localized field (statement parts, category title/description):
- Exact locale match – use the requested language
- English fallback – if the requested locale isn’t available, use
en - First available – if English isn’t available either, use whatever locale exists
The resolved locale is returned in the X-Resolved-Locale response header.
Only one locale is returned per item – the client receives pre-resolved text, not all translations.
Platform filtering
The API reads the Vanilla-App-OS header (case-insensitive) to determine the platform:
- If
ios→ filters out items wherehiddenOnIOS: true - If
android→ filters out items wherehiddenOnAndroid: true - If neither matches → no platform filtering
This applies to both categories and statements.
Response headers
| Header | Present on | Description |
|---|---|---|
X-Trace-Id | All responses | Request trace ID for debugging |
X-Resolved-Version | Content endpoints | Snapshot version that was served |
X-Resolved-Locale | Content endpoints | Locale that was resolved |
X-Cache-Status | Content endpoints | HIT or MISS |
Vanilla-Enc-Nonce | Encrypted endpoints | Base64-encoded IV for decryption |
Content-Type | All responses | application/octet-stream (encrypted) or application/json (unencrypted) |
Caching
Encrypted responses are cached in Redis for 4 hours. Cache keys include: app identifier, version, language, platform, and (for statements) category ID and page number.
Subsequent identical requests return the cached encrypted blob directly – the same IV and ciphertext. The X-Cache-Status: HIT header indicates a cached response.
Error responses
All errors follow this format:
{
"status": 401,
"code": "INVALID_SIGNATURE",
"message": "Request signature verification failed",
"traceId": "req-uuid"
}
Validation errors include a details array:
{
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"traceId": "req-uuid",
"details": [
{ "path": "categoryId", "message": "Required" }
]
}
Error codes
| Code | HTTP Status | Cause |
|---|---|---|
MISSING_HEADERS | 401 | Required Vanilla-* header(s) missing |
INVALID_TIMESTAMP | 401 | Timestamp outside ±300s tolerance |
NONCE_REPLAY | 401 | Nonce already used within 5-minute window |
MISSING_SIGNATURE | 401 | Vanilla-Signature header missing |
INVALID_SIGNATURE | 401 | HMAC signature doesn’t match |
AUTH_ERROR | 401 | Other authentication failure |
VALIDATION_ERROR | 400 | Invalid request parameters |
BAD_REQUEST | 400 | Malformed request |
SNAPSHOT_NOT_FOUND | 404 | No snapshot found for app version |
CATEGORY_NOT_FOUND | 404 | Category not found in snapshot |
NOT_FOUND | 404 | Route not found |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded (1000 req / 15 min) |
APP_INFO_NOT_FOUND | 500 | App info missing in snapshot payload |
SNAPSHOT_DATA_ERROR | 500 | Database error fetching snapshot |
INTERNAL_ERROR | 500 | Unexpected server error |
Flutter/Dart integration example
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:encrypt/encrypt.dart' as encrypt;
import 'package:http/http.dart' as http;
import 'package:uuid/uuid.dart';
class ContentApiClient {
static const _baseUrl = 'https://content-api.vanilla.nl';
final String _hmacSecret;
final String _bundleId;
final String _appVersion;
final String _appBuild;
final String _platform; // 'ios' or 'android'
ContentApiClient({
required String hmacSecret,
required String bundleId,
required String appVersion,
required String appBuild,
required String platform,
}) : _hmacSecret = hmacSecret,
_bundleId = bundleId,
_appVersion = appVersion,
_appBuild = appBuild,
_platform = platform;
/// Build signed headers for a request
Map<String, String> _buildHeaders(String method, String path,
String query, String language) {
final timestamp = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
final nonce = const Uuid().v4();
final headers = {
'vanilla-timestamp': timestamp,
'vanilla-nonce': nonce,
'vanilla-app-version': _appVersion,
'vanilla-app-build': _appBuild,
'vanilla-app-os': _platform,
'vanilla-app-language': language.toLowerCase(),
'vanilla-app-bundle': _bundleId,
};
// Build canonical string
final sortedHeaders = headers.entries.toList()
..sort((a, b) => a.key.compareTo(b.key));
final headerLines =
sortedHeaders.map((e) => '${e.key}:${e.value}').join('\n');
final canonical = '$method\n$path\n$query\n$headerLines';
// Sign
final hmac = Hmac(sha256, utf8.encode(_hmacSecret));
final signature = hmac.convert(utf8.encode(canonical)).toString();
return {
...headers.map((k, v) =>
MapEntry(k.split('-').map((s) =>
'${s[0].toUpperCase()}${s.substring(1)}'
).join('-'), v)),
'Vanilla-Signature': signature,
};
}
/// Decrypt an AES-256-GCM response
Map<String, dynamic> _decrypt(Uint8List body, String encNonce) {
final keyBytes = utf8.encode(_hmacSecret);
final key = Uint8List(32);
key.setRange(0, keyBytes.length.clamp(0, 32), keyBytes);
final iv = base64.decode(encNonce);
final ciphertext = body.sublist(0, body.length - 16);
final tag = body.sublist(body.length - 16);
// Use platform AES-256-GCM decryption
// (exact API depends on your crypto library)
final encrypter = encrypt.Encrypter(
encrypt.AES(encrypt.Key(key), mode: encrypt.AESMode.gcm),
);
final decrypted = encrypter.decryptBytes(
encrypt.Encrypted(Uint8List.fromList([...ciphertext, ...tag])),
iv: encrypt.IV(iv),
);
return json.decode(utf8.decode(decrypted));
}
/// Fetch categories (encrypted)
Future<Map<String, dynamic>> getCategories(String language) async {
final path = '/v1/categories';
final headers = _buildHeaders('GET', path, '', language);
final response = await http.get(
Uri.parse('$_baseUrl$path'),
headers: headers,
);
if (response.statusCode != 200) {
throw Exception('API error ${response.statusCode}: ${response.body}');
}
return _decrypt(
response.bodyBytes,
response.headers['vanilla-enc-nonce']!,
);
}
/// Fetch statements for a category (encrypted, paginated)
Future<Map<String, dynamic>> getStatements(
String categoryId, String language, {int page = 1}) async {
final path = '/v1/statements';
final query = 'categoryId=$categoryId&page=$page';
final headers = _buildHeaders('GET', path, query, language);
final response = await http.get(
Uri.parse('$_baseUrl$path?$query'),
headers: headers,
);
if (response.statusCode != 200) {
throw Exception('API error ${response.statusCode}: ${response.body}');
}
return _decrypt(
response.bodyBytes,
response.headers['vanilla-enc-nonce']!,
);
}
/// Fetch app info (unencrypted JSON)
Future<Map<String, dynamic>> getAppInfo(String language) async {
final path = '/v1/app';
final headers = _buildHeaders('GET', path, '', language);
final response = await http.get(
Uri.parse('$_baseUrl$path'),
headers: headers,
);
if (response.statusCode != 200) {
throw Exception('API error ${response.statusCode}: ${response.body}');
}
return json.decode(response.body);
}
}
Usage:
final client = ContentApiClient(
hmacSecret: 'your-shared-secret',
bundleId: 'com.psycatgames.truthordare',
appVersion: '1.2.0',
appBuild: '42',
platform: 'ios',
);
// Fetch app info + schema
final appInfo = await client.getAppInfo('en');
// Fetch all categories
final categoriesData = await client.getCategories('en');
final categories = categoriesData['categories'] as List;
// Fetch statements for the first category
final statementsData = await client.getStatements(
categories[0]['id'],
'en',
);
final statements = statementsData['statements'] as List;
// Reconstruct full text from parts using schema join string
final schema = appInfo['app']['contentSchemaType']['structure'];
final joinStr = schema['join'] as String;
for (final stmt in statements) {
final parts = stmt['parts'] as List;
final fullText = (schema['parts'] as List).map((partName) {
if (partName.startsWith('{') && partName.endsWith('}')) {
// Literal -- use translation from schema
return schema['literals'][partName]['en'];
}
return parts.firstWhere((p) => p['partName'] == partName)['text'];
}).join(joinStr);
print(fullText);
}