How to Receive Webhook Events in a Java Application
Build a Java webhook endpoint that verifies signatures, handles duplicate deliveries safely, and acknowledges events before slow work begins.

A Java application receives a webhook by exposing a public HTTPS POST endpoint, reading the exact request bytes, verifying the sender’s documented signature, deduplicating deliveries, and returning a 2XX response quickly. In Spring Boot, use a controller that accepts the raw servlet request; verify it before parsing JSON or triggering business logic. Put slow work on a queue or background worker.
This guide uses GitHub’s X-Hub-Signature-256 HMAC-SHA-256 header as a concrete example. The header names and signature format are provider-specific: adapt the verifier to the provider that sends your events. GitHub recommends validating the signature before further processing and responding within 10 seconds. GitHub signature validation · GitHub webhook best practices
1. Create a Spring Boot endpoint
Use a Spring Boot web application with Java 17 or later. The minimal Maven dependency is Spring Web; the example below uses Jackson, which Spring Boot’s web starter includes, to parse an event after verification. Keep the webhook secret outside source control, for example in an environment variable.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Configure the secret in your runtime environment:
export GITHUB_WEBHOOK_SECRET='replace-with-the-secret-configured-at-the-provider'
Read the body from HttpServletRequest as bytes. Don’t bind it to a Java object first: JSON parsing and reserialization can change whitespace, escaping, or key order, which changes the bytes the signature authenticates.
2. Verify the signature over the raw body
GitHub sends an HMAC-SHA-256 digest in X-Hub-Signature-256, formatted as sha256= followed by a hexadecimal digest. Compute the HMAC over the exact raw bytes using the configured webhook secret. Compare the expected and received values with a constant-time comparison rather than ordinary string equality.

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HexFormat;
final class GitHubSignatureVerifier {
private final byte[] secret;
GitHubSignatureVerifier(String secret) {
if (secret == null || secret.isBlank()) {
throw new IllegalArgumentException("Webhook secret is required");
}
this.secret = secret.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
boolean isValid(String header, byte[] rawBody) {
if (header == null || !header.startsWith("sha256=")) return false;
final byte[] supplied;
try {
supplied = HexFormat.of().parseHex(header.substring("sha256=".length()));
} catch (IllegalArgumentException malformedHex) {
return false;
}
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
byte[] expected = mac.doFinal(rawBody);
return MessageDigest.isEqual(expected, supplied);
} catch (java.security.GeneralSecurityException e) {
throw new IllegalStateException("HMAC-SHA-256 is unavailable", e);
}
}
}
Use the provider’s precise algorithm, header, encoding, and signed input. A different provider may sign a timestamp plus body, use Base64, or provide an official SDK. Don’t assume this GitHub-specific verifier works for another service. GitHub documents the SHA-256 header and recommends it over the legacy SHA-1 header. GitHub: validating webhook deliveries
3. Deduplicate, record, and acknowledge
Providers retry deliveries, and network failures can make it unclear whether a prior attempt succeeded. Use the provider’s delivery identifier as an idempotency key. Persist the delivery ID and event state in a database with a unique constraint in production; an in-memory set is only suitable as a small runnable illustration because it is lost on restart and isn’t shared across instances.

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@RestController
class GitHubWebhookController {
private final GitHubSignatureVerifier verifier;
private final ObjectMapper mapper;
private final Set<String> seen = ConcurrentHashMap.newKeySet();
GitHubWebhookController(ObjectMapper mapper) {
this.mapper = mapper;
this.verifier = new GitHubSignatureVerifier(
System.getenv("GITHUB_WEBHOOK_SECRET"));
}
@PostMapping(path = "/webhooks/github", consumes = "application/json")
ResponseEntity<String> receive(
@RequestHeader(value = "X-Hub-Signature-256", required = false) String signature,
@RequestHeader(value = "X-GitHub-Delivery", required = false) String deliveryId,
@RequestHeader(value = "X-GitHub-Event", required = false) String eventName,
HttpServletRequest request) throws IOException {
byte[] raw = request.getInputStream().readAllBytes();
if (!verifier.isValid(signature, raw)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid signature");
}
if (deliveryId == null || deliveryId.isBlank()) {
return ResponseEntity.badRequest().body("Missing delivery ID");
}
if (seen.contains(deliveryId)) {
return ResponseEntity.ok("Already received");
}
JsonNode payload = mapper.readTree(raw);
if (!Set.of("issues", "push", "pull_request").contains(eventName)) {
// Acknowledge valid but unhandled event types; record/observe as appropriate.
seen.add(deliveryId);
return ResponseEntity.ok("Event ignored");
}
// Replace this with a durable transaction/outbox or queue publication.
// The event handler must also be idempotent if a retry races this request.
process(eventName, payload, deliveryId);
seen.add(deliveryId);
return ResponseEntity.accepted().body("Accepted");
}
private void process(String eventName, JsonNode payload, String deliveryId) {
// Perform only fast work here, or persist/enqueue for a worker.
}
}
The example keeps the flow visible, but its in-memory deduplication has a race across simultaneous requests and a crash window between processing and marking the ID. A production handler should atomically claim the ID in durable storage and persist the event or enqueue it in the same transaction when possible. Return 2XX for an already accepted delivery so a retry does not repeat work. Return a non-2XX only when you want the provider to retry or the request is invalid, according to that provider’s retry policy.
4. Configure the provider and deploy safely
- Deploy the application at a stable, publicly reachable HTTPS URL, such as
https://example.com/webhooks/github. A webhook sender must reach it from outside your local development machine. - In the provider’s webhook configuration, enter that URL, select JSON, set the same secret held by
GITHUB_WEBHOOK_SECRET, and subscribe only to event types the endpoint handles. - Ensure your reverse proxy and application accept the provider’s request body size, preserve the relevant headers, and route
POSTrequests to the application. - Send a provider test delivery, inspect the delivery status and server logs, then test an invalid signature and a repeated delivery ID.
- Monitor response status, processing latency, queue depth, signature failures, and duplicate counts. Never log the secret or unrestricted sensitive payloads.
GitHub supplies X-GitHub-Event, X-GitHub-Delivery, and X-Hub-Signature-256. Use the event header to route the payload and the delivery header for deduplication; validate the signature before trusting either header or the body. GitHub webhook events and payloads
5. Queue slow work and handle retries
A webhook request should do only enough work to authenticate, validate basic shape, durably record or enqueue the delivery, and acknowledge it. Sending email, calling several downstream APIs, generating reports, or waiting on a long database operation can exceed the sender’s acknowledgement window. GitHub advises a 2XX response within 10 seconds. Move longer tasks to a queue or background worker and return once the event is safely accepted. GitHub best practices
Use a durable queue or transactional outbox when losing an accepted event is unacceptable. Workers should retry transient failures with bounded exponential backoff and jitter, and route exhausted messages to a dead-letter path for review. Make downstream effects idempotent too: a worker can retry after completing an external action but before saving its completion state. Keep the original delivery ID attached throughout processing.
6. Provider-specific security details
- Raw bytes: Sign and verify the bytes as received, before JSON parsing. Don’t trim, normalize line endings, or reconstruct the JSON.
- Constant-time MAC comparison: Avoid leaking signature match information through an early-exit string comparison.
- Timestamps and replay: If a scheme signs a timestamp, validate it against a reasonable tolerance and keep host clocks synchronized. GitHub’s example signature scheme is body HMAC; other providers define timestamp handling differently.
- Secret management: Load secrets from deployment configuration or a secret manager. Rotate them using the provider’s documented procedure and avoid exposing them in exception messages.
- Input limits: Set a reasonable maximum request size at the proxy and application layers. Reject malformed or oversized requests without attempting expensive parsing.
- Event validation: Allow only known event types and validate required fields before business logic. Payload schemas can evolve; handle unknown optional fields and version assumptions carefully.
- Network controls: HTTPS is essential. IP allowlisting can be an additional control where provider guidance supports it, but it does not replace signature verification.
For another provider, start from its official signature documentation or SDK. For example, DocSpring emphasizes verification before acting, while Svix’s Java guidance covers its own signing format and timestamp behavior; neither format should be substituted for GitHub’s. Svix payload verification
7. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Every delivery returns 401 | Wrong secret, wrong signature header, or body changed before verification. | Confirm the configured secret on both sides. Read the raw bytes directly and use the provider’s exact algorithm and header. |
| Signature parsing fails | Header missing, malformed, or encoded differently from the GitHub format. | Reject malformed values; check the provider’s actual header and encoding. Don’t silently accept a different format. |
| Provider reports a timeout | Handler waits for business work or downstream services. | Persist/enqueue quickly and return 2XX. Move work to a worker and set bounded timeouts on outbound calls. |
| Duplicate action happens | Delivery ID wasn’t persisted atomically, or downstream processing isn’t idempotent. | Add a unique database constraint on provider plus delivery ID. Make workers and external actions safe to retry. |
| Events arrive but are ignored | Subscription excludes the event, routing header isn’t handled, or code only recognizes a subset. | Check the provider’s event configuration and X-GitHub-Event. Log safe metadata for unhandled event types. |
| Works locally, fails after deployment | Endpoint isn’t publicly reachable, proxy strips headers, TLS or route configuration is wrong. | Verify the public HTTPS URL, proxy forwarding, firewall rules, and application route from the provider’s delivery log. |
| Large payload fails | Proxy or servlet request size limit is too low, or memory is constrained by buffering. | Set intentional limits based on provider payloads; reject oversized bodies predictably and avoid buffering multiple copies. |
8. Performance, reliability, and cost
Webhook receipt is usually a short network and persistence operation. Avoid blocking the request thread on slow work. A queue adds operational cost and another component to monitor, but it gives a clear boundary between provider acknowledgement and processing. Choose durable storage, retention, and retry policies based on the impact of losing or repeating an event.
Bound concurrency and queue growth so an event burst cannot exhaust database connections or memory. Apply timeouts to downstream calls, use backpressure where supported, and use jittered retry delays to avoid retry storms. Keep enough event metadata to trace a delivery from HTTP request to worker, while redacting credentials and sensitive data. Provider retry windows and timeouts vary; consult the provider’s current documentation rather than assuming GitHub’s guidance applies universally.
There is no single cost figure for a Java webhook receiver: costs depend on hosting, traffic, durable storage, queue choice, retention, and downstream work. Start by measuring delivery volume, average payload size, processing time, and retry rate. Capacity-plan for bursts rather than just average requests, and alert on oldest queued event as well as queue length.
Or skip the browser setup
For a separate task—capturing a webpage screenshot from Java—ScreenshotNeo is a website screenshot API and MCP server. This webhook implementation still belongs in your Java application; a screenshot API does not receive provider webhook events. If your workflow also needs page captures, a single GET returns an image or PDF, and the Java HTTP client can call it:
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
String key = System.getenv("SCREENSHOTNEO_API_KEY");
String url = "https://stripe.com";
String query = "access_key=" + URLEncoder.encode(key, StandardCharsets.UTF_8)
+ "&url=" + URLEncoder.encode(url, StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.screenshotneo.com/v1/shot?" + query)).GET().build();
HttpResponse<byte[]> response = HttpClient.newHttpClient().send(
request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
Files.write(Path.of("shot.webp"), response.body());
} else {
throw new IllegalStateException("Screenshot request failed: " + response.statusCode());
}
See the ScreenshotNeo API documentation for request options. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000. Sign up free for ScreenshotNeo.
FAQ
Can I receive webhooks without Spring Boot?
Yes. Any Java server framework can expose an HTTPS POST route. Preserve the raw body and headers so verification happens before deserialization; the framework changes, but the security and reliability steps remain.
Should an invalid signature receive 2XX?
No. Reject it without performing business actions. Use the provider’s documented status and retry behavior; repeated invalid attempts should be observable and rate-limited at the edge where appropriate.
Can I process the event before returning?
Only if that work reliably fits inside the sender’s timeout and you can handle retries safely. For slow or variable work, durably enqueue first and acknowledge afterward.
Do I need to store the whole payload?
Store enough durable information to process, audit, or recover the event according to your application’s needs. Apply retention limits and data minimization; a delivery ID and status alone may not be enough to replay failed work.