Skip to content

Omnichannel Entitlement Sync: Cross-Platform Subscription Access

How to build a reliable entitlement synchronization layer that keeps subscription access consistent across web, iOS, and Android using EventBridge, webhooks, and idempotent processing.

Abstract

When a product sells subscriptions across web, iOS, and Android, each platform emits its own subscription events with its own schema, lifecycle, and delivery delay. An entitlement synchronization layer is the piece that turns those heterogeneous events into a single consistent answer to "what can this user access right now?". A naive per-platform handler drifts out of sync within hours; the underlying problem is distributed state, not webhook plumbing.

This post shows how to design that layer on AWS EventBridge. It covers event normalization across Stripe, App Store Server Notifications v2, and Google Play RTDN; idempotent webhook handlers; a DynamoDB entitlement store; and the edge cases (refunds, family sharing, grace periods) that break cross-platform subscriptions in production.

The Cross-Platform Entitlement Problem

When your product accepts payments from multiple sources -- Stripe for web, Apple App Store for iOS, Google Play for Android -- each platform sends subscription events in its own format, at its own pace, with its own lifecycle model.

The challenge is not receiving these events. The challenge is turning them into a single, consistent answer to: "What can this user access right now?"

Each payment provider has different event names for similar concepts. Apple calls it DID_RENEW. Stripe calls it invoice.payment_succeeded. Google calls it SUBSCRIPTION_RENEWED. A renewal on Apple might arrive minutes after it happens, while Stripe fires almost immediately.

Without an entitlement layer, your application code ends up with platform-specific checks scattered everywhere. That approach breaks the moment you add a fourth payment source or change your subscription tiers.

Designing the Entitlement Layer

The entitlement layer sits between your payment providers and your application logic. It answers one question: given a user ID, what features and access levels are currently active?

Source of Truth Principle

The entitlement store -- not the payment provider -- is the source of truth for access decisions. Payment providers are the source of truth for billing state, but your entitlement store is the source of truth for what the user can do.

This distinction matters. A payment provider might report a subscription as "past_due" while the user still has access during a grace period. Your entitlement layer defines those rules, not the provider.

Entitlement Table Design

Entitlements are not simple booleans. A subscription can be active, in a grace period, paused, or in billing retry -- and each state maps to different access levels.

typescript
// DynamoDB table design for entitlementsinterface EntitlementRecord {  // Partition key: composite of userId  pk: string;  // "USER#usr_abc123"  // Sort key: entitlement identifier  sk: string;  // "ENT#premium"
  userId: string;  entitlementId: string;  // "premium", "team", "enterprise"  status: "active" | "grace_period" | "billing_retry" | "paused" | "expired" | "revoked";  source: "apple" | "google" | "stripe" | "manual";  sourceSubscriptionId: string;  plan: string;  // "monthly_premium", "annual_team"  features: string[];  // ["unlimited_projects", "api_access", "priority_support"]
  activatedAt: string;  // ISO 8601  expiresAt: string;  // ISO 8601  gracePeriodEndsAt?: string;
  lastEventId: string;  // For idempotency tracking  lastEventTimestamp: string;  updatedAt: string;  ttl?: number;  // Auto-cleanup for expired records}

The source field tracks which platform created this entitlement. This becomes critical when handling conflicts -- if the same user subscribes on both Apple and Stripe, you need to know which entitlement takes priority.

Feature Mapping

Map subscription plans to concrete features rather than relying on plan names. This decouples your access logic from your pricing structure.

typescript
const PLAN_FEATURES: Record<string, string[]> = {  "free": ["basic_access", "3_projects"],  "monthly_premium": ["unlimited_projects", "api_access", "export"],  "annual_premium": ["unlimited_projects", "api_access", "export", "priority_support"],  "team": ["unlimited_projects", "api_access", "export", "priority_support", "team_management"],};

When checking access, query features rather than plan names:

typescript
async function hasFeature(userId: string, feature: string): Promise<boolean> {  const entitlements = await getActiveEntitlements(userId);  return entitlements.some(ent =>    ent.status === "active" && ent.features.includes(feature)  );}

Cross-Platform Sync Strategies

There are three approaches to keeping clients in sync with your entitlement store.

Polling

The client periodically calls your entitlement API. Simple to implement, but adds latency -- a user might wait up to the polling interval before seeing their access update.

Best for: applications where real-time access updates are not critical. Typical polling interval: 30-60 seconds for active sessions, 5 minutes for background.

Push (WebSocket / Push Notifications)

The server pushes entitlement changes to connected clients via WebSocket or mobile push notifications. Provides near-instant updates but adds infrastructure complexity.

Best for: applications where immediate access changes matter (collaboration tools, streaming services).

Combine server-side webhook processing with client-side smart polling. The server processes webhooks and updates the entitlement store immediately. Clients poll on a regular interval, but also refresh on specific triggers.

