Discord Activities SDK Setup Guide (JavaScript)
---
sidebar_label: Discord Activities (JS) sidebar_category: Gaming SDK Guides
Discord Activities SDK Setup Guide (JavaScript)
This guide covers integrating Gaming Intelligence into Discord Activities (Embedded Apps) using JavaScript or TypeScript. Discord Activities run inside Discord's client as an iframe and use Discord's Embedded App SDK for identity and context. The Linkzly integration uses the browser's built-in fetch API for HTTP and the Web Crypto API for HMAC signing — no additional dependencies beyond the Discord Embedded App SDK itself.
Discord Activity context: Discord Activities run inside a sandboxed iframe within the Discord client. All outbound
fetchrequests are proxied through Discord's proxy layer. Your ingestion endpoint must be HTTPS;http://requests are blocked by Discord's CSP. The Linkzly ingestion endpoint (https://gaming.linkzly.com) is HTTPS and supports this environment.
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 |
| Signing Secret | Linkzly Console → Gaming → [Your Game] → Settings → SDK Configuration → Signing Secret |
| Discord Developer Application | discord.com/developers/applications |
| Discord Embedded App SDK | npm install @discord/embedded-app-sdk |
| Node.js 18+ (dev) | For local development; the Activity runtime is a browser context |
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.
Platform Value
All events sent from Discord Activities must include "platform": "discord" in the event payload.
Architecture Overview
The integration has three layers:
- Discord Embedded App SDK: Establishes the RPC connection with the Discord client and provides the authenticated Discord user identity.
- Linkzly client class: Wraps the
fetchAPI to serialize and send events to the ingestion endpoint. - HMAC signing: The Web Crypto API computes HMAC-SHA256 signatures entirely in the browser — no server-side component needed.
Because the Signing Secret is embedded in the client, treat it with the same care as the SDK Key. If your Activity has a backend server, move signature computation server-side and proxy requests through your backend.
Step 1: Install and Initialize the Discord Embedded App SDK
npm install @discord/embedded-app-sdk
Initialize the Discord SDK at the top of your Activity's entry point. This must run before any Discord identity calls:
// main.js (or main.ts)
import { DiscordSDK } from '@discord/embedded-app-sdk';
// Your Discord application's client ID from the Developer Portal
const DISCORD_CLIENT_ID = 'YOUR_DISCORD_APPLICATION_CLIENT_ID';
export const discordSdk = new DiscordSDK(DISCORD_CLIENT_ID);
async function setupDiscord() {
// 1. Wait for the SDK to be ready (establishes RPC connection)
await discordSdk.ready();
// 2. Authorize with the required OAuth2 scopes
const { code } = await discordSdk.commands.authorize({
client_id: DISCORD_CLIENT_ID,
response_type: 'code',
state: '',
prompt: 'none',
scope: [
'identify', // Required to read the user's Discord identity
'guilds',
'guilds.members.read',
],
});
// 3. Exchange the code for an access token on your backend,
// then authenticate with Discord
const response = await fetch('/api/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const { access_token } = await response.json();
await discordSdk.commands.authenticate({ access_token });
// 4. Return the authenticated user
const { user } = await discordSdk.commands.authenticate({ access_token });
return user;
}
Step 2: Get Discord User Identity
After authentication, use the Discord user's ID as the Linkzly player_id. Discord user IDs are stable across sessions for a given Discord account.
// Call after setupDiscord() resolves
async function getLinkzlyPlayerId() {
// discordSdk.instanceId is the current Activity instance (channel-specific)
// The authenticated user object contains the Discord user ID
const { user } = await discordSdk.commands.authenticate({
access_token: window.__DISCORD_ACCESS_TOKEN__, // stored from setupDiscord
});
// Use Discord user ID as the stable player identifier
return user.id; // e.g., "123456789012345678"
}
Alternatively, if you already have the user object from setupDiscord():
// user is returned from discordSdk.commands.authenticate()
const playerId = user.id; // Discord user ID (stable)
const playerUsername = user.username; // Optional: store for display purposes
Step 3: Linkzly Gaming Client
Create a LinkzlyGamingClient class that wraps the fetch API. This is the single file you need to add to your Activity:
// linkzly-gaming.js
class LinkzlyGamingClient {
/**
* @param {Object} options
* @param {string} options.sdkKey - Your Linkzly SDK Key
* @param {string} options.organizationId - Your Linkzly Organization ID
* @param {string} options.gameId - Your Linkzly Game ID
* @param {string} [options.signingSecret] - Required if HMAC Signing is enabled
* @param {string} [options.baseUrl] - Override for development/staging
*/
constructor({ sdkKey, organizationId, gameId, signingSecret = null,
baseUrl = 'https://gaming.linkzly.com' }) {
this.sdkKey = sdkKey;
this.organizationId = organizationId;
this.gameId = gameId;
this.signingSecret = signingSecret;
this.baseUrl = baseUrl;
}
/**
* Send an array of events to the Linkzly ingestion endpoint.
* Automatically computes HMAC signature if signingSecret is provided.
*
* @param {Object[]} events - Array of event objects.
* @returns {Promise<Object>} The 202 batch response.
*/
async sendEvents(events) {
const payload = {
batch_id: crypto.randomUUID(),
batch_timestamp: new Date().toISOString(),
events,
};
const bodyString = JSON.stringify(payload);
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.sdkKey}`,
'X-Organization-ID': this.organizationId,
'X-Game-ID': this.gameId,
};
// Add HMAC signing headers if a signing secret is configured
if (this.signingSecret) {
const timestamp = String(Date.now()); // Unix milliseconds
const nonce = crypto.randomUUID();
const signature = await computeHmacSignature(
this.signingSecret, timestamp, nonce, bodyString);
headers['X-Timestamp'] = timestamp;
headers['X-Nonce'] = nonce;
headers['X-Signature-256'] = signature;
}
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(); // BatchResponse
}
}
Step 4: HMAC Signing with Web Crypto
HMAC signing is enabled by default for all new games. The Web Crypto API (crypto.subtle) is available in all modern browsers and in Discord's Activity iframe environment.
To disable HMAC during development: Console → Gaming → [Your Game] → Settings → Security Settings → HMAC Signing Required → Off.
/**
* Compute HMAC-SHA256 using the Web Crypto API.
* Signing string format: "{timestamp}.{nonce}.{bodyString}"
*
* @param {string} signingSecret - Your Linkzly Signing Secret
* @param {string} timestamp - Unix timestamp in milliseconds (string)
* @param {string} nonce - UUID v4
* @param {string} bodyString - The JSON-serialized request body
* @returns {Promise<string>} - Hex-encoded HMAC-SHA256 signature
*/
async function computeHmacSignature(signingSecret, timestamp, nonce, bodyString) {
const encoder = new TextEncoder();
// Import the signing secret as a CryptoKey
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(signingSecret),
{ name: 'HMAC', hash: 'SHA-256' },
false, // not extractable
['sign']
);
// Build the signing string
const signingString = `${timestamp}.${nonce}.${bodyString}`;
// Compute the signature
const signature = await crypto.subtle.sign(
'HMAC',
key,
encoder.encode(signingString)
);
// Hex-encode the result
return Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
How the signature is computed:
- Serialize the request body to a JSON string (
JSON.stringify(payload)). - Build the signing string:
"{timestamp}.{nonce}.{bodyString}" - Compute
HMAC-SHA256of the signing string using your Signing Secret as the key. - Hex-encode the resulting bytes.
- Send the result in
X-Signature-256, along withX-Timestamp(Unix milliseconds) andX-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.
Step 5: TypeScript Types
If your Activity uses TypeScript, add these interfaces:
// linkzly-gaming.ts
interface EventData {
[key: string]: string | number | boolean | null | EventData;
}
interface GameEvent {
event_id: string;
event_type: string;
timestamp: string; // ISO 8601
platform: 'discord';
player_id: string;
session_id: string;
data?: EventData;
}
interface BatchResponse {
success: boolean;
batch_id: string;
events_received: number;
events_valid: number;
events_dropped: number;
trace_id: string;
server_timestamp: string;
}
interface LinkzlyClientOptions {
sdkKey: string;
organizationId: string;
gameId: string;
signingSecret?: string;
baseUrl?: string;
}
Step 6: Full Integration Example
Putting it all together — initialization, identity, and event tracking:
// activity.js
import { DiscordSDK } from '@discord/embedded-app-sdk';
const DISCORD_CLIENT_ID = 'YOUR_DISCORD_APPLICATION_CLIENT_ID';
const discordSdk = new DiscordSDK(DISCORD_CLIENT_ID);
// Linkzly credentials — move to environment variables or server config in production
const gaming = new LinkzlyGamingClient({
sdkKey: '// YOUR_SDK_KEY',
organizationId: '// YOUR_ORG_ID',
gameId: '// YOUR_GAME_ID',
signingSecret: '// YOUR_SIGNING_SECRET', // omit if HMAC is disabled
});
let playerId = null;
let sessionId = null;
async function main() {
// 1. Set up Discord SDK and get authenticated user
await discordSdk.ready();
const { code } = await discordSdk.commands.authorize({
client_id: DISCORD_CLIENT_ID,
response_type: 'code',
state: '',
prompt: 'none',
scope: ['identify', 'guilds'],
});
// Exchange code for access_token on your backend
const tokenResponse = await fetch('/api/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const { access_token } = await tokenResponse.json();
const { user } = await discordSdk.commands.authenticate({ access_token });
// 2. Set up Linkzly session
playerId = user.id; // Discord user ID
sessionId = crypto.randomUUID(); // New session ID per Activity instance
// 3. Track session start
await gaming.sendEvents([{
event_id: crypto.randomUUID(),
event_type: 'session_start',
timestamp: new Date().toISOString(),
platform: 'discord',
player_id: playerId,
session_id: sessionId,
data: {
discord_instance_id: discordSdk.instanceId,
discord_channel_id: discordSdk.channelId,
discord_guild_id: discordSdk.guildId,
},
}]);
// 4. Start the Activity UI
startGame();
}
// 5. Track in-game events
async function onLevelComplete(levelId, score, durationSeconds) {
await gaming.sendEvents([{
event_id: crypto.randomUUID(),
event_type: 'level_complete',
timestamp: new Date().toISOString(),
platform: 'discord',
player_id: playerId,
session_id: sessionId,
data: {
level_id: levelId,
score: score,
duration_seconds: durationSeconds,
},
}]);
}
// 6. Track session end when the Activity closes
discordSdk.subscribe('ACTIVITY_INSTANCE_PARTICIPANTS_UPDATE', async (data) => {
// When the participant count drops to 0, the Activity is ending
if (data.participants.length === 0) {
await gaming.sendEvents([{
event_id: crypto.randomUUID(),
event_type: 'session_end',
timestamp: new Date().toISOString(),
platform: 'discord',
player_id: playerId,
session_id: sessionId,
data: {},
}]);
}
});
main().catch(console.error);
Batching Events
Send multiple events in a single request to reduce network overhead. The ingestion endpoint accepts up to 1,000 events per batch (1 MB body limit, 10 KB per event):
const eventQueue = [];
function queueEvent(eventType, data = {}) {
eventQueue.push({
event_id: crypto.randomUUID(),
event_type: eventType,
timestamp: new Date().toISOString(),
platform: 'discord',
player_id: playerId,
session_id: sessionId,
data,
});
}
async function flushEvents() {
if (eventQueue.length === 0) return;
const batch = eventQueue.splice(0, 1000); // Respect the 1,000 event limit
await gaming.sendEvents(batch);
}
// Queue events throughout the game
queueEvent('coin_collected', { coin_id: 'gold-42', x: 120, y: 340 });
queueEvent('enemy_defeated', { enemy_type: 'goblin', weapon: 'sword' });
// Flush periodically and on session end
setInterval(flushEvents, 5000);
Error Handling
async function safeSendEvents(events) {
try {
const result = await gaming.sendEvents(events);
if (result.events_dropped > 0) {
console.warn(
`Linkzly: ${result.events_dropped} event(s) dropped. ` +
`Trace ID: ${result.trace_id}`
);
}
return result;
} catch (error) {
const msg = error.message || '';
if (msg.includes('429')) {
// Rate limited — back off and retry
console.warn('Linkzly: Rate limited. Back off and retry.');
} else if (msg.includes('401')) {
console.error('Linkzly: Authentication failed — check SDK Key and HMAC configuration.');
} else if (msg.includes('400')) {
console.error('Linkzly: Malformed payload — check required event fields.');
} else {
console.error('Linkzly: Send error:', msg);
}
}
}
Development and Staging
During local development, point the client at the development ingestion endpoint instead of production:
const gaming = new LinkzlyGamingClient({
sdkKey: '// YOUR_SDK_KEY',
organizationId: '// YOUR_ORG_ID',
gameId: '// YOUR_GAME_ID',
signingSecret: '// YOUR_SIGNING_SECRET',
baseUrl: process.env.NODE_ENV === 'development'
? 'https://linkzly-gaming-tracking-development.mec-fahid.workers.dev'
: 'https://gaming.linkzly.com',
});
Troubleshooting
fetch is blocked in the Discord Activity
Discord proxies all outbound requests from Activities. The Linkzly endpoint (https://gaming.linkzly.com) must be allowlisted in your Discord Developer Portal application's URL Mapping configuration. Add gaming.linkzly.com as an allowlisted external URL prefix.
HMAC mismatch (HTTP 401)
- Confirm the Signing Secret matches the value shown in Console → Gaming → [Your Game] → Settings → SDK Configuration.
- Confirm
Date.now()is being used (milliseconds, not seconds). - Confirm the signing string is
${timestamp}.${nonce}.${bodyString}— the body must be the exact JSON string passed tofetch, not re-serialized. - The replay window is 300 seconds — ensure the client clock is not significantly ahead or behind UTC.
Events dropped (events_dropped > 0)
Check that each event includes all required fields: event_id, event_type, timestamp (ISO 8601), platform, player_id, and session_id. Events missing required fields are dropped silently. The trace_id in the response can be shared with Linkzly support for diagnosis.
crypto.randomUUID is undefined
crypto.randomUUID() requires a secure context (HTTPS). Discord Activities always run in a secure iframe context, so this should be available. If testing outside Discord (e.g., in a plain HTTP local server), use a UUID polyfill or switch to https://localhost.
crypto.subtle is not available
crypto.subtle requires a secure context. Inside the Discord Activity iframe, this is always available. If you see this error, you are likely running the Activity outside of the Discord client in a non-HTTPS context.
discordSdk.commands.authenticate fails
Ensure the OAuth2 token exchange on your backend returns a valid access token. The access token must be exchanged using your Discord application's client secret, which should never be exposed in frontend code.
Next Steps
Was this helpful?
Help us improve our documentation