Skip to main content
Manage entitlement state on your server and communicate changes to your frontend application.
The Web SDK’s client-side event methods (on(), observeEntitlement()) have been deprecated for security reasons. Entitlement state should be managed on your server with updates communicated to clients as needed.

Why Server-Side State Management

Security: Client-side event listeners can be manipulated or bypassed. Critical entitlement state must be managed server-side. Consistency: Server-side state ensures all clients and sessions see the same entitlement status. Audit Trail: Server-managed state allows proper logging and tracking of entitlement changes.

State Management Patterns

Pattern 1: On-Demand Validation

Validate entitlements when needed (page load, navigation, feature access):
// Frontend - Check on page load
async function loadPremiumPage() {
  const response = await fetch('/api/validate-entitlements', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId: getCurrentUserId() }),
  });
  
  const entitlements = await response.json();
  
  if (entitlements.all?.freeTrial) {
    renderPremiumContent();
  } else {
    renderUpgradePrompt();
  }
}
Best for: Simple applications, infrequent entitlement checks

Pattern 2: Session-Based Caching

Cache entitlement state in user session:
// Backend - Express with session
app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
}));

// Middleware to load entitlements
async function loadEntitlements(req, res, next) {
  if (!req.session.entitlements || req.session.entitlementsExpiry < Date.now()) {
    const entitlements = await validateEntitlements(req.session.userId);
    req.session.entitlements = entitlements;
    req.session.entitlementsExpiry = Date.now() + 5 * 60 * 1000; // 5 minutes
  }
  next();
}

app.get('/premium-feature', loadEntitlements, (req, res) => {
  if (req.session.entitlements.all?.freeTrial) {
    res.render('premium-feature');
  } else {
    res.redirect('/upgrade');
  }
});
Best for: Server-rendered applications, reduced API calls

Pattern 3: WebSocket Updates

Push real-time entitlement updates to connected clients:
// Backend - WebSocket server
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });
const clients = new Map(); // userId -> WebSocket

wss.on('connection', (ws, req) => {
  const userId = getUserIdFromRequest(req);
  clients.set(userId, ws);
  
  ws.on('close', () => {
    clients.delete(userId);
  });
});

// Notify client when entitlements change
async function notifyEntitlementChange(userId) {
  const ws = clients.get(userId);
  if (ws && ws.readyState === WebSocket.OPEN) {
    const entitlements = await validateEntitlements(userId);
    ws.send(JSON.stringify({
      type: 'entitlement_changed',
      data: entitlements,
    }));
  }
}

// Frontend - WebSocket client
const ws = new WebSocket('ws://localhost:8080');

ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  if (message.type === 'entitlement_changed') {
    updateUIWithEntitlements(message.data);
  }
};
Best for: Real-time applications, instant updates

Pattern 4: Polling

Periodically check for entitlement updates:
// Frontend - Polling with exponential backoff
class EntitlementPoller {
  constructor(userId, interval = 30000) {
    this.userId = userId;
    this.interval = interval;
    this.timerId = null;
  }
  
  async checkEntitlements() {
    try {
      const response = await fetch('/api/validate-entitlements', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId: this.userId }),
      });
      
      const entitlements = await response.json();
      this.onUpdate(entitlements);
    } catch (error) {
      console.error('Failed to check entitlements:', error);
    }
  }
  
  start(callback) {
    this.onUpdate = callback;
    this.checkEntitlements(); // Initial check
    this.timerId = setInterval(() => this.checkEntitlements(), this.interval);
  }
  
  stop() {
    if (this.timerId) {
      clearInterval(this.timerId);
      this.timerId = null;
    }
  }
}

// Usage
const poller = new EntitlementPoller('user_123', 30000);
poller.start((entitlements) => {
  console.log('Entitlements updated:', entitlements);
  updateUI(entitlements);
});

// Cleanup
window.addEventListener('beforeunload', () => poller.stop());
Best for: Applications needing periodic updates, verification monitoring

Framework Integration

React

  • Context API
  • Custom Hook
import { createContext, useContext, useState, useEffect } from 'react';

const EntitlementContext = createContext(null);

