Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

  1. Expose a public HTTPS route. Configure the provider to send events to a stable URL such as https://api.example.com/webhooks/provider.
  2. Capture the raw body and headers. Read the request bytes before any JSON parser or object mapper changes whitespace, escaping, or key order.
  3. Authenticate the message. Apply the provider’s documented signature algorithm. For GitHub, this means the X-Hub-Signature-256 header and HMAC-SHA-256.
  4. 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.
  5. 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.
  6. 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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import 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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

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.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Frequently 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.

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.