# Uploads


Upload a file to Intuizi without owning any cloud storage. The upload flow is
a three-step primitive shared by [POI submissions](/api/v2/poi/submissions)
and [Cohorts](/api/v2/cohorts):

1. **Reserve** - `POST /api/v2/uploads/create` returns a presigned `PUT` URL
   and an opaque `upload_reference`.
2. **Upload** - `PUT` the file bytes to the URL before it expires. No Intuizi
   authentication on this request - the signature in the URL is the
   credential.
3. **Create** - pass `upload_reference` to the create endpoint that matches
   the reserved purpose:
   [Create Submission by Upload](/api/v2/poi/submissions#post-apiv2my-datapoissubmissionscreate-by-upload)
   for `poi_submission`, or
   [Create Cohort](/api/v2/cohorts#post-apiv2analysescohortscreate) for
   `cohort`.

The presigned URL is a **one-key, short-lived write credential**: it can write
exactly one object, cannot read or list anything, and expires 15 minutes after
it is issued. The `upload_reference` is **one-shot** - once a create has used
it, it cannot be used again. A cohort create refused by the build budget or the
data-scan limit, a file over the size cap, and a POI file that fails validation
all use it up. A cohort create that fails with a generic error leaves it
usable until `expires_at`, so send the create again. So does a create rejected
with `No file has been uploaded for this reference yet.`, which has two
causes. If the `PUT` had not finished, finish it and send the create again. If
a `poi_submission` file has a header row but no data rows, the create fails
the same way every time, so add the rows and upload the file again for a new
reference. A reference must also be claimed before `expires_at`: a create
after that is rejected even if the `PUT` succeeded. An uploaded file that is
never claimed is removed automatically within 24 hours.

## Create an Upload {#post-apiv2uploadscreate}

`POST /api/v2/uploads/create`

Reserves an upload slot and returns the presigned `PUT` URL plus the
`upload_reference` the create endpoints accept.

**Auth:** bearer token + `Accept: application/json` + `Content-Type:
application/json`. Rate limit: write bucket (30 requests/min).

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `purpose` | string | **Yes** | What the upload is for: `poi_submission` or `cohort`. Determines where the file is stored and its size cap. |
| `filename` | string | No | The original filename, used to name the stored object. A `poi_submission` filename must end in `.csv` or `.txt`. For a `cohort` upload the name also decides how the file is imported: a name ending `.csv`, `.gz`, or `.parquet` (case-sensitive) imports as one file, and any other name as a folder. Left out, the object is named `upload.csv`. |
| `content_length` | integer | **Yes** | The exact byte size of the file you will `PUT`. Must not exceed the purpose's cap - `50 MB` for `poi_submission`, `1 GB` for `cohort` (the cap is echoed back as `max_content_length`). |
| `content_type` | string | No | The MIME type your `PUT` will send. Defaults to `text/csv`. |

{{< tabs >}}

  {{< tab name="cURL" >}}
  ```bash
  curl -X POST "https://console.intuizi.com/api/v2/uploads/create" \
    -H "Authorization: Bearer <YOUR_TOKEN>" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json" \
    -d '{
      "purpose": "cohort",
      "filename": "devices.csv",
      "content_length": 1048576
    }'

  # Then PUT the file to the returned upload_url with the returned headers:
  curl -X PUT "<UPLOAD_URL>" \
    -H "Content-Type: text/csv" \
    --data-binary @devices.csv
  ```
  {{< /tab >}}

  {{< tab name="Python" >}}
  ```python
  import os
  import requests

  size = os.path.getsize("devices.csv")

  reserved = requests.post(
      "https://console.intuizi.com/api/v2/uploads/create",
      headers={
          "Authorization": "Bearer <YOUR_TOKEN>",
          "Content-Type": "application/json",
          "Accept": "application/json",
      },
      json={"purpose": "cohort", "filename": "devices.csv", "content_length": size},
  ).json()["data"][0]

  with open("devices.csv", "rb") as f:
      requests.put(reserved["upload_url"], data=f, headers=reserved["headers"])

  upload_reference = reserved["upload_reference"]
  ```
  {{< /tab >}}

  {{< tab name="JavaScript" >}}
  ```javascript
  const file = fileInput.files[0];

  const res = await fetch("https://console.intuizi.com/api/v2/uploads/create", {
    method: "POST",
    headers: {
      Authorization: "Bearer <YOUR_TOKEN>",
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      purpose: "cohort",
      filename: file.name,
      content_length: file.size,
    }),
  });
  const reserved = (await res.json()).data[0];

  await fetch(reserved.upload_url, {
    method: "PUT",
    headers: reserved.headers,
    body: file,
  });

  const uploadReference = reserved.upload_reference;
  ```
  {{< /tab >}}

  {{< tab name="PHP" >}}
  ```php
  $size = filesize('devices.csv');

  $reserved = Http::withToken('<YOUR_TOKEN>')->acceptJson()
      ->post('https://console.intuizi.com/api/v2/uploads/create', [
          'purpose' => 'cohort',
          'filename' => 'devices.csv',
          'content_length' => $size,
      ])->json('data.0');

  Http::withHeaders($reserved['headers'])
      ->withBody(file_get_contents('devices.csv'), $reserved['headers']['Content-Type'])
      ->put($reserved['upload_url']);

  $uploadReference = $reserved['upload_reference'];
  ```
  {{< /tab >}}

{{< /tabs >}}

### Response

```json
{
  "status": "success",
  "code": 201,
  "message": "Resource created successfully.",
  "data": [
    {
      "upload_reference": "upl_01k0p3v9example",
      "upload_url": "https://s3.us-west-2.amazonaws.com/...?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=...",
      "method": "PUT",
      "headers": { "Content-Type": "text/csv" },
      "expires_at": "2026-07-21 14:35:00",
      "max_content_length": 1073741824
    }
  ]
}
```

- `upload_reference` is the only value you pass onwards - the create endpoints
  never take the URL.
- `upload_url` is valid until `expires_at`, and a create has to claim the
  reference before then too. If it expires before the `PUT` completes or before
  the create, reserve a new upload. References are cheap.
- A file larger than the reserved cap is rejected when the reference is
  claimed, so the size gate holds even if the `PUT` succeeded.
- Reserve and upload again if the file changes - a reference always points at
  exactly the bytes that were `PUT` for it.

The `Idempotency-Key` header is not used on this endpoint: reserving a slot
creates no resource, and an unused reservation simply expires.
