[AI Readability Summary]
A common payment webhook failure in Spring Boot occurs when the application receives an application/x-www-form-urlencoded callback, but a logging aspect incorrectly serializes HttpServletRequest and consumes the request body before the controller runs. As a result, the controller cannot access the parameters. The key fixes are to correctly identify Servlet objects in the aspect, avoid reflective reads of request streams, and preserve the raw body in the controller for reliable parsing and troubleshooting.
Technical Specification Snapshot
| Parameter | Description |
|---|---|
| Language | Java |
| Framework | Spring Boot / Spring MVC |
| Protocol | HTTP POST |
| Content Type | application/x-www-form-urlencoded |
| Container | Tomcat |
| Key Components | AOP logging aspect, HttpServletRequest, Jackson |
| Core Dependencies | jakarta.servlet, Spring Web, Jackson |
| Symptom | Controller getParameterMap() returns empty, causing callback failure |
The failure happens because the request body is consumed before business logic runs
This failure appears specifically in asynchronous notifications from payment platforms and is concentrated around application/x-www-form-urlencoded. The visible symptom is that controller parameters are empty, but the actual root cause is that the request body was read before the request reached the controller.
The logs show that contentLength is clearly greater than 0, which means the third-party platform did send content. At the same time, queryString=null indicates that the parameters were not placed in the URL. Then getReader() throws IllegalStateException, which directly points to one conclusion: the input stream has already been consumed.
[Tomcat] -> [CorsFilter] -> [Interceptor] -> [RequestLogAspect] -> [Controller]
This call chain matters. If any upstream component reads the InputStream or Reader, the controller may fail when it tries to read the request later.
The root cause is that the logging aspect incorrectly processes Servlet objects
After checking filters and interceptors, the real trigger is RequestLogAspect. Before the controller executes, it iterates through the method arguments and tries to serialize them into JSON for logging.
If the parameter filtering logic fails, HttpServletRequest gets passed to Jackson. When Jackson serializes an object, it reflectively invokes many getXxx() methods, and methods such as getInputStream(), getReader(), and getParameterMap() can all cause side effects.
private Object processParameterData(Object obj) {
try {
ObjectMapper objectMapper = new ObjectMapper();
// Serialize the parameter object to JSON. If the object wraps the request body, getters may be triggered.
String jsonStr = objectMapper.writeValueAsString(obj);
return jsonStr;
} catch (Exception e) {
// If serialization fails, fall back to the class name and hash value.
return obj.getClass().getSimpleName() + "@" + Integer.toHexString(obj.hashCode());
}
}
The problem here is not Jackson itself. The real issue is passing an object to Jackson that should never be serialized in the first place.
The existing isServletObject check is unreliable
The original implementation depends on package-name string matching, such as checking whether the class name contains jakarta.servlet. This looks reasonable for interface types, but runtime objects are often container implementations, such as Tomcat’s RequestFacade, whose package name is org.apache.catalina.connector.
That means HttpServletRequest belongs to the Servlet package as an interface, but its implementation class does not necessarily live in a Servlet package. As a result, the filtering logic misses it, and the aspect mistakenly serializes the request object as if it were a normal Java Bean.
private boolean isServletObject(Object obj) {
String className = obj.getClass().getName();
return className.contains("javax.servlet")
|| className.contains("jakarta.servlet")
|| className.contains("org.springframework.web")
|| className.contains("org.springframework.ui");
}
The flaw in this code is that it guesses type from the class name instead of identifying the object by interface.
A safer fix should cover both the aspect and the controller
The smallest patch is to add a dummy parameter such as @RequestBody(required = false) String body in the controller. This relies on Spring’s parameter resolution side effects to trigger cached form parsing early. It can stop the immediate outage, but it does not address the root cause.
A better approach is to fix the aspect at the source and use the controller as a safety net. The first part prevents the request body from being consumed incorrectly again. The second part ensures that even if a new component is inserted into the request chain later, the application still preserves the raw body for troubleshooting.
The aspect should use explicit instanceof checks
private boolean isServletObject(Object obj) {
// Identify by interface instead of guessing from the implementation package name.
if (obj instanceof jakarta.servlet.ServletRequest
|| obj instanceof jakarta.servlet.ServletResponse
|| obj instanceof jakarta.servlet.http.HttpSession
|| obj instanceof org.springframework.web.multipart.MultipartFile
|| obj instanceof org.springframework.ui.Model) {
return true;
}
String className = obj.getClass().getName();
return className.startsWith("jakarta.servlet")
|| className.startsWith("javax.servlet")
|| className.startsWith("org.apache.catalina")
|| className.startsWith("org.springframework.web")
|| className.startsWith("org.springframework.ui");
}
The core value of this fix is straightforward: use instanceof first as the primary safeguard, then use prefix matching as a secondary filter for framework objects. This reduces false negatives caused by differences across servlet containers.
The controller should preserve the raw body first and then parse parameters manually
request.getParameterMap() depends on the container’s implicit form parsing. That behavior can become unreliable in scenarios involving proxies, character encoding, or request streams that were consumed too early. A more robust approach is to read the raw body first and then parse it manually according to URL-encoding rules.
@PostMapping(value = "/webhook", produces = MediaType.TEXT_PLAIN_VALUE)
public String webhook(HttpServletRequest request) {
// Preserve the raw request body first so it can be reviewed during troubleshooting.
String rawBody = readRawBody(request);
Map<String, String> params = new HashMap<>();
if (request.getQueryString() != null && !request.getQueryString().isEmpty()) {
// Handle the small number of platforms that send parameters in the query string.
params.putAll(parseUrlEncoded(request.getQueryString(), "UTF-8"));
}
if (rawBody != null && !rawBody.isEmpty()) {
// Manually parse form-urlencoded content to reduce dependence on container behavior.
params.putAll(parseUrlEncoded(rawBody,
request.getCharacterEncoding() != null ? request.getCharacterEncoding() : "UTF-8"));
}
if (params.isEmpty()) {
return "failure";
}
return "success";
}
The goal of this code is not to replace Spring MVC. It is to deliver more predictable behavior in payment callback scenarios, where observability and reliability matter most.
The image shows page-sharing guidance rather than technical structure