export function EntitlementProvider({ children, userId }) {
  const [entitlements, setEntitlements] = useState(null);
  const [loading, setLoading] = useState(true);
  
  const refreshEntitlements = async () => {
    try {
      const response = await fetch('/api/validate-entitlements', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId }),
      });
      
      const data = await response.json();
      setEntitlements(data);
    } catch (error) {
      console.error('Failed to load entitlements:', error);
    } finally {
      setLoading(false);
    }
  };
  
  useEffect(() => {
    refreshEntitlements();
    
    // Poll every 30 seconds
    const interval = setInterval(refreshEntitlements, 30000);
    return () => clearInterval(interval);
  }, [userId]);
  
  return (
    <EntitlementContext.Provider value={{ entitlements, loading, refreshEntitlements }}>
      {children}
    </EntitlementContext.Provider>
  );
}

export function useEntitlements() {
  return useContext(EntitlementContext);
}

// Usage in component
function PremiumFeature() {
  const { entitlements, loading } = useEntitlements();
  
  if (loading) return <LoadingSpinner />;
  if (!entitlements?.all?.freeTrial) return <UpgradePrompt />;
  return <PremiumContent />;
}

Vue

  • Composable
  • Pinia Store
import { ref, onMounted, onUnmounted } from 'vue';

export function useEntitlements(userId) {
  const entitlements = ref(null);
  const loading = ref(true);
  const error = ref(null);
  let pollInterval = null;
  
  const validate = async () => {
    try {
      loading.value = true;
      const response = await fetch('/api/validate-entitlements', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId }),
      });
      
      if (!response.ok) {
        throw new Error('Validation failed');
      }
      
      entitlements.value = await response.json();
      error.value = null;
    } catch (err) {
      error.value = err.message;
    } finally {
      loading.value = false;
    }
  };
  
  onMounted(() => {
    validate();
    // Poll every 30 seconds
    pollInterval = setInterval(validate, 30000);
  });
  
  onUnmounted(() => {
    if (pollInterval) {
      clearInterval(pollInterval);
    }
  });
  
  return { entitlements, loading, error, refresh: validate };
}
Usage:
<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <div v-else>
    <button @click="refresh">Refresh</button>
    <PremiumContent v-if="entitlements.all?.freeTrial" />
    <UpgradePrompt v-else />
  </div>
</template>

<script setup>
import { useEntitlements } from './composables/useEntitlements';

const { entitlements, loading, error, refresh } = useEntitlements('user_123');
</script>

Angular

import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, interval } from 'rxjs';
import { switchMap, catchError, tap } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';

interface Entitlements {
  all: Record<string, any>;
  verified: Record<string, any>;
  provisional: Record<string, any>;
}

@Injectable({ providedIn: 'root' })
export class EntitlementService {
  private entitlementsSubject = new BehaviorSubject<Entitlements | null>(null);
  public entitlements$ = this.entitlementsSubject.asObservable();
  
  private loadingSubject = new BehaviorSubject<boolean>(false);
  public loading$ = this.loadingSubject.asObservable();
  
  constructor(private http: HttpClient) {}
  
  validate(userId: string): Observable<Entitlements> {
    this.loadingSubject.next(true);
    
    return this.http.post<Entitlements>('/api/validate-entitlements', { userId }).pipe(
      tap(entitlements => {
        this.entitlementsSubject.next(entitlements);
        this.loadingSubject.next(false);
      }),
      catchError(error => {
        console.error('Validation failed:', error);
        this.loadingSubject.next(false);
        throw error;
      })
    );
  }
  
  startPolling(userId: string, intervalMs: number = 30000): Observable<Entitlements> {
    return interval(intervalMs).pipe(
      switchMap(() => this.validate(userId))
    );
  }
  
  hasEntitlement(type: string, scope: string = 'all'): boolean {
    const entitlements = this.entitlementsSubject.value;
    return !!entitlements?.[scope]?.[type];
  }
}
Usage:
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { EntitlementService } from './services/entitlement.service';

@Component({
  selector: 'app-premium-feature',
  template: `
    <div *ngIf="loading$ | async">Loading...</div>
    <app-premium-content *ngIf="hasAccess"></app-premium-content>
    <app-upgrade-prompt *ngIf="!hasAccess && !(loading$ | async)"></app-upgrade-prompt>
  `
})
export class PremiumFeatureComponent implements OnInit, OnDestroy {
  hasAccess = false;
  loading$ = this.entitlementService.loading$;
  private subscription?: Subscription;
  
  constructor(private entitlementService: EntitlementService) {}
  
  ngOnInit() {
    // Initial validation
    this.entitlementService.validate('user_123').subscribe();
    
    // Subscribe to changes
    this.subscription = this.entitlementService.entitlements$.subscribe(entitlements => {
      this.hasAccess = !!entitlements?.all?.freeTrial;
    });
    
    // Start polling
    this.entitlementService.startPolling('user_123', 30000).subscribe();
  }
  
