> ## Documentation Index
> Fetch the complete documentation index at: https://docs.blobhub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Multipart Upload

Upload a model archive (`model.tar.gz`) too large for one [Upload Model](/blob-types/onnx/upload) request. The
platform hands out a presigned URL for each 10 MiB part, the client sends the parts straight to storage, and one
more command assembles them.

1. **`initiate_upload`** with the archive's size. The answer is an operation id and a part plan.
2. **`PUT` each part** to its presigned URL, and keep the `ETag` each one answers with.
3. **`complete_upload`** with the ETags, in part order. The platform then processes the archive exactly as it does a
   single-part upload.
4. **Poll** [Get Operation](/rest-api/operations/get-operation) until the operation is `completed` or `failed`.

If anything fails after step 1, send **`cancel_upload`** to discard the parts already sent. The
[Python SDK](/sdk/onnx) does all four steps, and the cancel, in `Onnx.upload()`.

## **POST** `/revisions/:id/data/command` (Command: `initiate_upload`)

### Request Body

| Parameter | Type | Required | Description |
| :- | :- | :- | :- |
| `command` | string | Yes | Must be `initiate_upload`. |
| `path` | string | Yes | Must be `model.tar.gz`. |
| `size` | integer | Yes | Size of the archive in bytes. At least `1`. |

### Response

| Parameter | Type | Description |
| :- | :- | :- |
| `operation_id` | string | ID of the upload operation. |
| `operation` | object | The operation, with its part plan in `parts`. |

`operation` carries `id`, `blob_id`, `org_id`, `revision_id`, `type` (`upload`), `status` (`in_progress`) and
`parts` — nothing about where storage keeps the upload. Each part is:

| Field | Type | Description |
| :- | :- | :- |
| `size` | integer | Bytes in this part. |
| `offset` | integer | Where the part starts in the archive. |
| `upload.url` | string | The presigned URL to `PUT` the part to. Valid for one hour. |
| `upload.headers` | object | Headers to send with that `PUT`: `Content-Length`, the part's size. |

Parts are 10 MiB each and in order, and the last one holds the remainder, so together they cover the archive
exactly and none is empty — an archive that is an exact multiple of 10 MiB included. Only this response carries the
URLs: [Get Operation](/rest-api/operations/get-operation) shows each part as its `size` and `offset` alone.

### Errors

| Status | Error | Cause |
| :- | :- | :- |
| 400 | `invalid_request_body` | `path` is not `model.tar.gz`, `size` is missing or below `1`, or a field is unlisted. |
| 400 | `limit_exceeded` | `size` is larger than the blob's `space_per_revision` limit. |
| 403 | `forbidden` | The revision does not exist, or the caller cannot write to its blob. |
| 409 | `revision_not_ready`, `revision_not_writable` | The revision is not `ready`, or it is committed. |

### Example

<CodeGroup>
  ```json Request theme={null}
  {
    "command": "initiate_upload",
    "path": "model.tar.gz",
    "size": 15728640
  }
  ```

  ```json Response theme={null}
  {
    "status": "success",
    "operation_id": "op_upload_456",
    "operation": {
      "id": "op_upload_456",
      "blob_id": "blob_789",
      "org_id": "org_123",
      "revision_id": "rev_001",
      "type": "upload",
      "status": "in_progress",
      "parts": [
        {
          "size": 10485760,
          "offset": 0,
          "upload": {"url": "https://…", "headers": {"Content-Length": "10485760"}}
        },
        {
          "size": 5242880,
          "offset": 10485760,
          "upload": {"url": "https://…", "headers": {"Content-Length": "5242880"}}
        }
      ]
    }
  }
  ```
</CodeGroup>

## Sending the parts

`PUT` each part's `size` bytes, read from its `offset`, to its `upload.url`, with its `upload.headers`. Send no API
key or token: the URL itself is the authorization. Storage answers each `PUT` with an `ETag` header, quotes
included; keep it exactly as received.

```bash theme={null}
split -b 10485760 model.tar.gz part-                 # part-aa, part-ab, … in part order
curl -s -T part-aa "$PART_1_URL" -D - -o /dev/null   # prints the response headers, ETag among them
```

A part that fails can be sent again. Its URL works for an hour from `initiate_upload`.

## **POST** `/revisions/:id/data/command` (Command: `complete_upload`)

### Request Body

| Parameter | Type | Required | Description |
| :- | :- | :- | :- |
| `command` | string | Yes | Must be `complete_upload`. |
| `operation_id` | string | Yes | The `operation_id` that `initiate_upload` returned. Not empty. |
| `parts` | array | Yes | One `{"hash": "<ETag>"}` per part, in part order. At least one. |

Each element is exactly `{"hash": "<the ETag storage returned for that part>"}`; the platform numbers the parts by
their position in the array.

### Response

Returns `{"status": "success"}`. The archive is assembled and its processing queued; follow it with
[Get Operation](/rest-api/operations/get-operation).

### Errors

| Status | Error | Cause |
| :- | :- | :- |
| 400 | `invalid_request_body` | `operation_id` or `parts` is empty, or a part is not exactly `{"hash": "<string>"}`. |
| 403 | `forbidden` | `operation_id` is not this revision's own multipart upload, or the caller cannot write here. |
| 409 | `revision_not_ready`, `revision_not_writable` | The revision is not `ready`, or it is committed. |
| 500 | `internal_server_error` | Storage refused to assemble the parts: one is missing, or a `hash` is not its ETag. |

**The operation must be this revision's own.** An id from another revision or blob, one that names a single-part
upload, and one that does not exist all answer the same 403, so an operation id cannot be probed from elsewhere.

### Example

<CodeGroup>
  ```json Request theme={null}
  {
    "command": "complete_upload",
    "operation_id": "op_upload_456",
    "parts": [
      {"hash": "\"9b2cf535f27731c974343645a3985328\""},
      {"hash": "\"6f5902ac237024bdd0c176cb93063dc4\""}
    ]
  }
  ```

  ```json Response theme={null}
  {
    "status": "success"
  }
  ```
</CodeGroup>

## **POST** `/revisions/:id/data/command` (Command: `cancel_upload`)

Discards an upload's parts in storage. Send it when an upload cannot be completed; the presigned URLs stop working.
The operation record itself is left as it was, `in_progress`, so stop polling it.

### Request Body

| Parameter | Type | Required | Description |
| :- | :- | :- | :- |
| `command` | string | Yes | Must be `cancel_upload`. |
| `operation_id` | string | Yes | The `operation_id` that `initiate_upload` returned. Not empty. |

### Response

Returns `{"status": "success"}`.

### Errors

| Status | Error | Cause |
| :- | :- | :- |
| 400 | `invalid_request_body` | `operation_id` is empty, or an unlisted field was sent. |
| 403 | `forbidden` | `operation_id` is not this revision's own multipart upload, or the caller cannot write here. |
| 409 | `revision_not_ready`, `revision_not_writable` | The revision is not `ready`, or it is committed. |

### Example

```json Request theme={null}
{
  "command": "cancel_upload",
  "operation_id": "op_upload_456"
}
```

## See also

* [Upload Model](/blob-types/onnx/upload) — one request, for an archive of 4 MiB or less.
* [Get Operation](/rest-api/operations/get-operation) — following the processing.
* [Python SDK → ONNX](/sdk/onnx) — the same flow in one call.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.