diff --git a/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java b/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java
new file mode 100644
index 000000000..71e69bc75
--- /dev/null
+++ b/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java
@@ -0,0 +1,88 @@
+package com.clearfolio.viewer.config;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.UUID;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+
+import reactor.core.publisher.Mono;
+
+/**
+ * Publishes the current HTTP API version and rejects explicitly unsupported
+ * client version requests before controller dispatch.
+ *
+ *
Clearfolio's shipped public routes are currently rooted at {@code /api/v1}.
+ * Existing clients that do not send a version header therefore continue to use
+ * that path-selected contract. Clients that do send the version header must
+ * request the exact current version; unsupported values fail closed with a
+ * controlled error envelope instead of being silently interpreted as v1.
+ */
+@Component
+public final class ApiVersionWebFilter implements WebFilter {
+
+ /**
+ * Response/request header used for explicit API contract negotiation.
+ */
+ public static final String VERSION_HEADER = "X-Clearfolio-Api-Version";
+
+ /**
+ * Version identifier for the currently shipped {@code /api/v1} contract.
+ */
+ public static final String CURRENT_VERSION = "v1";
+
+ /**
+ * Creates the stateless API-version negotiation filter.
+ */
+ public ApiVersionWebFilter() {
+ // No mutable state is required for exact version negotiation.
+ }
+
+ /**
+ * Applies version negotiation to public API routes while leaving non-API
+ * surfaces unchanged.
+ *
+ * @param exchange current reactive HTTP exchange
+ * @param chain remaining WebFlux filter chain
+ * @return completion signal for the filtered request
+ */
+ @Override
+ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) {
+ String path = exchange.getRequest().getPath().value();
+ if (!path.startsWith("/api/")) {
+ return chain.filter(exchange);
+ }
+
+ exchange.getResponse().getHeaders().set(VERSION_HEADER, CURRENT_VERSION);
+ List requestedVersions = exchange.getRequest().getHeaders().get(VERSION_HEADER);
+ if (requestedVersions == null) {
+ return chain.filter(exchange);
+ }
+ if (requestedVersions.size() == 1) {
+ String requestedVersion = requestedVersions.get(0);
+ if (requestedVersion.isBlank() || CURRENT_VERSION.equals(requestedVersion)) {
+ return chain.filter(exchange);
+ }
+ }
+
+ exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
+ exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
+ byte[] body = unsupportedVersionBody();
+ return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body)));
+ }
+
+ private byte[] unsupportedVersionBody() {
+ String body = ("{\"errorCode\":\"UNSUPPORTED_API_VERSION\","
+ + "\"message\":\"Unsupported API version\","
+ + "\"traceId\":\"%s\","
+ + "\"details\":{\"supportedVersion\":\"v1\"},"
+ + "\"code\":\"UNSUPPORTED_API_VERSION\"}")
+ .formatted(UUID.randomUUID());
+ return body.getBytes(StandardCharsets.UTF_8);
+ }
+}
diff --git a/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java b/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java
new file mode 100644
index 000000000..30695a674
--- /dev/null
+++ b/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java
@@ -0,0 +1,136 @@
+package com.clearfolio.viewer.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.web.server.WebFilterChain;
+
+import reactor.core.publisher.Mono;
+
+class ApiVersionWebFilterTest {
+
+ @Test
+ void nonApiRequestsBypassVersionNegotiation() {
+ ApiVersionWebFilter filter = new ApiVersionWebFilter();
+ MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/healthz"));
+ AtomicBoolean invoked = new AtomicBoolean();
+ WebFilterChain chain = current -> {
+ invoked.set(true);
+ return Mono.empty();
+ };
+
+ filter.filter(exchange, chain).block();
+
+ assertThat(invoked).isTrue();
+ assertThat(exchange.getResponse().getHeaders()).doesNotContainKey(ApiVersionWebFilter.VERSION_HEADER);
+ }
+
+ @Test
+ void missingVersionHeaderUsesCurrentV1Contract() {
+ ApiVersionWebFilter filter = new ApiVersionWebFilter();
+ MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/v1/convert/jobs"));
+ AtomicBoolean invoked = new AtomicBoolean();
+
+ filter.filter(exchange, current -> {
+ invoked.set(true);
+ return Mono.empty();
+ }).block();
+
+ assertThat(invoked).isTrue();
+ assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER))
+ .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION);
+ }
+
+ @Test
+ void explicitCurrentVersionIsAcceptedWithoutChangingRouting() {
+ ApiVersionWebFilter filter = new ApiVersionWebFilter();
+ MockServerWebExchange exchange = MockServerWebExchange.from(
+ MockServerHttpRequest.get("/api/v1/viewer/00000000-0000-0000-0000-000000000000")
+ .header(ApiVersionWebFilter.VERSION_HEADER, ApiVersionWebFilter.CURRENT_VERSION)
+ );
+ AtomicBoolean invoked = new AtomicBoolean();
+
+ filter.filter(exchange, current -> {
+ invoked.set(true);
+ return Mono.empty();
+ }).block();
+
+ assertThat(invoked).isTrue();
+ assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER))
+ .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION);
+ }
+
+ @Test
+ void blankVersionHeaderPreservesBackwardCompatibleV1Default() {
+ ApiVersionWebFilter filter = new ApiVersionWebFilter();
+ MockServerWebExchange exchange = MockServerWebExchange.from(
+ MockServerHttpRequest.get("/api/v1/convert/jobs")
+ .header(ApiVersionWebFilter.VERSION_HEADER, " ")
+ );
+ AtomicBoolean invoked = new AtomicBoolean();
+
+ filter.filter(exchange, current -> {
+ invoked.set(true);
+ return Mono.empty();
+ }).block();
+
+ assertThat(invoked).isTrue();
+ assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER))
+ .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION);
+ }
+
+ @Test
+ void conflictingDuplicateVersionHeadersFailClosedBeforeControllerDispatch() {
+ ApiVersionWebFilter filter = new ApiVersionWebFilter();
+ MockServerWebExchange exchange = MockServerWebExchange.from(
+ MockServerHttpRequest.get("/api/v1/convert/jobs")
+ .header(ApiVersionWebFilter.VERSION_HEADER, ApiVersionWebFilter.CURRENT_VERSION, "v2")
+ );
+ AtomicBoolean invoked = new AtomicBoolean();
+
+ filter.filter(exchange, current -> {
+ invoked.set(true);
+ return Mono.empty();
+ }).block();
+
+ assertThat(invoked).isFalse();
+ assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER))
+ .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION);
+ assertThat(exchange.getResponse().getBodyAsString().block())
+ .contains("\"errorCode\":\"UNSUPPORTED_API_VERSION\"")
+ .doesNotContain("v2");
+ }
+
+ @Test
+ void unsupportedVersionFailsClosedBeforeControllerDispatch() {
+ ApiVersionWebFilter filter = new ApiVersionWebFilter();
+ MockServerWebExchange exchange = MockServerWebExchange.from(
+ MockServerHttpRequest.get("/api/v1/convert/jobs")
+ .header(ApiVersionWebFilter.VERSION_HEADER, "v2")
+ );
+ AtomicBoolean invoked = new AtomicBoolean();
+
+ filter.filter(exchange, current -> {
+ invoked.set(true);
+ return Mono.empty();
+ }).block();
+
+ assertThat(invoked).isFalse();
+ assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER))
+ .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION);
+ assertThat(exchange.getResponse().getHeaders().getContentType().toString()).isEqualTo("application/json");
+ String body = exchange.getResponse().getBodyAsString().block();
+ assertThat(body)
+ .contains("\"errorCode\":\"UNSUPPORTED_API_VERSION\"")
+ .contains("\"code\":\"UNSUPPORTED_API_VERSION\"")
+ .contains("\"supportedVersion\":\"v1\"")
+ .doesNotContain("v2");
+ }
+}