# Create Signed Upload

Source: https://help.zira.us/developers/api-reference/create-signed-upload
Summary: Create signed upload details for client-side file upload flows.
Updated: 2026-08-06

## `POST /files/signed-uploads/create`

Use this endpoint when a client needs upload instructions and metadata for a direct file upload.

## Authentication

```http
x-api-key: <your-api-key>
Content-Type: application/json
```

See [API Authentication](/developers/api-reference/api-authentication) for how to create a key.

## Request body

Send the file metadata needed to create a signed upload request.

```json
{
  "name": "field-tag.pdf",
  "fileType": "pdf",
  "fileSize": 1024,
  "fileSizeLoose": true,
  "site": {
    "id": "1512"
  },
  "company": {
    "id": "1216"
  },
  "deviceId": "43510",
  "channelId": "45187"
}
```

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | string | yes | Original file name, including extension. |
| `fileSize` | number | yes | File size in bytes. Must be 100 B to 100 MB, and must match the file you upload, unless `fileSizeLoose` is true. |
| `company` | object | no | Company that owns the file, as `{ id }`. Required unless you send `conversation` instead. |
| `site` | object | no | Site that owns the file, as `{ id }`. Optional, and not allowed alongside `conversation`. |
| `conversation` | object | no | Conversation that owns the file, as `{ id }`. Use this instead of `company`/`site` — never both. |
| `fileType` | string | no | File extension. Send this or `fileMimeType`; with neither, the file is stored as `.bin`. |
| `fileMimeType` | string | no | Explicit MIME type. Takes precedence over `fileType` and becomes the required `Content-Type` of the upload. |
| `fileSizeLoose` | boolean | no | When true, `fileSize` is treated as advisory rather than checked against the bytes you upload. |
| `deviceId` | string | no | Data source to attach the file to. Only takes effect when `assetType` is sent too. |
| `assetType` | string | no | What kind of asset this is for the data source. Required alongside `deviceId` for the attachment to happen. |
| `channelId` | string | no | Channel to attach the file to. |
| `originProperties` | object | no | Your own metadata, stored with the file and returned by `GET /files/{fileId}`. Put an identifier here to match the file back to your record. |

## Response

`200 OK`

Returns an upload form to send your file to — `data.signedUrl.url` is where it goes, `data.signedUrl.fields` are the form fields that must accompany it, and `data.name` is the temporary name the file lands under. Nothing exists in Zira yet at this point.

```json
{
  "data": {
    "signedUrl": {
      "url": "https://user-documents-prod.s3.us-west-2.amazonaws.com/",
      "fields": {
        "Content-Type": "application/pdf",
        "bucket": "user-documents-prod",
        "key": "temp/field-tag_1783364043007.pdf",
        "x-amz-meta-metadata": "<json metadata string>",
        "X-Amz-Algorithm": "AWS4-HMAC-SHA256",
        "X-Amz-Credential": "<credential>",
        "X-Amz-Date": "20260114T093403Z",
        "X-Amz-Security-Token": "<token>",
        "Policy": "<base64 policy>",
        "X-Amz-Signature": "<signature>"
      }
    },
    "name": "field-tag_1783364043007.pdf"
  }
}
```

| Field | Type | Description |
| --- | --- | --- |
| `data.signedUrl.url` | string | Where to send the file. Upload with a multipart `POST` to this URL. |
| `data.signedUrl.fields` | object | Form fields for the upload. Include every one of them in the multipart body, and add the file part last. |
| `data.name` | string | The unique temporary name the upload lands under. It is not the name the file ends up with — Zira stores it under the original `name` you sent — so use this only to recognise your own upload in flight. |

### Good to know

- This call does not create anything in Zira and does not return a file ID. The file record is created after your upload lands, so there is nothing to look up until then.
- A signing failure is not reported as an error: you get a `200` with `signedUrl` set to `null`. Check that `data.signedUrl` is present before using it.
- To match the file back to your own record afterwards, put your own identifier in `originProperties` — it is stored with the file and comes back on `GET /files/{fileId}`.
- The upload form expires five minutes after this call. Request it immediately before uploading, not in advance.
- Unless you send `fileSizeLoose: true`, `fileSize` must be between 100 bytes and 100 MB, and the file you upload must be exactly that many bytes.
- A successful upload answers `204` (or `201`) from storage with an empty body. Do not send `x-api-key` with the upload — the form fields are the authorisation.
- Either `company` (optionally with `site`) or `conversation` — never both, and `site` is not allowed with `conversation`. Only `name` and `fileSize` are always required.
- To attach the file to a data source you must send both `deviceId` and `assetType`. `deviceId` on its own does nothing.
- The body is strict: an unknown field, or any query string at all, fails with `400`. Inline base64 file content is not supported.

## Uploading the file (step 2)

`POST` the file to `data.signedUrl.url` as `multipart/form-data`. Add every entry from
`data.signedUrl.fields` as a form field, then append the file bytes as a field named `file` — last.
Do **not** send the `x-api-key` header to storage; the signed fields are the authorisation.

A successful upload answers `204 No Content` (or `201`) with an empty body.

Storage does not know anything about Zira, so it cannot give you a file ID. The Zira file record is
created afterwards, when the uploaded object is processed. To find the file later, either match on
`data.name` or put your own identifier in `originProperties` — it comes back on
[Get File](/developers/api-reference/get-file).

## Errors

Failures return a JSON body with a `message` and an `internalErrorCode`, at the status shown below. Whenever the underlying error carries structured detail — which every schema-validation failure does — the body is replaced with a generic `Internal server error.` / `02-001` pair while the real status code is kept. Branch on the HTTP status and on `internalErrorCode`; never parse the message text.

```json
{
  "message": "Internal server error.",
  "internalErrorCode": "02-001"
}
```

| Status | Meaning |
| --- | --- |
| `400` | The request failed validation — a missing required field, a field the schema does not allow, an unparseable body, a value out of range, or a malformed `lastValue`. |
| `401` | The API key resolved to a user context that is not allowed to read or write the record you asked for. |
| `403` | The `x-api-key` header is missing or is not a recognised key. API Gateway rejects the call before the endpoint runs, so the body is only `{"message":"Forbidden"}`. |
| `409` | The write conflicts with an existing record, or it references a record that does not exist. |
| `429` | The API key exceeded the rate limit or quota on its usage plan. Also returned by API Gateway rather than the endpoint. |
| `500` | Unexpected server error, or the database returned no result where one was required. |
