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"
}
FieldRequiredWhat it is
kindYesimage or attachment. See the table below
fileNameYesThe file's name. Only its extension is kept in the stored file's address
contentTypeYesThe file's type, like image/jpeg. Send null if the browser didn't report one
sizeYesThe file's size in bytes. The upload must be exactly this size

What each kind accepts

kindUp toTypes
image10 MiBJPEG, PNG, GIF, WebP, AVIF. Not SVG
attachment25 MiBThe 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: the contentType from the response. It can differ from what you sent, for example in lowercase.
  • Content-Disposition: the contentDisposition from the response, only when it isn't null. 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:

app/api/commission-photo/route.js
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.

On this page