typescript
// Client-side entitlement check with smart cachingclass EntitlementClient {  private cache: Map<string, { data: EntitlementRecord[]; fetchedAt: number }> = new Map();  private readonly CACHE_TTL_MS = 30_000; // 30 seconds
  async getEntitlements(userId: string, forceRefresh = false): Promise<EntitlementRecord[]> {    const cached = this.cache.get(userId);    const now = Date.now();
    if (!forceRefresh && cached && (now - cached.fetchedAt) < this.CACHE_TTL_MS) {      return cached.data;    }
    const response = await fetch(`/api/entitlements/${userId}`);    const data = await response.json();
    this.cache.set(userId, { data, fetchedAt: now });    return data;  }
  // Call on app foreground, after purchase, on push notification  async refreshEntitlements(userId: string): Promise<EntitlementRecord[]> {    return this.getEntitlements(userId, true);  }}

The key triggers for a forced refresh:

  • App returns to foreground
  • User completes a purchase flow
  • Push notification received about subscription changes
  • User navigates to a premium feature

Webhook Reliability

Webhooks are the backbone of server-side entitlement updates. But webhooks are unreliable by nature -- they can arrive out of order, be duplicated, or fail silently. Building a reliable webhook pipeline requires four patterns.

1. Queue-First Processing

Return HTTP 200 immediately upon receiving the webhook. Then process it asynchronously. This prevents timeouts and ensures the payment provider does not retry unnecessarily.

