diff --git a/web/input/input-state-container.react.js b/web/input/input-state-container.react.js
--- a/web/input/input-state-container.react.js
+++ b/web/input/input-state-container.react.js
@@ -8,9 +8,11 @@
 import _partition from 'lodash/fp/partition.js';
 import _sortBy from 'lodash/fp/sortBy.js';
 import _memoize from 'lodash/memoize.js';
+import _throttle from 'lodash/throttle.js';
 import * as React from 'react';
 import { useDispatch } from 'react-redux';
 import { createSelector } from 'reselect';
+import uuid from 'uuid';
 
 import {
   createLocalMessageActionType,
@@ -23,6 +25,7 @@
 import { newThread } from 'lib/actions/thread-actions.js';
 import {
   uploadMultimedia,
+  uploadMediaMetadata,
   updateMultimediaMessageMediaActionType,
   deleteUpload,
   type MultimediaUploadCallbacks,
@@ -32,6 +35,7 @@
   useModalContext,
   type PushModal,
 } from 'lib/components/modal-provider.react.js';
+import blobService from 'lib/facts/blob-service.js';
 import commStaffCommunity from 'lib/facts/comm-staff-community.js';
 import { getNextLocalUploadID } from 'lib/media/media-utils.js';
 import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors.js';
@@ -52,10 +56,12 @@
 import type { CalendarQuery } from 'lib/types/entry-types.js';
 import type {
   UploadMultimediaResult,
+  UploadMediaMetadataRequest,
   MediaMissionStep,
   MediaMissionFailure,
   MediaMissionResult,
   MediaMission,
+  Dimensions,
 } from 'lib/types/media-types.js';
 import { messageTypes } from 'lib/types/message-types-enum.js';
 import {
@@ -81,6 +87,9 @@
   useServerCall,
   useDispatchActionPromise,
 } from 'lib/utils/action-utils.js';
+import { toBase64URL } from 'lib/utils/base64.js';
+import { makeBlobServiceEndpointURL } from 'lib/utils/blob-service.js';
+import type { CallServerEndpointOptions } from 'lib/utils/call-server-endpoint.js';
 import { getConfig } from 'lib/utils/config.js';
 import { getMessageForException, cloneError } from 'lib/utils/errors.js';
 
@@ -119,6 +128,9 @@
     extras: MultimediaUploadExtras,
     callbacks: MultimediaUploadCallbacks,
   ) => Promise<UploadMultimediaResult>,
+  +uploadMediaMetadata: (
+    input: UploadMediaMetadataRequest,
+  ) => Promise<UploadMultimediaResult>,
   +deleteUpload: (id: string) => Promise<void>,
   +sendMultimediaMessage: (
     threadID: string,
@@ -992,6 +1004,123 @@
     });
   }
 
+  async blobServiceUpload(
+    input: {
+      file: File,
+      blobHash: string,
+      encryptionKey: string,
+      dimensions: Dimensions,
+      loop?: boolean,
+    },
+    options?: ?CallServerEndpointOptions,
+  ): Promise<void> {
+    const newHolder = uuid.v4();
+    const blobHash = toBase64URL(input.blobHash);
+
+    // 1. Assign new holder for blob with given blobHash
+    let blobAlreadyExists: boolean;
+    try {
+      const assignHolderEndpoint = blobService.httpEndpoints.ASSIGN_HOLDER;
+      const assignHolderResponse = await fetch(
+        makeBlobServiceEndpointURL(assignHolderEndpoint),
+        {
+          method: assignHolderEndpoint.method,
+          body: JSON.stringify({
+            holder: newHolder,
+            blob_hash: blobHash,
+          }),
+          headers: {
+            'content-type': 'application/json',
+          },
+        },
+      );
+
+      if (!assignHolderResponse.ok) {
+        const { status, statusText } = assignHolderResponse;
+        throw new Error(`Server responded with HTTP ${status}: ${statusText}`);
+      }
+      const { data_exists: dataExistsResponse } =
+        await assignHolderResponse.json();
+      blobAlreadyExists = dataExistsResponse;
+    } catch (e) {
+      throw new Error(
+        `Failed to assign holder: ${
+          getMessageForException(e) ?? 'unknown error'
+        }`,
+      );
+    }
+
+    // 2. Upload blob contents if blob doesn't exist
+    if (!blobAlreadyExists) {
+      const formData = new FormData();
+      formData.append('blob_hash', blobHash);
+      formData.append('blob_data', input.file);
+
+      const xhr = new XMLHttpRequest();
+      const uploadEndpoint = blobService.httpEndpoints.UPLOAD_BLOB;
+      xhr.open(
+        uploadEndpoint.method,
+        makeBlobServiceEndpointURL(uploadEndpoint),
+      );
+      if (options?.timeout) {
+        xhr.timeout = options.timeout;
+      }
+      if (options && options.onProgress) {
+        const { onProgress } = options;
+        xhr.upload.onprogress = _throttle(
+          ({ loaded, total }) => onProgress(loaded / total),
+          50,
+        );
+      }
+
+      let failed = false;
+      const responsePromise = new Promise((resolve, reject) => {
+        xhr.onload = () => {
+          if (failed) {
+            return;
+          }
+          resolve();
+        };
+        xhr.onabort = () => {
+          failed = true;
+          reject(new Error('request aborted'));
+        };
+        xhr.onerror = event => {
+          failed = true;
+          reject(event);
+        };
+        if (options && options.timeout) {
+          xhr.ontimeout = event => {
+            failed = true;
+            reject(event);
+          };
+        }
+        if (options && options.abortHandler) {
+          options.abortHandler(() => {
+            failed = true;
+            reject(new Error('request aborted'));
+            xhr.abort();
+          });
+        }
+      });
+
+      if (!failed) {
+        xhr.send(formData);
+      }
+      await responsePromise;
+    }
+
+    // 3. Send upload metadata to the keyserver, return response
+    return await this.props.uploadMediaMetadata({
+      ...input.dimensions,
+      loop: input.loop ?? false,
+      blobHolder: newHolder,
+      encryptionKey: input.encryptionKey,
+      mimeType: input.file.type,
+      filename: input.file.name,
+    });
+  }
+
   handleAbortCallback(
     threadID: string,
     localUploadID: string,
@@ -1521,6 +1650,7 @@
     );
     const calendarQuery = useSelector(nonThreadCalendarQuery);
     const callUploadMultimedia = useServerCall(uploadMultimedia);
+    const callUploadMediaMetadata = useServerCall(uploadMediaMetadata);
     const callDeleteUpload = useServerCall(deleteUpload);
     const callSendMultimediaMessage = useServerCall(
       legacySendMultimediaMessage,
@@ -1559,6 +1689,7 @@
         pendingRealizedThreadIDs={pendingToRealizedThreadIDs}
         calendarQuery={calendarQuery}
         uploadMultimedia={callUploadMultimedia}
+        uploadMediaMetadata={callUploadMediaMetadata}
         deleteUpload={callDeleteUpload}
         sendMultimediaMessage={callSendMultimediaMessage}
         sendTextMessage={callSendTextMessage}