Linkzly

React Native SDK Setup Guide

---

14 min read

sidebar_label: React Native sidebar_category: Gaming SDK Guides

React Native SDK Setup Guide

This guide covers integrating Gaming Intelligence into a React Native app using TypeScript. There is no npm package to install — integration uses React Native's built-in fetch API to call the Gaming Intelligence REST API directly. This works for both iOS and Android from a single codebase, and is compatible with both bare React Native and Expo projects.


Prerequisites

Before you begin, have the following ready:

Requirement Where to Find It
SDK Key Linkzly Console → Gaming → [Your Game] → Settings → SDK Configuration → SDK Key
Organization ID Linkzly Console → Organization Settings
Game ID Linkzly Console → Gaming → [Your Game] → Settings → General Settings
React Native Version 0.71 or later
Expo Compatible with bare workflow and managed workflow (Expo SDK 49+)

Your SDK key is masked by default in the console. Use the eye icon to reveal it or the copy button to copy it directly.


Installation

No npm packages are required for basic integration — fetch and AppState are built into React Native. However, if your app targets React Native versions below 0.71 and needs UUID generation, install one of these:

# <span id="option-a-uuid-package-with-a-polyfill-most-common"></span>Option A: uuid package with a polyfill (most common)
npm install uuid react-native-get-random-values
# <span id="then-import-react-native-get-random-values-at-the-top-of-your-indexjs-entry-file"></span>Then import 'react-native-get-random-values' at the top of your index.js entry file

# <span id="option-b-expo-provides-cryptorandomuuid-natively-from-expo-sdk-49"></span>Option B: Expo (provides crypto.randomUUID natively from Expo SDK 49+)
npx expo install expo-crypto

For HMAC signing (required when HMAC Signing Required is enabled in Game Settings), crypto.subtle is available natively in React Native 0.74+ and Expo SDK 51+. For older versions, see the HMAC Signing section.


Setup

Create src/lib/linkzlyGaming.ts. This file is the complete integration — the client class, TypeScript types, and the HMAC signing helper:

// src/lib/linkzlyGaming.ts
import { Platform } from 'react-native';

// ── Types ──────────────────────────────────────────────────────────────────────

export interface EventData {
  [key: string]: string | number | boolean | null | EventData;
}

export interface GameEvent {
  event_id: string;
  event_type: string;
  timestamp: string;       // ISO 8601
  platform: string;        // 'ios' | 'android'
  player_id: string;
  session_id: string;
  sdk_version?: string;
  game_version?: string;
  country?: string;
  data?: EventData;
}

export interface BatchResponse {
  success: boolean;
  batch_id: string;
  events_received: number;
  events_valid: number;
  events_dropped: number;
  trace_id: string;
  server_timestamp: string;
}

export interface LinkzlyGamingOptions {
  sdkKey: string;
  organizationId: string;
  gameId: string;
  gameVersion?: string;
  baseUrl?: string;
  signingSecret?: string;  // Required when HMAC Signing is enabled
}

// ── Client ─────────────────────────────────────────────────────────────────────

export class LinkzlyGamingClient {
  private sdkKey: string;
  private organizationId: string;
  private gameId: string;
  private gameVersion: string;
  private baseUrl: string;
  private signingSecret?: string;
  private readonly platform: 'ios' | 'android';

  constructor(options: LinkzlyGamingOptions) {
    this.sdkKey = options.sdkKey;
    this.organizationId = options.organizationId;
    this.gameId = options.gameId;
    this.gameVersion = options.gameVersion ?? '';
    this.baseUrl = options.baseUrl ?? 'https://gaming.linkzly.com';
    this.signingSecret = options.signingSecret;
    // Platform.OS is 'ios' or 'android' in React Native
    this.platform = Platform.OS === 'ios' ? 'ios' : 'android';
  }

  /**
   * Build a fully-populated GameEvent object.
   * Call this to construct events before passing them to sendEvents().
   */
  buildEvent(
    eventType: string,
    playerId: string,
    sessionId: string,
    data?: EventData
  ): GameEvent {
    return {
      event_id: randomUUID(),
      event_type: eventType,
      timestamp: new Date().toISOString(),
      platform: this.platform,
      player_id: playerId,
      session_id: sessionId,
      game_version: this.gameVersion || undefined,
      data,
    };
  }

