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):Copy
// 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();
}
}
Pattern 2: Session-Based Caching
Cache entitlement state in user session:Copy
// 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');
}
});
Pattern 3: WebSocket Updates
Push real-time entitlement updates to connected clients:Copy
// 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);
}
};
Pattern 4: Polling
Periodically check for entitlement updates:Copy
// 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());
Framework Integration
React
- Context API
- Custom Hook
Copy
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
Copy
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 };
}
Copy
<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
Copy
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];
}
}
Copy
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:Copy
// 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);
}
}
Copy
// 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:Copy
// 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:Copy
// ✅ 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:Copy
// 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:Copy
// ✅ 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:Copy
// ✅ 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:Copy
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:Copy
// ❌ 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);
}
}
Related Documentation
- Server-Side Validation - HMAC authentication and API details
- Track Entitlements Guide - Complete validation patterns
- Present Offers - Offer presentation flow
Next Steps
- Server-Side Validation - Learn HMAC-based validation
- Framework Integration - Deep dive into framework patterns