Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.
To receive webhook events in Java, expose a public HTTPS POST endpoint, read the exact request bytes, verify the provider’s signature before parsing JSON, deduplicate by delivery ID, enqueue slow work, and return a 2XX response quickly. Spring Boot and Spring MVC provide a practical implementation; the signature header, timestamp rules, and event ID depend on the provider.
What a production webhook receiver must do
A webhook receiver is an internet-facing application endpoint. The sender makes an HTTP POST request containing an event payload and authentication headers. Your endpoint should complete only the work needed to authenticate, validate, record, and queue that delivery.
- Expose a public HTTPS route. Configure the provider to send events to a stable URL such as
https://api.example.com/webhooks/provider. - Capture the raw body and headers. Read the request bytes before any JSON parser or object mapper changes whitespace, escaping, or key order.
- Authenticate the message. Apply the provider’s documented signature algorithm. For GitHub, this means the
X-Hub-Signature-256header and HMAC-SHA-256. - Reject stale or replayed messages. If the provider signs a timestamp, enforce a tolerance with a synchronized system clock. Store a provider delivery or event ID and atomically claim it before dispatching work.
- Validate and route the event. Parse JSON only after authentication, check the event type and required fields, and ignore subscriptions your application does not handle.
- Acknowledge quickly. Return a 2XX response within the provider’s timeout. GitHub documents a 10-second response target; database-heavy work, API calls, emails, and file processing belong on a queue or background executor.
Spring Boot endpoint: raw bytes first, JSON later
Prerequisites
- Java 17 or a newer supported Java release for your Spring Boot line.
- A Spring Boot web application with Spring MVC.
- A public HTTPS deployment and a webhook secret stored in an environment variable or secret manager.
- A durable store for delivery IDs and an asynchronous queue, database outbox, or equivalent worker mechanism.
Controller
The following teaching implementation reads the body once, verifies it, atomically claims the delivery ID, and queues the raw payload. Adapt header names and verification rules to your provider.
Crashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minuteWindows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallimport jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping('/webhooks')
public class ProviderWebhookController {
private final SignatureVerifier verifier;
private final DeliveryStore deliveryStore;
private final WebhookQueue queue;
public ProviderWebhookController(SignatureVerifier verifier,
DeliveryStore deliveryStore,
WebhookQueue queue) {
this.verifier = verifier;
this.deliveryStore = deliveryStore;
this.queue = queue;
}
@PostMapping(path = '/provider', consumes = 'application/json')
public ResponseEntity<Void> receive(@RequestHeader HttpHeaders headers,
HttpServletRequest request) throws IOException {
byte[] rawBody = request.getInputStream().readAllBytes();
if (!verifier.isValid(headers, rawBody)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
String deliveryId = first(headers, 'X-Provider-Delivery', 'X-GitHub-Delivery');
if (deliveryId == null || deliveryId.isBlank()) {
return ResponseEntity.badRequest().build();
}
// claim() must be an atomic insert/put-if-absent operation.
if (!deliveryStore.claim(deliveryId)) {
return ResponseEntity.ok().build();
}
String eventType = first(headers, 'X-GitHub-Event', 'X-Provider-Event');
queue.publish(new WebhookMessage(deliveryId, eventType, rawBody));
return ResponseEntity.accepted().build();
}
private static String first(HttpHeaders headers, String... names) {
for (String name : names) {
String value = headers.getFirst(name);
if (value != null) return value;
}
return null;
}
}
record WebhookMessage(String deliveryId, String eventType, byte[] rawBody) {}
interface DeliveryStore {
/** Returns true only when this delivery ID was newly claimed. */
boolean claim(String deliveryId);
}
interface WebhookQueue {
void publish(WebhookMessage message);
}
Do not use a check-then-insert sequence for claim; two concurrent requests can both pass the check. Use a database unique constraint, an atomic key-value-store insert, or an equivalent transactional operation. A duplicate delivery is normally acknowledged with 200 after the original claim has been recorded, so the sender does not keep retrying work you already accepted.
Verify signatures against the exact request
GitHub HMAC-SHA-256 example
GitHub sends X-GitHub-Event, X-GitHub-Delivery, and X-Hub-Signature-256. The signature value has a sha256= prefix followed by a hexadecimal HMAC. GitHub recommends this SHA-256 header instead of its legacy SHA-1 header.
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.HttpHeaders;
public final class GitHubSignatureVerifier implements SignatureVerifier {
private final byte[] secret;
public GitHubSignatureVerifier(String secret) {
this.secret = secret.getBytes(StandardCharsets.UTF_8);
}
@Override
public boolean isValid(HttpHeaders headers, byte[] rawBody) {
String supplied = headers.getFirst('X-Hub-Signature-256');
if (supplied == null || !supplied.startsWith('sha256=')) {
return false;
}
try {
Mac mac = Mac.getInstance('HmacSHA256');
mac.init(new SecretKeySpec(secret, 'HmacSHA256'));
byte[] digest = mac.doFinal(rawBody);
String expected = 'sha256=' + HexFormat.of().formatHex(digest);
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.US_ASCII),
supplied.getBytes(StandardCharsets.US_ASCII));
} catch (GeneralSecurityException ex) {
return false;
}
}
}
interface SignatureVerifier {
boolean isValid(HttpHeaders headers, byte[] rawBody);
}
Use a constant-time comparison for MAC values. Never compute the digest from a parsed and re-serialized object: a harmless formatting change can produce different bytes and therefore a different signature. Keep the secret out of source control and redact signature headers, authorization values, and payload fields that contain credentials from logs.
Timestamp-based providers
Not every provider uses GitHub’s format. Some sign a timestamp concatenated with the raw body, use Base64 rather than hexadecimal, or provide an SDK that constructs the signed message. Follow that provider’s exact byte order, encoding, header names, and secret format. Parse the timestamp, compare it with a synchronized clock, and reject deliveries outside the provider’s documented tolerance before doing business work. A valid MAC without freshness protection can still be replayed.
Rank #2
Parse, validate, and route only after authentication
After the signature check succeeds, deserialize the raw bytes with Jackson or your chosen JSON library. Treat the result as untrusted input even though it was authenticated: authentication proves who signed the message, not that every field is acceptable for your application.
- Check the event header against an allow-list such as
push,issues, or the provider’s equivalent. - Validate required fields, maximum lengths, numeric ranges, and identifiers before writing domain data.
- Keep a versioned event model when the provider can change schemas or send multiple API versions.
- Record the delivery ID, event type, received time, verification result, and processing state for diagnosis.
- Do not subscribe to event types the endpoint cannot safely handle; filtering at the provider reduces unnecessary traffic.
For stronger delivery guarantees, write the authenticated payload and an outbox record in one database transaction, then let a worker publish or process the outbox entry. If queue publication fails after you mark a delivery as claimed, the durable record must make that message visible for retry rather than silently dropping it.
Fast acknowledgements, retries, and dead letters
Your HTTP handler should not wait for third-party APIs, long-running database reports, image processing, or user notifications. Put those operations behind a queue or bounded executor and return 202 Accepted once the authenticated delivery is durably queued. Returning 200 OK is also valid when the provider expects a synchronous success response; choose one convention and document it.
Retries are normal. Use exponential backoff with jitter for transient failures, cap the number of attempts, and move permanently failing messages to a dead-letter queue. Do not retry invalid signatures, malformed JSON, unsupported event types, or schema violations as if they were temporary outages. Monitor queue age, processing latency, failure counts, dead-letter volume, and the proportion of duplicate delivery IDs.
Servlet versus reactive Spring applications
Spring MVC
The servlet example above is straightforward because HttpServletRequest.getInputStream() exposes the body bytes. Ensure no filter, form parser, or request wrapper consumes or transforms the stream before the controller. If you need request logging, cache the bytes once in a bounded wrapper and never log the secret or complete sensitive payload.
Spring WebFlux
In WebFlux, collect the incoming DataBuffer content exactly once, release buffers correctly, and pass the resulting byte array to the verifier before calling bodyToMono for JSON mapping. Avoid converting to a String and back unless the provider explicitly defines a character encoding; signatures are over bytes. Apply a maximum body size to prevent memory exhaustion and use back-pressure-aware queue publication.
Rank #4
Testing a Java webhook receiver
Local signature test
Create a fixed body and compute its HMAC with the same secret configured in the application. The following shell example sends a GitHub-shaped request; replace the URL with your HTTPS endpoint.
SECRET='replace-with-test-secret'
BODY='{"action":"ping"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i -X POST 'https://api.example.com/webhooks/provider'
-H 'Content-Type: application/json'
-H 'X-GitHub-Event: ping'
-H 'X-GitHub-Delivery: test-delivery-001'
-H "X-Hub-Signature-256: sha256=$SIG"
--data "$BODY"
Test at least these cases:
- A correct signature returns a 2XX response and creates one queued message.
- Changing one body byte or one signature character returns 401 and performs no business action.
- Sending the same delivery ID twice queues it once and acknowledges the second request.
- A missing delivery ID, unsupported event type, oversized body, stale timestamp, and malformed JSON follow your documented failure policy.
- A slow worker does not make the HTTP request exceed the provider’s timeout.
Common failures and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Every request returns 401 | The verifier hashes parsed JSON, uses the wrong secret, or includes the sha256= prefix incorrectly. |
Hash the exact bytes read from the request, confirm the configured secret, and compare the complete provider-formatted value. |
| Valid deliveries fail after adding a filter | A servlet filter consumed or modified the input stream. | Capture the body once in a bounded wrapper and pass the same bytes to verification and parsing. |
| Duplicate business records appear | Delivery IDs are checked non-atomically or idempotency is applied after side effects. | Atomically claim the provider ID before queueing and enforce a unique database key. |
| The provider reports timeouts | The handler performs slow work before responding. | Persist or enqueue the authenticated message, return 2XX promptly, and process it asynchronously. |
| Retries continue after successful processing | The response is non-2XX, malformed, or sent after the provider’s timeout. | Return a plain 200 or 202 response on the success path and inspect load-balancer and application timeouts. |
| Timestamp validation is inconsistent | Application clocks are unsynchronized or the tolerance is undocumented. | Synchronize hosts, parse the provider’s timestamp exactly, and use its stated tolerance. |
Deployment and security checklist
- Terminate TLS correctly and ensure the route is reachable from the provider’s network.
- Restrict accepted HTTP methods and content types; reject unexpectedly large bodies.
- Store secrets in environment variables or a secret-management service and rotate them according to provider support.
- Use structured logs with delivery ID and event type, but redact secrets, authorization headers, and sensitive payload fields.
- Apply rate limits and connection limits appropriate for expected bursts.
- Separate authentication failures, schema failures, queue failures, and worker failures in metrics.
- Keep dead-letter replay tooling protected and require an operator to choose whether replaying a message is safe.
Or skip the browser setup
If you need a clean visual capture of a webhook dashboard, documentation page, or test result, ScreenshotNeo provides a single-call website screenshot API and an MCP server for AI agents. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed; each response identifies the result with X-Page-Verdict and X-Billed headers.
curl -G 'https://api.screenshotneo.com/v1/shot' -d access_key=YOUR_API_KEY --data-urlencode url=https://screenshotneo.com/docs/ -o shot.webp
See the parameter reference and options in the ScreenshotNeo documentation. The service also exposes MCP tools named take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 shots, and every feature is included on every plan. Create a free ScreenshotNeo account.
FAQ
Can one Java endpoint receive events from several providers?
Yes. Use separate paths or a provider-discriminating header, then select a verifier and schema for that provider. Never apply one provider’s signature parser to another provider’s messages.
Best Value
Should I keep the original payload after processing?
Retain the delivery ID, event type, processing state, and whatever raw or normalized payload is necessary for audit, replay, and incident investigation. Apply your organization’s retention and privacy rules, and encrypt sensitive data at rest.
Is an SDK required to verify a webhook?
No. An SDK can reduce provider-specific mistakes, but a Java implementation can verify a documented HMAC or signed-message format directly as long as it uses the exact raw bytes, correct encoding, freshness checks, and constant-time comparison.
Do these 3 things before closing this tab:
1Scan for outdated or missing drivers - takes under a minute2Repair Windows errors before they cause bigger problems3Fix the driver behind crashes, sound loss and screen glitchesFrequently Asked Questions
Can one Java endpoint receive events from several providers?
Yes. Use separate paths or identify the provider before selecting its verifier and schema. Keep each provider’s signature and timestamp rules isolated.
Is an SDK required to verify a webhook?
No. A carefully implemented verifier can follow the provider’s documented signed-message format directly; an SDK is optional convenience.
Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

