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

HeaderFormatDescription
Vanilla-TimestampUnix seconds (integer string)Current time. Must be within ±300s of server time.
Vanilla-NonceUUID v4Unique per request. Rejected if reused within 5 minutes.
Vanilla-App-VersionSemver stringApp version (e.g., 1.2.3). Used for snapshot resolution.
Vanilla-App-BuildStringBuild number (e.g., 42).
Vanilla-App-OSStringPlatform identifier: iOS or Android.
Vanilla-App-LanguageLocale codeRequested language (e.g., en, de). Lowercase.
Vanilla-SignatureHex stringHMAC-SHA256 of the canonical string.

You must also include at least one of:

HeaderFormatDescription
Vanilla-App-IdUUIDDirect app ID (if known).
Vanilla-App-BundleBundle ID stringiOS/Android bundle ID (e.g., com.psycatgames.truthordare).

Optional:

HeaderFormatDescription
Vanilla-Snapshot-IdUUIDRequest 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
  1. METHOD – HTTP method, uppercase (GET)
  2. PATH – URL path without query string (/v1/categories)
  3. QUERY – Query string with parameters sorted by key, URL-encoded. Empty string if no query params.
  4. HEADERS – All vanilla-* headers as name:value pairs, 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 except vanilla-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

CheckThresholdError code
Timestamp drift±300 secondsINVALID_TIMESTAMP
Nonce reuse5-minute TTL in RedisNONCE_REPLAY
Signature mismatchINVALID_SIGNATURE
Missing headersMISSING_HEADERS

Encryption (AES-256-GCM)

The /v1/categories and /v1/statements endpoints return encrypted binary responses. The /v1/app endpoint returns unencrypted JSON.

Parameters

ParameterValue
AlgorithmAES-256-GCM
Key length32 bytes
Key sourceFirst 32 bytes of HMAC secret (zero-padded if secret is shorter)
IV length12 bytes (randomly generated per response)
Auth tag length16 bytes

Response format

  • Body: encrypted_data + auth_tag (binary)
  • Content-Type: application/octet-stream
  • Vanilla-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:

  • statements is a flat array of statement IDs belonging to this category
  • localizedTexts contains only the resolved locale (not all languages)
  • The locale field is stripped from localizedTexts to save bandwidth

GET /v1/statements

Returns paginated statements for a category. Encrypted.

Query parameters:

ParameterRequiredDefaultDescription
categoryIdYesUUID of the category
pageNo1Page 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:

  • parts contains only the resolved locale (not all languages). The locale field is stripped.
  • Use the schema definition from /v1/app to 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:

  1. Exact match – if a version mapping exists for the exact requested version, use that snapshot
  2. Highest ≤ requested – if no exact match, find the highest mapped version that is less than or equal to the requested version (semver comparison)
  3. 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):

  1. Exact locale match – use the requested language
  2. English fallback – if the requested locale isn’t available, use en
  3. 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 where hiddenOnIOS: true
  • If android → filters out items where hiddenOnAndroid: true
  • If neither matches → no platform filtering

This applies to both categories and statements.


Response headers

HeaderPresent onDescription
X-Trace-IdAll responsesRequest trace ID for debugging
X-Resolved-VersionContent endpointsSnapshot version that was served
X-Resolved-LocaleContent endpointsLocale that was resolved
X-Cache-StatusContent endpointsHIT or MISS
Vanilla-Enc-NonceEncrypted endpointsBase64-encoded IV for decryption
Content-TypeAll responsesapplication/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

CodeHTTP StatusCause
MISSING_HEADERS401Required Vanilla-* header(s) missing
INVALID_TIMESTAMP401Timestamp outside ±300s tolerance
NONCE_REPLAY401Nonce already used within 5-minute window
MISSING_SIGNATURE401Vanilla-Signature header missing
INVALID_SIGNATURE401HMAC signature doesn’t match
AUTH_ERROR401Other authentication failure
VALIDATION_ERROR400Invalid request parameters
BAD_REQUEST400Malformed request
SNAPSHOT_NOT_FOUND404No snapshot found for app version
CATEGORY_NOT_FOUND404Category not found in snapshot
NOT_FOUND404Route not found
TOO_MANY_REQUESTS429Rate limit exceeded (1000 req / 15 min)
APP_INFO_NOT_FOUND500App info missing in snapshot payload
SNAPSHOT_DATA_ERROR500Database error fetching snapshot
INTERNAL_ERROR500Unexpected 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);
}