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.

  1. App picks a file

    HEIC, JPEG, MP4 or MOV

  2. Backend presigns

    Checks the user, sets the folder

  3. App PUTs bytes

    URLSession or OkHttp, no SDK needed

  4. Backend confirms

    Returns delivery URLs

  5. App renders

    Adds dpr for its screen

Up to 500 MB per file. Content-Type and Content-Length on the PUT must match what was presigned.

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)
}

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 }),
  };
};

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.

UseParametersNotes
Avatarwidth=64&height=64&fit=cover&radius=max&format=webp&dpr=3Circular crop done by Dreep, not the app
Feed photowidth=384&fit=inside&format=avif&quality=65&dpr=2Never crops; dpr scales the output
Full screenwidth=1080&fit=inside&format=avif&quality=75Already in pixels, so no dpr
Video posterformat=webp&time=2&width=750A frame from the video; time is clamped to the last frame
Short clipformat=mp4&trimStart=0&trimEnd=6&width=640Six seconds for autoplay in a feed
GIF previewformat=gif&trimStart=0&trimEnd=3&fps=12&width=384Keep GIFs short and low fps; they grow fast
Images up to 4000 px on either side. Video transformations are counted separately: 20 a month on Free, 1,000 on Starter and 4,000 on Pro.

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

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.