Block Image


In my book Spring Boot 3 API Mastery, I discussed various asynchronous communication techniques between clients and servers, with a focus on the publisher/subscriber pattern.

In this tutorial, I’ll instead dive into the webhook approach, showing you how to implement it with Spring Boot. We’ll build an example server (webhook-server, representing a shipping provider) and a client (webhook-client, representing an e-commerce application interested in receiving updates about product shipments).

You can find the complete code on the GitHub repository: spring-webhook.

Block Image

What is a webhook and how it compares to polling

A webhook is a technique that enables asynchronous communication between a server and multiple clients.

Compared to HTTP polling, where clients periodically make HTTP calls to the server to check if an event occurred or if the state of an entity has changed, webhooks use the opposite approach.

The server (also called the webhook provider) exposes a registration API for clients (also known as webhook consumers or webhook listeners), where they must specify several parameters including a callback URL and the types of events they are interested in. When an event occurs on the server, it notifies all registered clients that subscribed to that event by invoking their callback URL.

The main advantage of webhooks over polling lies in the efficiency and timeliness of communication between systems:

  • The server actively notifies the client when a new event occurs ("there’s an update, here’s the data").
  • Real time: you receive the event immediately, without waiting for the next polling interval.
  • Efficiency: no unnecessary requests, reduced traffic and costs.
  • Simplicity for clients: no need to schedule jobs or manage polling mechanisms.

The trade-off is that implementing the server side of webhooks is more complex.

Building the webhook server

The only dependencies used in the server app are:

  • spring-boot-starter-web, to expose HTTP APIs using Tomcat as Servlet Container.
  • spring-retry, to allow the server to retry failed callback calls.
  • spring-boot-starter-aop, required by Spring Retry.

You can download the project skeleton by running the following cURL:

curl https://start.spring.io/starter.zip -d groupId=com.vincenzoracca \
  -d artifactId=webhook-server -d name=webhook-server \
  -d packageName=com.vincenzoracca.webhookserver \
  -d dependencies=web -d javaVersion=21 -d bootVersion=3.5.4 \
  -d type=maven-project -o webhook-server.zip

Full dependencies:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.retry</groupId>
        <artifactId>spring-retry</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Domain models on the server

The models used are Webhook and ShipmentEvent, representing the webhook entity and a shipping event, respectively:

// com.vincenzoracca.webhookserver.model.Webhook.java
public record Webhook(
        String webhookId,
        String callbackUrl,
        String secret,
        EventFilter eventFilter
) {

    // hide the secret in toString()

    public enum EventFilter {
        ALL,
        COMPLETED,
        CANCELED
    }
}

// com.vincenzoracca.webhookserver.model.ShipmentEvent.java
public record ShipmentEvent(
        String eventId,
        String orderId,
        ShipmentStatus status
) {

    public enum ShipmentStatus {
        COMPLETED,
        CANCELED,
    }
}

Webhook details:

  • webhookId: a UUID generated by the server during registration and returned to the client.
  • callbackUrl: the callback URL provided by the client.
  • secret: generated by the server and returned to the client (explained in the security section).
  • eventFilter: specifies which event types the client wants to subscribe to. With "ALL", the client subscribes to all events.

ShipmentEvent details:

  • eventId: unique identifier for the event (useful for idempotency).
  • orderId: unique identifier of the order.

Server business logic

The server must expose CRUD APIs for the webhook resource so clients can register, update or delete their subscription. For simplicity, we’ll create only the registration API:

// com.vincenzoracca.webhookserver.service.impl.WebhookService.java
@Service
public class WebhookService {

    private static final Logger log = LoggerFactory.getLogger(WebhookService.class);

    private final WebhookDao webhookDao;

    public WebhookService(WebhookDao webhookDao) {
        this.webhookDao = webhookDao;
    }

    public Webhook registerWebhook(WebhookRegistrationRequest request) {
        String secret = "secret"; // secret mocked
        String webhookId = UUID.randomUUID().toString();
        var registration = new Webhook(
                webhookId,
                request.callbackUrl(),
                secret, request.eventFilter());
        webhookDao.insert(registration);
        log.info("Webhook registration success: {}",
                registration.webhookId());
        return registration;
    }

}

The server also needs to notify clients when an event occurs, according to the eventFilter:

