Coming soonPostila isn’t publicly available yet — we’re putting on the finishing touches. Sign-ups open at launch.

Developer documentation

Add uploads to your app in minutes

The @postila/sdk SDK gives you a drop-in React picker and a framework-free upload client, plus delivery/transform URLs and per-file access control — all against your own account and storage.

Overview

The SDK ships two layers from one package:

  • Headless core (@postila/sdk/core) — a framework-free UploaderClient plus URL/transform helpers. No React dependency; use it in any framework, a Node script, or a serverless function.
  • React components (@postila/sdk) — a drop-in <PickerOverlay> modal, inline <DropPane>, a usePicker() hook, camera capture, and an in-picker image editor.

A third entry, @postila/sdk/server, holds the server-only helpers that need your key's secret: getUploadToken() and getSignedDeliveryUrl().

Uploads go direct to storage via a presigned URL when your plan enables it (bytes never transit our API); otherwise the client transparently falls back to a proxied upload. You don't choose — it probes and picks.

Set up the product

Before any code, grab three things from the product:

  1. An account. Sign up — your plan sets your limits and which delivery-protection modes you may use.
  2. A public API key (pk_…). Create it under API keys in your dashboard. The public key goes in client code. Each key also has a secret used only server-side to sign URLs — never ship the secret to a browser.
  3. The API base URL (apiUrl). Optional — it defaults to https://api.postila.app. Only set it if you were given a different endpoint.

Install

bash
npm install @postila/sdk

react / react-dom (v18 or v19) are optional peer deps — only needed for the React components. The /core entry has no peer deps.

Quick start

React — the picker

tsx
import { useState } from 'react'
import { PickerOverlay } from '@postila/sdk'
import '@postila/sdk/styles.css'

export function UploadButton() {
  const [open, setOpen] = useState(false)
  return (
    <>
      <button onClick={() => setOpen(true)}>Upload</button>
      <PickerOverlay
        apikey="pk_your_public_key"
        open={open}
        onClose={() => setOpen(false)}
        onUploadDone={(res) => console.log(res.filesUploaded)}
      />
    </>
  )
}

Headless — the client

ts
import { UploaderClient } from '@postila/sdk/core'

const client = new UploaderClient({ apikey: 'pk_your_public_key' })

const result = await client.upload(file, { filename: 'photo.jpg' })
console.log(result.handle) // "abc123def456"
console.log(result.url)    // delivery URL for the original

⚠️ The option/prop name is apikey (all lowercase) — apiKey is not recognized.

Uploading files

client.upload(file, options?) returns a FileResult and automatically chooses direct-vs-proxied and single-shot-vs-multipart by size (see Large files). Use client.uploadAll(files, options?) for a concurrency-limited batch.

Upload optionWhat it does
onProgress(percent: number) => void — real byte progress, 0–100
filenameOverride the stored filename
signalAbortSignal to cancel (throws UploaderError code ABORTED)
deliveryProtectionPer-upload 'public' | 'hotlink' | 'signed' (see below)
allowedOriginsPer-upload origin lock (see below)

FileResult is { handle, url, filename, mimetype, size, status, transformBase?, urlExpiresAt? }. The handle is the stable id — store it. url is the delivery URL for the original. transformBase is the host this file's transforms render on (see Delivery & transforms). urlExpiresAt is normally absent; if it is set, url stops working at that time, so keep the handle and fetch a fresh URL with getFile().

Progress, cancel, and batches

ts
const controller = new AbortController()

const result = await client.upload(file, {
  onProgress: (percent) => setProgress(percent), // 0–100
  signal: controller.signal,
})

// cancel button: controller.abort() → upload() throws UploaderError code 'ABORTED'

// Several files, 3 at a time by default. Never throws: one settled result per file.
const results = await client.uploadAll(files, { concurrency: 4 })
for (const r of results) {
  if (r.status === 'fulfilled') console.log(r.value.handle)
  else console.error(r.reason)
}

Requests that hit a network error or a 5xx are tried up to 3 times with backoff; 4xx errors are never retried.

Large files

There is nothing to configure. The client picks the path and splits big files into parts on its own, reporting one 0–100 progress number for the whole file:

  • Direct to storage (plans with direct upload, and every BYO plan): files up to 100 MB go in one presigned PUT. Files over 100 MB go in parts — 4 in flight, each with its own short-lived URL and retries — so a flaky connection can't fail a multi-GB upload. A cancelled or failed upload is aborted so no parts linger. Only your plan's max file size applies.
  • Proxied (everything else): files up to 5 MB go in one request; bigger files go in 5 MB parts through the API.