  /**
   * Send an array of events to the Linkzly ingestion endpoint.
   * Returns a 202 BatchResponse on success, or throws on network/HTTP error.
   */
  async sendEvents(events: GameEvent[]): Promise<BatchResponse> {
    const payload = {
      batch_id: randomUUID(),
      batch_timestamp: new Date().toISOString(),
      events,
    };

    const bodyString = JSON.stringify(payload);

    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${this.sdkKey}`,
      'X-Organization-ID': this.organizationId,
      'X-Game-ID': this.gameId,
    };

    if (this.signingSecret) {
      const timestamp = String(Date.now()); // Unix milliseconds
      const nonce     = randomUUID();
      const signature = await computeHmacSignature(
        this.signingSecret, timestamp, nonce, bodyString
      );
      headers['X-Signature-256'] = signature;
      headers['X-Timestamp']     = timestamp;
      headers['X-Nonce']         = nonce;
    }

    const response = await fetch(`${this.baseUrl}/api/v1/gaming/events`, {
      method: 'POST',
      headers,
      body: bodyString,
    });

    if (!response.ok) {
      const text = await response.text();
      throw new Error(`Linkzly ingestion error ${response.status}: ${text}`);
    }

    return response.json() as Promise<BatchResponse>;
  }
}

// ── UUID helper ────────────────────────────────────────────────────────────────

/**
 * Generate a UUID v4.
 * Uses crypto.randomUUID() on React Native 0.71+ and Expo SDK 49+.
 * Falls back to a Math.random()-based implementation for older versions.
 */
function randomUUID(): string {
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
    return crypto.randomUUID();
  }
  // Fallback for React Native < 0.71 without react-native-get-random-values
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
    const r = (Math.random() * 16) | 0;
    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
  });
}

// ── HMAC signing ───────────────────────────────────────────────────────────────

/**
 * Compute HMAC-SHA256 and return a hex string.
 * Uses crypto.subtle (available in React Native 0.74+ / Expo SDK 51+).
 * For older versions, see the HMAC Signing section of the SDK guide.
 */
export async function computeHmacSignature(
  signingSecret: string,
  timestamp: string,
  nonce: string,
  bodyString: string
): Promise<string> {
  const signingString = `${timestamp}.${nonce}.${bodyString}`;

  if (typeof crypto !== 'undefined' && crypto.subtle) {
    const key = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(signingSecret),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign']
    );
    const signature = await crypto.subtle.sign(
      'HMAC',
      key,
      new TextEncoder().encode(signingString)
    );
    return Array.from(new Uint8Array(signature))
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');
  }

  throw new Error(
    'crypto.subtle is not available. Upgrade to React Native 0.74+ / Expo SDK 51+, ' +
    'or install react-native-quick-crypto. See the HMAC Signing section of the SDK guide.'
  );
}

Instantiation

Create a shared singleton module that your entire app imports. This prevents multiple client instances from being created:

// src/lib/gaming.ts
import { LinkzlyGamingClient } from './linkzlyGaming';

export const gaming = new LinkzlyGamingClient({
  sdkKey:        process.env.LINKZLY_SDK_KEY        ?? '',
  organizationId: process.env.LINKZLY_ORG_ID        ?? '',
  gameId:        process.env.LINKZLY_GAME_ID        ?? '',
  gameVersion:   process.env.LINKZLY_GAME_VERSION   ?? '1.0.0',
  signingSecret: process.env.LINKZLY_SIGNING_SECRET,   // optional
});

Use .env files with react-native-dotenv (bare workflow) or Expo's EXPO_PUBLIC_ prefix convention (managed workflow) to supply credentials without hardcoding them.

Expo managed workflow example:

// src/lib/gaming.ts (Expo)
import Constants from 'expo-constants';
import { LinkzlyGamingClient } from './linkzlyGaming';

const extra = Constants.expoConfig?.extra ?? {};

export const gaming = new LinkzlyGamingClient({
  sdkKey:         extra.linkzlySdkKey        ?? '',
  organizationId: extra.linkzlyOrgId         ?? '',
  gameId:         extra.linkzlyGameId        ?? '',
  gameVersion:    extra.linkzlyGameVersion   ?? '1.0.0',
  signingSecret:  extra.linkzlySigningSecret,
});
// app.config.ts
export default {
  extra: {
    linkzlySdkKey:        process.env.LINKZLY_SDK_KEY,
    linkzlyOrgId:         process.env.LINKZLY_ORG_ID,
    linkzlyGameId:        process.env.LINKZLY_GAME_ID,
    linkzlyGameVersion:   process.env.LINKZLY_GAME_VERSION,
    linkzlySigningSecret: process.env.LINKZLY_SIGNING_SECRET,
  },
};

Tracking Events

Session Start

Send a session_start event when the player begins a play session. Generate a new sessionId UUID for each session:

import { gaming } from '../lib/gaming';

const sessionId = crypto.randomUUID();
const playerId  = 'player-001'; // Your authenticated player ID

await gaming.sendEvents([
  gaming.buildEvent('session_start', playerId, sessionId),
]);

Tracking Game Events

// Level completed
await gaming.sendEvents([
  gaming.buildEvent('level_complete', playerId, sessionId, {
    level_id:         'level-4',
    score:            9800,
    duration_seconds: 112,
    stars:            3,
  }),
]);

// In-app purchase
await gaming.sendEvents([
  gaming.buildEvent('purchase', playerId, sessionId, {
    item_id:  'starter-pack',
    amount:   2.99,
    currency: 'USD',
    store:    Platform.OS === 'ios' ? 'app_store' : 'google_play',
  }),
]);

// Achievement unlocked
await gaming.sendEvents([
  gaming.buildEvent('achievement_unlocked', playerId, sessionId, {
    achievement_id:   'first-win',
    achievement_name: 'First Victory',
    points:           50,
  }),
]);

// Custom event
await gaming.sendEvents([
  gaming.buildEvent('custom', playerId, sessionId, {
    custom_event_name: 'tutorial_skipped',
    step:              3,
  }),
]);

For a complete list of supported event types, see Section 17.1 of the SDKs documentation.

Batching Multiple Events

Send multiple events in a single request to reduce network overhead. The ingestion endpoint accepts up to 1,000 events per batch:

await gaming.sendEvents([
  gaming.buildEvent('currency_earned', playerId, sessionId, {
    amount:        500,
    currency_type: 'coins',
    source:        'daily_reward',
  }),
  gaming.buildEvent('level_start', playerId, sessionId, {
    level_id: 'level-5',
  }),
  gaming.buildEvent('achievement_unlocked', playerId, sessionId, {
    achievement_id:   'streak-7',
    achievement_name: '7-Day Streak',
  }),
]);

Session Management

Use React Native's AppState module to send session lifecycle events when the app moves between foreground and background. The recommended pattern is a custom hook that you mount in your root navigator component:

// src/hooks/useGamingSession.ts
import { useEffect, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { gaming } from '../lib/gaming';

export function useGamingSession(playerId: string | null): React.MutableRefObject<string> {
  const sessionIdRef = useRef<string>(crypto.randomUUID());

  useEffect(() => {
    if (!playerId) return;

    // Send session_start on initial mount
    gaming
      .sendEvents([gaming.buildEvent('session_start', playerId, sessionIdRef.current)])
      .catch(console.error);

    const subscription = AppState.addEventListener(
      'change',
      (nextState: AppStateStatus) => {
        if (nextState === 'active') {
          // App returned to foreground — generate a new session ID
          sessionIdRef.current = crypto.randomUUID();
          gaming
            .sendEvents([gaming.buildEvent('session_start', playerId, sessionIdRef.current)])
            .catch(console.error);
        }

        if (nextState === 'background' || nextState === 'inactive') {
          // App moved to background — end the current session
          gaming
            .sendEvents([gaming.buildEvent('session_end', playerId, sessionIdRef.current)])
            .catch(console.error);
        }
      }
    );

    return () => {
      // Component unmount — end the session and remove the listener
      gaming
        .sendEvents([gaming.buildEvent('session_end', playerId, sessionIdRef.current)])
        .catch(console.error);
      subscription.remove();
    };
  }, [playerId]);

  return sessionIdRef;
}

Mount the hook in your root App component:

// App.tsx
import { useGamingSession } from './src/hooks/useGamingSession';

export default function App() {
  const playerId = useAuthStore(state => state.playerId); // null before login
  const sessionIdRef = useGamingSession(playerId);

  return <RootNavigator />;
}

Pass sessionIdRef.current to child components that need to track events within the same session.


Platform Detection

The client automatically sets the platform field to "ios" or "android" based on Platform.OS. You do not need to set it manually. The buildEvent() helper handles this for you.

If your app also targets web via React Native Web (Platform.OS === 'web'), the client maps it to "android" by default. Override this at construction time if needed:

// Override for a React Native Web build
export const gaming = new LinkzlyGamingClient({
  sdkKey:        'YOUR_SDK_KEY',
  organizationId: 'YOUR_ORG_ID',
  gameId:        'YOUR_GAME_ID',
  // For web targets, construct with a modified baseUrl or handle separately
});

For store-specific data, use Platform.OS directly in your event payloads:

import { Platform } from 'react-native';

await gaming.sendEvents([
  gaming.buildEvent('purchase', playerId, sessionId, {
    item_id:  'gem-pack-100',
    amount:   0.99,
    currency: 'USD',
    store:    Platform.OS === 'ios' ? 'app_store' : 'google_play',
  }),
]);

HMAC Request Signing

HMAC signing is enabled by default for all new games. When enabled, every request must include a valid HMAC-SHA256 signature.

Pass signingSecret in the client options — the client computes the signature and attaches X-Signature-256, X-Timestamp, and X-Nonce to every outgoing request automatically:

export const gaming = new LinkzlyGamingClient({
  sdkKey:         'YOUR_SDK_KEY',
  organizationId: 'YOUR_ORG_ID',
  gameId:         'YOUR_GAME_ID',
  signingSecret:  'YOUR_SIGNING_SECRET',
});

How the signature is computed:

  1. Serialize the request body to a JSON string.
  2. Build the signing string: "{timestamp}.{nonce}.{bodyString}"
  3. Compute HMAC-SHA256 of the signing string using your signing secret as the key.
  4. Hex-encode the resulting bytes.
  5. Send the result in X-Signature-256, along with X-Timestamp (Unix milliseconds) and X-Nonce (UUID v4).

The replay window is fixed at 300 seconds. Requests timestamped more than 5 minutes from the server clock are rejected with 401 Unauthorized.

Crypto availability by platform:

Environment crypto.subtle available Notes
React Native 0.74+ Yes Built into Hermes
Expo SDK 51+ Yes Built into the Hermes runtime
React Native 0.71–0.73 No Use react-native-quick-crypto
Expo SDK 49–50 No Use expo-crypto

Using react-native-quick-crypto for older React Native versions:

npm install react-native-quick-crypto
npx pod-install  # iOS only
// src/lib/hmac-legacy.ts
import QuickCrypto from 'react-native-quick-crypto';

export async function computeHmacSignatureLegacy(
  signingSecret: string,
  timestamp: string,
  nonce: string,
  bodyString: string
): Promise<string> {
  const signingString = `${timestamp}.${nonce}.${bodyString}`;
  const hmac = QuickCrypto.createHmac('sha256', signingSecret);
  hmac.update(signingString);
  return hmac.digest('hex');
}

Using expo-crypto for older Expo versions:

npx expo install expo-crypto
// src/lib/hmac-expo.ts
import * as Crypto from 'expo-crypto';

export async function computeHmacSignatureExpo(
  signingSecret: string,
  timestamp: string,
  nonce: string,
  bodyString: string
): Promise<string> {
  const signingString = `${timestamp}.${nonce}.${bodyString}`;
  // expo-crypto does not expose raw HMAC; use react-native-quick-crypto for full HMAC support
  // Alternatively, disable HMAC for development and enable it server-side via a backend proxy
  throw new Error('expo-crypto does not support HMAC-SHA256 directly. Use react-native-quick-crypto.');
}

Security note: Do not hardcode your signing secret in JavaScript source files that are bundled into the app binary. App binaries can be reverse-engineered. For production mobile apps, the recommended pattern is to proxy event batches through your backend server, which holds the signing secret and computes the signature server-side before forwarding to the Linkzly ingestion endpoint. This removes the need to distribute the signing secret to client devices.

If you choose to embed the signing secret in the app (for example, in a first-party game where reverse engineering is an acceptable risk), use Expo's EXPO_PUBLIC_ environment variables or Gradle/Xcode build settings to inject it at build time rather than committing it to source control.

To disable HMAC for development, toggle off HMAC Signing Required in Game SettingsSecurity Settings and omit signingSecret from the client options. Re-enable it before releasing to production.


Error Handling

Wrap sendEvents() calls in try/catch to handle both network failures and HTTP error status codes:

async function safeTrack(
  eventType: string,
  playerId: string,
  sessionId: string,
  data?: EventData
): Promise<void> {
  try {
    const result = await gaming.sendEvents([
      gaming.buildEvent(eventType, playerId, sessionId, data),
    ]);

    if (result.events_dropped > 0) {
      console.warn(
        `[Linkzly] ${result.events_dropped} event(s) dropped. ` +
        `Trace ID: ${result.trace_id}`
      );
    }
  } catch (error: unknown) {
    const message = error instanceof Error ? error.message : String(error);

    if (message.includes('429')) {
      // Rate limited — back off and retry after the Retry-After interval
      console.warn('[Linkzly] Rate limited. Retry after backoff.');
    } else if (message.includes('401')) {
      console.error('[Linkzly] Authentication failed — check SDK key and signing secret.');
    } else if (message.includes('400')) {
      console.error('[Linkzly] Malformed event payload — check required fields.');
    } else if (
      message.includes('Network request failed') ||
      message.includes('Failed to fetch')
    ) {
      // No connectivity — store locally and retry when back online
      console.warn('[Linkzly] No network. Event queued for retry.');
    } else {
      console.error('[Linkzly] Send error:', message);
    }
  }
}

Offline support: React Native does not include a built-in event queue. For games with intermittent connectivity (common on mobile), store unsent events in AsyncStorage or a SQLite database and flush them using @react-native-community/netinfo when connectivity is restored.


Android Network Security

If you are testing against a local development server (http://) instead of the production HTTPS endpoint, Android's network security policy blocks cleartext traffic by default.

Create android/app/src/main/res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">localhost</domain>
        <domain includeSubdomains="true">10.0.2.2</domain>
    </domain-config>
</network-security-config>

Reference it in android/app/src/main/AndroidManifest.xml:

<application
    android:networkSecurityConfig="@xml/network_security_config"
    ...>

The development endpoint at https://linkzly-gaming-tracking-development.mec-fahid.workers.dev uses HTTPS and does not require any network security configuration changes.


Troubleshooting

Network request failed error

  1. Confirm the device has internet connectivity. Test with a simple fetch('https://gaming.linkzly.com') call.
  2. On Android, check that the network security config allows HTTPS traffic to gaming.linkzly.com.
  3. In Expo Go during development, ensure your development machine and device are on the same network if routing through a local proxy.

401 Unauthorized with HMAC enabled

  1. Confirm signingSecret in the client options matches the value in Game Settings → SDK Configuration exactly.
  2. Verify the device clock is synchronized. The replay window is 300 seconds (5 minutes).
  3. Ensure the body string passed to computeHmacSignature is identical to the string sent in the request body — do not JSON-serialize the payload twice.

Events dropped (events_dropped > 0)

Check that each event object includes all required fields: event_id, event_type, timestamp (ISO 8601), platform, player_id, and session_id. Events with missing required fields are dropped silently. The trace_id in the response can be provided to Linkzly support for diagnosis.

crypto.randomUUID is not a function

Upgrade to React Native 0.71 or later, or install react-native-get-random-values and import it at the top of your entry file (index.js). The randomUUID() helper in the client class includes a Math.random()-based fallback for environments where the native implementation is unavailable, but that fallback is not cryptographically secure — use it only for testing.

Platform detected incorrectly

Platform.OS returns 'ios', 'android', or 'web'. The client maps 'ios' and anything else to their respective platform values. If you are building a React Native Web target and need the platform set to 'web', construct a separate client instance with an explicit override for the web build target.

HMAC signing throws "crypto.subtle is not available"

Install react-native-quick-crypto (bare workflow) or use the expo-crypto-based approach described in the HMAC Signing section. For Expo SDK 51+ and React Native 0.74+, crypto.subtle is available natively and no additional packages are required.

Was this helpful?

Help us improve our documentation