// com.vincenzoracca.webhookserver.service.impl.ShipmentWebhookProducer.java
@Service
public class ShipmentWebhookProducer implements ShipmentProducer {

    private static final Logger log = LoggerFactory.getLogger(ShipmentWebhookProducer.class);

    private final WebhookDao webhookDao;
    private final ClientInvoker clientInvoker;

    public ShipmentWebhookProducer(WebhookDao webhookDao,
                                   ClientInvoker clientInvoker) {
        this.webhookDao = webhookDao;
        this.clientInvoker = clientInvoker;
    }


    @Override
    public void sendEvent(ShipmentEvent event) {
        webhookDao.findAllByEventFilters(Webhook.EventFilter
                        .valueOf(event.status().name()))
                .forEach(webhook -> {
                    invokeClient(webhook.callbackUrl(), event);
                    log.info("Sent {} to webhook {}",
                            event, webhook.webhookId());
                });
    }

    private void invokeClient(String callbackUrl, ShipmentEvent event) {
        try {
            clientInvoker.invoke(callbackUrl, event);
        }
        catch (Exception e) {
            log.error("Error while invoking client {}", event, e);
            // handle with custom logic
        }
    }
}

ClientInvoker is responsible for invoking clients over HTTP and applying retry policies:

// com.vincenzoracca.webhookserver.util.ClientInvoker
@Component
public class ClientInvoker {

    private final RestClient restClient;

    public ClientInvoker(RestClient.Builder builder) {
        this.restClient = builder.build();
    }

    @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
    public void invoke(String url, ShipmentEvent event) {
        restClient.post()
                .uri(url)
                .body(event)
                .retrieve().toBodilessEntity();

    }
}

Remember that the @Retryable annotation only works if the annotated method is called directly from a Spring bean. For example, if we had annotated the invokeClient method in the ShipmentWebhookProducer class, the retry mechanism would not have worked (even if we had made the method public).

The DAO
class is not particularly relevant, since it simply uses a ConcurrentHashMap to store the data.

Server REST APIs

// com.vincenzoracca.webhookserver.api.WebhookController
@RestController
@RequestMapping("webhooks")
public class WebhookController {

    private final WebhookService webhookService;

    public WebhookController(WebhookService webhookService) {
        this.webhookService = webhookService;
    }

    @PostMapping
    public ResponseEntity<Webhook> registerWebhook(@RequestBody WebhookRegistrationRequest request) {
        var webhook = webhookService.registerWebhook(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(webhook);
    }
}

// com.vincenzoracca.webhookserver.model.WebhookRegistrationRequest
public record WebhookRegistrationRequest(
        String callbackUrl,
        Webhook.EventFilter eventFilter
) {
}

Also, we simulate order status changes with:

// com.vincenzoracca.webhookserver.api.SimulatorController
@RestController
public class SimulatorController {

    private final ShipmentProducer shipmentProducer;

    public SimulatorController(ShipmentProducer shipmentProducer) {
        this.shipmentProducer = shipmentProducer;
    }

    @PostMapping("simulate")
    public ResponseEntity<Void> simulateEvent(@RequestBody ShipmentEvent event) {
        shipmentProducer.sendEvent(event);
        return ResponseEntity.noContent().build();
    }
}

Building the webhook client

The client app only requires spring-boot-starter-web.

The skeleton of the project can be downloaded by executing the following cURL::

curl https://start.spring.io/starter.zip -d groupId=com.vincenzoracca \
    -d artifactId=webhook-client -d name=webhook-client \
    -d packageName=com.vincenzoracca.webhookclient \
    -d dependencies=web -d javaVersion=21 -d bootVersion=3.5.4 \
    -d type=maven-project -o webhook-client.zip

Client business logic

The client’s business logic is quite straightforward: it simply consumes the events sent by the server. However, thanks to the eventId field included in each event, the client can implement an idempotency mechanism, ensuring that previously received events are not processed again.

This is particularly useful when the server retries sending a notification (for example, due to a timeout or temporary error): the client can recognize that the event has already been handled and discard the duplicate, thus maintaining consistency and avoiding redundant processing.

// com.vincenzoracca.webhookclient.service.impl.ShipmentWebhookConsumer
@Service
public class ShipmentWebhookConsumer implements ShipmentConsumer {