  ngOnDestroy() {
    this.subscription?.unsubscribe();
  }
}

After Offer Claimed

When a user claims an offer, refresh entitlement state:
// Frontend
async function handleOfferClaimed() {
  const result = await Encore.presentOffer();
  
  if (result.granted) {
    // Notify backend of the grant
    await fetch('/api/offer-claimed', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        userId: getCurrentUserId(),
        entitlement: result.entitlement,
      }),
    });
    
    // Refresh entitlement state
    const response = await fetch('/api/validate-entitlements', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId: getCurrentUserId() }),
    });
    
    const entitlements = await response.json();
    updateUI(entitlements);
  }
}
// Backend endpoint
app.post('/api/offer-claimed', async (req, res) => {
  const { userId, entitlement } = req.body;
  
  // Log the claim
  await logOfferClaim(userId, entitlement);
  
  // Validate current state
  const entitlements = await validateEntitlements(userId);
  
  // Notify connected clients (if using WebSocket)
  notifyEntitlementChange(userId);
  
  res.json({ success: true, entitlements });
});

Monitoring Verification Status

Check when provisional entitlements become verified:
// Backend - Check verification periodically
async function checkVerificationStatus(userId, transactionId) {
  const maxAttempts = 12; // 12 attempts = 1 minute
  const delayMs = 5000; // 5 seconds
  
  for (let i = 0; i < maxAttempts; i++) {
    const entitlements = await validateEntitlements(userId);
    
    // Check if entitlement is now verified
    const verified = Object.values(entitlements.verified).some(
      ent => ent.transactionId === transactionId
    );
    
    if (verified) {
      // Notify user of verification
      await notifyUser(userId, 'entitlement_verified');
      return true;
    }
    
    // Wait before next check
    await new Promise(resolve => setTimeout(resolve, delayMs));
  }
  
  return false; // Still provisional after timeout
}

Best Practices

1. Fail Securely

When validation fails, deny access:
// ✅ Good - Fail closed
async function checkAccess(userId) {
  try {
    const entitlements = await validateEntitlements(userId);
    return !!entitlements.all?.freeTrial;
  } catch (error) {
    console.error('Validation failed:', error);
    return false; // Deny access on error
  }
}

2. Cache Appropriately

Balance freshness with performance:
// Good - 5 minute cache
const CACHE_TTL = 5 * 60 * 1000;

// Too aggressive - 10 second cache
const CACHE_TTL = 10 * 1000;

// Too long - 1 hour cache
const CACHE_TTL = 60 * 60 * 1000;

3. Minimize Validation Calls

Validate once per session or page load:
// ✅ Good - Validate on page load
useEffect(() => {
  validateEntitlements();
}, []); // Only on mount

// ❌ Bad - Validate on every render
function Component() {
  validateEntitlements(); // Runs every render!
  return <div>...</div>;
}

4. Update UI Reactively

Respond to entitlement changes:
// ✅ Good - Reactive updates
entitlements$.subscribe(data => {
  updateUI(data);
});

// ❌ Bad - Manual checks everywhere
if (hasAccess) { /* ... */ }
// Duplicate logic in multiple places

5. Provide User Feedback

Show loading and error states:
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!hasAccess) return <UpgradePrompt />;
return <PremiumContent />;

Migration from Client-Side Events

If you were using deprecated client-side events:
// ❌ Old - Client-side events (deprecated)
import Encore from '@encore/web-sdk';

Encore.on('entitlementChanged', (event) => {
  updateUI(event);
});

const observable = Encore.observeEntitlement({ type: 'freeTrial' });
observable.subscribe(isActive => {
  setHasAccess(isActive);
});

// ✅ New - Server-managed state
// Option 1: Polling
const poller = setInterval(async () => {
  const response = await fetch('/api/validate-entitlements', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId: getCurrentUserId() }),
  });
  const entitlements = await response.json();
  updateUI(entitlements);
}, 30000);

// Option 2: WebSocket
const ws = new WebSocket('ws://localhost:8080');
ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  if (message.type === 'entitlement_changed') {
    updateUI(message.data);
  }
};

// Option 3: Manual refresh after actions
async function handleOfferClaimed() {
  const result = await Encore.presentOffer();
  if (result.granted) {
    const entitlements = await refreshEntitlements();
    updateUI(entitlements);
  }
}

Next Steps