Palbase
Sign inGet started

iOS SDK

Uploads

An upload from Palbe is one POST — a multipart/form-data request to /v1/storage/upload/<route> carrying the file and, alongside it, the JSON your handler's @Body expects. Storage authorizes the request against the route's @Upload declaration, writes the bytes, renders the bucket's variants, calls your completion handler with what it learned about the object, and hands that handler's own response back to the device — so the value your await returns is the handler's return value, decoded into a real Swift type. Your app never uploads to a bucket it names, never commits anything, and never polls.

Note: There used to be a three-phase handshake here — authorize, direct PUT to a signed URL, complete — and the server never had a counterpart for it. It was removed from the SDK on 2026-08-24. If you are reading an older sample that sets X-Palbase-Upload-Phase, it describes a protocol that returned 401 against every real project.

The route, not the bucket

The first argument is the routeposts/images for an @Upload("/images") on @Controller("/posts") — never a bucket name. Storage forwards that segment to the backend's authorize step, which reads the route's own @Upload declaration and answers with the bucket and the object path. The client never chooses where the bytes land, which is exactly what stops a caller from writing into a bucket the route was never allowed to touch. The path is rendered server-side from the token's user identity; an anonymous caller renders {userId} as anonymous.

The typed call

Codegen emits a typed method for every @Upload route:

let post = try await pb.posts.createImage(
    file: .data(pickedPhotoData),          // PhotosPicker gives you Data
    input: PostsCreateImageRequest(caption: "sunset over the wing"),
    onProgress: { p in progress = p.fraction }
)
print(post.url, post.caption)

One await. Behind it the SDK builds the multipart body, sends it, and decodes the response into the generated result type. There is no second call.

PBFileSource

.data(bytes)                                     // type sniffed from the bytes
.data(bytes, contentType: "image/heic")          // you already know it
.url(fileURL)                                    // read from disk, type from the extension
.url(fileURL, filename: "receipt.pdf")           // override the object's name

The untyped escape hatch

pb.upload reaches the same route shape without a generated method — useful before codegen has run, or for a route your app addresses dynamically. Two overloads, one for a file on disk and one for in-memory bytes:

@discardableResult
func upload<O: Decodable & Sendable>(
    _ name: String,
    fileURL: URL,
    filename: String? = nil,
    contentType: String? = nil,
    fields: [String: String] = [:],
    constraints: UploadConstraints? = nil,
    headers: [String: String] = [:],
    as: O.Type = O.self,
    onProgress: (@Sendable (BackendUploadProgress) -> Void)? = nil
) async throws(BackendError) -> O

@discardableResult
func upload<O: Decodable & Sendable>(
    _ name: String,
    fileData: Data,
    filename: String,                 // required in the in-memory variant
    contentType: String? = nil,
    fields: [String: String] = [:],
    constraints: UploadConstraints? = nil,
    headers: [String: String] = [:],
    as: O.Type = O.self,
    onProgress: (@Sendable (BackendUploadProgress) -> Void)? = nil
) async throws(BackendError) -> O
import Palbe

struct AttachResult: Decodable, Sendable {
    let filename: String
    let size: Int
}

let result: AttachResult = try await pb.upload(
    "todos/attach",
    fileURL: pickedImageURL,
    fields: ["caption": "receipt"],
    constraints: UploadConstraints(maxSize: 5_000_000, allowedTypes: ["image/jpeg", "image/png"])
) { progress in
    print("\(Int(progress.fraction * 100))% sent")
}

That call goes to POST /v1/storage/upload/todos/attach. Posting the multipart at the route itself — POST /todos/attach — is what the backend now refuses by name: "This endpoint accepts uploads through storage, not directly."

ParameterDefaultDescription
nameThe route (leading slash tolerated), not a bucket
fileURL / fileDataThe file, from disk or memory. An unreadable fileURL throws .transport
filenamethe URL's last path componentThe name on the file part (required when uploading Data)
contentTyperesolved — see belowThe file part's MIME type
fields[:]Your handler's JSON input — see the wire section
constraintsnilClient-side size/type guard
headers[:]Extra request headers
onProgressnilProgress ticks, on a background queue

Progress

public struct BackendUploadProgress: Sendable, Equatable {
    public let phase: BackendUploadPhase
    public let sentBytes: Int64
    public let totalBytes: Int64
    public var fraction: Double   // 0.0 … 1.0; 0 when the total is unknown
}
PhaseWhat is happeningfraction
.uploadingthe multipart body going upreal
.processingthe request is sent; your completion handler is running1

.processing matters more than it looks: on a large file most of the wall clock after the bar fills is the server's time, not the network's, and without a phase a progress view sits at 100% and looks hung. The typed path emits both phases; the untyped pb.upload overloads emit .uploading only.

Note: The property is fraction — a plain Double. BackendUploadPhase also declares .authorizing, left over from the retired handshake; no code path ever emits it. The SDK's own README still prints progress.fractionCompleted, which does not exist — ignore it.

The denominator is the multipart body length, not the file's size. Using the file size made the bar exceed 100%: a 4 194 625-byte file reported 4 194 953 bytes sent.