    private static final Logger log = LoggerFactory.getLogger(ShipmentWebhookConsumer.class);

    private final ShipmentEventDao shipmentEventDao;

    public ShipmentWebhookConsumer(ShipmentEventDao shipmentEventDao) {
        this.shipmentEventDao = shipmentEventDao;
    }

    @Override
    public void consumeEvent(ShipmentEvent event) {
        ShipmentEvent oldValue = shipmentEventDao.putIfAbsent(event);
        if(oldValue == null) {
            log.info("Shipment event {} has been saved", event);
        }
        else {
            log.warn("Shipment event {} duplicated", event);
        }
    }
}

Client REST API

The only API exposed by the client is the API callback registered on the server, which is used to consume incoming events:

// com.vincenzoracca.webhookclient.api.ShipmentNotificationController
@RestController
@RequestMapping("shipment-notifications")
public class ShipmentNotificationController {

    private static final String SECRET = "secret"; // secret mocked

    private final ShipmentConsumer shipmentConsumer;
    private final ObjectMapper objectMapper;

    public ShipmentNotificationController(ShipmentConsumer shipmentConsumer,
                                          ObjectMapper objectMapper) {
        this.shipmentConsumer = shipmentConsumer;
        this.objectMapper = objectMapper;
    }

    @PostMapping
    public ResponseEntity<Void> consumeEvent(
            @RequestBody String rawBody) throws JsonProcessingException {

        var event = objectMapper.readValue(rawBody, ShipmentEvent.class);
        shipmentConsumer.consumeEvent(event);
        return ResponseEntity.accepted().build();
    }
}

Note that the request body class type is not ShipmentEvent, but rather the raw string representation of the original body, which is then deserialized afterward. We will see in the security section why this choice is essential.

At this point, the client implementation is complete. However, there are still some security best practices we should apply.

Webhook Security

Because webhooks expose Internet-accessible endpoints, clients must verify the authenticity and integrity of requests to prevent replay attacks or fake payloads.

A common approach:

  1. The server concatenates a timestamp with the payload.
  2. It signs this value with HMAC-SHA256 using a shared secret.
  3. It sends both timestamp and signature in HTTP headers.

The client:

  • Validates the timestamp (within a tolerance window, e.g. 5 minutes).
  • Recomputes the HMAC and compares it to the received signature.
  • Rejects invalid requests.
Note: Idempotency avoids re-processing legitimate duplicates, but it does not protect against tampering or fake webhooks. Signature + timestamp are still required.
Security Application on the Server

The server is responsible for:

  1. Generating and communicating a secret to the client when a new webhook is registered.
  2. Signing the webhook events sent to the clients.
// com.vincenzoracca.webhookserver.util.SecurityServerUtil
@Component
public class SecurityServerUtil {

    private static final String HMAC_ALGO = "HmacSHA256";

    private static final int DEFAULT_NUM_BYTES = 32;
    private static final SecureRandom RNG = new SecureRandom();

    private final ObjectMapper mapper;

    public SecurityServerUtil(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    public record SigHeaders(long timestamp, String signature) {}

    public String newSecret() {
//        byte[] buf = new byte[DEFAULT_NUM_BYTES];
//        RNG.nextBytes(buf);
//        return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
        return "secret"; // secret mocked
    }

    public SigHeaders sign(String secret, Object value) {
        var bodyJson = toJson(value);
        long ts = Instant.now().toEpochMilli();
        String toSign = ts + "\n" + bodyJson;
        String sig = "sha256=" + hmacSha256(secret, toSign);
        return new SigHeaders(ts, sig);
    }

    private String hmacSha256(String secret, String data) {
        try {
            var mac = javax.crypto.Mac.getInstance(HMAC_ALGO);
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGO));
            byte[] h = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder(h.length * 2);
            for (byte b : h) sb.append(String.format("%02x", b));
            return sb.toString();
        } catch (Exception e) {
            throw new IllegalStateException("HMAC error", e);
        }
    }

