Container Fields
Reading, displaying, and writing FileMaker container data from a Web Viewer app.
The Web Viewer bridge moves JSON strings, not binary. Container fields hold binary. So every container workflow in a Web Viewer app comes down to one of two choices: encode the bytes as Base64 and pass them through a FileMaker script, or keep the bytes in FileMaker entirely and drive a native script step from the web app.
Uploads are handled for you by containerUpload and the add-on's script. Reads are not — you build those, so most of this page is about the FileMaker calcs involved.
Why the Data API container value is not enough
Data API responses represent a container field as a URL, not as file content. Those URLs are tied to a Data API session, so putting one straight into <img src> inside a Web Viewer usually fails to render.
Uploading with containerUpload
WebViewerAdapter implements containerUpload by Base64-encoding the file and handing it to a FileMaker script that decodes it back into the container field. The call looks the same as it does in a browser-hosted app:
await client.containerUpload({
containerFieldName: "Photo",
file, // a File from an <input type="file">
recordId: 3,
});This never goes through the Data API script and never batches, because the Data API uploads containers through a separate multipart endpoint that the Execute FileMaker Data API script step does not expose.
New in @proofkit/webviewer 3.3.0
containerUpload needs all three of:
@proofkit/webviewer3.3.0 or later. Earlier versions throwContainer upload is not supported in webviewer.- FileMaker Pro 22.0 or later, for the
Go to List of Recordsscript step the upload script uses to reach a record by ID. - A ProofKit add-on that includes the
PK_container_uploadscript. On an older add-on the adapter rejects withContainerUploadTimeoutErrorafter the configured timeout.
Pass a File, not a bare Blob. FileMaker's Base64Decode needs a file name with an extension, and a Blob does not carry one:
// Blob from a canvas, fetch, or clipboard
const file = new File([blob], "signature.png", { type: blob.type });Options
export const client = DataApi({
adapter: new WebViewerAdapter({
scriptName: "PK_execute_data_api",
container: {
scriptName: "PK_container_upload",
timeoutMs: 60_000,
maxFileBytes: 20 * 1024 * 1024,
},
}),
layout: "API_Assets",
});| Option | Default | Notes |
|---|---|---|
container.scriptName | "PK_container_upload" | The add-on's container script. Override if your solution renamed it. |
container.timeoutMs | 60000 | Stops the promise hanging when no script answers. 0 waits indefinitely. |
container.maxFileBytes | 20971520 (20 MB) | Checked before encoding, so oversized files fail fast. 0 disables the check. |
A timeout is an unknown outcome
The timeout ends the JavaScript wait. It cannot stop a FileMaker script that is already running, so a slow upload can commit after the promise rejects. ContainerUploadTimeoutError carries outcome: "unknown" for that reason — do not treat it as a confirmed failure.
import { ContainerUploadTimeoutError } from "@proofkit/webviewer/adapter";
try {
await client.containerUpload({ containerFieldName: "Photo", file, recordId: 3 });
} catch (error) {
if (error instanceof ContainerUploadTimeoutError) {
// The write may have landed. Refetch the record before telling the user it failed.
}
}Retrying is safe: the upload sets one field from a payload that fully determines the result, so sending the same file to the same record twice leaves the same end state. Refetch first anyway, so you do not upload again while the original script is still running.
Current limits
- Repetitions above 1 are rejected client-side until the script supports them. Upload to repetition 1, or write the field with your own script.
- Errors come back as
FileMakerErrorwith the real FileMaker code:101for a missing record,102for a field not on the layout,105for a missing layout,306for a stalemodId.
The rest of this page covers reading containers, and the script patterns to use when you need behavior beyond a straight field write.
Reading a container
There is no containerRead. Reading is yours to build, because only you know which record to reach and what shape the screen needs.
The FileMaker side is one script that returns the file as Base64. Start from the add-on's FETCH CALLBACK TEMPLATE, which already parses the request and sends the callback, and replace its business-logic block with two calcs:
Set Variable [ $result ; Value: JSONSetElement ( "" ;
[ "fileName" ; GetContainerAttribute ( Customers::Photo ; "filename" ) ; JSONString ] ;
[ "base64" ; Base64EncodeRFC ( 4648 ; Customers::Photo ) ; JSONString ]
) ]GetContainerAttribute gives you the file name, which the browser needs to pick a MIME type and to name a download. Base64EncodeRFC turns the bytes into something that survives a JSON payload.
Use Base64EncodeRFC, not Base64Encode
Base64Encode inserts line breaks every 76 characters, which makes the string
invalid inside a data: URL. Base64EncodeRFC ( 4648 ; field ) returns an
unbroken string.
Never leave the Web Viewer's layout
SendCallBack uses Perform JavaScript in Web Viewer, which can only reach a
Web Viewer on the layout that is current when the step runs. If your script
uses Go to Layout to find the record, the callback silently fails and the
fmFetch promise never settles.
Reach the record in a New Window, then Close Window before sending the
callback. Script variables survive the window closing, so build $result
inside the window and use it after.
On the web side, turn the response into a data URL:
import { fmFetch } from "@proofkit/webviewer";
const MIME_BY_EXTENSION: Record<string, string> = {
gif: "image/gif",
jpg: "image/jpeg",
pdf: "application/pdf",
png: "image/png",
webp: "image/webp",
};
function mimeFromFileName(fileName: string) {
const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
return MIME_BY_EXTENSION[extension] ?? "application/octet-stream";
}
export async function getCustomerPhoto(recordId: string) {
const { base64, fileName } = await fmFetch<{ base64: string; fileName: string }>(
"Get Container",
{ recordId }
);
return {
dataUrl: `data:${mimeFromFileName(fileName)};base64,${base64}`,
fileName,
};
}Unsafe casting
The type passed to fmFetch is not validated against what the script actually
returns. Validate with zod if the script and the app change
independently, and decide how an empty container should come back — an empty
base64 string, or a found flag your script sets.
Rendering the result
A data URL works directly as an image source or download target.
import { useQuery } from "@tanstack/react-query";
import { getCustomerPhoto } from "./containers";
export function CustomerPhoto({ recordId }: { recordId: string }) {
const { data } = useQuery({
queryFn: () => getCustomerPhoto(recordId),
queryKey: ["customer-photo", recordId],
});
if (!data) {
return <p>Loading photo…</p>;
}
return <img alt={`Photo for customer ${recordId}`} src={data.dataUrl} />;
}For anything large, convert to a blob URL instead. A data URL keeps the whole Base64 string in the DOM; a blob URL keeps one reference and lets the browser stream from memory.
export function base64ToBlobUrl(base64: string, mimeType: string) {
const binary = atob(base64);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
return URL.createObjectURL(new Blob([bytes], { type: mimeType }));
}Call URL.revokeObjectURL(url) when the component unmounts, or the blob stays alive for the lifetime of the Web Viewer session.
Writing a container with your own script
containerUpload covers a plain write to a container field. Write your own script when the upload needs more than that: validation, related-record creation, an audit trail, or several fields in one transaction.
Copy the add-on's PK_container_upload script as a starting point rather than building the envelope from scratch. The calc that does the actual work is the mirror of the read:
Set Field [ Customers::Photo ; Base64Decode ( $base64 ; $fileName ) ]Always pass the file name to Base64Decode
Base64Decode ( text ; fileNameWithExtension ) stores the result as a named
file with the right extension. Without the second argument FileMaker stores an
untitled .dat file, and the container will not preview or export correctly.
To Base64-encode the file in the browser, chunk it. String.fromCharCode(...bytes) on a multi-megabyte array blows the argument limit and throws:
const CHUNK_SIZE = 0x8000;
export async function fileToBase64(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (let index = 0; index < bytes.length; index += CHUNK_SIZE) {
binary += String.fromCharCode(...bytes.subarray(index, index + CHUNK_SIZE));
}
return btoa(binary);
}The same two rules from reading apply: do the record work in a New Window and close it before the callback, and capture Get ( LastError ) immediately after Set Field rather than after the commit, or a failed write reports success.
After a successful write, invalidate the query that reads the container so the UI picks up the new file. See Runtime Under the Hood for the caching model.
Skipping the bridge entirely
Base64 inflates payloads by roughly a third, and every byte crosses the bridge as a string. When files are large, or when the user is already sitting in FileMaker, it is often better to let FileMaker handle the bytes and only send a signal across.
Use callFMScript to trigger a script that runs Insert File, Insert Picture, or Insert from URL with FileMaker's own dialog, then refetch the record when the script reports back:
import { callFMScript } from "@proofkit/webviewer";
callFMScript("Attach File To Customer", { recordId });The script can call back into the app with a Web Viewer command once the user finishes, which keeps a multi-megabyte file out of the JSON payload completely.
Other cases worth pushing to FileMaker:
- Exporting a container to disk with
Export Field Contents. - Fetching a remote file directly into a container with
Insert from URL. - Generating a PDF from a FileMaker layout instead of rendering it in the browser.
Uploading directly with OttoFMS
If the file is hosted on a server running OttoFMS, the web app can POST the file to the server as multipart form data instead of routing it through a script. No Base64, no bridge, and no FileMaker dialog for the user.
Register the webhook with the File Receiver option enabled, then post to the file receiver endpoint:
// Bundled into the Web Viewer, so treat this value as public. See the warning below.
const OTTO_DATA_API_KEY = import.meta.env.VITE_OTTO_DATA_API_KEY;
export async function uploadViaOttoFMS(recordId: string, file: File) {
const form = new FormData();
form.append("file", file);
form.append("recordId", recordId);
const response = await fetch(
"https://your.server.host/otto/filereceiver/YourFile.fmp12/customer-photos",
{
body: form,
headers: { Authorization: `Bearer ${OTTO_DATA_API_KEY}` },
method: "POST",
}
);
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
}
}OttoFMS saves the upload under the server's Documents/otto/{uuid}/ folder and runs your OttoReceiver script with an uploaded_files array in the payload. Each entry carries fieldname, originalname, mimetype, size, destination, filename, and path. The script uses path to pull the file into a container with Insert File or Insert PDF. Uploads are deleted after 24 hours, so move the file into the solution on receipt.
This puts a Data API key in the client
Anything the Web Viewer holds is readable by anyone who can open the file, so treat this key as public. An OttoFMS Data API key inherits the privilege set of the FileMaker account it was created with, which makes the privilege set the only thing standing between a copied key and your data.
Create a dedicated account for it and lock the privilege set down as close to write-only as FileMaker allows: create-only access on the one target table, no access to any other table, and no script or layout access beyond what the receiver needs. Never reuse a full-access or admin key here.
Test the request from inside a real Web Viewer before committing to this path. A Web Viewer is not an ordinary browser page, and cross-origin requests do not always behave the same way there as they do in local development.
Sizing guidance
- Return thumbnails in list views and fetch the full-size container only when a detail view opens.
- Keep single transfers small. Multi-megabyte Base64 strings are slow to build in FileMaker, slow to parse in JavaScript, and hold up the bridge while they move.
- Keep container fields off broad list layouts used by Execute Data API queries. See Batching Data API Requests for page-size tuning on container-heavy layouts.
- Cache aggressively. A container rarely changes between renders, so a long
staleTimein TanStack Query avoids repeat transfers.
Full web apps
Outside a Web Viewer, containers go over HTTP:
- @proofkit/fmdapi exposes
client.containerUpload({ containerFieldName, file, recordId })for uploads. - @proofkit/fmodata models containers with
containerField(). Container fields cannot be included in.select(); read them with.getSingleField().