Server-Sent Events
Real-time offer updates via SSE
Server-Sent Events (SSE) provide a real-time, one-way communication channel from Stacked to your client application. Use SSE to receive instant notifications when new offers surface for a player.
Why Use SSE?
Instead of polling the campaigns endpoint repeatedly, SSE allows you to:
- Receive instant notifications when offers surface
- Reduce API calls and server load
- Improve player experience with real-time updates
- Maintain a persistent connection for live events
Real-time vs Polling
SSE is ideal for real-time offer surfacing (triggered by player actions or events). For batch offer checking, use the campaigns endpoint instead.
Connect to SSE Stream
Establish a persistent SSE connection to receive real-time offer updates.
GET /sse/connectAuthentication: JWT via Authorization: Bearer <token> header
Custom EventSource Required
The native EventSource API doesn't support custom headers. You must use a custom implementation with the Fetch API to pass the JWT token.
Implementation
class CustomEventSource {
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
private decoder = new TextDecoder();
private eventBuffer = '';
private eventListeners: Map<string, Set<(event: MessageEvent) => void>> = new Map();
public onopen: ((event: Event) => void) | null = null;
public onerror: ((event: Event) => void) | null = null;
constructor(private url: string, private headers: Record<string, string>) {
this.connect();
}
private async connect() {
try {
const response = await fetch(this.url, {
method: 'GET',
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
...this.headers
},
credentials: 'same-origin'
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (this.onopen) this.onopen(new Event('open'));
this.reader = response.body!.getReader();
await this.readStream();
} catch (error) {
if (this.onerror) this.onerror(new Event('error'));
}
}
private async readStream() {
while (this.reader) {
const { done, value } = await this.reader.read();
if (done) break;
const chunk = this.decoder.decode(value, { stream: true });
this.eventBuffer += chunk;
this.processBuffer();
}
}
private processBuffer() {
const lines = this.eventBuffer.split('\n');
this.eventBuffer = lines.pop() || '';
let eventType = 'message';
let eventData = '';
for (const line of lines) {
if (line === '') {
if (eventData) {
this.dispatchEvent(eventType, eventData.trim());
eventType = 'message';
eventData = '';
}
} else if (line.startsWith(':')) {
continue; // Comment
} else if (line.startsWith('event:')) {
eventType = line.slice(6).trim();
} else if (line.startsWith('data:')) {
eventData += line.slice(5).trim() + '\n';
}
}
}
private dispatchEvent(type: string, data: string) {
const event = new MessageEvent(type, {
data: data.endsWith('\n') ? data.slice(0, -1) : data
});
const listeners = this.eventListeners.get(type);
if (listeners) {
listeners.forEach(listener => listener(event));
}
}
addEventListener(type: string, listener: (event: MessageEvent) => void) {
if (!this.eventListeners.has(type)) {
this.eventListeners.set(type, new Set());
}
this.eventListeners.get(type)!.add(listener);
}
close() {
if (this.reader) {
this.reader.cancel();
this.reader = null;
}
}
}
// Usage
const eventSource = new CustomEventSource(
'https://api.pixels.xyz/v1/sse/connect',
{ 'Authorization': `Bearer ${jwt}` }
);
eventSource.addEventListener('offer_surfaced', (event) => {
const offer = JSON.parse(event.data);
console.log('New offer:', offer);
displayOfferNotification(offer);
});
eventSource.onopen = () => console.log('Connected');
eventSource.onerror = () => console.error('Connection error');// The client SDK handles SSE connection automatically
import { OfferwallClient } from '@pixels-online/pixels-buildon-client-js-sdk';
const client = new OfferwallClient({
env: 'test',
autoConnect: true,
tokenProvider: async () => jwt
});
// Listen for offer surfaced events
client.events.on('offer_surfaced', (data) => {
console.log('New offer:', data.offer.name);
displayOfferNotification(data.offer);
});
// initialize the client (establishes SSE connection)
await client.initialize();Event Types
offer_surfaced
Sent when a new offer becomes available for the player in real-time.
{
offer: IClientOffer // The offer that was surfaced
}{
"offer": {
"offerId": "offer-abc123",
"instanceId": "instance-xyz789",
"playerId": "player-123",
"gameId": "my-game",
"name": "Boss Battle Boost",
"description": "You failed the boss! Get a 50% damage boost for 10 gems",
"image": "https://cdn.example.com/boss-boost.png",
"status": "surfaced",
"rewards": [
{
"kind": "item",
"rewardId": "damage_boost",
"name": "Damage Boost",
"amount": 1
}
],
"completionConditions": {
"spendCurrency": {
"id": "gems",
"name": "Gems",
"amount": 10
}
},
"createdAt": "2025-01-15T14:30:00Z",
"expiresAt": "2025-01-15T15:30:00Z"
}
}:heartbeat
Sent every 30 seconds to keep the connection alive. These are comment-only messages and don't trigger event listeners.
Example:
:heartbeatConnection Management
Heartbeat & Keep-Alive
Stacked sends a heartbeat comment every 30 seconds to keep the connection alive and help detect disconnections. No action required from your client.
Stale Connection Timeout
Connections are considered stale after 2 minutes of inactivity and will be automatically closed. Implement reconnection logic to handle this.
Auto-Reconnect
Always implement reconnection logic to handle network interruptions, JWT expiration, and server restarts.
Troubleshooting
Connection Keeps Dropping
- Check JWT expiration - refresh before it expires
- Implement exponential backoff for reconnection
- Verify network stability
Not Receiving Events
- Ensure offer has
realTime: trueflag set in dashboard - Verify offer surfacing conditions are met
- Check that player is not at max offer slots
Multiple Connections
Avoid creating multiple SSE connections for the same player - use a singleton pattern.
Stacked