    private String toJson(Object value) {
        try {
            return mapper.writeValueAsString(value);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
}

This utility class is used in the two steps listed above:

// WebhookService.java
@Service
public class WebhookService {

    private static final Logger log = LoggerFactory.getLogger(WebhookService.class);

    private final WebhookDao webhookDao;
    private final SecurityServerUtil securityServerUtil;

    public WebhookService(WebhookDao webhookDao, SecurityServerUtil securityServerUtil) {
        this.webhookDao = webhookDao;
        this.securityServerUtil = securityServerUtil;
    }


    public Webhook registerWebhook(WebhookRegistrationRequest request) {
        String secret = securityServerUtil.newSecret();
        String webhookId = UUID.randomUUID().toString();
        var registration = new Webhook(
                webhookId,
                request.callbackUrl(),
                secret, request.eventFilter());
        webhookDao.insert(registration);
        log.info("Webhook registration success: {}", 
                registration.webhookId());
        return registration;
    }

}

// ShipmentWebhookProducer.java
@Service
public class ShipmentWebhookProducer implements ShipmentProducer {

    private static final Logger log = LoggerFactory.getLogger(ShipmentWebhookProducer.class);

    private final WebhookDao webhookDao;
    private final ClientInvoker clientInvoker;
    private final SecurityServerUtil securityServerUtil;

    public ShipmentWebhookProducer(WebhookDao webhookDao, ClientInvoker clientInvoker, SecurityServerUtil securityServerUtil) {
        this.webhookDao = webhookDao;
        this.clientInvoker = clientInvoker;
        this.securityServerUtil = securityServerUtil;
    }


    @Override
    public void sendEvent(ShipmentEvent event) {
        webhookDao.findAllByEventFilters(Webhook.EventFilter.valueOf(event.status().name()))
                .forEach(webhook -> {
                    SecurityServerUtil.SigHeaders sign = securityServerUtil.sign(webhook.secret(), event);
                    invokeClient(webhook.callbackUrl(), sign.timestamp(), sign.signature(), event);
                    log.info("Sent {} to webhook {}", 
                            event, webhook.webhookId());
                });
    }

    private void invokeClient(String callbackUrl, long timestamp, String signature, ShipmentEvent event) {
        try {
            clientInvoker.invoke(callbackUrl, timestamp,signature, event);
        }
        catch (Exception e) {
            log.error("Error while invoking client {}", event, e);
            // handle with custom logic
        }
    }
}

// ClientInvoker.java
@Component
public class ClientInvoker {

    private final RestClient restClient;

    public ClientInvoker(RestClient.Builder builder) {
        this.restClient = builder.build();
    }

    @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
    public void invoke(String url, long timestamp, String signature, 
                       ShipmentEvent event) {
        restClient.post()
                .uri(url)
                .header("X-Timestamp", String.valueOf(timestamp))
                .header("X-Signature", signature)
                .body(event)
                .retrieve().toBodilessEntity();

    }
}
Security Application on the Client

The client must:

  1. Store the secret generated by the server (in this tutorial we use a fixed mocked string, so this step is simplified).
  2. Upon receiving a webhook event:
    1. Verify that the timestamp is within the allowed time window.
    2. Recalculate the signature using the shared secret and compare it with the received signature.
// com.vincenzoracca.webhookclient.util.SecurityClientUtil
@Component
public class SecurityClientUtil {

    private static final String HMAC_ALGO = "HmacSHA256";
    private static final Duration TOLETANCE_MINUTES = Duration.ofMinutes(5);


    public boolean isInToleranceTime(long requestTimestamp) {
        long now = Instant.now().toEpochMilli();
//        var delayNow = Instant.ofEpochMilli(now).plus(10, ChronoUnit.MINUTES).toEpochMilli();
//        return delayNow - requestTimestamp <= TOLETANCE_MINUTES.toMillis(); // for testing KO
        return now - requestTimestamp <= TOLETANCE_MINUTES.toMillis();
    }

    public boolean verifySignature(String secret, long requestTimestamp, 
                                   String rawBody, String signature) {
        String expected = "sha256=" + hmacSha256(secret, requestTimestamp + "\n" + rawBody);
        return expected.equals(signature);
    }

    private String hmacSha256(String secret, String data) {
        try {
            var mac = Mac.getInstance(HMAC_ALGO);
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGO));
            byte[] h = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder(h.length * 2);
            for (byte b : h) sb.append(String.format("%02x", b));
            return sb.toString();
        } catch (Exception e) {
            throw new IllegalStateException("HMAC error", e);
        }
    }
}

