LogoStacked
API

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/connect

Authentication: 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:

:heartbeat

Connection 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: true flag 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.