diff --git a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx
index 160bbd9708..7c32959920 100644
--- a/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx
+++ b/apps/web/app/(org)/dashboard/caps/components/CapCard/CapCard.tsx
@@ -7,6 +7,7 @@ import {
 	DropdownMenuTrigger,
 } from "@cap/ui";
 import type { Video } from "@cap/web-domain";
+import { HttpClient } from "@effect/platform";
 import {
 	faCheck,
 	faCopy,
@@ -19,6 +20,7 @@ import {
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 import { useMutation } from "@tanstack/react-query";
 import clsx from "clsx";
+import { Effect, Option } from "effect";
 import Link from "next/link";
 import { useRouter } from "next/navigation";
 import { type PropsWithChildren, useState } from "react";
@@ -111,27 +113,29 @@ export const CapCard = ({
 
 	const router = useRouter();
 
-	const downloadMutation = useMutation({
-		mutationFn: async () => {
-			const response = await downloadVideo(cap.id);
-			if (response.success && response.downloadUrl) {
-				const fetchResponse = await fetch(response.downloadUrl);
-				const blob = await fetchResponse.blob();
-
-				const blobUrl = window.URL.createObjectURL(blob);
-				const link = document.createElement("a");
-				link.href = blobUrl;
-				link.download = response.filename;
-				link.style.display = "none";
-				document.body.appendChild(link);
-				link.click();
-				document.body.removeChild(link);
-
-				window.URL.revokeObjectURL(blobUrl);
-			} else {
-				throw new Error("Failed to get download URL");
-			}
-		},
+	const downloadMutation = useEffectMutation({
+		mutationFn: () =>
+			Effect.gen(function* () {
+				const result = yield* withRpc((r) => r.VideoGetDownloadInfo(cap.id));
+				const httpClient = yield* HttpClient.HttpClient;
+				if (Option.isSome(result)) {
+					const fetchResponse = yield* httpClient.get(result.value.downloadUrl);
+					const blob = yield* fetchResponse.arrayBuffer;
+
+					const blobUrl = window.URL.createObjectURL(new Blob([blob]));
+					const link = document.createElement("a");
+					link.href = blobUrl;
+					link.download = result.value.fileName;
+					link.style.display = "none";
+					document.body.appendChild(link);
+					link.click();
+					document.body.removeChild(link);
+
+					window.URL.revokeObjectURL(blobUrl);
+				} else {
+					throw new Error("Failed to get download URL");
+				}
+			}),
 	});
 
 	const deleteMutation = useMutation({
diff --git a/apps/web/lib/EffectRuntime.ts b/apps/web/lib/EffectRuntime.ts
index 8cde1b8a2e..2ffa90e390 100644
--- a/apps/web/lib/EffectRuntime.ts
+++ b/apps/web/lib/EffectRuntime.ts
@@ -1,4 +1,5 @@
 import * as WebSdk from "@effect/opentelemetry/WebSdk";
+import { FetchHttpClient } from "@effect/platform";
 import { Layer, ManagedRuntime } from "effect";
 import {
 	makeUseEffectMutation,
@@ -9,7 +10,11 @@ import { getTracingConfig } from "./tracing";
 
 const TracingLayer = WebSdk.layer(getTracingConfig);
 
-const RuntimeLayer = Layer.mergeAll(Rpc.Default, TracingLayer);
+const RuntimeLayer = Layer.mergeAll(
+	Rpc.Default,
+	TracingLayer,
+	FetchHttpClient.layer,
+);
 
 export type RuntimeLayer = typeof RuntimeLayer;
 
diff --git a/packages/web-backend/src/Videos/VideosRpcs.ts b/packages/web-backend/src/Videos/VideosRpcs.ts
index 5f2e12d238..ce5dc3fbea 100644
--- a/packages/web-backend/src/Videos/VideosRpcs.ts
+++ b/packages/web-backend/src/Videos/VideosRpcs.ts
@@ -31,6 +31,15 @@ export const VideosRpcsLive = Video.VideoRpcs.toLayer(
 						UnknownException: () => new InternalError({ type: "unknown" }),
 					}),
 				),
+			VideoGetDownloadInfo: (videoId) =>
+				videos.getDownloadInfo(videoId).pipe(
+					provideOptionalAuth,
+					Effect.catchTags({
+						DatabaseError: () => new InternalError({ type: "database" }),
+						UnknownException: () => new InternalError({ type: "unknown" }),
+						S3Error: () => new InternalError({ type: "s3" }),
+					}),
+				),
 		};
 	}),
 );
diff --git a/packages/web-backend/src/Videos/index.ts b/packages/web-backend/src/Videos/index.ts
index e258a1d7c1..fc77017804 100644
--- a/packages/web-backend/src/Videos/index.ts
+++ b/packages/web-backend/src/Videos/index.ts
@@ -15,19 +15,21 @@ export class Videos extends Effect.Service<Videos>()("Videos", {
 		const policy = yield* VideosPolicy;
 		const s3Buckets = yield* S3Buckets;
 
+		const getById = (id: Video.VideoId) =>
+			repo
+				.getById(id)
+				.pipe(
+					Policy.withPublicPolicy(policy.canView(id)),
+					Effect.withSpan("Videos.getById"),
+				);
+
 		return {
 			/*
 			 * Get a video by ID. Will fail if the user does not have access.
 			 */
 			// This is only for external use since it does an access check,
 			// internal use should prefer the repo directly
-			getById: (id: Video.VideoId) =>
-				repo
-					.getById(id)
-					.pipe(
-						Policy.withPublicPolicy(policy.canView(id)),
-						Effect.withSpan("Videos.getById"),
-					),
+			getById,
 
 			/*
 			 * Delete a video. Will fail if the user does not have access.
@@ -129,6 +131,34 @@ export class Videos extends Effect.Service<Videos>()("Videos", {
 			}),
 
 			create: Effect.fn("Videos.create")(repo.create),
+
+			getDownloadInfo: Effect.fn("Videos.getDownloadInfo")(function* (
+				videoId: Video.VideoId,
+			) {
+				const [video] = yield* getById(videoId).pipe(
+					Effect.flatMap(
+						Effect.catchTag(
+							"NoSuchElementException",
+							() => new Video.NotFoundError(),
+						),
+					),
+				);
+
+				const [bucket] = yield* S3Buckets.getBucketAccess(video.bucketId);
+
+				return yield* Option.fromNullable(Video.Video.getSource(video)).pipe(
+					Option.filter((v) => v._tag === "Mp4Source"),
+					Option.map((v) =>
+						bucket.getSignedObjectUrl(v.getFileKey()).pipe(
+							Effect.map((downloadUrl) => ({
+								fileName: `${video.name}.mp4`,
+								downloadUrl,
+							})),
+						),
+					),
+					Effect.transposeOption,
+				);
+			}),
 		};
 	}),
 	dependencies: [
diff --git a/packages/web-domain/src/Video.ts b/packages/web-domain/src/Video.ts
index 3c0f688450..3480f8cdbf 100644
--- a/packages/web-domain/src/Video.ts
+++ b/packages/web-domain/src/Video.ts
@@ -156,4 +156,16 @@ export class VideoRpcs extends RpcGroup.make(
 			VerifyVideoPasswordError,
 		),
 	}),
+	Rpc.make("VideoGetDownloadInfo", {
+		payload: VideoId,
+		success: Schema.Option(
+			Schema.Struct({ fileName: Schema.String, downloadUrl: Schema.String }),
+		),
+		error: Schema.Union(
+			NotFoundError,
+			InternalError,
+			PolicyDeniedError,
+			VerifyVideoPasswordError,
+		),
+	}),
 ) {}