// ShipmentNotificationController.java
@RestController
@RequestMapping("shipment-notifications")
public class ShipmentNotificationController {

    private static final String SECRET = "secret"; // secret mocked

    private final ShipmentConsumer shipmentConsumer;
    private final SecurityClientUtil securityClientUtil;
    private final ObjectMapper objectMapper;

    public ShipmentNotificationController(ShipmentConsumer shipmentConsumer,
                                          SecurityClientUtil securityClientUtil, ObjectMapper objectMapper) {
        this.shipmentConsumer = shipmentConsumer;
        this.securityClientUtil = securityClientUtil;
        this.objectMapper = objectMapper;
    }

    @PostMapping
    public ResponseEntity<Void> consumeEvent(
            @RequestHeader("X-Timestamp") long requestTimestamp,
            @RequestHeader("X-Signature") String signature,
            @RequestBody String rawBody) 
            throws JsonProcessingException {

        if(! securityClientUtil.isInToleranceTime(requestTimestamp)) {
            return ResponseEntity
                    .status(HttpStatus.FORBIDDEN)
                    .build();
        }

        if(! securityClientUtil.verifySignature(SECRET, requestTimestamp, rawBody, signature)) {
            return ResponseEntity
                    .status(HttpStatus.UNAUTHORIZED)
                    .build();
        }

        var event = objectMapper.readValue(rawBody, ShipmentEvent.class);
        shipmentConsumer.consumeEvent(event);
        return ResponseEntity.accepted().build();
    }
}
Why must you use rawBody and not the deserialized object? Because the HMAC signature is calculated over the exact bytes that the server sent (timestamp + "\n" + body). To verify it, you need to recalculate the HMAC over the exact same bytes you received. If you deserialize the object and then re-serialize it, you may end up with different bytes even if the logical content is the same (since client and server might serialize differently). And the verification could fail.

Let’s Test the Application

Start both the client and the server.

Register the client on the server with the following cURL:

curl --location 'localhost:8080/webhooks' \
--header 'Content-Type: application/json' \
--data '{
    "callbackUrl": "http://localhost:8081/shipment-notifications",
    "eventFilter": "COMPLETED"
}'

You will receive a 200 OK response with a body similar to:

{
  "webhookId":"93fa5502-cdb5-4bfb-a528-f7b87d263b24",
  "callbackUrl":"http://localhost:8081/shipment-notifications",
  "secret":"secret",
  "eventFilter":"COMPLETED"
}

And in the server logs:

Webhook registration success: 93fa5502-cdb5-4bfb-a528-f7b87d263b24

Now let’s simulate the trigger of an event with the following cURL:

curl --location 'localhost:8080/simulate' \
--header 'Content-Type: application/json' \
--data '{
    "eventId": "20250817",
    "orderId": "1",
    "status": "COMPLETED"
}'

The response will be 204 No Content. Server logs:

Sent ShipmentEvent[eventId=20250817, orderId=1, status=COMPLETED] 
    to webhook 93fa5502-cdb5-4bfb-a528-f7b87d263b24

Client logs:

Shipment event ShipmentEvent[eventId=20250817, orderId=1, status=COMPLETED] 
    has been saved

If we try to trigger an event with status CANCELED (the client subscribed only to COMPLETED events):

curl --location 'localhost:8080/simulate' \
--header 'Content-Type: application/json' \
--data '{
    "eventId": "20250819",
    "orderId": "1",
    "status": "CANCELED"
}'

No "Sent ShipmentEvent" message will appear in the server logs, since the only registered client is not interested in that type of event.

Conclusion

In this tutorial, we implemented a full webhook mechanism in Spring Boot 3 and Java 21, building both the server (provider) and client (consumer).

Compared to polling, webhooks provide real-time notifications and eliminate unnecessary traffic. They also require careful considerations:

  • Retry handling on the server.
  • Idempotency on the client.
  • Security with shared secrets, timestamps and HMAC signatures.

This approach offers a solid foundation for integrating heterogeneous systems in a scalable, reliable and secure way.

If you want to dive deeper into modern and asynchronous API design, check out my book Spring Boot 3 API Mastery.