Article
In the subscription economy, managing recurring billing, subscriptions, and revenue operations requires specialized tools. Recurly is a powerful enterprise-grade subscription management platform that handles billing, payments, and revenue operations, while Java provides the robust backend infrastructure needed for scalable subscription businesses. Integrating Recurly with Java applications enables companies to automate complex billing scenarios while maintaining control over their subscription logic.
What is Recurly?
Recurly is a subscription management platform that provides:
- Recurring Billing: Automated invoicing and payment collection
- Subscription Lifecycle Management: Handle upgrades, downgrades, and cancellations
- Revenue Optimization: Reduce churn and increase lifetime value
- Multiple Payment Gateways: Support for various payment methods
- Dunning Management: Automated failed payment recovery
- Analytics and Reporting: Revenue recognition and business insights
Why Recurly with Java?
- Complex Billing Scenarios: Handle usage-based pricing, tiers, and add-ons
- Enterprise Scale: Manage millions of subscriptions reliably
- Revenue Operations: Automated invoicing, taxes, and revenue recognition
- Payment Optimization: Reduce failed payments and churn
- Compliance: PCI DSS compliant, SOC 1 & 2 certified
Recurly Architecture for Java Applications
Java Application → Recurly Java Client → Recurly REST API → Subscription Management ↓ Webhooks → Event Processing → Database ↓ Payment Gateways → Transaction Processing
Setting Up Recurly Java Client
1. Add Dependencies:
<!-- pom.xml --> <dependencies> <!-- Recurly Official Client --> <dependency> <groupId>com.recurly</groupId> <artifactId>recurly-client</artifactId> <version>3.35.0</version> </dependency> <!-- Spring Boot Starters --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Database --> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <!-- Validation --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> </dependencies>
2. Configuration Properties:
# application.yml recurly: api-key: ${RECURLY_API_KEY:your-recurly-api-key} subdomain: ${RECURLY_SUBDOMAIN:your-company} default-currency: ${RECURLY_DEFAULT_CURRENCY:USD} timeout-ms: 30000 page-size: 50 app: base-url: ${APP_BASE_URL:http://localhost:8080} webhook-secret: ${WEBHOOK_SECRET:your-webhook-secret} 3. Recurly Configuration:
@Configuration @ConfigurationProperties(prefix = "recurly") @Data public class RecurlyConfig { private String apiKey; private String subdomain; private String defaultCurrency; private Integer timeoutMs; private Integer pageSize; @Bean public com.recurly.v3.Client recurlyClient() { return new com.recurly.v3.Client(apiKey); } @Bean public com.recurly.v3.resources.Subscriptions subscriptionsClient() { return recurlyClient().newSubscriptions(); } @Bean public com.recurly.v3.resources.Accounts accountsClient() { return recurlyClient().newAccounts(); } @Bean public com.recurly.v3.resources.Plans plansClient() { return recurlyClient().newPlans(); } } Core Subscription Service
1. Account Management Service:
@Service @Slf4j public class RecurlyAccountService { private final com.recurly.v3.resources.Accounts accountsClient; private final AccountRepository accountRepository; private final RecurlyConfig recurlyConfig; public RecurlyAccountService(com.recurly.v3.resources.Accounts accountsClient, AccountRepository accountRepository, RecurlyConfig recurlyConfig) { this.accountsClient = accountsClient; this.accountRepository = accountRepository; this.recurlyConfig = recurlyConfig; } public Account createAccount(CreateAccountRequest request) { try { AccountCreate accountCreate = new AccountCreate(); accountCreate.setCode(request.getAccountCode()); accountCreate.setEmail(request.getEmail()); accountCreate.setFirstName(request.getFirstName()); accountCreate.setLastName(request.getLastName()); accountCreate.setCompany(request.getCompany()); // Set billing info if provided if (request.getBillingInfo() != null) { accountCreate.setBillingInfo(createBillingInfo(request.getBillingInfo())); } Account account = accountsClient.createAccount(accountCreate); // Save to local database saveLocalAccountRecord(account, request); log.info("Created Recurly account: {}", account.getCode()); return account; } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to create Recurly account", e); throw new RecurlyServiceException("Failed to create account: " + e.getMessage(), e); } } public Account getAccount(String accountCode) { try { return accountsClient.getAccount(accountCode); } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to get Recurly account: {}", accountCode, e); throw new RecurlyServiceException("Failed to get account: " + e.getMessage(), e); } } public Account updateAccount(String accountCode, UpdateAccountRequest request) { try { AccountUpdate accountUpdate = new AccountUpdate(); accountUpdate.setEmail(request.getEmail()); accountUpdate.setFirstName(request.getFirstName()); accountUpdate.setLastName(request.getLastName()); accountUpdate.setCompany(request.getCompany()); Account account = accountsClient.updateAccount(accountCode, accountUpdate); // Update local database updateLocalAccountRecord(account); log.info("Updated Recurly account: {}", accountCode); return account; } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to update Recurly account: {}", accountCode, e); throw new RecurlyServiceException("Failed to update account: " + e.getMessage(), e); } } public void closeAccount(String accountCode) { try { accountsClient.closeAccount(accountCode); // Update local database closeLocalAccountRecord(accountCode); log.info("Closed Recurly account: {}", accountCode); } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to close Recurly account: {}", accountCode, e); throw new RecurlyServiceException("Failed to close account: " + e.getMessage(), e); } } public BillingInfo updateBillingInfo(String accountCode, BillingInfoRequest request) { try { BillingInfoCreate billingInfoCreate = createBillingInfo(request); BillingInfo billingInfo = accountsClient.createBillingInfo(accountCode, billingInfoCreate); log.info("Updated billing info for account: {}", accountCode); return billingInfo; } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to update billing info for account: {}", accountCode, e); throw new RecurlyServiceException("Failed to update billing info: " + e.getMessage(), e); } } private BillingInfoCreate createBillingInfo(BillingInfoRequest request) { BillingInfoCreate billingInfo = new BillingInfoCreate(); billingInfo.setFirstName(request.getFirstName()); billingInfo.setLastName(request.getLastName()); billingInfo.setNumber(request.getCardNumber()); billingInfo.setMonth(request.getExpMonth()); billingInfo.setYear(request.getExpYear()); billingInfo.setCvv(request.getCvv()); billingInfo.setAddress(request.getAddress().getStreet1()); billingInfo.setCity(request.getAddress().getCity()); billingInfo.setState(request.getAddress().getState()); billingInfo.setZip(request.getAddress().getPostalCode()); billingInfo.setCountry(request.getAddress().getCountry()); return billingInfo; } private void saveLocalAccountRecord(Account account, CreateAccountRequest request) { AccountRecord record = AccountRecord.builder() .accountCode(account.getCode()) .email(account.getEmail()) .firstName(account.getFirstName()) .lastName(account.getLastName()) .company(account.getCompany()) .state(account.getState()) .createdAt(Instant.now()) .build(); accountRepository.save(record); } private void updateLocalAccountRecord(Account account) { accountRepository.findByAccountCode(account.getCode()) .ifPresent(record -> { record.setEmail(account.getEmail()); record.setFirstName(account.getFirstName()); record.setLastName(account.getLastName()); record.setCompany(account.getCompany()); record.setState(account.getState()); record.setUpdatedAt(Instant.now()); accountRepository.save(record); }); } private void closeLocalAccountRecord(String accountCode) { accountRepository.findByAccountCode(accountCode) .ifPresent(record -> { record.setState("closed"); record.setUpdatedAt(Instant.now()); accountRepository.save(record); }); } } 2. Subscription Management Service:
@Service @Slf4j public class RecurlySubscriptionService { private final com.recurly.v3.resources.Subscriptions subscriptionsClient; private final com.recurly.v3.resources.Plans plansClient; private final SubscriptionRepository subscriptionRepository; private final RecurlyConfig recurlyConfig; public RecurlySubscriptionService(com.recurly.v3.resources.Subscriptions subscriptionsClient, com.recurly.v3.resources.Plans plansClient, SubscriptionRepository subscriptionRepository, RecurlyConfig recurlyConfig) { this.subscriptionsClient = subscriptionsClient; this.plansClient = plansClient; this.subscriptionRepository = subscriptionRepository; this.recurlyConfig = recurlyConfig; } public Subscription createSubscription(CreateSubscriptionRequest request) { try { SubscriptionCreate subscriptionCreate = new SubscriptionCreate(); subscriptionCreate.setAccount(createAccountReference(request.getAccountCode())); subscriptionCreate.setPlanCode(request.getPlanCode()); subscriptionCreate.setCurrency(recurlyConfig.getDefaultCurrency()); subscriptionCreate.setUnitAmount(request.getUnitAmount()); // Set custom fields if provided if (request.getCustomFields() != null) { subscriptionCreate.setCustomFields(createCustomFields(request.getCustomFields())); } // Set add-ons if provided if (request.getAddOns() != null && !request.getAddOns().isEmpty()) { subscriptionCreate.setAddOns(createSubscriptionAddOns(request.getAddOns())); } // Set billing info if provided if (request.getBillingInfo() != null) { subscriptionCreate.setBillingInfo(createBillingInfo(request.getBillingInfo())); } Subscription subscription = subscriptionsClient.createSubscription(subscriptionCreate); // Save to local database saveLocalSubscriptionRecord(subscription, request); log.info("Created subscription: {} for account: {}", subscription.getId(), request.getAccountCode()); return subscription; } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to create subscription for account: {}", request.getAccountCode(), e); throw new RecurlyServiceException("Failed to create subscription: " + e.getMessage(), e); } } public Subscription getSubscription(String subscriptionId) { try { return subscriptionsClient.getSubscription(subscriptionId); } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to get subscription: {}", subscriptionId, e); throw new RecurlyServiceException("Failed to get subscription: " + e.getMessage(), e); } } public Subscription updateSubscription(String subscriptionId, UpdateSubscriptionRequest request) { try { SubscriptionUpdate subscriptionUpdate = new SubscriptionUpdate(); if (request.getPlanCode() != null) { subscriptionUpdate.setPlanCode(request.getPlanCode()); } if (request.getUnitAmount() != null) { subscriptionUpdate.setUnitAmount(request.getUnitAmount()); } if (request.getAddOns() != null) { subscriptionUpdate.setAddOns(createSubscriptionAddOns(request.getAddOns())); } Subscription subscription = subscriptionsClient.updateSubscription(subscriptionId, subscriptionUpdate); // Update local database updateLocalSubscriptionRecord(subscription); log.info("Updated subscription: {}", subscriptionId); return subscription; } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to update subscription: {}", subscriptionId, e); throw new RecurlyServiceException("Failed to update subscription: " + e.getMessage(), e); } } public Subscription cancelSubscription(String subscriptionId, CancelSubscriptionRequest request) { try { SubscriptionCancel subscriptionCancel = new SubscriptionCancel(); if (request.getTimeframe() != null) { subscriptionCancel.setTimeframe(SubscriptionCancel.Timeframe.fromValue(request.getTimeframe())); } Subscription subscription = subscriptionsClient.cancelSubscription(subscriptionId, subscriptionCancel); // Update local database cancelLocalSubscriptionRecord(subscriptionId); log.info("Canceled subscription: {}", subscriptionId); return subscription; } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to cancel subscription: {}", subscriptionId, e); throw new RecurlyServiceException("Failed to cancel subscription: " + e.getMessage(), e); } } public Subscription reactivateSubscription(String subscriptionId) { try { Subscription subscription = subscriptionsClient.reactivateSubscription(subscriptionId); // Update local database reactivateLocalSubscriptionRecord(subscriptionId); log.info("Reactivated subscription: {}", subscriptionId); return subscription; } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to reactivate subscription: {}", subscriptionId, e); throw new RecurlyServiceException("Failed to reactivate subscription: " + e.getMessage(), e); } } public Pause cancelSubscriptionPause(String subscriptionId) { try { Pause pause = subscriptionsClient.removeSubscriptionPause(subscriptionId); log.info("Removed pause from subscription: {}", subscriptionId); return pause; } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to remove subscription pause: {}", subscriptionId, e); throw new RecurlyServiceException("Failed to remove subscription pause: " + e.getMessage(), e); } } public List<Subscription> getAccountSubscriptions(String accountCode) { try { SubscriptionList result = subscriptionsClient.listSubscriptions( new QueryParams().withLimit(recurlyConfig.getPageSize()) .withParam("account", accountCode) ); return result.getData(); } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to get subscriptions for account: {}", accountCode, e); throw new RecurlyServiceException("Failed to get subscriptions: " + e.getMessage(), e); } } private AccountPurchase createAccountReference(String accountCode) { AccountPurchase accountRef = new AccountPurchase(); accountRef.setCode(accountCode); return accountRef; } private List<SubscriptionAddOn> createSubscriptionAddOns(List<AddOnRequest> addOns) { return addOns.stream() .map(this::createSubscriptionAddOn) .collect(Collectors.toList()); } private SubscriptionAddOn createSubscriptionAddOn(AddOnRequest addOn) { SubscriptionAddOn subscriptionAddOn = new SubscriptionAddOn(); subscriptionAddOn.setCode(addOn.getCode()); subscriptionAddOn.setUnitAmount(addOn.getUnitAmount()); subscriptionAddOn.setQuantity(addOn.getQuantity()); return subscriptionAddOn; } private BillingInfoCreate createBillingInfo(BillingInfoRequest request) { BillingInfoCreate billingInfo = new BillingInfoCreate(); billingInfo.setFirstName(request.getFirstName()); billingInfo.setLastName(request.getLastName()); billingInfo.setNumber(request.getCardNumber()); billingInfo.setMonth(request.getExpMonth()); billingInfo.setYear(request.getExpYear()); billingInfo.setCvv(request.getCvv()); return billingInfo; } private List<CustomField> createCustomFields(Map<String, String> customFields) { return customFields.entrySet().stream() .map(entry -> { CustomField field = new CustomField(); field.setName(entry.getKey()); field.setValue(entry.getValue()); return field; }) .collect(Collectors.toList()); } private void saveLocalSubscriptionRecord(Subscription subscription, CreateSubscriptionRequest request) { SubscriptionRecord record = SubscriptionRecord.builder() .subscriptionId(subscription.getId()) .accountCode(request.getAccountCode()) .planCode(request.getPlanCode()) .state(subscription.getState()) .unitAmount(request.getUnitAmount()) .currency(recurlyConfig.getDefaultCurrency()) .startDate(subscription.getCurrentPeriodStartedAt().toInstant()) .createdAt(Instant.now()) .build(); subscriptionRepository.save(record); } private void updateLocalSubscriptionRecord(Subscription subscription) { subscriptionRepository.findBySubscriptionId(subscription.getId()) .ifPresent(record -> { record.setState(subscription.getState()); record.setUnitAmount(subscription.getUnitAmount()); record.setUpdatedAt(Instant.now()); subscriptionRepository.save(record); }); } private void cancelLocalSubscriptionRecord(String subscriptionId) { subscriptionRepository.findBySubscriptionId(subscriptionId) .ifPresent(record -> { record.setState("canceled"); record.setUpdatedAt(Instant.now()); subscriptionRepository.save(record); }); } private void reactivateLocalSubscriptionRecord(String subscriptionId) { subscriptionRepository.findBySubscriptionId(subscriptionId) .ifPresent(record -> { record.setState("active"); record.setUpdatedAt(Instant.now()); subscriptionRepository.save(record); }); } } Plan and Pricing Management
1. Plan Management Service:
@Service @Slf4j public class RecurlyPlanService { private final com.recurly.v3.resources.Plans plansClient; private final PlanRepository planRepository; public RecurlyPlanService(com.recurly.v3.resources.Plans plansClient, PlanRepository planRepository) { this.plansClient = plansClient; this.planRepository = planRepository; } public Plan createPlan(CreatePlanRequest request) { try { PlanCreate planCreate = new PlanCreate(); planCreate.setCode(request.getCode()); planCreate.setName(request.getName()); planCreate.setDescription(request.getDescription()); planCreate.setIntervalUnit(PlanCreate.IntervalUnit.fromValue(request.getIntervalUnit())); planCreate.setIntervalLength(request.getIntervalLength()); // Set pricing List<Pricing> pricingList = new ArrayList<>(); Pricing pricing = new Pricing(); pricing.setCurrency(request.getCurrency()); pricing.setUnitAmount(request.getUnitAmount()); pricingList.add(pricing); planCreate.setCurrencies(pricingList); // Set trial if provided if (request.getTrialUnit() != null && request.getTrialLength() != null) { planCreate.setTrialUnit(PlanCreate.TrialUnit.fromValue(request.getTrialUnit())); planCreate.setTrialLength(request.getTrialLength()); } Plan plan = plansClient.createPlan(planCreate); // Save to local database saveLocalPlanRecord(plan); log.info("Created plan: {}", plan.getCode()); return plan; } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to create plan: {}", request.getCode(), e); throw new RecurlyServiceException("Failed to create plan: " + e.getMessage(), e); } } public Plan getPlan(String planCode) { try { return plansClient.getPlan(planCode); } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to get plan: {}", planCode, e); throw new RecurlyServiceException("Failed to get plan: " + e.getMessage(), e); } } public List<Plan> getAllPlans() { try { PlanList result = plansClient.listPlans( new QueryParams().withLimit(100) // Adjust based on your needs ); return result.getData(); } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to list plans", e); throw new RecurlyServiceException("Failed to list plans: " + e.getMessage(), e); } } public Plan updatePlan(String planCode, UpdatePlanRequest request) { try { PlanUpdate planUpdate = new PlanUpdate(); planUpdate.setName(request.getName()); planUpdate.setDescription(request.getDescription()); Plan plan = plansClient.updatePlan(planCode, planUpdate); // Update local database updateLocalPlanRecord(plan); log.info("Updated plan: {}", planCode); return plan; } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to update plan: {}", planCode, e); throw new RecurlyServiceException("Failed to update plan: " + e.getMessage(), e); } } public void deletePlan(String planCode) { try { plansClient.deactivatePlan(planCode); // Update local database deactivateLocalPlanRecord(planCode); log.info("Deleted plan: {}", planCode); } catch (com.recurly.v3.exception.ApiException e) { log.error("Failed to delete plan: {}", planCode, e); throw new RecurlyServiceException("Failed to delete plan: " + e.getMessage(), e); } } private void saveLocalPlanRecord(Plan plan) { PlanRecord record = PlanRecord.builder() .planCode(plan.getCode()) .name(plan.getName()) .description(plan.getDescription()) .intervalUnit(plan.getIntervalUnit().toString()) .intervalLength(plan.getIntervalLength()) .state(plan.getState()) .createdAt(Instant.now()) .build(); planRepository.save(record); } private void updateLocalPlanRecord(Plan plan) { planRepository.findByPlanCode(plan.getCode()) .ifPresent(record -> { record.setName(plan.getName()); record.setDescription(plan.getDescription()); record.setState(plan.getState()); record.setUpdatedAt(Instant.now()); planRepository.save(record); }); } private void deactivateLocalPlanRecord(String planCode) { planRepository.findByPlanCode(planCode) .ifPresent(record -> { record.setState("inactive"); record.setUpdatedAt(Instant.now()); planRepository.save(record); }); } } Data Models
1. Request/Response Models:
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreateAccountRequest { @NotBlank private String accountCode; @Email @NotBlank private String email; @NotBlank private String firstName; @NotBlank private String lastName; private String company; private BillingInfoRequest billingInfo; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreateSubscriptionRequest { @NotBlank private String accountCode; @NotBlank private String planCode; @NotNull private Integer unitAmount; private BillingInfoRequest billingInfo; private List<AddOnRequest> addOns; private Map<String, String> customFields; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class BillingInfoRequest { @NotBlank private String firstName; @NotBlank private String lastName; @NotBlank private String cardNumber; @NotBlank private String expMonth; @NotBlank private String expYear; private String cvv; private AddressRequest address; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class AddOnRequest { @NotBlank private String code; @NotNull private Integer unitAmount; @NotNull private Integer quantity; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class CreatePlanRequest { @NotBlank private String code; @NotBlank private String name; private String description; @NotBlank private String intervalUnit; // "months" or "days" @NotNull private Integer intervalLength; @NotBlank private String currency; @NotNull private Integer unitAmount; private String trialUnit; private Integer trialLength; } 2. Database Entities:
@Entity @Table(name = "account_records") @Data @Builder @NoArgsConstructor @AllArgsConstructor public class AccountRecord { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "account_code", unique = true, nullable = false) private String accountCode; @Column(name = "email") private String email; @Column(name = "first_name") private String firstName; @Column(name = "last_name") private String lastName; @Column(name = "company") private String company; @Column(name = "state") private String state; @Column(name = "created_at") private Instant createdAt; @Column(name = "updated_at") private Instant updatedAt; @PrePersist protected void onCreate() { createdAt = Instant.now(); } @PreUpdate protected void onUpdate() { updatedAt = Instant.now(); } } @Entity @Table(name = "subscription_records") @Data @Builder @NoArgsConstructor @AllArgsConstructor public class SubscriptionRecord { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "subscription_id", unique = true, nullable = false) private String subscriptionId; @Column(name = "account_code", nullable = false) private String accountCode; @Column(name = "plan_code", nullable = false) private String planCode; @Column(name = "state") private String state; @Column(name = "unit_amount") private Integer unitAmount; @Column(name = "currency") private String currency; @Column(name = "start_date") private Instant startDate; @Column(name = "created_at") private Instant createdAt; @Column(name = "updated_at") private Instant updatedAt; @PrePersist protected void onCreate() { createdAt = Instant.now(); } @PreUpdate protected void onUpdate() { updatedAt = Instant.now(); } } REST Controllers
1. Account Controller:
@RestController @RequestMapping("/api/accounts") @Slf4j @Validated public class AccountController { private final RecurlyAccountService accountService; public AccountController(RecurlyAccountService accountService) { this.accountService = accountService; } @PostMapping public ResponseEntity<Account> createAccount(@Valid @RequestBody CreateAccountRequest request) { try { Account account = accountService.createAccount(request); return ResponseEntity.status(HttpStatus.CREATED).body(account); } catch (RecurlyServiceException e) { log.error("Account creation failed", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @GetMapping("/{accountCode}") public ResponseEntity<Account> getAccount(@PathVariable String accountCode) { try { Account account = accountService.getAccount(accountCode); return ResponseEntity.ok(account); } catch (RecurlyServiceException e) { log.error("Failed to get account: {}", accountCode, e); return ResponseEntity.notFound().build(); } } @PutMapping("/{accountCode}") public ResponseEntity<Account> updateAccount(@PathVariable String accountCode, @Valid @RequestBody UpdateAccountRequest request) { try { Account account = accountService.updateAccount(accountCode, request); return ResponseEntity.ok(account); } catch (RecurlyServiceException e) { log.error("Failed to update account: {}", accountCode, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @DeleteMapping("/{accountCode}") public ResponseEntity<Void> closeAccount(@PathVariable String accountCode) { try { accountService.closeAccount(accountCode); return ResponseEntity.noContent().build(); } catch (RecurlyServiceException e) { log.error("Failed to close account: {}", accountCode, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @PutMapping("/{accountCode}/billing-info") public ResponseEntity<BillingInfo> updateBillingInfo(@PathVariable String accountCode, @Valid @RequestBody BillingInfoRequest request) { try { BillingInfo billingInfo = accountService.updateBillingInfo(accountCode, request); return ResponseEntity.ok(billingInfo); } catch (RecurlyServiceException e) { log.error("Failed to update billing info for account: {}", accountCode, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } } 2. Subscription Controller:
@RestController @RequestMapping("/api/subscriptions") @Slf4j @Validated public class SubscriptionController { private final RecurlySubscriptionService subscriptionService; public SubscriptionController(RecurlySubscriptionService subscriptionService) { this.subscriptionService = subscriptionService; } @PostMapping public ResponseEntity<Subscription> createSubscription(@Valid @RequestBody CreateSubscriptionRequest request) { try { Subscription subscription = subscriptionService.createSubscription(request); return ResponseEntity.status(HttpStatus.CREATED).body(subscription); } catch (RecurlyServiceException e) { log.error("Subscription creation failed", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @GetMapping("/{subscriptionId}") public ResponseEntity<Subscription> getSubscription(@PathVariable String subscriptionId) { try { Subscription subscription = subscriptionService.getSubscription(subscriptionId); return ResponseEntity.ok(subscription); } catch (RecurlyServiceException e) { log.error("Failed to get subscription: {}", subscriptionId, e); return ResponseEntity.notFound().build(); } } @PutMapping("/{subscriptionId}") public ResponseEntity<Subscription> updateSubscription(@PathVariable String subscriptionId, @Valid @RequestBody UpdateSubscriptionRequest request) { try { Subscription subscription = subscriptionService.updateSubscription(subscriptionId, request); return ResponseEntity.ok(subscription); } catch (RecurlyServiceException e) { log.error("Failed to update subscription: {}", subscriptionId, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @PostMapping("/{subscriptionId}/cancel") public ResponseEntity<Subscription> cancelSubscription(@PathVariable String subscriptionId, @Valid @RequestBody CancelSubscriptionRequest request) { try { Subscription subscription = subscriptionService.cancelSubscription(subscriptionId, request); return ResponseEntity.ok(subscription); } catch (RecurlyServiceException e) { log.error("Failed to cancel subscription: {}", subscriptionId, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @PostMapping("/{subscriptionId}/reactivate") public ResponseEntity<Subscription> reactivateSubscription(@PathVariable String subscriptionId) { try { Subscription subscription = subscriptionService.reactivateSubscription(subscriptionId); return ResponseEntity.ok(subscription); } catch (RecurlyServiceException e) { log.error("Failed to reactivate subscription: {}", subscriptionId, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } @GetMapping("/account/{accountCode}") public ResponseEntity<List<Subscription>> getAccountSubscriptions(@PathVariable String accountCode) { try { List<Subscription> subscriptions = subscriptionService.getAccountSubscriptions(accountCode); return ResponseEntity.ok(subscriptions); } catch (RecurlyServiceException e) { log.error("Failed to get subscriptions for account: {}", accountCode, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } } Webhook Handling
1. Webhook Controller:
@RestController @RequestMapping("/webhooks/recurly") @Slf4j public class RecurlyWebhookController { private final SubscriptionRepository subscriptionRepository; private final AccountRepository accountRepository; public RecurlyWebhookController(SubscriptionRepository subscriptionRepository, AccountRepository accountRepository) { this.subscriptionRepository = subscriptionRepository; this.accountRepository = accountRepository; } @PostMapping public ResponseEntity<String> handleWebhook(@RequestBody String payload, @RequestHeader("Recurly-Signature") String signature) { try { // Verify webhook signature (simplified - use proper verification in production) if (!verifyWebhookSignature(payload, signature)) { log.warn("Invalid webhook signature"); return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } // Parse webhook notification JsonNode notification = new ObjectMapper().readTree(payload); String type = notification.get("type").asText(); log.info("Received Recurly webhook: {}", type); // Handle different webhook types switch (type) { case "new_subscription_notification": handleNewSubscription(notification); break; case "updated_subscription_notification": handleUpdatedSubscription(notification); break; case "canceled_subscription_notification": handleCanceledSubscription(notification); break; case "renewed_subscription_notification": handleRenewedSubscription(notification); break; case "successful_payment_notification": handleSuccessfulPayment(notification); break; case "failed_payment_notification": handleFailedPayment(notification); break; default: log.debug("Unhandled webhook type: {}", type); } return ResponseEntity.ok("Webhook processed"); } catch (Exception e) { log.error("Webhook processing failed", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } private boolean verifyWebhookSignature(String payload, String signature) { // Implement proper webhook signature verification // Recurly provides signatures for verification return true; // Simplified for example } private void handleNewSubscription(JsonNode notification) { JsonNode subscription = notification.get("subscription"); String subscriptionId = subscription.get("uuid").asText(); String accountCode = subscription.get("account").get("code").asText(); subscriptionRepository.findBySubscriptionId(subscriptionId) .ifPresentOrElse( record -> log.info("Subscription already exists: {}", subscriptionId), () -> { // Create new subscription record SubscriptionRecord newRecord = SubscriptionRecord.builder() .subscriptionId(subscriptionId) .accountCode(accountCode) .planCode(subscription.get("plan").get("code").asText()) .state(subscription.get("state").asText()) .unitAmount(subscription.get("unit_amount").asInt()) .currency(subscription.get("currency").asText()) .startDate(Instant.parse(subscription.get("activated_at").asText())) .createdAt(Instant.now()) .build(); subscriptionRepository.save(newRecord); log.info("Created subscription record from webhook: {}", subscriptionId); } ); } private void handleUpdatedSubscription(JsonNode notification) { JsonNode subscription = notification.get("subscription"); String subscriptionId = subscription.get("uuid").asText(); subscriptionRepository.findBySubscriptionId(subscriptionId) .ifPresent(record -> { record.setState(subscription.get("state").asText()); record.setUnitAmount(subscription.get("unit_amount").asInt()); record.setUpdatedAt(Instant.now()); subscriptionRepository.save(record); log.info("Updated subscription from webhook: {}", subscriptionId); }); } private void handleCanceledSubscription(JsonNode notification) { JsonNode subscription = notification.get("subscription"); String subscriptionId = subscription.get("uuid").asText(); subscriptionRepository.findBySubscriptionId(subscriptionId) .ifPresent(record -> { record.setState("canceled"); record.setUpdatedAt(Instant.now()); subscriptionRepository.save(record); log.info("Canceled subscription from webhook: {}", subscriptionId); }); } private void handleRenewedSubscription(JsonNode notification) { JsonNode subscription = notification.get("subscription"); String subscriptionId = subscription.get("uuid").asText(); log.info("Subscription renewed: {}", subscriptionId); // Handle renewal logic } private void handleSuccessfulPayment(JsonNode notification) { JsonNode transaction = notification.get("transaction"); String accountCode = transaction.get("account").get("code").asText(); log.info("Successful payment for account: {}", accountCode); // Handle successful payment logic } private void handleFailedPayment(JsonNode notification) { JsonNode transaction = notification.get("transaction"); String accountCode = transaction.get("account").get("code").asText(); log.warn("Failed payment for account: {}", accountCode); // Handle failed payment logic } } Error Handling
1. Custom Exceptions:
public class RecurlyServiceException extends RuntimeException { public RecurlyServiceException(String message) { super(message); } public RecurlyServiceException(String message, Throwable cause) { super(message, cause); } } @RestControllerAdvice @Slf4j public class RecurlyExceptionHandler { @ExceptionHandler(RecurlyServiceException.class) public ResponseEntity<ErrorResponse> handleRecurlyException(RecurlyServiceException e) { log.error("Recurly service error", e); ErrorResponse error = ErrorResponse.builder() .code("RECURLY_SERVICE_ERROR") .message("Subscription service temporarily unavailable") .timestamp(Instant.now()) .build(); return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(error); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e) { List<String> errors = e.getBindingResult() .getFieldErrors() .stream() .map(error -> error.getField() + ": " + error.getDefaultMessage()) .collect(Collectors.toList()); ErrorResponse error = ErrorResponse.builder() .code("VALIDATION_ERROR") .message("Invalid request parameters") .details(errors) .timestamp(Instant.now()) .build(); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); } } @Data @Builder class ErrorResponse { private String code; private String message; private Object details; private Instant timestamp; } Best Practices
1. Health Check:
@Component public class RecurlyHealthIndicator implements HealthIndicator { private final RecurlyPlanService planService; public RecurlyHealthIndicator(RecurlyPlanService planService) { this.planService = planService; } @Override public Health health() { try { // Try to list plans to verify connectivity planService.getAllPlans(); return Health.up().withDetail("recurly", "connected").build(); } catch (Exception e) { return Health.down() .withDetail("recurly", "disconnected") .withDetail("error", e.getMessage()) .build(); } } } Conclusion
Integrating Recurly with Java applications provides a robust foundation for managing complex subscription businesses at scale. The combination of Recurly's enterprise-grade subscription management with Java's reliability and performance creates a powerful platform for SaaS companies, subscription services, and any business relying on recurring revenue models.
By leveraging the Recurly Java client, developers can implement sophisticated billing scenarios, handle subscription lifecycle events, and maintain synchronization between Recurly and their application data. The comprehensive webhook support enables real-time updates and ensures data consistency across systems, making it possible to build responsive, event-driven subscription management systems that scale with business growth.