Handle Paystack Webhooks in Spring Boot
To handle Paystack webhooks in Spring Boot, create a controller that reads the raw body with @RequestBody String, computes an HMAC SHA-512 hash using javax.crypto.Mac, compares it to the X-Paystack-Signature header, and processes the event asynchronously. Return ResponseEntity.ok().
Security Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.ignoringRequestMatchers("/webhooks/**"))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/webhooks/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
}
Webhook Controller
@RestController
@RequestMapping("/webhooks")
public class WebhookController {
@Value("${paystack.secret-key}")
private String secretKey;
@Autowired
private PaystackService paystackService;
@PostMapping("/paystack")
public ResponseEntity<String> handleWebhook(
@RequestBody String body,
@RequestHeader("X-Paystack-Signature") String signature) {
if (!verifySignature(body, signature)) {
return ResponseEntity.badRequest().body("Invalid signature");
}
// Parse and process
ObjectMapper mapper = new ObjectMapper();
try {
JsonNode event = mapper.readTree(body);
String eventType = event.get("event").asText();
JsonNode data = event.get("data");
processEvent(eventType, data);
} catch (Exception e) {
// Log error but still return 200
}
return ResponseEntity.ok("OK");
}
private boolean verifySignature(String body, String signature) {
try {
Mac mac = Mac.getInstance("HmacSHA512");
SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), "HmacSHA512");
mac.init(keySpec);
byte[] hash = mac.doFinal(body.getBytes());
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return MessageDigest.isEqual(
hexString.toString().getBytes(),
signature.getBytes()
);
} catch (Exception e) {
return false;
}
}
private void processEvent(String eventType, JsonNode data) {
if ("charge.success".equals(eventType)) {
String reference = data.get("reference").asText();
paystackService.verifyAndFulfill(reference);
}
}
}
Testing
@Test
public void testValidWebhook() throws Exception {
String payload = "{\"event\":\"charge.success\",\"data\":{\"reference\":\"test\"}}";
Mac mac = Mac.getInstance("HmacSHA512");
SecretKeySpec keySpec = new SecretKeySpec("sk_test_xxx".getBytes(), "HmacSHA512");
mac.init(keySpec);
String signature = Hex.encodeHexString(mac.doFinal(payload.getBytes()));
mockMvc.perform(post("/webhooks/paystack")
.content(payload)
.contentType(MediaType.APPLICATION_JSON)
.header("X-Paystack-Signature", signature))
.andExpect(status().isOk());
}
Key Takeaways
- ✓Use @RequestBody String to get the raw body for HMAC computation.
- ✓javax.crypto.Mac with HmacSHA512 handles the signature computation.
- ✓Disable CSRF for the webhook endpoint in your SecurityFilterChain.
- ✓Use @Async for non-blocking event processing after returning 200.
- ✓Make handlers idempotent. Paystack retries failed deliveries.
- ✓Spring Security configuration must permit the webhook URL without authentication.
Frequently Asked Questions
- Why does my webhook return 403?
- Your Spring Security configuration is probably blocking the webhook URL or CSRF is enabled. Add the webhook path to CSRF ignore list and permit it without authentication.
- Should I use @Async for webhook processing?
- Yes. @Async lets you return 200 immediately and process in a separate thread. Make sure to enable @EnableAsync on your configuration class.
- Can I use Spring Events for webhook processing?
- Yes. Publish an ApplicationEvent from the controller and handle it with an @EventListener. This decouples the webhook receipt from processing.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon