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