# ZoomHub API reference: Uploads Part of the [ZoomHub API reference](https://zoomhub.net/docs/api.md), whose index covers authentication, errors and the other resources. > **Beta:** The v2 API is in beta: endpoints, attributes and error codes may still change before it is final. Questions or feedback: [daniel@zoomhub.net](mailto:daniel@zoomhub.net). Base URL: `https://api.zoomhub.net`. Send your API key as `Authorization: Bearer `; see [Authentication](https://zoomhub.net/docs/api.md#authentication). ## Uploads An upload brings a file from your computer to ZoomHub: an image of any type, or a PDF. It takes three requests, and the file goes straight to storage, never through ZoomHub’s servers: [create an upload](https://zoomhub.net/docs/api/uploads.md#uploads-create) that names the file, and get back a URL for each of its parts; `PUT` each part to its URL; then [complete the upload](https://zoomhub.net/docs/api/uploads.md#uploads-complete), which makes it an [asset](https://zoomhub.net/docs/api/assets.md#assets) and starts processing it. The plan sets how large a file may be; on Business, the plan the API is part of, ZoomHub refuses one larger than 5 GB. By default a file goes up in one part, as it is. Ask for parts of a size you choose to resume an interrupted upload, retry small pieces, or send them in parallel. Every step is safe to repeat: the create with an `Idempotency-Key`, the others as they are. With `curl` and [jq](https://jqlang.org/), an upload in one part is three commands, which read your key from the `ZOOMHUB_API_KEY` environment variable: ```sh api=https://api.zoomhub.net file=scan-0042.tif content_type=image/tiff curl --silent --fail-with-body --max-time 120 "$api/v2/uploads" \ --header "Authorization: Bearer $ZOOMHUB_API_KEY" \ --header "Content-Type: application/json" \ --data "$(jq --null-input --arg name "$(basename "$file")" --arg type "$content_type" \ --argjson size "$(wc -c < "$file")" \ '{filename: $name, content_type: $type, size_bytes: $size}')" \ > upload.json curl --fail --max-time 120 --upload-file "$file" "$(jq --raw-output '.parts[0].url' upload.json)" curl --silent --fail-with-body --max-time 120 \ --header "Authorization: Bearer $ZOOMHUB_API_KEY" \ --header "Content-Type: application/json" \ --data '{"asset": {"title": "Scan 42"}}' \ "$api/v2/uploads/$(jq --raw-output .id upload.json)/complete" ``` The last command prints the completed upload. Poll [Retrieve an asset](https://zoomhub.net/docs/api/assets.md#assets-retrieve) with its `asset_id` until the asset’s `status` is `succeeded` or `failed`. In parts, create the upload with a `part_size_bytes`, cut the file at its parts’ boundaries, and `PUT` each part; `split` cuts it the same way on macOS and Linux. The commands need bash or zsh, and start in a new temporary directory, so that no `part.*` file left from another upload goes along: ```bash file="$(cd "$(dirname "$file")" && pwd -P)/$(basename "$file")" cd "$(mktemp -d)" part_size=8388608 curl --silent --fail-with-body --max-time 120 "$api/v2/uploads" \ --header "Authorization: Bearer $ZOOMHUB_API_KEY" \ --header "Content-Type: application/json" \ --data "$(jq --null-input --arg name "$(basename "$file")" --arg type "$content_type" \ --argjson size "$(wc -c < "$file")" --argjson part_size "$part_size" \ '{filename: $name, content_type: $type, size_bytes: $size, part_size_bytes: $part_size}')" \ > upload.json split -b "$(jq .part_size_bytes upload.json)" -a 5 "$file" part. jq --raw-output '.parts[].url' upload.json | paste - <(ls part.*) | while read -r url part; do curl --fail --retry 3 --max-time 120 --upload-file "$part" "$url"; done ``` Then complete the upload as above, from the same directory. A part that didn’t go up is just its `PUT` again, and after a lost terminal, [Retrieve an upload](https://zoomhub.net/docs/api/uploads.md#uploads-retrieve) gives fresh URLs for the parts. > **Note:** Send each part with its length, as `curl --upload-file` does: storage refuses a part sent in chunks, as curl sends one it reads from a pipe. A part’s URL is storage’s, not ZoomHub’s: don’t send your key there. ### The upload object A file on its way to storage, and where to send its parts. Every response with an upload object carries `Cache-Control: no-store, private`: its part URLs let whoever holds them send the file, so don’t cache it. **Attributes** - `object` (string): Always `upload`. - `id` (string): The upload’s ID. Only its creator can use it. - `status` (enum): Where the upload is. Only a `pending` one takes parts. - `pending`: Waiting for its parts, and to be completed. - `completed`: Made an asset, which `asset_id` names. - `cancelled`: Cancelled; its parts are gone. - `expired`: Not completed by `expires_at`. Start a new upload. - `filename` (string): The file’s name, as ZoomHub keeps it: the last segment of the name the create sent, cut to 255 bytes. It becomes the asset’s original filename. - `content_type` (string): The file’s media type. - `size_bytes` (integer): The file’s size in bytes. - `part_size_bytes` (integer): The size of every part but the last, which takes the rest. - `expires_at` (string): When the upload expires unless completed, a day after it was created, in RFC 3339 UTC. The parts’ URLs work until then. - `parts` (array of objects, nullable): The parts to send, while the upload is `pending`; `null` after. - `number` (integer): The part’s number, from `1`, in file order. - `size_bytes` (integer): How many bytes of the file it holds. - `url` (string): Where to `PUT` the part’s bytes. It is storage’s, signed for this part alone. - `asset_id` (string, nullable): The asset the upload became, once it is `completed`; `null` before. - `created_at` (string): When the upload was created, in RFC 3339 UTC. **Example 1** ```json { "asset_id": null, "content_type": "image/tiff", "created_at": "2026-10-23T18:00:00Z", "expires_at": "2026-10-24T18:00:00Z", "filename": "scan-0042.tif", "id": "Smp7UpXAb12CdEfG", "object": "upload", "part_size_bytes": 8388608, "parts": [ { "number": 1, "size_bytes": 8388608, "url": "https://sample-sources.example.com/uploads/sample?partNumber=1&X-Amz-Signature=sample" }, { "number": 2, "size_bytes": 4194304, "url": "https://sample-sources.example.com/uploads/sample?partNumber=2&X-Amz-Signature=sample" } ], "size_bytes": 12582912, "status": "pending" } ``` **Example 2** ```json { "asset_id": "smpAsset1", "content_type": "image/tiff", "created_at": "2026-10-23T18:00:00Z", "expires_at": "2026-10-24T18:00:00Z", "filename": "scan-0042.tif", "id": "Smp7UpXAb12CdEfG", "object": "upload", "part_size_bytes": 8388608, "parts": null, "size_bytes": 12582912, "status": "completed" } ``` ### Create an upload — `POST /v2/uploads` **Authentication:** API key. Send an API key. Starts an upload of one file, and answers with the URLs to send its parts to. Name the file, its type and its size. Without `part_size_bytes`, the file goes up in one part. An upload counts toward the account’s upload limit from now until it expires, unless it is cancelled. To retry a create whose answer you lost, send an `Idempotency-Key` header, such as a UUID: up to 255 visible ASCII characters. The same key from the same account answers with the same upload for at least 24 hours, as it now is, instead of starting another. **Body parameters** - `filename` (string, required): The file’s name on your computer, such as `scan-0042.tif`. ZoomHub keeps only its last path segment, cut to 255 bytes in UTF-8. - `content_type` (string, required): The file’s media type: any `image/` type, or `application/pdf`. - `size_bytes` (integer, required): The file’s size in bytes. - `part_size_bytes` (integer, optional): The size of each part but the last, from 5,242,880 (5 MiB) to 5,368,709,120 (5 GiB). A file may have at most 10,000 parts, so ZoomHub raises a size too small for that. Without it, the file goes up in one part. **Responses** - `201 Created`: The new upload object, with its `parts`. - `200 OK`: An `Idempotency-Key` this account sent before: that upload object, as it now is. - `400 Bad Request`, error code `bad_request`: The body isn’t JSON, misses a required key, has a key the endpoint doesn’t know, or breaks a limit; the `Idempotency-Key` isn’t one; or `part_size_bytes` is out of range. The detail says which. - `400 Bad Request`, error code `file_too_large`: The file is larger than the account’s plan takes. - `400 Bad Request`, error code `unsupported_content_type`: The `content_type` is neither an image type nor `application/pdf`. - `401 Unauthorized`, error code `not_authenticated`: No `Authorization` header. - `401 Unauthorized`, error code `invalid_api_key`: The key is unknown or revoked, or the header isn’t a `Bearer` key. - `403 Forbidden`, error code `plan_required`: The key is valid, but its account’s plan doesn’t include the API. - `403 Forbidden`, error code `forbidden`: The account’s email address may not upload. - `422 Unprocessable Entity`, error code `idempotency_key_reused`: The `Idempotency-Key` named an upload of another file. - `429 Too Many Requests`, error code `upload_limit_reached`: The account has reached its upload limit, counting its pending uploads. - `503 Service Unavailable`, error code `uploads_disabled`: ZoomHub isn’t accepting new content right now. Retry later. **Request** ```bash curl "https://api.zoomhub.net/v2/uploads" \ --header "Authorization: Bearer $ZOOMHUB_API_KEY" \ --header "Content-Type: application/json" \ --data '{ "content_type": "image/tiff", "filename": "scan-0042.tif", "part_size_bytes": 8388608, "size_bytes": 12582912 }' ``` **Response** `201 Created` ```json { "asset_id": null, "content_type": "image/tiff", "created_at": "2026-10-23T18:00:00Z", "expires_at": "2026-10-24T18:00:00Z", "filename": "scan-0042.tif", "id": "Smp7UpXAb12CdEfG", "object": "upload", "part_size_bytes": 8388608, "parts": [ { "number": 1, "size_bytes": 8388608, "url": "https://sample-sources.example.com/uploads/sample?partNumber=1&X-Amz-Signature=sample" }, { "number": 2, "size_bytes": 4194304, "url": "https://sample-sources.example.com/uploads/sample?partNumber=2&X-Amz-Signature=sample" } ], "size_bytes": 12582912, "status": "pending" } ``` ### Retrieve an upload — `GET /v2/uploads/{id}` **Authentication:** API key. Send an API key. Returns the upload with the given ID, with fresh URLs for its parts while it is pending. Use it to go on with an upload whose part URLs you lost. Only the upload’s creator, or an admin, may read it. **Path parameters** - `id` (string, required): The upload’s ID. **Responses** - `200 OK`: The upload object. - `401 Unauthorized`, error code `not_authenticated`: No `Authorization` header. - `401 Unauthorized`, error code `invalid_api_key`: The key is unknown or revoked, or the header isn’t a `Bearer` key. - `403 Forbidden`, error code `plan_required`: The key is valid, but its account’s plan doesn’t include the API. - `404 Not Found`, error code `not_found`: No upload with this ID that the key’s account may use: its own, or any for an admin. **Request** ```bash curl "https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG" \ --header "Authorization: Bearer $ZOOMHUB_API_KEY" ``` **Response** `200 OK` ```json { "asset_id": null, "content_type": "image/tiff", "created_at": "2026-10-23T18:00:00Z", "expires_at": "2026-10-24T18:00:00Z", "filename": "scan-0042.tif", "id": "Smp7UpXAb12CdEfG", "object": "upload", "part_size_bytes": 8388608, "parts": [ { "number": 1, "size_bytes": 8388608, "url": "https://sample-sources.example.com/uploads/sample?partNumber=1&X-Amz-Signature=sample" }, { "number": 2, "size_bytes": 4194304, "url": "https://sample-sources.example.com/uploads/sample?partNumber=2&X-Amz-Signature=sample" } ], "size_bytes": 12582912, "status": "pending" } ``` ### Complete an upload — `POST /v2/uploads/{id}/complete` **Authentication:** API key. Send an API key. Makes the uploaded file an asset, once every part is in storage, and starts processing it. The body holds the new asset’s settings, as [Create an asset](https://zoomhub.net/docs/api/assets.md#assets-create) takes them, and is optional: without one, the asset gets none. The asset is yours, and its ID is the upload’s `asset_id`. Completing is safe to repeat: a completed upload answers as it is, whatever the body. A refusal over the settings, the plan or a part leaves the upload pending, so you can fix what it names and complete it again. **Path parameters** - `id` (string, required): The upload’s ID. **Body parameters** - `asset` (object, optional, nullable): The settings of the asset the upload becomes. - `title` (string, optional): At most 250 characters, single line; surrounding whitespace is trimmed. - `caption` (string, optional): At most 250 characters, single line; surrounding whitespace is trimmed. - `description` (string, optional): At most 10000 characters, line breaks allowed; surrounding whitespace is trimmed. - `reference_id` (string, optional): Your own identifier for the image, such as a catalog number, unique among your assets. Needs a plan that includes reference IDs. At most 100 characters, single line; surrounding whitespace is trimmed. - `visibility` (enum, optional): Who may view the asset. To protect it with a password, update it once it is created. - `public`: Anyone may view it, and it may be listed on its owner’s profile. - `unlisted`: Anyone with its link may view it; it is listed nowhere. The default. **Responses** - `200 OK`: The completed upload object, with its `asset_id`. - `400 Bad Request`, error code `bad_request`: The body isn’t JSON, has a key the endpoint doesn’t know, or breaks a limit. The detail says which. - `401 Unauthorized`, error code `not_authenticated`: No `Authorization` header. - `401 Unauthorized`, error code `invalid_api_key`: The key is unknown or revoked, or the header isn’t a `Bearer` key. - `403 Forbidden`, error code `plan_required`: The key is valid, but its account’s plan doesn’t include the API. - `403 Forbidden`, error code `forbidden`: The account’s email address may not upload. The upload is cancelled. - `403 Forbidden`, error code `subscription_required`: The account’s plan doesn’t include reference IDs. - `404 Not Found`, error code `not_found`: No upload with this ID that the key’s account may use: its own, or any for an admin. - `409 Conflict`, error code `reference_id_taken`: Another of your assets already uses this `reference_id`. - `409 Conflict`, error code `upload_cancelled`: The upload was cancelled. - `409 Conflict`, error code `upload_expired`: The upload expired before it was completed. - `409 Conflict`, error code `upload_incomplete`: A part isn’t in storage, or isn’t the size `parts` says; the detail names the first such part. Send it again, then complete the upload. - `409 Conflict`, error code `url_already_exists`: Another asset already holds this upload’s file. Start a new upload. - `503 Service Unavailable`, error code `uploads_disabled`: ZoomHub isn’t accepting new content right now. Retry later. **Request** ```bash curl "https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG/complete" \ --header "Authorization: Bearer $ZOOMHUB_API_KEY" \ --header "Content-Type: application/json" \ --data '{ "asset": { "title": "Scan 42" } }' ``` **Response** `200 OK` ```json { "asset_id": "smpAsset1", "content_type": "image/tiff", "created_at": "2026-10-23T18:00:00Z", "expires_at": "2026-10-24T18:00:00Z", "filename": "scan-0042.tif", "id": "Smp7UpXAb12CdEfG", "object": "upload", "part_size_bytes": 8388608, "parts": null, "size_bytes": 12582912, "status": "completed" } ``` ### Cancel an upload — `POST /v2/uploads/{id}/cancel` **Authentication:** API key. Send an API key. Cancels an upload and discards the parts sent so far. A cancelled upload no longer counts toward the account’s upload limit. Cancelling is safe to repeat: a cancelled upload answers as it is. **Path parameters** - `id` (string, required): The upload’s ID. **Responses** - `200 OK`: The cancelled upload object. - `401 Unauthorized`, error code `not_authenticated`: No `Authorization` header. - `401 Unauthorized`, error code `invalid_api_key`: The key is unknown or revoked, or the header isn’t a `Bearer` key. - `403 Forbidden`, error code `plan_required`: The key is valid, but its account’s plan doesn’t include the API. - `404 Not Found`, error code `not_found`: No upload with this ID that the key’s account may use: its own, or any for an admin. - `409 Conflict`, error code `upload_completed`: The upload is completed and its asset made. Delete the asset instead. **Request** ```bash curl "https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG/cancel" \ --request POST \ --header "Authorization: Bearer $ZOOMHUB_API_KEY" ``` **Response** `200 OK` ```json { "asset_id": null, "content_type": "image/tiff", "created_at": "2026-10-23T18:00:00Z", "expires_at": "2026-10-24T18:00:00Z", "filename": "scan-0042.tif", "id": "Smp7UpXAb12CdEfG", "object": "upload", "part_size_bytes": 8388608, "parts": null, "size_bytes": 12582912, "status": "cancelled" } ```