Pass directUpload: false to the client to force the proxied path (not possible on BYO plans).

Your own bucket (BYO)

On a bring-your-own-bucket plan, files go from the browser straight into your bucket. The SDK does this automatically — the same upload() call and the same picker, no code changes.

Your bucket's CORS policy must allow your site's origin, or every upload fails in the browser with an opaque network error. Connect the bucket on the console's Storage page, which also checks CORS. The full setup — bucket, scoped credentials, the CORS JSON — is in the BYO guide.

  • The client asks the API once whether the account uploads direct. If that check fails and the API refuses the proxied upload with BYOB_DIRECT_UPLOAD_REQUIRED, the client switches to direct and re-sends that file once (SDK ≥ 1.13.0).
  • With no bucket connected, uploads fail with 409 BUCKET_NOT_CONNECTED — there is no fallback to platform storage.
  • deleteFile() stops delivery and purges our caches but never deletes the object from your bucket.

Signed upload tokens

Your publishable key is visible in your page, so by default anyone who copies it can upload to your account. To require authorization, turn on Require signed policies on API calls on the console's Security page. Then every upload and file-management call needs a short-lived token that only your backend can mint. Wire up the backend route before flipping the switch — it takes effect immediately.

ts — your backend
import { getUploadToken } from '@postila/sdk/server'

// An authenticated route on YOUR backend, e.g. GET /api/upload-token.
// It holds the API key secret — never ship the secret to a browser.
app.get('/api/upload-token', requireYourAuth, (_req, res) => {
  const token = getUploadToken({
    secret: process.env.POSTILA_API_SECRET!,
    expiresIn: 3600,            // seconds; keep it short
    maxSize: 25 * 1024 * 1024,  // optional: reject files over 25 MB (403)
  })
  res.json(token) // { policy, signature, expiresAt }
})
ts — the browser
const { policy, signature } = await fetch('/api/upload-token').then((r) => r.json())

const client = new UploaderClient({ apikey: 'pk_your_public_key', security: { policy, signature } })
await client.upload(file)

// React: <PickerOverlay apikey="pk_…" security={{ policy, signature }} … />

Other getUploadToken options: call (default ['store']; also read, list, remove), minSize, and handle (required for a read token). The SDK doesn't refresh tokens: when one expires, calls fail with 403 until you pass a fresh pair (a new client, or a new security prop on the picker). Mint one per upload session and give it enough lifetime for your largest file.

Managing files

client.listFiles(query?), client.getFile(handle), and client.deleteFile(handle) let your backend manage the account's files without a console session. The list is paginated (50 per page) and takes the same filters as the console's Files view: q, mimetype, status, tag, size and date bounds, and sort.

ts
// List, inspect, and delete with the same API key you upload with
const { files, total, page, pageSize } = await client.listFiles({
  mimetype: 'image',     // prefix match
  sort: '-created_at',
  page: 1,               // 50 per page
})

const record = await client.getFile(handle)   // fresh url, status, width, height, tags; 404s on unknown handles
await client.deleteFile(handle)               // stops serving immediately; bytes GC'd async

Deleting stops delivery immediately (soft-delete plus edge cache purge); the stored bytes and derivatives are cleaned up in the background. If your account requires signed policies, each operation is gated by its policy call: list, read, or remove — a handle-bound read policy from a delivery URL deliberately cannot enumerate the account. Pass a per-call override as { security: { policy, signature } } in the second argument (it also takes a signal).

listFiles filters: q, mimetype, status, tag, sizeMin, sizeMax, dateFrom, dateTo, sort (created_at, size, filename; prefix - for descending), and page.

Delivery & transforms

Every file has a stable handle. FileResult.url serves the original; build a transformed derivative (resize, crop, format, quality…) with transformUrl() and the op builders. The API renders and caches the derivative on first request.

ts
import { transformUrl, resize, output, quality } from '@postila/sdk/core'

// Pass the upload result (or a getFile() record): the URL lands on the host the
// API chose for this file — its transformBase (public → CDN hostname,
// hotlink/signed and BYO files → edge).
const thumb = transformUrl({
  file: result,
  ops: [resize({ w: 200, h: 200, fit: 'crop' }), output({ format: 'webp' })],
})
// -> https://cdn.postila.app/resize=w:200,h:200,fit:crop/output=format:webp/<handle>

// <img src={thumb} alt="" />

Op builders: resize({ w, h, fit }) (fit: cover, contain, fill, inside, outside, crop), crop({ x, y, w, h }), rotate({ deg }), flip(), flop(), quality({ n }) (1–100), output({ format }).

