Paystack Integration in Java: Complete Payment Processing for African Markets

Paystack is a leading payment processor in Africa that enables businesses to accept payments online and offline. This comprehensive guide covers integrating Paystack's API into Java applications.


Overview of Paystack Features

Key Capabilities:

  • Online Payments: Card, bank transfer, USSD, mobile money
  • Recurring Payments: Subscriptions and recurring billing
  • Transfers: Payouts to banks and mobile money
  • Identity Verification: Bank account validation, BVN verification
  • Payment Pages: Hosted checkout pages
  • Settlement: Automated settlements to your bank account

Supported African Payment Methods:

  • 🇳🇬 Nigeria: Cards, Bank Transfer, USSD
  • 🇬🇭 Ghana: Cards, Mobile Money
  • 🇰🇪 Kenya: M-Pesa, Cards
  • 🇿🇦 South Africa: Cards

Setup and Dependencies

1. Maven Dependencies
<properties> <spring-boot.version>3.1.0</spring-boot.version> <jackson.version>2.15.2</jackson.version> <okhttp.version>4.11.0</okhttp.version> </properties> <dependencies> <!-- Spring Boot --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> <version>${spring-boot.version}</version> </dependency> <!-- HTTP Client --> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>${okhttp.version}</version> </dependency> <!-- JSON Processing --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>${jackson.version}</version> </dependency> <!-- Testing --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <version>${spring-boot.version}</version> <scope>test</scope> </dependency> </dependencies>
2. Paystack Configuration
@Configuration @ConfigurationProperties(prefix = "paystack") public class PaystackConfig { private String secretKey; private String publicKey; private String baseUrl = "https://api.paystack.co"; private int timeoutSeconds = 30; // Getters and setters public String getSecretKey() { return secretKey; } public void setSecretKey(String secretKey) { this.secretKey = secretKey; } public String getPublicKey() { return publicKey; } public void setPublicKey(String publicKey) { this.publicKey = publicKey; } public String getBaseUrl() { return baseUrl; } public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } public int getTimeoutSeconds() { return timeoutSeconds; } public void setTimeoutSeconds(int timeoutSeconds) { this.timeoutSeconds = timeoutSeconds; } }
# application.yml paystack: secret-key: ${PAYSTACK_SECRET_KEY:sk_test_xxx} public-key: ${PAYSTACK_PUBLIC_KEY:pk_test_xxx} base-url: https://api.paystack.co timeout-seconds: 30

Core Implementation

1. HTTP Client Service
@Component @Slf4j public class PaystackHttpClient { private final PaystackConfig config; private final OkHttpClient httpClient; private final ObjectMapper objectMapper; public PaystackHttpClient(PaystackConfig config, ObjectMapper objectMapper) { this.config = config; this.objectMapper = objectMapper; this.httpClient = new OkHttpClient.Builder() .connectTimeout(Duration.ofSeconds(config.getTimeoutSeconds())) .readTimeout(Duration.ofSeconds(config.getTimeoutSeconds())) .writeTimeout(Duration.ofSeconds(config.getTimeoutSeconds())) .build(); } public <T> T get(String endpoint, Class<T> responseType) { Request request = buildRequest(endpoint).get().build(); return executeRequest(request, responseType); } public <T> T post(String endpoint, Object requestBody, Class<T> responseType) { try { String jsonBody = objectMapper.writeValueAsString(requestBody); RequestBody body = RequestBody.create(jsonBody, MediaType.parse("application/json")); Request request = buildRequest(endpoint).post(body).build(); return executeRequest(request, responseType); } catch (JsonProcessingException e) { throw new PaystackException("Failed to serialize request body", e); } } private Request.Builder buildRequest(String endpoint) { return new Request.Builder() .url(config.getBaseUrl() + endpoint) .addHeader("Authorization", "Bearer " + config.getSecretKey()) .addHeader("Content-Type", "application/json") .addHeader("User-Agent", "Java-Paystack-Client/1.0"); } private <T> T executeRequest(Request request, Class<T> responseType) { try (Response response = httpClient.newCall(request).execute()) { String responseBody = response.body().string(); if (!response.isSuccessful()) { handleErrorResponse(response, responseBody); } return objectMapper.readValue(responseBody, responseType); } catch (IOException e) { throw new PaystackException("HTTP request failed", e); } } private void handleErrorResponse(Response response, String responseBody) { try { PaystackError error = objectMapper.readValue(responseBody, PaystackError.class); throw new PaystackApiException(error.getMessage(), response.code(), error); } catch (JsonProcessingException e) { throw new PaystackException("Failed to parse error response: " + responseBody, e); } } }
2. Domain Models
// Common Response Wrapper @Data public class PaystackResponse<T> { private boolean status; private String message; private T data; } // Error Response @Data public class PaystackError { private boolean status; private String message; } // Custom Exceptions public class PaystackException extends RuntimeException { public PaystackException(String message) { super(message); } public PaystackException(String message, Throwable cause) { super(message, cause); } } public class PaystackApiException extends PaystackException { private final int statusCode; private final PaystackError error; public PaystackApiException(String message, int statusCode, PaystackError error) { super(message); this.statusCode = statusCode; this.error = error; } public int getStatusCode() { return statusCode; } public PaystackError getError() { return error; } }

Payment Processing

1. Transaction Models
@Data public class InitializeTransactionRequest { @NotBlank private String email; @NotBlank private String amount; // in kobo (NGN) or cents private String reference; private String callbackUrl; private Map<String, Object> metadata; private String currency = "NGN"; private String[] channels; // ["card", "bank", "ussd", "qr", "mobile_money"] public InitializeTransactionRequest(String email, String amount) { this.email = email; this.amount = amount; this.reference = generateReference(); } private String generateReference() { return "txn_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8); } } @Data public class InitializeTransactionResponse { private boolean status; private String message; private TransactionData data; } @Data public class TransactionData { private String authorizationUrl; private String accessCode; private String reference; } @Data public class VerifyTransactionResponse { private boolean status; private String message; private TransactionDetails data; } @Data public class TransactionDetails { private Long id; private String domain; private String status; // success, failed, abandoned private String reference; private String amount; private String currency; private String gatewayResponse; private String paidAt; private String createdAt; private Customer customer; private Authorization authorization; public boolean isSuccessful() { return "success".equals(status); } } @Data public class Customer { private Long id; private String email; private String customerCode; } @Data public class Authorization { private String authorizationCode; private String bin; private String last4; private String expMonth; private String expYear; private String channel; private String cardType; private String bank; private String countryCode; private String brand; private boolean reusable; private String signature; }
2. Transaction Service
public interface PaymentService { InitializeTransactionResponse initializeTransaction(InitializeTransactionRequest request); VerifyTransactionResponse verifyTransaction(String reference); TransactionListResponse listTransactions(Map<String, Object> params); TransactionResponse fetchTransaction(String transactionId); ChargeResponse chargeAuthorization(ChargeRequest request); } @Service @Slf4j public class PaystackPaymentService implements PaymentService { private final PaystackHttpClient httpClient; public PaystackPaymentService(PaystackHttpClient httpClient) { this.httpClient = httpClient; } @Override public InitializeTransactionResponse initializeTransaction(InitializeTransactionRequest request) { log.info("Initializing transaction for email: {}, amount: {}", request.getEmail(), request.getAmount()); InitializeTransactionResponse response = httpClient.post( "/transaction/initialize", request, InitializeTransactionResponse.class ); log.info("Transaction initialized successfully. Reference: {}", response.getData().getReference()); return response; } @Override public VerifyTransactionResponse verifyTransaction(String reference) { log.info("Verifying transaction with reference: {}", reference); VerifyTransactionResponse response = httpClient.get( "/transaction/verify/" + reference, VerifyTransactionResponse.class ); log.info("Transaction verification completed. Status: {}, Message: {}", response.isStatus(), response.getMessage()); return response; } @Override public TransactionListResponse listTransactions(Map<String, Object> params) { String queryString = buildQueryString(params); return httpClient.get("/transaction" + queryString, TransactionListResponse.class); } @Override public TransactionResponse fetchTransaction(String transactionId) { return httpClient.get("/transaction/" + transactionId, TransactionResponse.class); } @Override public ChargeResponse chargeAuthorization(ChargeRequest request) { return httpClient.post("/transaction/charge_authorization", request, ChargeResponse.class); } private String buildQueryString(Map<String, Object> params) { if (params == null || params.isEmpty()) { return ""; } return "?" + params.entrySet().stream() .map(entry -> entry.getKey() + "=" + entry.getValue()) .collect(Collectors.joining("&")); } }
3. Webhook Handling
@Data public class PaystackWebhookEvent { private String event; private Map<String, Object> data; public boolean isChargeSuccess() { return "charge.success".equals(event); } public boolean isTransferSuccess() { return "transfer.success".equals(event); } } @Component @Slf4j public class PaystackWebhookService { private final PaymentService paymentService; public PaystackWebhookService(PaymentService paymentService) { this.paymentService = paymentService; } public void handleWebhookEvent(PaystackWebhookEvent event, String signature) { // Verify webhook signature here (implementation depends on your security requirements) log.info("Processing webhook event: {}", event.getEvent()); switch (event.getEvent()) { case "charge.success": handleSuccessfulCharge(event); break; case "transfer.success": handleSuccessfulTransfer(event); break; case "invoice.payment_failed": handleFailedPayment(event); break; default: log.debug("Unhandled webhook event: {}", event.getEvent()); } } private void handleSuccessfulCharge(PaystackWebhookEvent event) { try { // Extract transaction reference from webhook data String reference = extractReference(event); // Verify transaction to ensure it's legitimate VerifyTransactionResponse verification = paymentService.verifyTransaction(reference); if (verification.getData().isSuccessful()) { // Update your database, send confirmation email, etc. log.info("Payment confirmed for reference: {}", reference); processSuccessfulPayment(verification.getData()); } else { log.warn("Webhook received but transaction verification failed for reference: {}", reference); } } catch (Exception e) { log.error("Error processing successful charge webhook", e); } } private String extractReference(PaystackWebhookEvent event) { // Implementation depends on webhook data structure return ((Map<String, String>) event.getData()).get("reference"); } private void processSuccessfulPayment(TransactionDetails transaction) { // Update order status, send confirmation, etc. log.info("Processing successful payment: {} for {}", transaction.getAmount(), transaction.getCustomer().getEmail()); } private void handleSuccessfulTransfer(PaystackWebhookEvent event) { log.info("Processing successful transfer"); // Handle transfer completion } private void handleFailedPayment(PaystackWebhookEvent event) { log.warn("Processing failed payment"); // Handle payment failure } }

Advanced Features

1. Recurring Payments (Subscriptions)
@Data public class CreatePlanRequest { private String name; private String amount; private String interval; // daily, weekly, monthly, quarterly, annually private String description; public CreatePlanRequest(String name, String amount, String interval) { this.name = name; this.amount = amount; this.interval = interval; } } @Data public class CreateSubscriptionRequest { private String customer; private String plan; private String authorization; public CreateSubscriptionRequest(String customer, String plan, String authorization) { this.customer = customer; this.plan = plan; this.authorization = authorization; } } @Service @Slf4j public class SubscriptionService { private final PaystackHttpClient httpClient; public SubscriptionService(PaystackHttpClient httpClient) { this.httpClient = httpClient; } public CreatePlanResponse createPlan(CreatePlanRequest request) { return httpClient.post("/plan", request, CreatePlanResponse.class); } public CreateSubscriptionResponse createSubscription(CreateSubscriptionRequest request) { return httpClient.post("/subscription", request, CreateSubscriptionResponse.class); } public SubscriptionListResponse listSubscriptions(Map<String, Object> params) { String queryString = buildQueryString(params); return httpClient.get("/subscription" + queryString, SubscriptionListResponse.class); } public DisableSubscriptionResponse disableSubscription(String subscriptionCode) { return httpClient.post("/subscription/disable", Map.of("code", subscriptionCode, "token", "disable_token"), DisableSubscriptionResponse.class); } private String buildQueryString(Map<String, Object> params) { if (params == null || params.isEmpty()) return ""; return "?" + params.entrySet().stream() .map(entry -> entry.getKey() + "=" + entry.getValue()) .collect(Collectors.joining("&")); } }
2. Transfers and Payouts
@Data public class InitiateTransferRequest { private String source; private String amount; private String recipient; private String reason; public InitiateTransferRequest(String amount, String recipient) { this.source = "balance"; this.amount = amount; this.recipient = recipient; } } @Data public class CreateRecipientRequest { private String type; // nuban, mobile_money, basa private String name; private String accountNumber; private String bankCode; private String currency; public static CreateRecipientRequest forNuban(String name, String accountNumber, String bankCode) { CreateRecipientRequest request = new CreateRecipientRequest(); request.setType("nuban"); request.setName(name); request.setAccountNumber(accountNumber); request.setBankCode(bankCode); request.setCurrency("NGN"); return request; } } @Service @Slf4j public class TransferService { private final PaystackHttpClient httpClient; public TransferService(PaystackHttpClient httpClient) { this.httpClient = httpClient; } public InitiateTransferResponse initiateTransfer(InitiateTransferRequest request) { log.info("Initiating transfer of {} to recipient {}", request.getAmount(), request.getRecipient()); return httpClient.post("/transfer", request, InitiateTransferResponse.class); } public CreateRecipientResponse createRecipient(CreateRecipientRequest request) { return httpClient.post("/transferrecipient", request, CreateRecipientResponse.class); } public TransferListResponse listTransfers(Map<String, Object> params) { String queryString = buildQueryString(params); return httpClient.get("/transfer" + queryString, TransferListResponse.class); } public FinalizeTransferResponse finalizeTransfer(String transferCode, String otp) { Map<String, String> body = Map.of( "transfer_code", transferCode, "otp", otp ); return httpClient.post("/transfer/finalize_transfer", body, FinalizeTransferResponse.class); } private String buildQueryString(Map<String, Object> params) { if (params == null || params.isEmpty()) return ""; return "?" + params.entrySet().stream() .map(entry -> entry.getKey() + "=" + entry.getValue()) .collect(Collectors.joining("&")); } }
3. Customer Management
@Service @Slf4j public class CustomerService { private final PaystackHttpClient httpClient; public CustomerService(PaystackHttpClient httpClient) { this.httpClient = httpClient; } public CreateCustomerResponse createCustomer(String email, String firstName, String lastName, String phone) { Map<String, String> body = new HashMap<>(); body.put("email", email); body.put("first_name", firstName); body.put("last_name", lastName); if (phone != null) body.put("phone", phone); return httpClient.post("/customer", body, CreateCustomerResponse.class); } public CustomerListResponse listCustomers(Map<String, Object> params) { String queryString = buildQueryString(params); return httpClient.get("/customer" + queryString, CustomerListResponse.class); } public FetchCustomerResponse fetchCustomer(String customerCodeOrEmail) { return httpClient.get("/customer/" + customerCodeOrEmail, FetchCustomerResponse.class); } public UpdateCustomerResponse updateCustomer(String customerCode, Map<String, String> updates) { return httpClient.put("/customer/" + customerCode, updates, UpdateCustomerResponse.class); } private String buildQueryString(Map<String, Object> params) { if (params == null || params.isEmpty()) return ""; return "?" + params.entrySet().stream() .map(entry -> entry.getKey() + "=" + entry.getValue()) .collect(Collectors.joining("&")); } }

Spring Boot Controllers

1. Payment Controller
@RestController @RequestMapping("/api/payments") @Validated @Slf4j public class PaymentController { private final PaymentService paymentService; private final PaystackConfig paystackConfig; public PaymentController(PaymentService paymentService, PaystackConfig paystackConfig) { this.paymentService = paymentService; this.paystackConfig = paystackConfig; } @PostMapping("/initialize") public ResponseEntity<InitializeTransactionResponse> initializePayment( @Valid @RequestBody InitializeTransactionRequest request) { InitializeTransactionResponse response = paymentService.initializeTransaction(request); return ResponseEntity.ok(response); } @GetMapping("/verify/{reference}") public ResponseEntity<VerifyTransactionResponse> verifyPayment(@PathVariable String reference) { VerifyTransactionResponse response = paymentService.verifyTransaction(reference); return ResponseEntity.ok(response); } @PostMapping("/webhook") public ResponseEntity<String> handleWebhook( @RequestBody PaystackWebhookEvent event, @RequestHeader("x-paystack-signature") String signature) { // In production, verify the webhook signature first log.info("Received webhook event: {}", event.getEvent()); // Process the webhook asynchronously CompletableFuture.runAsync(() -> { // Your webhook processing logic here log.info("Processed webhook event: {}", event.getEvent()); }); return ResponseEntity.ok("Webhook received"); } @GetMapping("/public-key") public ResponseEntity<Map<String, String>> getPublicKey() { return ResponseEntity.ok(Map.of("publicKey", paystackConfig.getPublicKey())); } }
2. Subscription Controller
@RestController @RequestMapping("/api/subscriptions") @Slf4j public class SubscriptionController { private final SubscriptionService subscriptionService; public SubscriptionController(SubscriptionService subscriptionService) { this.subscriptionService = subscriptionService; } @PostMapping("/plans") public ResponseEntity<CreatePlanResponse> createPlan(@Valid @RequestBody CreatePlanRequest request) { CreatePlanResponse response = subscriptionService.createPlan(request); return ResponseEntity.ok(response); } @PostMapping public ResponseEntity<CreateSubscriptionResponse> createSubscription( @Valid @RequestBody CreateSubscriptionRequest request) { CreateSubscriptionResponse response = subscriptionService.createSubscription(request); return ResponseEntity.ok(response); } @GetMapping public ResponseEntity<SubscriptionListResponse> listSubscriptions( @RequestParam Map<String, Object> params) { SubscriptionListResponse response = subscriptionService.listSubscriptions(params); return ResponseEntity.ok(response); } }

Testing

1. Unit Tests
@ExtendWith(MockitoExtension.class) class PaystackPaymentServiceTest { @Mock private PaystackHttpClient httpClient; @InjectMocks private PaystackPaymentService paymentService; @Test void shouldInitializeTransactionSuccessfully() { // Given InitializeTransactionRequest request = new InitializeTransactionRequest( "[email protected]", "500000"); // 5000 NGN InitializeTransactionResponse expectedResponse = new InitializeTransactionResponse(); expectedResponse.setStatus(true); expectedResponse.setMessage("Authorization URL created"); TransactionData data = new TransactionData(); data.setAuthorizationUrl("https://checkout.paystack.com/test123"); data.setReference("test_ref_123"); expectedResponse.setData(data); when(httpClient.post(eq("/transaction/initialize"), any(), eq(InitializeTransactionResponse.class))) .thenReturn(expectedResponse); // When InitializeTransactionResponse response = paymentService.initializeTransaction(request); // Then assertThat(response.isStatus()).isTrue(); assertThat(response.getData().getReference()).isEqualTo("test_ref_123"); verify(httpClient).post(eq("/transaction/initialize"), any(), eq(InitializeTransactionResponse.class)); } @Test void shouldVerifyTransaction() { // Given String reference = "test_ref_123"; VerifyTransactionResponse expectedResponse = new VerifyTransactionResponse(); expectedResponse.setStatus(true); expectedResponse.setMessage("Verification successful"); TransactionDetails details = new TransactionDetails(); details.setStatus("success"); details.setReference(reference); expectedResponse.setData(details); when(httpClient.get(eq("/transaction/verify/" + reference), eq(VerifyTransactionResponse.class))) .thenReturn(expectedResponse); // When VerifyTransactionResponse response = paymentService.verifyTransaction(reference); // Then assertThat(response.isStatus()).isTrue(); assertThat(response.getData().isSuccessful()).isTrue(); } }
2. Integration Test Configuration
@SpringBootTest @TestPropertySource(properties = { "paystack.secret-key=test_secret_key", "paystack.public-key=test_public_key" }) public abstract class BaseIntegrationTest { @LocalServerPort protected int port; protected TestRestTemplate restTemplate = new TestRestTemplate(); protected String getBaseUrl() { return "http://localhost:" + port; } }

Best Practices

