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. The file is stored by Lahuta, 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 URL
Call POST /v1/uploads with the file's name, type and size. Lahuta checks them and returns a signed URL.
Upload the file
PUT the file's bytes to that URL within one hour.
Attach it
Put the returned publicUrl in a file cell with PUT /v1/groups.
1. Ask for an upload URL
curl -X POST https://api.lahuta.org/v1/uploads \
-H "X-Api-Key: $LAHUTA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "image",
"fileName": "plates-inspiration.jpg",
"contentType": "image/jpeg",
"size": 482113
}'{
"key": "images/0197a3c2-5b1e-7d40-9f2a-3c8e1b6d4f70/0197b37f-2c05-7e18-9a4d-6b1f3c8e2d70.jpg",
"url": "https://…the signed upload URL…",
"publicUrl": "https://media.lahuta.io/images/0197a3c2-5b1e-7d40-9f2a-3c8e1b6d4f70/0197b37f-2c05-7e18-9a4d-6b1f3c8e2d70.jpg",
"contentType": "image/jpeg",
"contentDisposition": null,
"expiresAt": "2026-09-25T17:12:40.000Z"
}| Field | Required | What it is |
|---|---|---|
kind | Yes | image or attachment. See the table below |
fileName | Yes | The file's name. Only its extension is kept in the stored file's address |
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 |
What each kind accepts
kind | Up to | Types |
|---|---|---|
image | 10 MiB | JPEG, PNG, GIF, WebP, AVIF. Not SVG |
attachment | 25 MiB | The image types, plus PDF, Word, Excel, plain text, CSV, Markdown, RTF and ZIP. An unknown type, null or application/octet-stream, is accepted as an attachment |
HTML files are never accepted. A file that doesn't fit its kind is refused with 422 and a message you can show to the person:
{
"_tag": "UploadRejected",
"reason": "too_large",
"message": "A image may be at most 10 MiB."
}2. Upload the file
Send the file's bytes to url with a PUT before expiresAt, one hour after you asked. Echo the headers exactly:
Content-Type: thecontentTypefrom the response. It can differ from what you sent, for example in lowercase.Content-Disposition: thecontentDispositionfrom the response, only when it isn'tnull. Attachments get one, so they download under their original name.
The signature covers these headers and the size. If anything differs, the upload is refused and you need to ask for a new URL.
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 URL
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({
kind: "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. Upload the bytes, echoing the signed headers
const put = await fetch(upload.url, {
method: "PUT",
headers: {
"Content-Type": upload.contentType,
...(upload.contentDisposition && {
"Content-Disposition": upload.contentDisposition,
}),
},
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({
url: upload.publicUrl,
name: file.name,
size: file.size,
contentType: upload.contentType,
})
}3. Attach it to a group row
A file cell takes up to 10 files, each with the publicUrl, a name, the size in bytes and the contentType:
{
"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": [
{
"url": "https://media.lahuta.io/images/0197a3c2-5b1e-7d40-9f2a-3c8e1b6d4f70/0197b37f-2c05-7e18-9a4d-6b1f3c8e2d70.jpg",
"name": "plates-inspiration.jpg",
"size": 482113,
"contentType": "image/jpeg"
}
]
}
]
}
]
}A file cell only accepts files uploaded through POST /v1/uploads by your own organization. Any other URL is refused with 422 ForeignFile, so a form can't slip in links to files hosted elsewhere.
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.
- Rate limit. Asking for upload URLs is limited to 300 an hour per organization. See Rate limits.
See Uploads in the API reference for every field.