diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java
new file mode 100644
index 000000000000..cd4bed572b4f
--- /dev/null
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClient.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ * * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.client.http.HttpMethods;
+import com.google.api.core.ApiFuture;
+import com.google.api.core.InternalApi;
+import com.google.api.core.SettableApiFuture;
+import com.google.api.gax.resumable.ResumableUploadClient;
+import com.google.api.gax.resumable.ResumableUploadSession;
+import com.google.api.gax.resumable.StartUploadRequest;
+import com.google.api.gax.rpc.ApiCallContext;
+import com.google.api.gax.rpc.ApiException;
+import com.google.api.gax.rpc.ApiExceptionFactory;
+import com.google.api.gax.rpc.ClientContext;
+import com.google.api.gax.rpc.StatusCode;
+import com.google.api.gax.rpc.UnaryCallable;
+import com.google.api.pathtemplate.PathTemplate;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Implementation of {@link ResumableUploadClient} using HTTP/JSON transport.
+ *
+ *
Executes the low-level HTTP wire calls for managing resumable upload sessions.
+ */
+@NullMarked
+@InternalApi
+public final class HttpJsonResumableUploadClient implements ResumableUploadClient {
+
+ private static final HttpJsonApiExceptionFactory API_EXCEPTION_FACTORY =
+ new HttpJsonApiExceptionFactory(Collections.emptySet());
+
+ private static final String UPLOAD_PROTOCOL_HEADER = "X-Goog-Upload-Protocol";
+ private static final String UPLOAD_COMMAND_HEADER = "X-Goog-Upload-Command";
+ private static final String UPLOAD_URL_HEADER = "X-Goog-Upload-URL";
+ private static final String UPLOAD_GRANULARITY_HEADER = "X-Goog-Upload-Chunk-Granularity";
+
+ private static final Map> START_UPLOAD_HEADERS =
+ ImmutableMap.of(
+ UPLOAD_PROTOCOL_HEADER, ImmutableList.of("resumable"),
+ UPLOAD_COMMAND_HEADER, ImmutableList.of("start"));
+
+ private static final ApiMethodDescriptor START_UPLOAD_DESCRIPTOR =
+ ApiMethodDescriptor.newBuilder()
+ .setFullMethodName("ResumableUpload/StartUpload")
+ .setHttpMethod(HttpMethods.POST)
+ .setType(ApiMethodDescriptor.MethodType.UNARY)
+ .setRequestFormatter(
+ new HttpRequestFormatter() {
+ @Override
+ public Map> getQueryParamNames(StartUploadRequest request) {
+ return request.getQueryParams();
+ }
+
+ @Override
+ public String getRequestBody(StartUploadRequest request) {
+ return Strings.nullToEmpty(request.getJsonPayload());
+ }
+
+ @Override
+ public String getPath(StartUploadRequest request) {
+ return request.getPath();
+ }
+
+ @Override
+ public PathTemplate getPathTemplate() {
+ return PathTemplate.create("{+path}");
+ }
+ })
+ .setResponseParser(StringHttpResponseParser.create())
+ .build();
+
+ private final ClientContext clientContext;
+
+ public static HttpJsonResumableUploadClient create(ClientContext clientContext) {
+ return new HttpJsonResumableUploadClient(clientContext);
+ }
+
+ private HttpJsonResumableUploadClient(ClientContext clientContext) {
+ this.clientContext = Preconditions.checkNotNull(clientContext);
+ }
+
+ @Override
+ public UnaryCallable startUploadCallable() {
+ return new UnaryCallable() {
+ @Override
+ public ApiFuture futureCall(
+ StartUploadRequest request, @Nullable ApiCallContext inputContext) {
+ Preconditions.checkNotNull(request);
+ HttpJsonCallContext context =
+ (HttpJsonCallContext)
+ HttpJsonCallContext.createDefault()
+ .nullToSelf(clientContext.getDefaultCallContext())
+ .merge(inputContext)
+ .withExtraHeaders(START_UPLOAD_HEADERS);
+
+ HttpJsonClientCall clientCall =
+ HttpJsonClientCalls.newCall(START_UPLOAD_DESCRIPTOR, context);
+
+ SettableApiFuture future = SettableApiFuture.create();
+ HttpJsonClientCalls.startUnaryCall(
+ clientCall, request, context, new StartUploadResponseListener(future));
+
+ return future;
+ }
+ };
+ }
+
+ private static class StartUploadResponseListener extends HttpJsonClientCall.Listener {
+
+ private final SettableApiFuture future;
+ @Nullable private String uploadUrl;
+ private long chunkGranularity = 1L;
+
+ StartUploadResponseListener(SettableApiFuture future) {
+ this.future = future;
+ }
+
+ @Override
+ public void onHeaders(HttpJsonMetadata responseHeaders) {
+ Map headers = responseHeaders.getHeaders();
+
+ String url = HttpHeadersUtils.getFirstHeader(headers, UPLOAD_URL_HEADER);
+ if (!Strings.isNullOrEmpty(url)) {
+ this.uploadUrl = url;
+ }
+
+ String granularityStr = HttpHeadersUtils.getFirstHeader(headers, UPLOAD_GRANULARITY_HEADER);
+ if (!Strings.isNullOrEmpty(granularityStr)) {
+ try {
+ this.chunkGranularity = Long.parseLong(granularityStr);
+ } catch (NumberFormatException ignored) {
+ this.chunkGranularity = 1L;
+ }
+ }
+ }
+
+ @Override
+ public void onMessage(@Nullable String message) {
+ // Response body is not needed for startUpload; session URL is in headers.
+ }
+
+ @Override
+ public void onClose(int statusCode, HttpJsonMetadata trailers) {
+ try {
+ if (statusCode >= 200 && statusCode < 300) {
+ if (!Strings.isNullOrEmpty(uploadUrl)) {
+ future.set(ResumableUploadSession.create(uploadUrl, chunkGranularity));
+ } else {
+ future.setException(
+ ApiExceptionFactory.createException(
+ "Start upload response did not contain upload session URL header",
+ /* cause= */ null,
+ HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
+ /* retryable= */ false));
+ }
+ } else {
+ Throwable cause = trailers != null ? trailers.getException() : null;
+ ApiException apiException =
+ cause != null
+ ? API_EXCEPTION_FACTORY.create(cause)
+ : ApiExceptionFactory.createException(
+ "Failed to start upload with status code: " + statusCode,
+ /* cause= */ null,
+ HttpJsonStatusCode.of(statusCode),
+ /* retryable= */ false);
+ future.setException(apiException);
+ }
+ } catch (Throwable t) {
+ future.setException(
+ ApiExceptionFactory.createException(
+ "Internal error processing start upload response",
+ t,
+ HttpJsonStatusCode.of(StatusCode.Code.INTERNAL),
+ /* retryable= */ false));
+ }
+ }
+ }
+}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java
new file mode 100644
index 000000000000..128fa1f882f6
--- /dev/null
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonResumableUploadClientTest.java
@@ -0,0 +1,273 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ * * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.http.LowLevelHttpRequest;
+import com.google.api.client.http.LowLevelHttpResponse;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.client.testing.http.MockLowLevelHttpRequest;
+import com.google.api.client.testing.http.MockLowLevelHttpResponse;
+import com.google.api.gax.resumable.ResumableUploadSession;
+import com.google.api.gax.resumable.StartUploadRequest;
+import com.google.api.gax.rpc.ApiCallContext;
+import com.google.api.gax.rpc.ClientContext;
+import com.google.api.gax.rpc.InternalException;
+import com.google.api.gax.rpc.NotFoundException;
+import com.google.api.gax.rpc.StatusCode;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class HttpJsonResumableUploadClientTest {
+
+ private static ExecutorService executorService;
+
+ @BeforeAll
+ static void setUp() {
+ executorService = Executors.newFixedThreadPool(2);
+ }
+
+ @AfterAll
+ static void tearDown() {
+ executorService.shutdownNow();
+ }
+
+ private static HttpJsonResumableUploadClient createClient(HttpTransport transport) {
+ ManagedHttpJsonChannel channel =
+ ManagedHttpJsonChannel.newBuilder()
+ .setEndpoint("test.googleapis.com")
+ .setExecutor(executorService)
+ .setHttpTransport(transport)
+ .build();
+
+ ClientContext clientContext =
+ ClientContext.newBuilder()
+ .setTransportChannel(HttpJsonTransportChannel.create(channel))
+ .setDefaultCallContext(HttpJsonCallContext.createDefault().withChannel(channel))
+ .build();
+
+ return HttpJsonResumableUploadClient.create(clientContext);
+ }
+
+ private static HttpJsonResumableUploadClient createClient(MockLowLevelHttpResponse response) {
+ return createClient(new MockHttpTransport.Builder().setLowLevelHttpResponse(response).build());
+ }
+
+ @Test
+ void startUpload_withUploadUrlHeader_success() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(200);
+ httpResponse.addHeader("X-Goog-Upload-URL", "https://test.googleapis.com/upload/session/abc");
+ httpResponse.addHeader("X-Goog-Upload-Chunk-Granularity", "262144");
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ StartUploadRequest request = StartUploadRequest.create("upload/v1/resources");
+
+ ResumableUploadSession session = client.startUploadCallable().call(request);
+
+ assertThat(session.getUploadUrl()).isEqualTo("https://test.googleapis.com/upload/session/abc");
+ assertThat(session.getChunkGranularity()).isEqualTo(262144L);
+ }
+
+
+ @Test
+ void startUpload_caseInsensitiveHeaders_success() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(200);
+ httpResponse.addHeader(
+ "x-goog-upload-url", "https://test.googleapis.com/upload/session/case-insensitive");
+ httpResponse.addHeader("x-goog-upload-chunk-granularity", "524288");
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ StartUploadRequest request = StartUploadRequest.create("upload/v1/resources");
+
+ ResumableUploadSession session = client.startUploadCallable().call(request);
+
+ assertThat(session.getUploadUrl())
+ .isEqualTo("https://test.googleapis.com/upload/session/case-insensitive");
+ assertThat(session.getChunkGranularity()).isEqualTo(524288L);
+ }
+
+ @Test
+ void startUpload_missingSessionUrlHeader_throwsException() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(200);
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ StartUploadRequest request = StartUploadRequest.create("upload/v1/resources");
+
+ ExecutionException exception =
+ assertThrows(
+ ExecutionException.class, () -> client.startUploadCallable().futureCall(request).get());
+
+ assertThat(exception.getCause()).isInstanceOf(InternalException.class);
+ assertThat(exception.getCause())
+ .hasMessageThat()
+ .contains("Start upload response did not contain upload session URL header");
+ }
+
+ @Test
+ void startUpload_withJsonPayloadAndQueryParams_sendsCorrectRequest() {
+ Map> capturedHeaders = new HashMap<>();
+ String[] capturedUrl = new String[1];
+ String[] capturedContent = new String[1];
+
+ HttpTransport httpTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ capturedUrl[0] = url;
+ return new MockLowLevelHttpRequest() {
+ @Override
+ public LowLevelHttpResponse execute() throws IOException {
+ capturedHeaders.putAll(getHeaders());
+ capturedContent[0] = getContentAsString();
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(200);
+ response.addHeader(
+ "X-Goog-Upload-URL", "https://test.googleapis.com/upload/session/123");
+ return response;
+ }
+ };
+ }
+ };
+
+ HttpJsonResumableUploadClient client = createClient(httpTransport);
+ Map> queryParams = new HashMap<>();
+ queryParams.put("uploadType", Collections.singletonList("resumable"));
+ queryParams.put("name", Collections.singletonList("my-resource.txt"));
+
+ StartUploadRequest request =
+ StartUploadRequest.newBuilder()
+ .setPath("upload/v1/resources")
+ .setJsonPayload("{\"contentType\":\"text/plain\"}")
+ .setQueryParams(queryParams)
+ .build();
+
+ ResumableUploadSession session = client.startUploadCallable().call(request);
+
+ assertThat(session.getUploadUrl()).isEqualTo("https://test.googleapis.com/upload/session/123");
+ assertThat(capturedUrl[0]).contains("https://test.googleapis.com/upload/v1/resources");
+ assertThat(capturedUrl[0]).contains("uploadType=resumable");
+ assertThat(capturedUrl[0]).contains("name=my-resource.txt");
+ assertThat(capturedContent[0]).isEqualTo("{\"contentType\":\"text/plain\"}");
+ assertThat(capturedHeaders).containsKey("x-goog-upload-protocol");
+ assertThat(capturedHeaders.get("x-goog-upload-protocol")).contains("resumable");
+ assertThat(capturedHeaders).containsKey("x-goog-upload-command");
+ assertThat(capturedHeaders.get("x-goog-upload-command")).contains("start");
+ }
+
+ @Test
+ void startUpload_serverReturnsError_throwsApiException() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(404);
+ httpResponse.setContent("{\"error\":{\"message\":\"Resource not found\"}}");
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ StartUploadRequest request = StartUploadRequest.create("upload/v1/nonexistent");
+
+ ExecutionException exception =
+ assertThrows(
+ ExecutionException.class, () -> client.startUploadCallable().futureCall(request).get());
+
+ assertThat(exception.getCause()).isInstanceOf(NotFoundException.class);
+ NotFoundException notFoundException = (NotFoundException) exception.getCause();
+ assertThat(notFoundException.getStatusCode().getCode()).isEqualTo(StatusCode.Code.NOT_FOUND);
+ }
+
+ @Test
+ void startUpload_withCustomExtraHeaders_preservesHeaders() {
+ Map> capturedHeaders = new HashMap<>();
+
+ HttpTransport httpTransport =
+ new MockHttpTransport() {
+ @Override
+ public LowLevelHttpRequest buildRequest(String method, String url) {
+ return new MockLowLevelHttpRequest() {
+ @Override
+ public LowLevelHttpResponse execute() {
+ capturedHeaders.putAll(getHeaders());
+ MockLowLevelHttpResponse response = new MockLowLevelHttpResponse();
+ response.setStatusCode(200);
+ response.addHeader(
+ "X-Goog-Upload-URL", "https://test.googleapis.com/upload/session/custom");
+ return response;
+ }
+ };
+ }
+ };
+
+ HttpJsonResumableUploadClient client = createClient(httpTransport);
+ StartUploadRequest request = StartUploadRequest.create("upload/v1/resources");
+
+ Map> customHeaders = new HashMap<>();
+ customHeaders.put("X-Custom-Header", Collections.singletonList("CustomValue"));
+
+ ApiCallContext callContext =
+ HttpJsonCallContext.createDefault().withExtraHeaders(customHeaders);
+
+ ResumableUploadSession session = client.startUploadCallable().call(request, callContext);
+
+ assertThat(session.getUploadUrl())
+ .isEqualTo("https://test.googleapis.com/upload/session/custom");
+ assertThat(capturedHeaders).containsKey("x-custom-header");
+ assertThat(capturedHeaders.get("x-custom-header")).contains("CustomValue");
+ assertThat(capturedHeaders).containsKey("x-goog-upload-protocol");
+ assertThat(capturedHeaders.get("x-goog-upload-protocol")).contains("resumable");
+ }
+
+ @Test
+ void startUpload_serverReturnsErrorWithoutException_throwsApiException() {
+ MockLowLevelHttpResponse httpResponse = new MockLowLevelHttpResponse();
+ httpResponse.setStatusCode(500);
+
+ HttpJsonResumableUploadClient client = createClient(httpResponse);
+ StartUploadRequest request = StartUploadRequest.create("upload/v1/resources");
+
+ ExecutionException exception =
+ assertThrows(
+ ExecutionException.class, () -> client.startUploadCallable().futureCall(request).get());
+
+ assertThat(exception.getCause()).isInstanceOf(com.google.api.gax.rpc.ApiException.class);
+ assertThat(exception.getCause()).hasMessageThat().contains("500");
+ }
+}