The API decides which host renders a transform, per plan and per file, and returns it as transformBase (SDK ≥ 1.11.0). Passing { file } uses it for you. If you only stored the handle, pass the record from getFile(handle) as file. The older { handle, apiUrl, ops } form still works (the API redirects to the right host) but costs an extra round trip.

Transforms work on every plan, including BYO, up to the plan's monthly cap — past it, the original is served. transformUrl returns null only in a rare server setup where the file has no transform host; fall back to file.url. For signed files, build transform URLs on your server with getSignedDeliveryUrl({ ops }) (below).

Protecting files

Each account has a delivery-protection mode that decides who can fetch a file's bytes. It's enforced on both the original and transform routes. Which modes you may use is set by your plan.

ModeWho can fetchUse for
publicAnyone with the link (default)Public images, avatars, marketing assets
hotlinkOnly requests whose Origin/Referer is allowlistedStore images you don't want hotlinked
signedOnly requests carrying a valid signed read URLSensitive / private files

Signed URLs

For a signed file, every view needs a URL carrying a policy + signature pair, made on your backend (it holds the key secret). getSignedDeliveryUrl() builds the whole URL in one call. Calling it per render is fine: within one stableWindow it returns the same URL, so the browser can cache the image (pass stableWindow: 0 for an exact expiry).

ts
import { getSignedDeliveryUrl } from '@postila/sdk/server'
import { resize } from '@postila/sdk/core'

// Runs on YOUR server — it holds the API key secret. Never ship the secret to a browser.
const src = getSignedDeliveryUrl({
  handle: file.handle,
  secret: process.env.POSTILA_API_SECRET!,
  baseUrl: 'https://edge.postila.app', // the origin of file.url
  expiresIn: 3600,                     // minimum seconds valid (default 300)
  // stableWindow: 3600,               // same URL within this window (default: expiresIn)
  // ops: [resize({ w: 800 })],        // optional transform
})
// → https://edge.postila.app/file/<handle>?policy=…&signature=…  — hand it to <img src>
ts
import { withSignedPolicy } from '@postila/sdk/core'

// Already have a policy + signature pair from your backend? Append it to any URL.
const url = withSignedPolicy(file.url, { policy, signature })

hotlink matches Origin/Referer, which is spoofable — treat it as an anti-hotlink deterrent, not strong access control. Use signed for real access control.

Per-file protection

Need different protection for different files — most product images on hotlink, a few documents signed and locked to one domain? Choose it per file, at upload, from your own code. Set a client-level default and/or a per-upload override; per-upload wins.

ts
// A dedicated client for sensitive uploads: signed + domain-locked.
const secure = new UploaderClient({
  apikey, apiUrl,
  deliveryProtection: 'signed',
  allowedOrigins: ['https://app.customer.com'],
})
await secure.upload(sensitiveFile)          // this file is signed + origin-locked

// The normal client stays on the account default (e.g. hotlink)…
const normal = new UploaderClient({ apikey, apiUrl })
await normal.upload(productImage)                                    // inherits account mode
await normal.upload(oneImage, { deliveryProtection: 'public' })      // per-upload override
  • Omit both fields → the file inherits the account's mode (default; existing files unaffected).
  • The chosen mode must be allowed by your plan, or the upload is rejected with 403 DELIVERY_MODE_NOT_ALLOWED.
  • For a signed file, the signature is the real gate; allowedOrigins adds a domain lock on top (defense-in-depth).
  • Set once at upload — there is no "change it later" call.

React components

All components take the shared props: apikey (required), apiUrl, security?, pickerOptions?, and the callbacks onUploadDone, onFileUploadFinished, onFileUploadFailed, onCancel.

They also take deliveryProtection and allowedOrigins (see Per-file protection).

  • <PickerOverlay> — full-screen modal with drag-and-drop, thumbnails, progress, retry, and an image editor. Controlled via open + onClose; also takes theme, className, style.
  • <DropPane> — inline drop zone you embed in a form. Same props plus hint; no image editor.
  • usePicker(options) — headless hook for a fully custom UI: returns files, addFiles, removeFile, editFile, retryFile, upload, progress, isUploading, isDone.
  • <CameraCapture> plus isCameraSupported(), shouldOfferCamera(), isMobileDevice(), defaultCameraFacingMode() — for custom camera UIs.