The callback runs on a background queue. To drive SwiftUI state, hop to the main actor:

let result: AttachResult = try await pb.upload(
    "todos/attach",
    fileURL: pickedImageURL
) { progress in
    Task { @MainActor in
        self.uploadFraction = progress.fraction
    }
}

Content types

The resolved type rides on the file part, and storage enforces the bucket's MIME allowlist when it writes. A wrong type is a rejected upload, not a wrong label. Resolution order, most specific first:

  1. What you passed as contentType:.
  2. The endpoint's pinned Content-Type, for a typed call.
  3. Byte sniffing, for a .data source.
  4. The filename's extension — png, jpg/jpeg, gif, webp, heic, pdf, json, txt, csv, mp4, mov, zip.
  5. application/octet-stream, as a last resort rather than a default.

The sniffer reads the first 16 bytes:

SignatureMIME
89 50 4E 47image/png
FF D8 FFimage/jpeg
47 49 46image/gif
25 50 44 46application/pdf
50 4B 03 04application/zip
RIFFWEBPimage/webp
ftyp brand hei* / mif* / msf*image/heic
ftyp brand avif / avisimage/avif
ftyp brand qt*video/quicktime
ftyp, any other brandvideo/mp4

HEIC is the normal case, not the edge case. A stock iPhone camera produces HEIC, PhotosPicker hands you Data with no filename, and HEIC is ISO-BMFF — its brand lives at offset 8, not 0. Sniffing is what makes the PhotosPicker path work at all. There is no client-side HEIC transcoding in Palbe: serve a variant, not the raw object.

Client-side constraints

public struct UploadConstraints: Sendable, Equatable {
    public let maxSize: Int?            // bytes; nil = no client-side size check
    public let allowedTypes: [String]   // MIME types; empty = no client-side type check
    public init(maxSize: Int? = nil, allowedTypes: [String] = [])
}

A violated constraint throws BackendError.validation with a single field error on "file" — the same shape as a server-side 400:

do {
    let result: AttachResult = try await pb.upload(
        "todos/attach",
        fileURL: url,
        constraints: UploadConstraints(maxSize: 5_000_000, allowedTypes: ["image/jpeg", "image/png"])
    )
} catch BackendError.validation(let v) {
    // e.g. "File exceeds maximum size of 5000000 bytes."
    showError(v.fields.first?.message ?? "Invalid file")
}

Warning: Constraints exist on the untyped pb.upload overloads only — the generated typed method takes no constraints: parameter. They are a round-trip saver, never a security boundary: the bucket's fileSizeLimit and allowedMimeTypes are what actually decide, and storage applies them at the write.

What goes on the wire

  • One POST /v1/storage/upload/<route> with a multipart/form-data body.
  • The bytes travel under the part name file, with your filename and the resolved Content-Type.
  • Your handler's input travels as one part named body, containing JSON. fields are collapsed into it rather than sent as loose parts, because storage reads body and looks at no other part — a flattened form returned 200 while the field never reached the handler. A fields entry you name "body" yourself is passed through untouched.
  • An Idempotency-Key is attached automatically (uploads mutate) and reused across the SDK's retries.
  • The multipart POST goes directly to Storage and is App Attest-exempt. Generated user backend calls can receive 401 app_attest_required and trigger enrollment and retry; an upload does not pass through that gate. See App Attest.

Failures throw the same BackendError cases as any other call — see Error Handling.

The backend side

An upload route is a controller method decorated with @Upload. It names the bucket and the object path; the bytes never pass through your code.

// modules/posts/posts.controller.ts
import { Controller, Upload, UploadedObject, Body, Storage, z } from "@palbase/backend";

const CreatePost = z.object({ caption: z.string().max(280) });
type CreatePost = z.infer<typeof CreatePost>;

const PostView = z.object({ id: z.string(), url: z.string(), caption: z.string() });
type PostView = z.infer<typeof PostView>;

@Controller("/posts")
export class PostsController {
  @Upload("/images", { bucket: "post-images", pathTemplate: "{userId}/{uploadId}-{filename}" })
  async createImage(
    @UploadedObject() obj: UploadedObject,
    @Body(CreatePost) body: CreatePost,
  ): Promise<PostView> {
    return {
      id: obj.uploadId,
      url: Storage.bucket(obj.bucket).getPublicUrl(obj.path),
      caption: body.caption,
    };
  }
}

@UploadedObject() injects what storage learned about the stored object — uploadId, bucket, path, size, contentType (detected from the bytes, not from the client's claim), checksum, width/height/thumbhash for images, and variants. @Body sees your caller's own payload, not the envelope storage wrapped it in.

Note: variants values are relative paths (/v1/files/<bucket>/<path>?variant=<name>). Only getPublicUrl(...) prefixes the stack's public origin, so hand a client a getPublicUrl result rather than a raw variants entry.

@Upload carries no maxSize or allowedTypes of its own — the bucket is the single source for both. If the completion handler answers 4xx, storage deletes the object it just stored and returns your status and body to the device verbatim.