Photo and video uploads for iOS and Android apps
Your app asks your backend for a presigned URL, PUTs the photo or video straight to Dreep, and your backend confirms it. The API key never ships in the app. HEIC from iPhones is accepted as is, delivery URLs choose AVIF or WebP, dpr 1 to 3 matches each screen, and videos get feed thumbnails, trimmed clips and GIF previews from the same URL.
The upload path from a phone
Your backend is the only thing holding the Dreep API key. The app holds a session with your backend and, for ten minutes at a time, a single-use upload URL.
App picks a file
HEIC, JPEG, MP4 or MOV
Backend presigns
Checks the user, sets the folder
App PUTs bytes
URLSession or OkHttp, no SDK needed
Backend confirms
Returns delivery URLs
App renders
Adds dpr for its screen
In the app: a plain HTTP PUT
There is no mobile SDK to add and none is needed. The upload is one PUT with the right Content-Type; the platform HTTP client sets Content-Length from the file.
import Foundation
struct PresignBody: Encodable { let filename: String; let contentType: String; let sizeBytes: Int64 }
struct UploadSlot: Decodable { let id: String; let uploadUrl: String?; let alreadyExists: Bool? }
// api is your backend, e.g. https://api.example.com. Add your own session auth header.
func upload(fileURL: URL, contentType: String, api: URL) async throws {
let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0
// 1. Ask your backend for a single-use upload URL
var presign = URLRequest(url: api.appendingPathComponent("uploads/presign"))
presign.httpMethod = "POST"
presign.setValue("application/json", forHTTPHeaderField: "Content-Type")
presign.httpBody = try JSONEncoder().encode(
PresignBody(filename: fileURL.lastPathComponent, contentType: contentType, sizeBytes: size))
let (data, _) = try await URLSession.shared.data(for: presign)
let slot = try JSONDecoder().decode(UploadSlot.self, from: data)
// 2. PUT the bytes straight to Dreep; Content-Type must match the presign
if let target = slot.uploadUrl.flatMap(URL.init(string:)) {
var put = URLRequest(url: target)
put.httpMethod = "PUT"
put.setValue(contentType, forHTTPHeaderField: "Content-Type")
let (_, response) = try await URLSession.shared.upload(for: put, fromFile: fileURL)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
}
// 3. Ask your backend to confirm
var confirm = URLRequest(url: api.appendingPathComponent("uploads/\(slot.id)/confirm"))
confirm.httpMethod = "POST"
_ = try await URLSession.shared.data(for: confirm)
}import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.File
private val http = OkHttpClient()
// Run on Dispatchers.IO. api is your backend; add your own session auth header.
fun upload(file: File, contentType: String, api: String) {
// 1. Ask your backend for a single-use upload URL
val body = JSONObject()
.put("filename", file.name)
.put("contentType", contentType) // "image/heic", "image/jpeg" or "video/mp4"
.put("sizeBytes", file.length())
.toString()
.toRequestBody("application/json".toMediaType())
val slot = http.newCall(Request.Builder().url("$api/uploads/presign").post(body).build())
.execute().use { JSONObject(it.body!!.string()) }
// 2. PUT the bytes straight to Dreep; Content-Type and length must match
if (!slot.optBoolean("alreadyExists")) {
val put = Request.Builder()
.url(slot.getString("uploadUrl"))
.put(file.asRequestBody(contentType.toMediaType()))
.build()
http.newCall(put).execute().use { check(it.isSuccessful) { "Upload failed: ${it.code}" } }
}
// 3. Ask your backend to confirm
val confirm = Request.Builder()
.url("$api/uploads/${slot.getString("id")}/confirm")
.post(ByteArray(0).toRequestBody(null))
.build()
http.newCall(confirm).execute().close()
}Backend: presign and confirm
Two endpoints on your server. Presign returns the upload URL to the app; confirm finalises the asset and hands back the delivery URLs the app will render.
import { Dreep } from "dreep";
const dreep = new Dreep({ apiKey: process.env.DREEP_API_KEY });
// POST /uploads/presign (the app calls this with its user session)
export const presignMobileUpload = async ({ userId, filename, contentType, sizeBytes }) => {
const upload = await dreep.presignUpload({
filename,
contentType,
sizeBytes,
folder: "users/" + userId + "/uploads",
});
return { id: upload.id, uploadUrl: upload.uploadUrl, alreadyExists: upload.alreadyExists };
};
// POST /uploads/:id/confirm
export const confirmMobileUpload = async ({ id }) => {
const asset = await dreep.confirmUpload({ id });
if (asset.mimetype.startsWith("video/")) {
return {
id: asset.id,
// An image format on a video returns a frame; append &time=2 to choose one.
poster: dreep.url(asset, { format: "webp", width: 750 }),
clip: dreep.url(asset, { format: "mp4", trimStart: 0, trimEnd: 6, width: 640 }),
preview: dreep.url(asset, { format: "gif", trimStart: 0, trimEnd: 3, fps: 12, width: 384 }),
};
}
// The app appends &dpr=1, 2 or 3 for its own screen.
return {
id: asset.id,
feed: dreep.url(asset, { width: 384, fit: "inside", format: "avif", quality: 65 }),
full: dreep.url(asset, { width: 1080, fit: "inside", format: "avif", quality: 75 }),
};
};// POST /uploads/presign
upload, err := client.PresignUpload(ctx, dreep.PresignOptions{
Filename: body.Filename,
ContentType: body.ContentType, // "image/heic" is accepted as is
SizeBytes: body.SizeBytes,
Destination: dreep.Destination{Folder: "users/" + userID + "/uploads"},
})
// POST /uploads/{id}/confirm
asset, err := client.ConfirmUpload(ctx, id, nil)
feed := client.URL(asset.ID, &dreep.Transform{
Width: 384,
Fit: dreep.FitInside,
Format: dreep.FormatAVIF,
Quality: 65,
})
// For a video: a 3-second, 12 fps GIF preview for the feed.
preview := client.URL(asset.ID, &dreep.Transform{
Format: dreep.FormatGIF,
TrimStart: dreep.Ptr(0.0),
TrimEnd: dreep.Ptr(3.0),
FPS: 12,
Width: 384,
})# 1. Your backend presigns (API key stays server side)
curl -X POST https://api.dreep.cloud/api/v1/upload/presign \
-H "Authorization: Bearer $DREEP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"filename":"IMG_0042.MOV","contentType":"video/quicktime","sizeBytes":48211340,
"folder":"users/u_193/uploads"}'
# 2. The app PUTs to uploadUrl, then 3. your backend confirms
curl -X POST https://api.dreep.cloud/api/v1/upload/$UPLOAD_ID/confirm \
-H "Authorization: Bearer $DREEP_API_KEY"
# Feed thumbnail at 2 seconds
curl -o poster.webp "https://cdn.dreep.cloud/api/v1/fetch/$UPLOAD_ID?format=webp&time=2&width=750"
# GIF preview: 202 while processing, 200 once cached. Re-request the same URL.
curl -i "https://cdn.dreep.cloud/api/v1/fetch/$UPLOAD_ID?format=gif&trimStart=0&trimEnd=3&fps=12&width=384"Sizes and formats for phone screens
Ask for the size in points and pass the screen scale as dpr, from UIScreen scale on iOS or display density on Android, rounded to 1, 2 or 3. AVIF and WebP at quality 65 keep feed images small on mobile data.
| Use | Parameters | Notes |
|---|---|---|
| Avatar | width=64&height=64&fit=cover&radius=max&format=webp&dpr=3 | Circular crop done by Dreep, not the app |
| Feed photo | width=384&fit=inside&format=avif&quality=65&dpr=2 | Never crops; dpr scales the output |
| Full screen | width=1080&fit=inside&format=avif&quality=75 | Already in pixels, so no dpr |
| Video poster | format=webp&time=2&width=750 | A frame from the video; time is clamped to the last frame |
| Short clip | format=mp4&trimStart=0&trimEnd=6&width=640 | Six seconds for autoplay in a feed |
| GIF preview | format=gif&trimStart=0&trimEnd=3&fps=12&width=384 | Keep GIFs short and low fps; they grow fast |
Video in a feed: thumbnails, clips and GIFs
A frame extract or remux comes back straight away. A re-encode, an HLS package or a GIF is heavier: the first request returns 202 Accepted and API clients receive { "error": "Asset is processing" }. Request the same URL again later and it returns 200 from cache. There are no webhooks, so this is polling the URL.
In practice, have your backend request the poster, clip and GIF URLs once right after confirm, so the work is done before anyone scrolls past. In the app, show the poster frame while a clip URL still returns 202, and treat 202 as retry later rather than as an error.
HEIC without a conversion step
iPhones save photos as HEIC by default, and some Android phones save HEIF. Upload the file as captured with Content-Type image/heic or image/heif. Dreep keeps it as the original and every delivery URL picks the output format, so the same photo renders as AVIF in your app and WebP on an older web browser without any client-side conversion.
What each feature does for a mobile team
No key in the binary
An API key in an app can be pulled out of the package. Presigned URLs are single use and expire after 10 minutes, so there is nothing long-lived to steal.
Direct to Dreep
A 200 MB video does not stream through your API servers or hold one of their connections for minutes.
dpr on the URL
One URL pattern for every device. The app supplies its scale; you do not maintain separate 1x, 2x and 3x renditions.
Video previews from the original
Posters, short MP4 clips and GIFs are URL parameters on the uploaded video, not a separate encoding pipeline.
Who this is for
- iOS and Android apps where users upload photos or short videos.
- Teams with a small backend who do not want uploads streaming through it.
- Apps that need feed thumbnails and GIF previews without running FFmpeg.
- React Native and Flutter apps too: the upload is a plain HTTP PUT.
When Dreep is not the right fit
- You need live streaming or a video player product. Dreep has neither; it produces files and HLS you play yourself.
- You need a completion webhook for long video jobs. Dreep returns 202 and you re-request the URL.
- You need DRM-protected playback. Signed URLs limit access by time, they are not DRM.
Frequently asked questions
Can I put the Dreep API key in my iOS or Android app?
No. The key is a full-project credential and can be extracted from an app package. Keep it on your backend and give the app a presigned upload URL for each file.
Do I need to convert HEIC to JPEG before uploading?
No. HEIC and HEIF are accepted inputs. Upload as captured and request format=avif or format=webp when you display the photo.
How do I choose the dpr value?
Use the screen scale: UIScreen scale on iOS, display density on Android, rounded to 1, 2 or 3. Request the size in points, for example width=384&dpr=3.
Why does my GIF or clip URL return 202?
Re-encodes and GIFs are processed in the background. The first request returns 202 Accepted; request the same URL again later and it returns 200 from cache. Warm these URLs from your backend after confirm so users rarely see it.
How large can a mobile upload be, and how long is the URL valid?
Up to 500 MB per file. Presigned URLs are single use and valid for 10 minutes, so request one right before the upload starts and request a new one if the user retries later.
Is there a Swift or Kotlin SDK?
No. The official SDKs are JavaScript and Go, for your backend. On the device the upload is a plain HTTP PUT, shown above with URLSession and OkHttp.
Related
Video transcoding API
MP4, WebM, HLS, GIF, trims and the 202 processing model.
Secure file uploads
Why the key stays on the server and what presigning checks.
Photo-sharing apps
Per-user folders, private albums and quotas at volume.
SDKs
JavaScript and Go SDKs for the backend half of this flow.
Image CDN API
Every image parameter, including dpr and radius.
Pricing
Image and video transformation limits per plan.
Video transformations in the docs
Trimming, GIFs, thumbnails and HLS parameters.
Upload from a real phone today
Add one presign endpoint to your backend, paste the Swift or Kotlin snippet, and render the photo with dpr for the device. The free plan includes 20 video transformations to test clips and GIFs.