Spring Boot makes webhook endpoints straightforward with @RestController, but Spring Security CSRF and JSON message converters can block or mutate payloads before your handler runs. Local testing should prove raw byte[] access, CSRF exemptions on webhook paths, and signature verification against untouched bytes.
Create a @RestController webhook endpoint
Keep webhook controllers thin. Accept raw bytes, verify signatures, then deserialize:
@RestController
@RequestMapping("/webhooks")
public class StripeWebhookController {
@PostMapping("/stripe")
public ResponseEntity handle(
@RequestBody byte[] payload,
@RequestHeader("Stripe-Signature") String signature) {
if (!verifier.isValid(payload, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
JsonNode event = objectMapper.readTree(payload);
handler.process(event);
return ResponseEntity.ok().build();
}
}
Using @RequestBody byte[] preserves the exact payload Tomcat received — ideal when providers sign raw JSON bodies.
Read the raw body when you need HttpServletRequest
Alternatively, read directly from the servlet request inside a filter or controller method:
byte[] body = request.getInputStream().readAllBytes();
String sig = request.getHeader("Stripe-Signature");
Avoid binding to a POJO or Map first — Jackson re-serialization changes whitespace and breaks HMAC checks. See the signature verification guide.
Disable CSRF for webhook paths only
Spring Security enables CSRF for browser sessions by default. Webhook POSTs fail with 403 unless you ignore CSRF for server-to-server routes:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.ignoringRequestMatchers("/webhooks/**"));
return http.build();
}
}
Scope the ignore pattern to /webhooks/** — do not disable CSRF globally.
Verify signature headers before business logic
Read provider headers such as Stripe-Signature or X-Hub-Signature-256, compute the expected digest from the raw byte array, and use a constant-time compare. Return 401 on mismatch, 200 quickly on success to reduce retries.
Local tunnel workflow
- Start the app:
./mvnw spring-boot:runor run from your IDE on port 8080. - Expose embedded Tomcat:
npx portpreview 8080. - Paste the tunnel URL plus
/webhooks/stripeinto the provider dashboard. - Send a test event and watch application logs for signature pass/fail.
- Replay the same event ID to confirm idempotent handling per retry patterns.
Common pitfalls
Filter order and body consumption
Filters that read HttpServletRequest.getInputStream() before your controller leave an empty stream. Use ContentCachingRequestWrapper if multiple layers must inspect the body.
Global @RequestBody binding
A global @ControllerAdvice or custom HttpMessageConverter that parses JSON early can alter bytes. Keep webhook endpoints on dedicated controllers with byte[] binding.
Slow handlers trigger retries
Providers retry on timeouts and non-2xx responses. Acknowledge fast, process async, and deduplicate by event ID.
Where to go deeper
For fundamentals, read what localhost tunneling is and how to debug webhooks locally. For signature mechanics, see the signature verification guide. For duplicate-safe handlers, read retry and idempotency patterns. start PortPreview free.
![A Spring Boot RestController on embedded Tomcat verifying webhook signature headers from raw byte[] payload received through a local tunnel.](/_next/image?url=%2Fimages%2Farticles%2Fspring-boot-webhook-local-testing%2Fcover.png&w=3840&q=75)