tsx
<PickerOverlay
  apikey="pk_your_public_key"
  open={open}
  onClose={() => setOpen(false)}
  pickerOptions={{
    accept: ['image/*', '.pdf'],    // MIME types or extensions
    maxSize: 10 * 1024 * 1024,      // bytes; bigger files are not added
    maxFiles: 5,
    fromSources: ['local_file_system', 'camera'],
    cameraFacingMode: 'environment', // omit: rear on phones, front on desktops
  }}
  deliveryProtection="hotlink"
  theme={{ accent: '#4f46e5', radius: '12px' }}
  onFileUploadFinished={(file) => console.log(file.handle)}
  onFileUploadFailed={(file, err) => console.error(file.name, err)}
  onUploadDone={({ filesUploaded, filesFailed }) => save(filesUploaded)}
/>

Camera. A Take photo button appears when the browser has a camera and the picker accepts images (HTTPS or localhost only). Hide it with fromSources: ['local_file_system']. Files over maxSize are dropped at selection without a callback.

Theming. Style via the --uploader-* CSS variables (page-wide), or the per-instance theme prop (accent, accentSoft, radius, font, … — see the UploaderTheme type). themeToVars(theme) turns a theme into those variables for your own wrapper. Dark mode auto-applies under data-theme="dark".

Editing images

Users can crop (free, 1:1, 4:3, 16:9, 3:2, or round), rotate, flip and resize an image in <PickerOverlay> before it uploads — the bytes are edited in the browser, at no server cost. Render <ImageEditor file={file} onApply={…} onCancel={…} /> yourself, or edit in code with editImage(), which returns a new File:

ts
import { editImage } from '@postila/sdk'

const edited = await editImage(file, {
  rotation: 90,
  crop: { x: 0, y: 0, width: 800, height: 800 },
  maxDimension: 1600,         // longest side, px
  outputType: 'image/webp',
  quality: 0.85,              // 0–1
})
await client.upload(edited)

Other options: flip: { horizontal, vertical }, circle: true (round mask, PNG output), fileName.

Usage warnings

When the account nears (85%) or passes a monthly allowance, the upload response carries a warning. The client logs it with console.warn, or calls your onUsageWarning hook:

ts
const client = new UploaderClient({
  apikey: 'pk_your_public_key',
  onUsageWarning: (w) => {
    // w.type: 'storage' | 'bandwidth' | 'transform' | 'uploads' …
    // w.level: 'approaching' (85%) | 'over'
    // w.usedBytes, w.limitBytes, w.percent, w.message
    showBanner(w.message)
  },
})

Bandwidth never blocks delivery; the message says what happens for the others. Warnings come back on direct-to-storage uploads only, and the picker components don't forward onUsageWarning — they always log.

Error handling

Uploads and file-management calls throw UploaderError (exported from @postila/sdk/core) with a typed code, an optional statusCode, and — when the API sent one — its own serverCode.

ts
import { UploaderError } from '@postila/sdk/core'

try {
  await client.upload(file, { signal })
} catch (err) {
  if (err instanceof UploaderError) {
    // err.code: ABORTED | NETWORK_ERROR | SERVER_ERROR | CLIENT_ERROR | INVALID_RESPONSE
    // err.statusCode: the HTTP status when available (e.g. 403, 413)
    // err.serverCode: the API's own code when it sent one (e.g. 'FILE_TOO_LARGE')
    if (err.serverCode === 'BUCKET_NOT_CONNECTED') showConnectBucketHint()
    console.error(err.code, err.statusCode, err.serverCode, err.message)
  }
}

CLIENT_ERROR carries the HTTP status — e.g. 403 for a disallowed mode/capability, 413 for a file over the plan limit, 429 for the upload cap, 409 BUCKET_NOT_CONNECTED for a BYO account with no bucket. Branch on serverCode rather than the message text.

Next.js

The components use React hooks, so in the App Router render them from a file that starts with 'use client'. Import @postila/sdk/server only from server code (route handlers, server actions) — it uses node:crypto and needs your secret, which belongs in an environment variable.

API reference

What the SDK calls under the hood:

ActionHTTP
Capability probeGET /api/capabilities
Direct uploadPOST /api/uploads/presign → PUT to bucket → POST /api/uploads/confirm
Direct upload, file over 100 MBPOST /api/uploads/multipart/start → /part-urls → PUT each part to bucket → /complete → POST /api/uploads/confirm
Cancel a direct multipart uploadPOST /api/uploads/multipart/abort
Proxied small filePOST /api/store
Proxied large filePOST /api/upload/start → /part → /complete
Original deliveryGET /file/:handle
Transform deliveryGET /<chain>/:handle

Auth: the publishable key rides the X-Uploader-Key header; a signed policy rides X-Uploader-Policy / X-Uploader-Signature headers or query params.

Ready to build?

Create an account and an API key, then drop the picker into your app.