PricingPrivacyTermsSign in

Connect your agent › The agent's guide

Files people send the app

Photos and documents from the people using the app (a profile photo, a receipt, a CV with a job application) go in image and file fields. The browser sends them straight to storage through the SDK; Apphaven checks each one before anyone can use it and serves it only to people who may read its record.

This is different from the owner's own pictures (a logo, the menu photos, a brochure): those go through the app's Add files page into the workspace, and you reference them by path (see "Files from the user" in the overview).

Fields

models:
  Profile:
    fields:
      name: string
      owner: { type: user, default: $user.id, readonly: true, required: true }
      photo: image                                # one photo
      gallery: { type: image, many: true }        # a list of photos
    access: { read: true, create: $user != null, update: owner == $user.id, delete: owner == $user.id }
  Application:
    fields:
      name: { type: string, required: true }
      email: { type: string, format: email, required: true }
      cv: { type: file, types: [pdf, document], max_mb: 10, required: true }
    access: { read: false, write: false }         # not through the API: people apply through the
                                                  # form below; the owner reads them on the Data page
kind accepted shown how
image JPEG, PNG, WebP, GIF (animated GIFs stay animated) in the page: a display copy, 1,600 px on its long side, and a 320 px thumbnail
pdf PDF downloaded
document Word, Excel and PowerPoint (.docx, .xlsx, .pptx) downloaded
text plain text and CSV, as UTF-8 downloaded
  • type: image takes photos only. type: file takes every kind unless types: narrows it (types: [image, pdf] for a photo or a scan of a receipt).
  • max_mb: the largest file, at most 25 MB (also the default). For photos it applies to the copy sent, which the SDK makes small, so phone photos of any size fit.
  • many: true for a list of up to 1,000 files (each save sends the whole list). A list nobody has saved yet reads as null, not [].
  • Refused at the check, with a message for the person: Apple's HEIC photos the browser can't convert (Safari converts them; other browsers on other systems may not), pictures over 100 megapixels, GIFs over 1,000 frames, older .doc/.xls/.ppt files, Office files with macros, embedded objects or links to outside templates or files, password-protected Office files (a PDF with a password is accepted), and anything that isn't what it says it is. AVIF photos are converted to JPEG by the SDK where the browser can draw them.
  • File fields can't be unique, indexed or given a default. Operations can take them as input (create and update) but can't set them, except to null, which clears one.
  • A record stores each file's id (att_…). A file belongs to one record: the same file can't be saved on two.

Sending a file, signed in

Pass the File from an <input type="file"> in the data. The SDK uploads and checks it first, then saves the record with its id:

await app.db.Profile.update(profile.id, { photo: input.files[0] });
await app.db.Profile.update(profile.id, { gallery: [...(profile.gallery ?? []), ...input.files] });   // keeps the ones it has

What the SDK does: a photo is drawn on a canvas in the browser into a display copy and a thumbnail (so its location and other metadata never leave the phone, and a 12-megapixel photo becomes a few hundred KB); other files are sent as they are. Each copy goes straight to storage, then the server checks what arrived. It resolves once the record is saved, or throws an ApphavenError whose message is written for the person (see "What goes wrong").

To show progress, or the photo before saving, upload first and save the id:

import { useState } from "react";
import { createClient } from "@apphaven/client";
const app = createClient();

export function PhotoPicker({ profile, onSaved }) {
  const [progress, setProgress] = useState(null);
  const [error, setError] = useState("");
  const [preview, setPreview] = useState(null);

  async function choose(e) {
    const file = e.target.files[0];
    e.target.value = ""; // the same file can be chosen again after an error
    if (!file) return;
    setError("");
    setPreview(URL.createObjectURL(file));
    setProgress(0);
    try {
      const photo = await app.files.upload(file, { model: "Profile", field: "photo", onProgress: setProgress });
      onSaved(await app.db.Profile.update(profile.id, { photo: photo.id }));
    } catch (err) {
      setError(err.message);
    } finally {
      setProgress(null);
      setPreview(null);
    }
  }

  const shown = preview ?? profile.photo?.thumb_url ?? profile.photo?.url;
  return (
    <div>
      {shown ? <img src={shown} alt="" width="96" height="96" style={{ objectFit: "cover", borderRadius: "50%" }} /> : null}
      <label>
        {progress === null ? "Choose a photo" : `Sending… ${Math.round(progress * 100)}%`}
        <input id="photo" type="file" accept="image/*" onChange={choose} disabled={progress !== null} hidden />
      </label>
      {error ? <p role="alert">{error}</p> : null}
    </div>
  );
}
  • app.files.upload(file, { model, field, onProgress }) resolves to { id, name, kind, content_type, size, width?, height?, variants } once the file is checked. For a form signed out, name the operation instead: { operation: "apply", field: "cv", onProgress }.
  • Upload for the field you save it in: a file sent for photo can't be saved in gallery.
  • Save it on a record within the hour: an upload nobody saves is deleted. Only the person who sent a file can save it, and at most 20 of theirs can wait unsaved at once.
  • If a save fails for another reason (a required field missing, say), the files it sent are still waiting: upload first, keep the ids, and save again with the ids rather than the Files, which would send them again.
  • accept="image/*" (or accept=".pdf,.docx") helps people pick the right thing; the server checks regardless.

A form for people who aren't signed in

Signed out, files come in only through a form: a create operation that takes the file field in its input and that signed-out people may use (here access: true opens it to everyone).

operations:
  apply:
    model: Application
    action: create
    input: [name, email, cv]
    access: true
