Uploads
Accept images and files from your website's forms and attach them to a group row in Lahuta.
Use uploads when a form on your site asks for a file: reference photos for a custom commission, a résumé for a studio assistant job, a shop's logo for a wholesale account. Lahuta keeps the file private to your organization, and you attach it to a row in a group with a file column, where your team can open it from the dashboard.
Uploading takes three steps:
Ask for an upload
Call POST /v1/uploads with the file's type, name, content type and size. Lahuta checks them and returns the file's assetId and where to send it.
Send the file
PUT the file's bytes to the returned target within one hour.
Attach it
Put the assetId in a file cell with PUT /v1/groups. There is no separate step to finish the upload: writing the cell checks that the file arrived.
1. Ask for an upload
curl -X POST https://api.lahuta.org/v1/uploads \
-H "X-Api-Key: $LAHUTA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "image",
"fileName": "plates-inspiration.jpg",
"contentType": "image/jpeg",
"size": 482113
}'{
"assetId": "0197b37f-2c05-7e18-9a4d-6b1f3c8e2d70",
"target": {
"_tag": "put",
"url": "https://…the signed upload URL…",
"headers": { "content-type": "image/jpeg" }
},
"expiresAt": "2026-09-25T17:12:40.000Z"
}| Field | Required | What it is |
|---|---|---|
type | Yes | image or file. See the table below |
fileName | Yes | The file's name. Your team downloads the file under this name |
contentType | Yes | The file's type, like image/jpeg. Send null if the browser didn't report one |
size | Yes | The file's size in bytes. The upload must be exactly this size |
Keep the assetId. It's the file from now on, and what a file cell takes.
What each type accepts
type | Up to | Types |
|---|---|---|
image | 10 MiB | JPEG, PNG, GIF, WebP, AVIF. Not SVG |
file | 100 MiB | The image types, MP4, MOV and WebM video, PDF, Word, Excel, plain text, CSV, Markdown, RTF and ZIP. An unknown type, null or application/octet-stream, is accepted |
HTML files are never accepted. A file that doesn't fit its type is refused with 422 and a message you can show to the person:
{
"_tag": "UploadRejected",
"reason": "too_large",
"message": "An image may be at most 10 MiB."
}2. Send the file
Send the file's bytes to target.url with a PUT before expiresAt, one hour after you asked. Send every header in target.headers exactly as given. The content-type can differ from what you sent, for example in lowercase.
The signature covers these headers and the size. If anything differs, the upload is refused and you need to ask again.
Here the whole flow runs in one route handler that receives the form:
export async function POST(request) {
const form = await request.formData()
const file = form.get("photo")
// 1. Ask for an upload
const sign = await fetch("https://api.lahuta.org/v1/uploads", {
method: "POST",
headers: {
"X-Api-Key": process.env.LAHUTA_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "image",
fileName: file.name,
contentType: file.type || null,
size: file.size,
}),
})
if (!sign.ok) {
const error = await sign.json().catch(() => null)
return Response.json(
{ error: error?.message ?? "That file can't be uploaded." },
{ status: 422 }
)
}
const upload = await sign.json()
// 2. Send the bytes, with the signed headers
const put = await fetch(upload.target.url, {
method: "PUT",
headers: upload.target.headers,
body: file,
})
if (!put.ok)
return Response.json(
{ error: "Upload failed. Please try again." },
{ status: 502 }
)
// 3. Hand back what a file cell needs
return Response.json({ assetId: upload.assetId })
}3. Attach it to a group row
A file cell takes up to 10 assetIds:
{
"name": "Commission requests",
"type": "person",
"rows": [
{
"contact": {
"kind": "person",
"firstName": "Hannah",
"emails": [{ "label": null, "value": "hannah.l@example.com" }]
},
"cols": [
{
"name": "Request",
"type": "text",
"value": "Eight dinner plates, matte white"
},
{
"name": "Reference photos",
"type": "file",
"value": ["0197b37f-2c05-7e18-9a4d-6b1f3c8e2d70"]
}
]
}
]
}The cell keeps each file's name, size and type. Writing it again replaces the files it holds.
A file cell only accepts files uploaded through POST /v1/uploads by your own organization, once their bytes have arrived. Anything else is refused with 422 AssetRejected, and nothing in the call is written:
reason | When |
|---|---|
not_found | The assetId isn't one of your organization's files |
not_ready | The file was never sent, or its upload URL had already expired |
wrong_visibility | The file is one of your public library images, not an upload |
When your site reads the group through a view, each file comes back with a url that opens it. See Show a group on your site.
Edge cases
- Check before you ask. Check the size and type in your form first, so people hear about a 40 MB photo before they wait for it to upload.
- Attach within a day. A file you upload but never put in a cell is deleted after a day.
- Rate limit. Asking for uploads is limited to 300 an hour per organization. See Rate limits.
See Uploads in the API reference for every field.