typescript
// Webhook ingress -- fast acknowledgment, async processingimport { EventBridgeClient, PutEventsCommand } from "@aws-sdk/client-eventbridge";
const eventBridge = new EventBridgeClient({});
export async function handler(event: APIGatewayProxyEvent) {  const provider = event.pathParameters?.provider; // "stripe", "apple", "google"  const body = JSON.parse(event.body || "{}");
  // Step 1: Verify signature (provider-specific)  if (!verifyWebhookSignature(provider, event)) {    return { statusCode: 401, body: "Invalid signature" };  }
  // Step 2: Normalize the event  const normalized = normalizePaymentEvent(provider, body);
  // Step 3: Forward to EventBridge immediately  await eventBridge.send(new PutEventsCommand({    Entries: [{      Source: "payments.webhook",      DetailType: `subscription.${normalized.action}`,      Detail: JSON.stringify(normalized),      EventBusName: "entitlements",    }],  }));
  // Step 4: Return 200 fast  return { statusCode: 200, body: "OK" };}

2. Event Normalization

Each provider sends different payloads. Normalize them into a common schema before further processing.

typescript
interface NormalizedSubscriptionEvent {  eventId: string;  // Unique event identifier  action: "created" | "renewed" | "canceled" | "expired" | "refunded" | "grace_period" | "billing_retry";  userId: string;  // Your internal user ID  source: "apple" | "google" | "stripe";  sourceSubscriptionId: string;  plan: string;  currency: string;  amount: number;  // In smallest unit (cents)  timestamp: string;  // ISO 8601  expiresAt: string;  metadata: Record<string, string>;}
function normalizePaymentEvent(  provider: string,  rawEvent: Record<string, unknown>): NormalizedSubscriptionEvent {  switch (provider) {    case "stripe":      return normalizeStripeEvent(rawEvent);    case "apple":      return normalizeAppleEvent(rawEvent);    case "google":      return normalizeGoogleEvent(rawEvent);    default:      throw new Error(`Unknown provider: ${provider}`);  }}
function normalizeStripeEvent(raw: Record<string, unknown>): NormalizedSubscriptionEvent {  const event = raw as { id: string; type: string; data: { object: Record<string, unknown> } };  const sub = event.data.object;
  const ACTION_MAP: Record<string, NormalizedSubscriptionEvent["action"]> = {    "customer.subscription.created": "created",    "invoice.payment_succeeded": "renewed",    "customer.subscription.deleted": "canceled",    "customer.subscription.updated": "renewed",    "charge.refunded": "refunded",  };
  return {    eventId: event.id,    action: ACTION_MAP[event.type] || "renewed",    userId: sub.metadata?.userId as string || "",    source: "stripe",    sourceSubscriptionId: sub.id as string,    plan: (sub.items as { data: { price: { id: string } }[] })?.data?.[0]?.price?.id || "",    currency: sub.currency as string || "usd",    amount: sub.amount as number || 0,    timestamp: new Date().toISOString(),    expiresAt: new Date((sub.current_period_end as number) * 1000).toISOString(),    metadata: sub.metadata as Record<string, string> || {},  };}

3. Idempotent Processing

Webhooks are delivered at-least-once. The same event can arrive multiple times. Use the event ID as an idempotency key with DynamoDB conditional writes.

typescript
import { makeIdempotent, IdempotencyConfig } from "@aws-lambda-powertools/idempotency";import { DynamoDBPersistenceLayer } from "@aws-lambda-powertools/idempotency/dynamodb";
const persistenceStore = new DynamoDBPersistenceLayer({  tableName: "IdempotencyStore",});
async function processEntitlementEvent(  event: NormalizedSubscriptionEvent): Promise<{ updated: boolean }> {  // Check if this event is newer than the last processed event  const current = await getEntitlement(event.userId, event.plan);
  if (current && current.lastEventTimestamp >= event.timestamp) {    // Stale event -- a newer event was already processed    return { updated: false };  }
  const statusMap: Record<string, EntitlementRecord["status"]> = {    created: "active",    renewed: "active",    canceled: "expired",    expired: "expired",    refunded: "revoked",    grace_period: "grace_period",    billing_retry: "billing_retry",  };
  await updateEntitlement({    userId: event.userId,    entitlementId: planToEntitlement(event.plan),    status: statusMap[event.action] || "active",    source: event.source,    sourceSubscriptionId: event.sourceSubscriptionId,    plan: event.plan,    features: PLAN_FEATURES[event.plan] || [],    expiresAt: event.expiresAt,    lastEventId: event.eventId,    lastEventTimestamp: event.timestamp,  });
  return { updated: true };}
// Wrap with Powertools idempotencyconst idempotencyConfig = new IdempotencyConfig({  eventKeyJmespath: "detail.eventId",});
export const handler = makeIdempotent(  async (event: { detail: NormalizedSubscriptionEvent }) => {    return processEntitlementEvent(event.detail);  },  {    persistenceStore,    config: idempotencyConfig,  });

The key insight: use both Powertools idempotency (for Lambda-level deduplication) and timestamp comparison (for handling out-of-order events). An event that arrives late should not overwrite a newer state.

4. Dead Letter Queue

Events that fail processing after all retries need somewhere to go. A DLQ captures these for investigation and replay.

EventBridge Entitlement Architecture

EventBridge is a natural fit for entitlement synchronization because it provides content-based routing, built-in retry, and native Lambda integration. Here is the full pipeline.

Event Bus and Rules

Create a dedicated event bus for entitlement events. Use rules to route events to the right processing targets.

typescript
// CDK infrastructure for the entitlement pipelineimport * as cdk from "aws-cdk-lib";import * as events from "aws-cdk-lib/aws-events";import * as targets from "aws-cdk-lib/aws-events-targets";import * as lambda from "aws-cdk-lib/aws-lambda-nodejs";import * as dynamodb from "aws-cdk-lib/aws-dynamodb";import * as sqs from "aws-cdk-lib/aws-sqs";
export class EntitlementSyncStack extends cdk.Stack {  constructor(scope: cdk.App, id: string) {    super(scope, id);
    // Entitlement store    const entitlementTable = new dynamodb.Table(this, "EntitlementTable", {      partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },      sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,      timeToLiveAttribute: "ttl",    });
    // Idempotency store    const idempotencyTable = new dynamodb.Table(this, "IdempotencyTable", {      partitionKey: { name: "id", type: dynamodb.AttributeType.STRING },      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,      timeToLiveAttribute: "expiration",    });
    // DLQ for failed events    const dlq = new sqs.Queue(this, "EntitlementDLQ", {      retentionPeriod: cdk.Duration.days(14),    });
    // Custom event bus    const bus = new events.EventBus(this, "EntitlementBus", {      eventBusName: "entitlements",    });
    // Entitlement sync Lambda    const syncFn = new lambda.NodejsFunction(this, "EntitlementSyncFn", {      entry: "src/handlers/entitlement-sync.ts",      environment: {        ENTITLEMENT_TABLE: entitlementTable.tableName,        IDEMPOTENCY_TABLE: idempotencyTable.tableName,      },      timeout: cdk.Duration.seconds(30),      retryAttempts: 2,      deadLetterQueue: dlq,    });
    entitlementTable.grantReadWriteData(syncFn);    idempotencyTable.grantReadWriteData(syncFn);
    // Route all subscription events to the sync Lambda    new events.Rule(this, "SubscriptionEventRule", {      eventBus: bus,      eventPattern: {        source: ["payments.webhook"],        detailType: [          "subscription.created",          "subscription.renewed",          "subscription.canceled",          "subscription.expired",          "subscription.refunded",          "subscription.grace_period",          "subscription.billing_retry",        ],      },      targets: [new targets.LambdaFunction(syncFn)],    });  }}

Event Schema

Use a consistent event schema that carries all the context needed for entitlement decisions.

json
{  "source": "payments.webhook",  "detail-type": "subscription.renewed",  "detail": {    "eventId": "evt_stripe_abc123",    "action": "renewed",    "userId": "usr_def456",    "source": "stripe",    "sourceSubscriptionId": "sub_xyz789",    "plan": "monthly_premium",    "currency": "usd",    "amount": 999,    "timestamp": "2025-04-02T10:30:00Z",    "expiresAt": "2025-05-02T10:30:00Z",    "metadata": {      "originalPlatform": "web"    }  }}

EventBridge provides built-in retry with exponential backoff -- up to 185 retries over 24 hours. Combined with the DLQ, this gives you multiple layers of failure protection.

Handling Multi-Platform Conflicts

The trickiest edge case: a user subscribes to your premium plan on both iOS (through Apple) and web (through Stripe). Now you have two active entitlements for the same feature set from different sources.

Conflict Resolution Strategy

Define a platform priority hierarchy. When conflicts arise, the higher-priority source wins for access decisions, but both entitlements remain tracked.

typescript
const PLATFORM_PRIORITY: Record<string, number> = {  manual: 100,  // Admin overrides always win  stripe: 80,  // Web subscriptions (your best margin)  google: 60,  apple: 40,  // Apple has highest revenue share};
async function resolveEntitlementConflict(  userId: string,  entitlementId: string): Promise<EntitlementRecord> {  const entitlements = await getAllEntitlements(userId, entitlementId);  const active = entitlements.filter(e =>    e.status === "active" || e.status === "grace_period"  );
  if (active.length <= 1) {    return active[0];  }
  // Multiple active entitlements -- pick highest priority  active.sort((a, b) =>    (PLATFORM_PRIORITY[b.source] || 0) - (PLATFORM_PRIORITY[a.source] || 0)  );
  return active[0];}

Warning: Do not automatically cancel the lower-priority subscription. Notify the user that they have duplicate subscriptions and let them choose which to keep. Automatic cancellation causes support tickets and chargebacks.

Grace Period Differences

Each platform handles grace periods differently:

  • Apple: Configurable billing grace period of 3, 16, or 28 days (6 days for weekly subscriptions), followed by a 60-day billing retry window where Apple attempts to recover payment
  • Google: Configurable grace period (up to 7 or 30 days), followed by an account hold period calculated as 60 days minus the grace period duration
  • Stripe: Configurable retry schedule via Smart Retries with customizable dunning behavior

Your entitlement layer needs to map these platform-specific states to your own grace period logic. The safest approach: maintain access during any active grace period and let the entitlement expire only after all retry windows close.

Reconciliation

Even with idempotent processing and DLQs, drift happens. A scheduled reconciliation job compares your entitlement store against each payment provider's subscription API and fixes discrepancies.

typescript
// Scheduled reconciliation -- runs every 6 hoursasync function reconcileEntitlements(): Promise<void> {  const activeEntitlements = await scanActiveEntitlements();
  for (const entitlement of activeEntitlements) {    const providerState = await fetchProviderSubscription(      entitlement.source,      entitlement.sourceSubscriptionId    );
    if (!providerState) {      // Subscription no longer exists at provider      await expireEntitlement(entitlement);      continue;    }
    const expectedStatus = mapProviderStatus(entitlement.source, providerState.status);    if (expectedStatus !== entitlement.status) {      await updateEntitlementStatus(entitlement, expectedStatus);      console.log(        `Reconciliation fix: ${entitlement.userId} ${entitlement.entitlementId} ` +        `${entitlement.status} -> ${expectedStatus}`      );    }  }}

Run reconciliation frequently enough to catch drift but not so frequently that you hit provider API rate limits. Every 4-6 hours works well for most products. Always log reconciliation fixes -- if you see many, your webhook pipeline has a gap.

Key Takeaways

  1. Separate billing state from access state. Payment providers own billing. Your entitlement store owns access. This separation lets you handle grace periods, conflicts, and promotions without touching payment logic.

  2. Normalize events early. Convert provider-specific webhooks to a common schema at the ingress point. Everything downstream works with one format.

  3. Build for at-least-once delivery. Webhooks can duplicate. EventBridge retries. Use idempotency keys and timestamp comparisons to handle this safely.

  4. Add reconciliation from day one. Webhook pipelines drift over time. A periodic reconciliation job catches issues before users notice.

  5. Never auto-cancel duplicate subscriptions. When a user has subscriptions on multiple platforms, notify them and let them decide. The support cost of accidental cancellations far exceeds the engineering cost of handling duplicates.

If you are building a subscription product, the entitlement layer is one of the first cross-cutting concerns worth getting right. It touches every platform, every feature gate, and every user session. Investing in a clean event-driven architecture here pays compound returns as your product grows.

For the payment provider selection that feeds into this architecture, see Payment Providers and Compliance. For mobile-specific receipt validation that produces the Apple and Google events consumed here, see Mobile IAP and Paywall Strategies. For the subscription lifecycle management that triggers these entitlement changes, see Subscription Lifecycle Management.

References

Related Posts