async function submit(e) {
  e.preventDefault();
  const f = new FormData(e.target);
  setSending(true);
  try {
    await app.ops.apply({ data: { name: f.get("name"), email: f.get("email"), cv: f.get("cv") } }); // an empty optional file input sends null
    setDone(true);
  } catch (err) {
    setError(err.message);
  } finally {
    setSending(false);
  }
}
// <form onSubmit={submit}> … <input name="cv" type="file" accept=".pdf,.docx" required /> …

The first file a visitor sends runs a quick check (Cloudflare Turnstile): usually invisible; at most a small box at the bottom right of the page, which can ask for a click. (The SDK asks for it with POST /_apphaven/uploads/session, which first answers 428: that's expected.) It lasts an hour for that form, and files sent through one form can't be saved anywhere else. A browser has one such session at a time: a visitor who starts a second form needs the check again for the first. A visitor can send about 15 files a minute this way; for more, have people sign in. Keep the button disabled while sending, and say what's happening: the check and the upload can take a few seconds on a phone.

The answer to a write the visitor can't read back (like read: false above) has each file's id, name, kind, content_type, size and variants, without links: enough to say "we got your CV (cv.pdf, 182 KB)".

Showing files

Reads return each file as an object (a list of them for many):

"photo": {
  "id": "att_3k9…",
  "name": "me.jpg",
  "kind": "image",
  "content_type": "image/jpeg",
  "size": 183204,
  "width": 1600,
  "height": 1200,
  "variants": ["display", "thumb"],
  "url": "https://my-app.onapphaven.com/_apphaven/files/att_3k9…",
  "thumb_url": "https://my-app.onapphaven.com/_apphaven/files/att_3k9…?v=thumb",
  "signed_url": "https://onapphaven-files.com/…?verify=…",
  "signed_thumb_url": "https://onapphaven-files.com/…?verify=…",
  "signed_expires_at": "2026-11-02T15:00:00.000Z"
}
  • In pages, use url and thumb_url. They're the file's stable address on the app: each time, it checks the person may read the record and sends them on to the file. Use thumb_url in lists and grids, url for the full photo. thumb_url is there only for photos with a thumbnail (not documents, nor a photo the SDK couldn't prepare): fall back to url.
  • signed_url works without signing in until signed_expires_at (one to two hours): for a download link right now. Never store it, email it or share it.
  • Photos show in the page. PDFs, documents and text files download when opened: link them with the file's name and size rather than embedding them.
  • expand on a reference brings the related record's files with their links too.
  • { "id": "att_…", "removed": true }: the file was removed. Show nothing, or "removed". { "id": "att_…", "unavailable": true }: files can't be shown right now. Show a placeholder; it's back on the next read.

Removing and replacing

Set the field to null, or save the list without the file, and it's removed; deleting the record removes its files. Replacing a photo is saving the new one in its place. A removed file stops working within moments, though a browser that opened it in the last hour can still show it from its cache. Apphaven keeps removed files 30 days, then deletes them.

Access is checked whenever the stable address is opened, but a signed_url someone already has keeps working until it expires: after making a record private, or removing someone from it, allow up to two hours for links they already had.

What goes wrong

Every refusal has a code and a message for the person: show the message as it is.

code when what to do
too_large, not_accepted, heic, too_many_pixels, too_many_frames, macros, old_office, encrypted, not_text, damaged, empty the file didn't pass the check, or isn't a kind the field takes (the message says what it takes) show the message; let them pick another file
bad_upload the page sent the file's copies wrong send files through the SDK, which gets them right
upload_failed the connection dropped offer to try again
busy many files are being checked at once try again in a moment
too_many_uploads 20 files are waiting unsaved save or wait, then try again
file_gone, expired it waited over an hour unsaved send it again
file_in_use it's already saved on another record send it again for this one
wrong_field it was sent for another field upload it for the field you save it in
blocked the same file was taken down by Apphaven after a report it can't be sent
upload_incomplete the copies hadn't all arrived send it again (the SDK tries a few times first)
rate_limited too many requests from one person in a minute wait a minute
files_unavailable files can't be sent or shown right now try again later
not_yours someone else sent it, or it was sent for another form each person sends their own
challenge_failed the signed-out check didn't pass try again
files_limited the app's file storage is full the person sees "This app can't take more files right now. Let its owner know."; the owner frees space or moves plan (the test version: 1 GB)

The test version and the live app

Each keeps its own files. The test version holds up to 1 GB; the live app's files count against its plan's storage, shown in app_status. When the owner publishes with the test version's data, the files its records hold come along. On the test version, the signed-out check always passes (it's private), so your tests and the owner can try a form freely; on the live app it's real.

Checking your work

  • test_api_write with attach sends a sample file for a field first, the way the SDK does, then makes the write with it: { "as": "alice", "method": "PATCH", "path": "/api/models/Profile/<id>", "attach": { "photo": "photo" } }. Samples: photo, pdf, text. Signed out, write through the form ("path": "/api/ops/apply", "body": { "data": { … } }, "attach": { "cv": "pdf" }).
  • view_page with a step { "attach": "#photo", "sample": "photo" } puts a phone-sized photo in the page's file input, as if a person chose it, so your page's own upload code runs. Then wait for something that appears only once the upload is done and saved (a preview drawn from the chosen file shows before that), and look at the screenshot. A file link that downloads shows under "Downloads", not as a failure.
  • test_api with "path": "/_apphaven/files/<id>" and as says whether that person may open the file: "Allowed", or not. Try the owner of the record, someone else, and signed out.
  • query_data shows each file's name, size, kind and state (saved, removed, refused and why, taken down), never a link. change_data can't put files on records (they come from uploads); it can clear a file field or delete records, which lets their files go.
  • app_status has an attachments section and notes: uploads refused in the last week and why, photos that arrived without a thumbnail (they didn't go through the SDK), and how full the test version is.
HomeDocsPricingPrivacyTermsSupportReport content A product of Efficacious, Inc.