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 returned401against every real project.
The route, not the bucket
The first argument is the route — posts/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."
| Parameter | Default | Description |
|---|---|---|
name | — | The route (leading slash tolerated), not a bucket |
fileURL / fileData | — | The file, from disk or memory. An unreadable fileURL throws .transport |
filename | the URL's last path component | The name on the file part (required when uploading Data) |
contentType | resolved — see below | The file part's MIME type |
fields | [:] | Your handler's JSON input — see the wire section |
constraints | nil | Client-side size/type guard |
headers | [:] | Extra request headers |
onProgress | nil | Progress 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
}
| Phase | What is happening | fraction |
|---|---|---|
.uploading | the multipart body going up | real |
.processing | the request is sent; your completion handler is running | 1 |
.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 plainDouble.BackendUploadPhasealso declares.authorizing, left over from the retired handshake; no code path ever emits it. The SDK's own README still printsprogress.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:
- What you passed as
contentType:. - The endpoint's pinned
Content-Type, for a typed call. - Byte sniffing, for a
.datasource. - The filename's extension —
png,jpg/jpeg,gif,webp,heic,pdf,json,txt,csv,mp4,mov,zip. application/octet-stream, as a last resort rather than a default.
The sniffer reads the first 16 bytes:
| Signature | MIME |
|---|---|
89 50 4E 47 | image/png |
FF D8 FF | image/jpeg |
47 49 46 | image/gif |
25 50 44 46 | application/pdf |
50 4B 03 04 | application/zip |
RIFF … WEBP | image/webp |
ftyp brand hei* / mif* / msf* | image/heic |
ftyp brand avif / avis | image/avif |
ftyp brand qt* | video/quicktime |
ftyp, any other brand | video/mp4 |
HEIC is the normal case, not the edge case. A stock iPhone camera produces HEIC, PhotosPicker hands you
Datawith 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 inPalbe: 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.uploadoverloads only — the generated typed method takes noconstraints:parameter. They are a round-trip saver, never a security boundary: the bucket'sfileSizeLimitandallowedMimeTypesare what actually decide, and storage applies them at the write.
What goes on the wire
- One
POST /v1/storage/upload/<route>with amultipart/form-databody. - The bytes travel under the part name
file, with your filename and the resolvedContent-Type. - Your handler's input travels as one part named
body, containing JSON.fieldsare collapsed into it rather than sent as loose parts, because storage readsbodyand looks at no other part — a flattened form returned200while the field never reached the handler. Afieldsentry you name"body"yourself is passed through untouched. - An
Idempotency-Keyis 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_requiredand 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:
variantsvalues are relative paths (/v1/files/<bucket>/<path>?variant=<name>). OnlygetPublicUrl(...)prefixes the stack's public origin, so hand a client agetPublicUrlresult rather than a rawvariantsentry.
@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.
Related
- Direct Uploads — the
@Uploaddecorator in full. - Storage — buckets, size and MIME limits, and variants.
- Calling Your Backend — the shared transport behavior.
- Error Handling —
BackendError.validationand friends.