  1. Security:
  • Never expose secret keys in client-side code
  • Validate webhook signatures
  • Use HTTPS in production
  1. Error Handling:
  • Implement retry logic for transient failures
  • Handle specific Paystack error codes appropriately
  • Log errors for monitoring
  1. Idempotency:
  • Use unique references for each transaction
  • Check for duplicate transactions before processing
  1. Monitoring:
  • Log all payment operations
  • Monitor webhook delivery failures
  • Track transaction success rates
@Component @Slf4j public class PaymentMonitoringService { @EventListener public void handlePaymentEvent(PaymentEvent event) { log.info("Payment event: {} - {}", event.getType(), event.getReference()); // Send to monitoring system (Prometheus, DataDog, etc.) Metrics.counter("payment_events") .tag("type", event.getType().toString()) .tag("status", event.getStatus().toString()) .increment(); } }

Conclusion

Paystack integration in Java enables African businesses to:

  • Accept multiple payment methods across different African countries
  • Handle recurring payments and subscriptions
  • Process payouts and transfers
  • Verify customer identities
  • Build robust payment systems with proper error handling and monitoring

The implementation shown provides a solid foundation that can be extended with additional Paystack features like bulk transfers, subaccounts, and dedicated virtual accounts. By following the patterns and best practices outlined, you can build secure, scalable payment processing systems tailored for African markets.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper