Bonaventure OgetoBy Bonaventure Ogeto|

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());
}

Ship Payments Faster

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