RevenueCat is a powerful subscription and in-app purchase infrastructure that simplifies monetization across iOS, Android, and web platforms. For Java developers building Android applications or backend services, RevenueCat provides a unified API to manage subscriptions, validate receipts, and analyze revenue data. Let's explore how to integrate RevenueCat into Java-based mobile ecosystems.
Why RevenueCat for Java Mobile Development?
Benefits for Java developers:
- Cross-platform Consistency - Unified subscription logic across iOS and Android
- Server-side Validation - Secure receipt validation from Java backend
- Real-time Analytics - Live subscription status and revenue tracking
- Scalability - Handles millions of users with proven infrastructure
- Reduced Complexity - Abstracts away platform-specific purchase flows
Android Integration
1. Dependencies Setup
// app/build.gradle
dependencies {
implementation 'com.revenuecat.purchases:purchases:5.10.0'
implementation 'com.android.billingclient:billing:6.0.1'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
}
2. RevenueCat Configuration Service
@Service
public class RevenueCatService {
private final Purchases purchases;
private final ObjectMapper objectMapper;
@Inject
public RevenueCatService(Application context) {
this.objectMapper = new ObjectMapper();
configureRevenueCat(context);
this.purchases = Purchases.getSharedInstance();
}
private void configureRevenueCat(Context context) {
PurchasesConfiguration.Builder config = new PurchasesConfiguration.Builder(context, "your_public_api_key")
.enableAdServicesTokenCollection(true)
.observerMode(false);
Purchases.configure(config.build());
// Set up listeners
purchases.setUpdatedCustomerInfoListener(this::onCustomerInfoUpdated);
purchases.setLogHandler(this::onRevenueCatLog);
}
// Purchase products
public CompletableFuture<PurchaseResult> purchaseProduct(Activity activity, String productId) {
CompletableFuture<PurchaseResult> future = new CompletableFuture<>();
purchases.getOfferings(new ReceiveOfferingsCallback() {
@Override
public void onReceived(@NonNull Offerings offerings) {
Package packageToPurchase = offerings.getCurrent().getPackage(productId);
if (packageToPurchase != null) {
makePurchase(activity, packageToPurchase, future);
} else {
future.completeExceptionally(new ProductNotFoundException("Product not found: " + productId));
}
}
@Override
public void onError(@NonNull PurchasesError error) {
future.completeExceptionally(new RevenueCatException("Failed to get offerings: " + error.getMessage()));
}
});
return future;
}
private void makePurchase(Activity activity, Package packageToPurchase,
CompletableFuture<PurchaseResult> future) {
purchases.purchasePackage(activity, packageToPurchase, new MakePurchaseCallback() {
@Override
public void onCompleted(@NonNull StoreTransaction storeTransaction,
@NonNull CustomerInfo customerInfo) {
PurchaseResult result = new PurchaseResult(
true,
"Purchase successful",
customerInfo,
storeTransaction
);
future.complete(result);
}
@Override
public void onError(@NonNull PurchasesError error, Boolean userCancelled) {
PurchaseResult result = new PurchaseResult(
false,
error.getMessage(),
null,
null
);
future.complete(result);
}
});
}
// Restore purchases
public CompletableFuture<CustomerInfo> restorePurchases() {
CompletableFuture<CustomerInfo> future = new CompletableFuture<>();
purchases.restorePurchases(new ReceiveCustomerInfoCallback() {
@Override
public void onReceived(@NonNull CustomerInfo customerInfo) {
future.complete(customerInfo);
}
@Override
public void onError(@NonNull PurchasesError error) {
future.completeExceptionally(new RevenueCatException("Restore failed: " + error.getMessage()));
}
});
return future;
}
// Get customer info
public CompletableFuture<CustomerInfo> getCustomerInfo() {
CompletableFuture<CustomerInfo> future = new CompletableFuture<>();
purchases.getCustomerInfo(new ReceiveCustomerInfoCallback() {
@Override
public void onReceived(@NonNull CustomerInfo customerInfo) {
future.complete(customerInfo);
}
@Override
public void onError(@NonNull PurchasesError error) {
future.completeExceptionally(new RevenueCatException("Failed to get customer info: " + error.getMessage()));
}
});
return future;
}
// Get available products
public CompletableFuture<List<RevenueCatProduct>> getProducts() {
CompletableFuture<List<RevenueCatProduct>> future = new CompletableFuture<>();
purchases.getOfferings(new ReceiveOfferingsCallback() {
@Override
public void onReceived(@NonNull Offerings offerings) {
List<RevenueCatProduct> products = new ArrayList<>();
Offering currentOffering = offerings.getCurrent();
if (currentOffering != null) {
for (Package pkg : currentOffering.getAvailablePackages()) {
RevenueCatProduct product = new RevenueCatProduct(
pkg.getIdentifier(),
pkg.getProduct().getTitle(),
pkg.getProduct().getDescription(),
pkg.getProduct().getPrice(),
pkg.getProduct().getPriceCurrencyCode(),
pkg.getPackageType().name()
);
products.add(product);
}
}
future.complete(products);
}
@Override
public void onError(@NonNull PurchasesError error) {
future.completeExceptionally(new RevenueCatException("Failed to get products: " + error.getMessage()));
}
});
return future;
}
// Check subscription status
public CompletableFuture<SubscriptionStatus> getSubscriptionStatus() {
return getCustomerInfo().thenApply(customerInfo -> {
EntitlementInfos entitlements = customerInfo.getEntitlements();
for (EntitlementInfo entitlement : entitlements.getAll().values()) {
if (entitlement.isActive()) {
return new SubscriptionStatus(
true,
entitlement.getIdentifier(),
entitlement.getPeriodType(),
entitlement.getLatestPurchaseDate(),
entitlement.getExpirationDate(),
entitlement.getStore()
);
}
}
return new SubscriptionStatus(false, null, null, null, null, null);
});
}
// Event listeners
private void onCustomerInfoUpdated(@NonNull CustomerInfo customerInfo) {
// Handle customer info updates (subscription changes, etc.)
Log.d("RevenueCat", "Customer info updated: " + customerInfo.getOriginalAppUserId());
// Notify subscribers
EventBus.getDefault().post(new CustomerInfoUpdatedEvent(customerInfo));
}
private void onRevenueCatLog(LogHandler.Loggable loggable) {
Log.d("RevenueCat", loggable.getMessage());
}
// Data models
@Data
public static class PurchaseResult {
private final boolean success;
private final String message;
private final CustomerInfo customerInfo;
private final StoreTransaction transaction;
}
@Data
public static class RevenueCatProduct {
private final String id;
private final String title;
private final String description;
private final String price;
private final String currency;
private final String packageType;
}
@Data
public static class SubscriptionStatus {
private final boolean isActive;
private final String entitlement;
private final String periodType;
private final Date latestPurchaseDate;
private final Date expirationDate;
private final String store;
}
}
Backend Server Integration
1. RevenueCat Webhook Handler
@RestController
@RequestMapping("/api/revenuecat")
@Slf4j
public class RevenueCatWebhookController {
private final RevenueCatWebhookService webhookService;
private final SubscriptionService subscriptionService;
private final ObjectMapper objectMapper;
public RevenueCatWebhookController(RevenueCatWebhookService webhookService,
SubscriptionService subscriptionService) {
this.webhookService = webhookService;
this.subscriptionService = subscriptionService;
this.objectMapper = new ObjectMapper();
this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
@PostMapping("/webhook")
public ResponseEntity<String> handleWebhook(
@RequestBody String payload,
@RequestHeader("RevenueCat-Signature") String signature) {
try {
// Verify webhook signature
if (!webhookService.verifySignature(payload, signature)) {
log.warn("Invalid webhook signature");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
RevenueCatWebhookEvent event = objectMapper.readValue(payload, RevenueCatWebhookEvent.class);
switch (event.getType()) {
case "INITIAL_PURCHASE":
handleInitialPurchase(event);
break;
case "RENEWAL":
handleRenewal(event);
break;
case "CANCELLATION":
handleCancellation(event);
break;
case "EXPIRATION":
handleExpiration(event);
break;
case "BILLING_ISSUE":
handleBillingIssue(event);
break;
case "PRODUCT_CHANGE":
handleProductChange(event);
break;
default:
log.info("Unhandled webhook type: {}", event.getType());
}
return ResponseEntity.ok("Webhook processed");
} catch (Exception e) {
log.error("Failed to process RevenueCat webhook", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
private void handleInitialPurchase(RevenueCatWebhookEvent event) {
String appUserId = event.getAppUserId();
String entitlement = event.getEntitlementId();
log.info("Initial purchase for user: {}, entitlement: {}", appUserId, entitlement);
// Update user subscription status in your database
subscriptionService.activateSubscription(appUserId, entitlement, event.getEventTimestamp());
// Send welcome email or trigger onboarding
notificationService.sendWelcomeEmail(appUserId);
}
private void handleRenewal(RevenueCatWebhookEvent event) {
String appUserId = event.getAppUserId();
log.info("Subscription renewed for user: {}", appUserId);
subscriptionService.renewSubscription(appUserId, event.getEventTimestamp());
}
private void handleCancellation(RevenueCatWebhookEvent event) {
String appUserId = event.getAppUserId();
log.info("Subscription cancelled for user: {}", appUserId);
subscriptionService.cancelSubscription(appUserId, event.getEventTimestamp());
// Send cancellation survey or retention offer
notificationService.sendCancellationSurvey(appUserId);
}
private void handleExpiration(RevenueCatWebhookEvent event) {
String appUserId = event.getAppUserId();
log.info("Subscription expired for user: {}", appUserId);
subscriptionService.expireSubscription(appUserId, event.getEventTimestamp());
// Send re-engagement email
notificationService.sendExpirationNotification(appUserId);
}
private void handleBillingIssue(RevenueCatWebhookEvent event) {
String appUserId = event.getAppUserId();
log.warn("Billing issue for user: {}", appUserId);
subscriptionService.flagBillingIssue(appUserId);
// Notify user about billing issue
notificationService.sendBillingIssueNotification(appUserId);
}
private void handleProductChange(RevenueCatWebhookEvent event) {
String appUserId = event.getAppUserId();
String newEntitlement = event.getNewProductId();
log.info("Product change for user: {}, new product: {}", appUserId, newEntitlement);
subscriptionService.changeSubscriptionPlan(appUserId, newEntitlement, event.getEventTimestamp());
}
}
2. RevenueCat API Client for Backend
@Service
@Slf4j
public class RevenueCatApiClient {
private final OkHttpClient httpClient;
private final ObjectMapper objectMapper;
private final RevenueCatConfig config;
public RevenueCatApiClient(RevenueCatConfig config) {
this.config = config;
this.objectMapper = new ObjectMapper();
this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
this.httpClient = new OkHttpClient.Builder()
.addInterceptor(new RevenueCatAuthInterceptor(config))
.addInterceptor(new HttpLoggingInterceptor(log::info).setLevel(HttpLoggingInterceptor.Level.BASIC))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
}
// Get subscriber information
public CompletableFuture<Subscriber> getSubscriber(String appUserId) {
return CompletableFuture.supplyAsync(() -> {
try {
Request request = new Request.Builder()
.url(config.getBaseUrl() + "/v1/subscribers/" + appUserId)
.get()
.build();
Response response = httpClient.newCall(request).execute();
if (!response.isSuccessful()) {
handleErrorResponse(response, "Failed to get subscriber: " + appUserId);
}
String responseBody = response.body().string();
return objectMapper.readValue(responseBody, Subscriber.class);
} catch (Exception e) {
throw new RevenueCatApiException("Failed to get subscriber: " + appUserId, e);
}
});
}
// Grant promotional entitlement
public CompletableFuture<Void> grantPromotionalEntitlement(String appUserId,
String entitlementId,
int durationDays) {
return CompletableFuture.runAsync(() -> {
try {
Map<String, Object> body = Map.of(
"entitlement_id", entitlementId,
"duration", Map.of(
"value", durationDays,
"unit", "days"
)
);
String jsonBody = objectMapper.writeValueAsString(body);
Request request = new Request.Builder()
.url(config.getBaseUrl() + "/v1/subscribers/" + appUserId + "/entitlements")
.post(RequestBody.create(jsonBody, MediaType.parse("application/json")))
.build();
Response response = httpClient.newCall(request).execute();
if (!response.isSuccessful()) {
handleErrorResponse(response, "Failed to grant entitlement");
}
} catch (Exception e) {
throw new RevenueCatApiException("Failed to grant promotional entitlement", e);
}
});
}
// Revoke promotional entitlement
public CompletableFuture<Void> revokePromotionalEntitlement(String appUserId, String entitlementId) {
return CompletableFuture.runAsync(() -> {
try {
Request request = new Request.Builder()
.url(config.getBaseUrl() + "/v1/subscribers/" + appUserId + "/entitlements/" + entitlementId)
.delete()
.build();
Response response = httpClient.newCall(request).execute();
if (!response.isSuccessful()) {
handleErrorResponse(response, "Failed to revoke entitlement");
}
} catch (Exception e) {
throw new RevenueCatApiException("Failed to revoke promotional entitlement", e);
}
});
}
// Create subscriber (if they don't exist)
public CompletableFuture<Subscriber> createSubscriber(String appUserId) {
return CompletableFuture.supplyAsync(() -> {
try {
Map<String, Object> body = Map.of(
"app_user_id", appUserId
);
String jsonBody = objectMapper.writeValueAsString(body);
Request request = new Request.Builder()
.url(config.getBaseUrl() + "/v1/subscribers")
.post(RequestBody.create(jsonBody, MediaType.parse("application/json")))
.build();
Response response = httpClient.newCall(request).execute();
if (!response.isSuccessful()) {
handleErrorResponse(response, "Failed to create subscriber");
}
String responseBody = response.body().string();
return objectMapper.readValue(responseBody, Subscriber.class);
} catch (Exception e) {
throw new RevenueCatApiException("Failed to create subscriber", e);
}
});
}
// Delete subscriber
public CompletableFuture<Void> deleteSubscriber(String appUserId) {
return CompletableFuture.runAsync(() -> {
try {
Request request = new Request.Builder()
.url(config.getBaseUrl() + "/v1/subscribers/" + appUserId)
.delete()
.build();
Response response = httpClient.newCall(request).execute();
if (!response.isSuccessful()) {
handleErrorResponse(response, "Failed to delete subscriber");
}
} catch (Exception e) {
throw new RevenueCatApiException("Failed to delete subscriber", e);
}
});
}
// Get subscription analytics
public CompletableFuture<SubscriptionAnalytics> getSubscriptionAnalytics(LocalDate startDate, LocalDate endDate) {
return CompletableFuture.supplyAsync(() -> {
try {
HttpUrl url = HttpUrl.parse(config.getBaseUrl() + "/v1/analytics/subscriptions")
.newBuilder()
.addQueryParameter("start_date", startDate.toString())
.addQueryParameter("end_date", endDate.toString())
.build();
Request request = new Request.Builder()
.url(url)
.get()
.build();
Response response = httpClient.newCall(request).execute();
if (!response.isSuccessful()) {
handleErrorResponse(response, "Failed to get analytics");
}
String responseBody = response.body().string();
return objectMapper.readValue(responseBody, SubscriptionAnalytics.class);
} catch (Exception e) {
throw new RevenueCatApiException("Failed to get subscription analytics", e);
}
});
}
private void handleErrorResponse(Response response, String context) throws IOException {
int code = response.code();
String body = response.body() != null ? response.body().string() : "No response body";
log.error("RevenueCat API error - Status: {}, Context: {}, Body: {}", code, context, body);
switch (code) {
case 400:
throw new RevenueCatBadRequestException(context + ": " + body);
case 401:
throw new RevenueCatAuthenticationException("Authentication failed");
case 404:
throw new RevenueCatNotFoundException("Resource not found");
case 429:
throw new RevenueCatRateLimitException("Rate limit exceeded");
default:
throw new RevenueCatApiException("API error - Status: " + code + ", Context: " + context);
}
}
// Authentication interceptor
private static class RevenueCatAuthInterceptor implements Interceptor {
private final RevenueCatConfig config;
public RevenueCatAuthInterceptor(RevenueCatConfig config) {
this.config = config;
}
@Override
public Response intercept(Chain chain) throws IOException {
Request originalRequest = chain.request();
Request authenticatedRequest = originalRequest.newBuilder()
.header("Authorization", "Bearer " + config.getSecretKey())
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.build();
return chain.proceed(authenticatedRequest);
}
}
}
3. Subscription Management Service
@Service
@Slf4j
public class SubscriptionService {
private final RevenueCatApiClient revenueCatClient;
private final SubscriptionRepository subscriptionRepository;
private final UserRepository userRepository;
public SubscriptionService(RevenueCatApiClient revenueCatClient,
SubscriptionRepository subscriptionRepository,
UserRepository userRepository) {
this.revenueCatClient = revenueCatClient;
this.subscriptionRepository = subscriptionRepository;
this.userRepository = userRepository;
}
@Transactional
public void syncUserSubscription(String appUserId) {
try {
Subscriber subscriber = revenueCatClient.getSubscriber(appUserId).get();
UserSubscription subscription = subscriptionRepository.findByAppUserId(appUserId)
.orElse(new UserSubscription(appUserId));
// Update subscription status from RevenueCat data
updateSubscriptionFromSubscriber(subscription, subscriber);
subscriptionRepository.save(subscription);
// Update user access levels
updateUserAccessLevels(appUserId, subscription);
} catch (Exception e) {
log.error("Failed to sync subscription for user: {}", appUserId, e);
throw new SubscriptionSyncException("Failed to sync subscription", e);
}
}
@Transactional
public void activateSubscription(String appUserId, String entitlementId, Instant activatedAt) {
UserSubscription subscription = subscriptionRepository.findByAppUserId(appUserId)
.orElse(new UserSubscription(appUserId));
subscription.setActive(true);
subscription.setEntitlementId(entitlementId);
subscription.setActivatedAt(activatedAt);
subscription.setLastSyncedAt(Instant.now());
subscriptionRepository.save(subscription);
// Grant access to premium features
grantPremiumAccess(appUserId);
log.info("Subscription activated for user: {}, entitlement: {}", appUserId, entitlementId);
}
@Transactional
public void cancelSubscription(String appUserId, Instant cancelledAt) {
UserSubscription subscription = subscriptionRepository.findByAppUserId(appUserId)
.orElseThrow(() -> new SubscriptionNotFoundException(appUserId));
subscription.setActive(false);
subscription.setCancelledAt(cancelledAt);
subscription.setLastSyncedAt(Instant.now());
subscriptionRepository.save(subscription);
// Revoke premium access (with grace period)
schedulePremiumAccessRevocation(appUserId);
log.info("Subscription cancelled for user: {}", appUserId);
}
@Transactional
public void expireSubscription(String appUserId, Instant expiredAt) {
UserSubscription subscription = subscriptionRepository.findByAppUserId(appUserId)
.orElseThrow(() -> new SubscriptionNotFoundException(appUserId));
subscription.setActive(false);
subscription.setExpiredAt(expiredAt);
subscription.setLastSyncedAt(Instant.now());
subscriptionRepository.save(subscription);
// Immediately revoke premium access
revokePremiumAccess(appUserId);
log.info("Subscription expired for user: {}", appUserId);
}
public boolean hasActiveSubscription(String appUserId) {
return subscriptionRepository.findByAppUserId(appUserId)
.map(UserSubscription::isActive)
.orElse(false);
}
public Optional<String> getActiveEntitlement(String appUserId) {
return subscriptionRepository.findByAppUserId(appUserId)
.filter(UserSubscription::isActive)
.map(UserSubscription::getEntitlementId);
}
public List<UserSubscription> findExpiringSubscriptions(Instant withinDays) {
return subscriptionRepository.findByActiveTrueAndExpiresAtBefore(withinDays);
}
public List<UserSubscription> findTrialsExpiringSoon(Instant withinDays) {
return subscriptionRepository.findByIsTrialTrueAndTrialEndsAtBefore(withinDays);
}
private void updateSubscriptionFromSubscriber(UserSubscription subscription, Subscriber subscriber) {
// Implementation to map RevenueCat subscriber data to local subscription model
Entitlements entitlements = subscriber.getSubscriber().getEntitlements();
boolean hasActiveEntitlement = entitlements.values().stream()
.anyMatch(Entitlement::getIsActive);
subscription.setActive(hasActiveEntitlement);
subscription.setLastSyncedAt(Instant.now());
// Update other fields from subscriber data
if (hasActiveEntitlement) {
entitlements.values().stream()
.filter(Entitlement::getIsActive)
.findFirst()
.ifPresent(activeEntitlement -> {
subscription.setEntitlementId(activeEntitlement.getProductIdentifier());
subscription.setExpiresAt(activeEntitlement.getExpiresDate());
subscription.setStore(activeEntitlement.getStore());
});
}
}
private void updateUserAccessLevels(String appUserId, UserSubscription subscription) {
User user = userRepository.findByAppUserId(appUserId)
.orElseThrow(() -> new UserNotFoundException(appUserId));
user.setPremiumAccess(subscription.isActive());
user.setPremiumFeatures(getPremiumFeaturesForEntitlement(subscription.getEntitlementId()));
userRepository.save(user);
}
private void grantPremiumAccess(String appUserId) {
User user = userRepository.findByAppUserId(appUserId)
.orElseThrow(() -> new UserNotFoundException(appUserId));
user.setPremiumAccess(true);
userRepository.save(user);
// Trigger any post-activation workflows
eventPublisher.publishEvent(new PremiumAccessGrantedEvent(appUserId));
}
private void revokePremiumAccess(String appUserId) {
User user = userRepository.findByAppUserId(appUserId)
.orElseThrow(() -> new UserNotFoundException(appUserId));
user.setPremiumAccess(false);
userRepository.save(user);
// Trigger any post-revocation workflows
eventPublisher.publishEvent(new PremiumAccessRevokedEvent(appUserId));
}
private void schedulePremiumAccessRevocation(String appUserId) {
// Schedule revocation for end of billing period
scheduler.schedule(() -> revokePremiumAccess(appUserId),
calculateRevocationDelay(appUserId));
}
private List<String> getPremiumFeaturesForEntitlement(String entitlementId) {
// Map entitlement IDs to specific feature sets
return featureMappingService.getFeaturesForEntitlement(entitlementId);
}
}
Data Models
1. RevenueCat Webhook Event Model
@Data
public class RevenueCatWebhookEvent {
private String type;
private String id;
private String appId;
private String appUserId;
private String entitlementId;
private String productId;
private String newProductId;
private Instant eventTimestamp;
private Map<String, Object> data;
public enum EventType {
INITIAL_PURCHASE,
RENEWAL,
CANCELLATION,
EXPIRATION,
BILLING_ISSUE,
PRODUCT_CHANGE,
SUBSCRIBER_ALIAS
}
}
@Data
public class Subscriber {
private SubscriberData subscriber;
private Request request;
@Data
public static class SubscriberData {
private String originalAppUserId;
private String originalApplicationVersion;
private FirstSeenInfo firstSeen;
private Map<String, Entitlement> entitlements;
private Map<String, Subscription> subscriptions;
private Map<String, NonSubscription> nonSubscriptions;
}
@Data
public static class Entitlement {
private String productIdentifier;
private Boolean isActive;
private Instant expiresDate;
private Instant purchaseDate;
private String store;
private String unsubscribeDetectedAt;
private String billingIssueDetectedAt;
}
}
@Data
public class UserSubscription {
private String id;
private String appUserId;
private boolean isActive;
private String entitlementId;
private String store;
private Instant activatedAt;
private Instant cancelledAt;
private Instant expiredAt;
private Instant expiresAt;
private boolean isTrial;
private Instant trialEndsAt;
private Instant lastSyncedAt;
private Map<String, Object> metadata;
public UserSubscription() {}
public UserSubscription(String appUserId) {
this.appUserId = appUserId;
this.isActive = false;
this.lastSyncedAt = Instant.now();
}
}
Configuration
1. RevenueCat Configuration
@Configuration
@ConfigurationProperties(prefix = "revenuecat")
@Data
public class RevenueCatConfig {
private String publicKey;
private String secretKey;
private String baseUrl = "https://api.revenuecat.com";
private String webhookSecret;
@Bean
public RevenueCatApiClient revenueCatApiClient() {
return new RevenueCatApiClient(this);
}
}
2. Application Properties
# application.yml
revenuecat:
public-key: ${REVENUECAT_PUBLIC_KEY}
secret-key: ${REVENUECAT_SECRET_KEY}
webhook-secret: ${REVENUECAT_WEBHOOK_SECRET}
base-url: https://api.revenuecat.com
Testing
1. Unit Tests
@ExtendWith(MockitoExtension.class)
class SubscriptionServiceTest {
@Mock
private RevenueCatApiClient revenueCatClient;
@Mock
private SubscriptionRepository subscriptionRepository;
@InjectMocks
private SubscriptionService subscriptionService;
@Test
void shouldActivateSubscriptionSuccessfully() {
// Given
String appUserId = "user123";
String entitlementId = "premium_monthly";
Instant activatedAt = Instant.now();
when(subscriptionRepository.findByAppUserId(appUserId))
.thenReturn(Optional.empty());
// When
subscriptionService.activateSubscription(appUserId, entitlementId, activatedAt);
// Then
verify(subscriptionRepository).save(argThat(subscription ->
subscription.getAppUserId().equals(appUserId) &&
subscription.isActive() &&
subscription.getEntitlementId().equals(entitlementId)
));
}
@Test
void shouldSyncSubscriptionFromRevenueCat() {
// Given
String appUserId = "user123";
Subscriber mockSubscriber = createMockSubscriber();
when(revenueCatClient.getSubscriber(appUserId))
.thenReturn(CompletableFuture.completedFuture(mockSubscriber));
when(subscriptionRepository.findByAppUserId(appUserId))
.thenReturn(Optional.of(new UserSubscription(appUserId)));
// When
subscriptionService.syncUserSubscription(appUserId);
// Then
verify(subscriptionRepository).save(argThat(subscription ->
subscription.isActive() &&
subscription.getLastSyncedAt() != null
));
}
}
Best Practices
- Error Handling - Implement comprehensive error handling for network failures
- Retry Logic - Add retry with exponential backoff for API calls
- Caching - Cache subscriber data to reduce API calls
- Security - Validate webhook signatures and use secure API key storage
- Monitoring - Track subscription metrics and API usage
- Testing - Mock RevenueCat API for reliable testing
- Grace Periods - Implement grace periods for subscription expirations
Conclusion
Integrating RevenueCat with Java applications provides a robust foundation for mobile subscription management. By implementing the patterns shown here, Java developers can:
- Simplify Subscription Logic - Abstract away platform-specific purchase flows
- Ensure Data Consistency - Keep local subscription state in sync with RevenueCat
- Automate Business Processes - Trigger workflows based on subscription events
- Gain Insights - Leverage RevenueCat's analytics for business intelligence
- Scale Confidently - Rely on RevenueCat's infrastructure for global scale
The combination of RevenueCat's powerful subscription platform with Java's enterprise capabilities creates a comprehensive solution for mobile monetization that can scale from startup to enterprise-level applications.