This image is an animated page-sharing guide. It does not describe the failure chain, request flow, or code structure, so it does not affect the technical analysis.
In practice, webhook design should follow two principles
First, never pass HttpServletRequest, HttpServletResponse, MultipartFile, or HttpSession to a generic serializer. They are not ordinary DTOs, and many of their getters have side effects.
Second, payment callback endpoints should read and store the raw body before they verify signatures, decode fields, or write data to storage. That way, even if parameter parsing fails, you can still determine from the logs whether the upstream never sent the payload, a proxy truncated it, or the application consumed the stream too early.
Recommended minimum logging fields
log.warn("notify failed: contentType={}, contentLength={}, queryString={}, bodyLen={}",
request.getContentType(),
request.getContentLength(),
request.getQueryString(),
rawBody != null ? rawBody.length() : -1);
These fields are enough to quickly distinguish among four common issues: no body, body already consumed, parameters not parsed, and character encoding problems.
FAQ
FAQ 1: Why does getParameterMap() return empty?
Because parameters for application/x-www-form-urlencoded usually come from the request body. If getInputStream() consumes the body before the controller runs, Tomcat can no longer parse the form data afterward, so it returns an empty parameter map.
FAQ 2: Why do the logs only show RequestFacade@xxxx?
Because Jackson triggered a stream read while serializing RequestFacade, which caused an exception. After catching that exception, the aspect fell back to outputting only the object class name and hash value. That behavior is strong supporting evidence that the request object was serialized by mistake.
FAQ 3: What is the best long-term fix?
First, fix the AOP aspect and use instanceof to exclude Servlet-related objects. Then make the webhook controller preserve the raw body before manual parsing and signature verification. Together, these two changes create a root-cause fix plus a safety-net design.
Core Summary
This article reviews a Spring Boot payment callback failure in which an application/x-www-form-urlencoded request was read by a logging aspect before it reached the controller, causing the parameter map to be empty. It breaks down the request chain, identifies the missing HttpServletRequest classification as the root cause, and presents two repair strategies: fixing the aspect at the source and adding controller-level fallback protection.