ContentsAPI reference
Docs

API reference

Beta
View as Markdown

Upload images, gather them into collections and configure the viewers that show them. Every endpoint comes with a request you can run in curl, JavaScript or Python.

The v2 API is in beta: endpoints, attributes and error codes may still change before it is final. Questions or feedback: daniel@zoomhub.net.

Built for coding agents

Give your coding agent one link — https://zoomhub.net/docs/api.md — and it has the whole API: every endpoint, a working curl example, and every error code. The reference is generated from the code that serves the API, and a test fails when a route goes undocumented, so it doesn’t drift.

Paste into your agent

Upload an image
Read https://zoomhub.net/docs/api.md. Using my API key from the ZOOMHUB_API_KEY environment variable, upload ./photo.jpg to ZoomHub, wait until it’s ready, and print its embed code.
Build a gallery
Read https://zoomhub.net/docs/api.md. Create a ZoomHub gallery from these image URLs: <urls>, and give me the embed code for its viewer.
Add to AGENTS.md
Read https://zoomhub.net/docs/api.md. Add a ZoomHub section to this project’s AGENTS.md so future sessions know how to call the API.

AGENTS.md snippet

Markdown
## ZoomHub

- API reference for agents: https://zoomhub.net/docs/api.md
- Read the API key from the `ZOOMHUB_API_KEY` environment variable. Never put the key in source code or in code that runs in a browser: call the API from server-side code.
- Processing is asynchronous: poll the asset until its `status` is `succeeded` or `failed`, waiting longer between attempts.
- Branch on the HTTP status and `code`, never on the prose in `detail`.
- Ask before deleting an asset, a collection or a viewer: a deletion can’t be undone.

Introduction

The ZoomHub API turns large images and PDFs into zoomable images you can embed anywhere, gathers them into collections, and configures the viewers that show them. It speaks JSON over HTTPS and answers with standard HTTP status codes.

Create assets from images on the web, or upload files, and get back zoomable images with a page to share and a script to embed. Collections, collection items and viewers arrange your images into sets and configure how they are shown.

Every endpoint is at this base URL:

https://api.zoomhub.net

The examples on this page call it with curl, JavaScript’s fetch and Python’s requests, and read your API key from the ZOOMHUB_API_KEY environment variable.

Authentication

Authenticate with an API key, sent as a Bearer token in the Authorization header. The scheme name Bearer is case-insensitive.

Authorization: Bearer sk-zh-v1-…

Create and revoke keys on your API keys page. An account holds up to 5 keys at a time. The API is part of the Business plan: a valid key whose account’s plan doesn’t include it is refused with 403 and the error code plan_required.

ZoomHub stores only a hash of each key, so a key is shown once, when you create it. A lost key can’t be recovered: revoke it and create another. A revoked key stops working at once.

Keep keys out of source code and out of web pages. A key in a page’s JavaScript is a key anyone who opens the page can copy: call the endpoints that need one from your server.

What each endpoint asks for:

  • Every write requires a key, and so does everything about uploads. What you create with a key belongs to your account.
  • Reads need no key, unless what they read is Protected (password-protected). Send your key to read your own Protected assets and collections.

The API host never reads cookies: a request is authenticated by its Authorization header alone, even in a browser where you are signed in to ZoomHub. On endpoints that require a key, a missing key is refused with 401 and the error code not_authenticated, an unknown or revoked one with 401 and invalid_api_key. On a read, a key that fails counts as no key.

Hosts

The API is served at https://api.zoomhub.net. ZoomHub’s pages, share links and embeds are on https://zoomhub.net. Responses link to both: a links.self URL is an API URL you can call with your key, while links.share and the embed links are pages for people.

An endpoint called on the other host may be refused with 404 and the error code wrong_host, whose detail names the host to use (see Errors):

{
  "code": "wrong_host",
  "detail": "This endpoint is served at https://api.zoomhub.net.",
  "request_id": "4fKx9mQ2TzR7wB1c",
  "status": 404,
  "title": "Not Found"
}

Tiles and their manifests (dzi.url and dzc.url) are on a content host. Read them, but don’t store their URLs: where an image’s tiles are served from can change, for instance when its owner protects it. Fetch the object again for a current URL.

Errors

ZoomHub answers with conventional HTTP status codes: 2xx for success, 4xx for a request that can’t be served as sent, and 5xx for a problem on ZoomHub’s side.

A refusal is a JSON object in the form of problem details, sent as application/problem+json:

{
  "code": "invalid_api_key",
  "detail": "Invalid API key",
  "request_id": "4fKx9mQ2TzR7wB1c",
  "status": 401,
  "title": "Unauthorized"
}
FieldMeaning
titleThe status’s reason phrase.
statusThe HTTP status again.
detailProse for people, specific to this request.
codeA stable, machine-readable identifier: one of the codes below.
request_idThe response’s Request-Id header again: quote it when you report a problem.

Branch on status and code, never on title or detail: they are worded for people and may change. Every refusal has this body. A request no endpoint takes, such as one to a path with an ID that can’t be an ID, answers 404 not_found for GET and HEAD, and 405 method_not_allowed for any other method; a failure on ZoomHub’s side answers 500 internal_error.

A 503 is temporary: retry with exponential backoff. When it carries a Retry-After header, wait at least that many seconds. Every code a caller can receive:

CodeStatusWhen
asset_not_ready400The asset added to a collection is still processing. Add it once its processing has succeeded, or name it in a batch, which takes assets still processing.
asset_processing_failed400The asset’s processing failed, so it can’t join a collection.
bad_request400The request can’t be carried out as sent; the detail says why. For instance: a body that isn’t valid JSON, misses a required key, has a key the endpoint doesn’t know or breaks a limit; an ID in the query that can’t be an ID; a collection asked to add its own members, a viewer’s options sent under the other viewer type, a viewer asked for something without an owner, or a list of viewers that named both filters or neither.
file_too_large400The upload’s size_bytes is larger than the account’s plan takes; the detail says how large a file may be.
invalid_url400The original_url isn’t a valid URL ZoomHub can fetch, or points into ZoomHub’s own uploads: upload the file instead.
subject_owner_mismatch400A viewer can show only its owner’s collections and images, and the new collection_id or content_id belongs to someone else.
unsupported_content_type400The upload’s content_type is neither an image type nor application/pdf.
invalid_api_key401The key is unknown or revoked, or the Authorization header doesn’t hold a Bearer key.
not_authenticated401The endpoint requires a key and the request has no Authorization header.
forbidden403You may not do this. It refuses a read of a Protected asset or collection you may not view, an asset added to a collection its owner doesn’t own, a write to an asset, collection or viewer whose owner you aren’t, and an email address that may not submit content.
plan_required403The key is valid, but its account’s plan doesn’t include the API. See Authentication.
subscription_required403The owner’s plan doesn’t include a feature the request asks for: password protection, a reference ID, hiding the ZoomHub logo, or a slideshow. The detail names the feature.
content_not_found404No asset has this ID, or it was deleted.
not_found404No collection, viewer or upload has this ID, or it was deleted, or an asset isn’t a member of the collection. An upload is its creator’s alone: anyone else’s answers as if it didn’t exist. Also a GET or HEAD to a path no endpoint takes, such as one with an ID that can’t be an ID.
wrong_host404The endpoint is served on the other host; the detail names it. See Hosts.
method_not_allowed405A request other than GET or HEAD that no endpoint takes: the path doesn’t take its method, or matches no endpoint.
in_foreign_collection409The asset can’t be protected while it is a member of a collection someone else owns. Ask them to remove it first.
reference_id_taken409Another of the owner’s assets already uses this reference_id.
upload_cancelled409The upload was cancelled, so it can’t be completed. Start a new one.
upload_completed409The upload is completed and its asset made, so it can’t be cancelled. Delete the asset instead.
upload_expired409The upload expired before it was completed. Start a new one.
upload_incomplete409S3 lacks one of the upload’s parts, or holds it at another size than parts says; the detail names the first such part. Send it again, then complete the upload.
url_already_exists409Someone else already submitted the URL; the answer doesn’t say which asset it is. Or, completing an upload, another asset already holds its file: start a new upload.
collection_full422The change would take a collection past what its owner’s plan holds: 1,000 members on Business. Nothing was added; the detail says how many fit and which plan holds more.
idempotency_key_reused422The Idempotency-Key named an upload of another file. Send a new key for a new upload.
upload_limit_reached429The account, or the email address, has reached its upload limit.
internal_error500Something failed on ZoomHub’s side. Retry later; if it keeps happening, reach out.
protection_pending503The change involves a Protected asset and the CDN wasn’t updated in time. An asset’s protection still took effect, and ZoomHub finishes the update within a minute; retrying is safe. A collection change saved nothing: retry it.
protection_unavailable503Password protection can’t be changed right now. Nothing was changed; retry later.
service_unavailable503ZoomHub couldn’t carry out the request right now, and nothing was saved. Retry later; when the answer has a Retry-After header, wait that long.
uploads_disabled503ZoomHub isn’t accepting new content right now. Nothing was created, and a pending upload stays pending: retry later. Other 503 answers carry other codes, such as service_unavailable.

CORS

Browsers can call the API from any origin through CORS. Preflight requests allow the methods GET, HEAD, POST, PUT, PATCH and DELETE, and the request headers Authorization and Content-Type.

Responses don’t allow credentials, so a browser never sends ZoomHub’s cookies along: authenticate with the Authorization header. A page can read only the response headers CORS always exposes, such as Cache-Control and Content-Type; Retry-After and Location aren’t among them.

IDs & objects

IDs are case-sensitive strings of letters and digits, with no prefix for their type: the field or header an ID comes in says what it names. Treat them as opaque: older IDs are shorter than newer ones, and an ID says nothing about what it names.

Every response from ZoomHub’s endpoints and pages carries a Request-Id header, such as Request-Id: 4fKx9mQ2TzR7wB1c, which names the request in the logs and which a refusal repeats in its body as request_id (see Errors): quote it when you report a problem.

Every object has an object attribute that names its type: asset, upload, collection, collection_item, added_items or viewer. A list is an object too: {"object": "list", "data": […], "has_more": false}, whose data holds the objects. Nothing is paged yet, so has_more is always false. Keys are snake_case, and so are query parameters.

Request bodies refuse keys they don’t know with 400, so a misspelled key fails instead of being ignored. Send them as JSON, with Content-Type: application/json.

Updates are partial, as in JSON Merge Patch: a key you leave out keeps its value, null clears a field that can be cleared, and a value replaces what is stored.

Each asset, and so each collection of assets, has a visibility. public assets may be listed on their owner’s profile. unlisted assets are open to anyone with their link but listed nowhere, and ask search engines not to index them. protected assets need a password, or their owner’s key, to view. A response about a Protected asset carries Cache-Control: no-store, private: don’t cache it.

Versioning

The version is part of the path: /v2. A version can gain endpoints, attributes and enum values, so ignore attributes you don’t know and handle values you don’t expect.

/v1, which kept the shape of the Zoom.it API, still answers the clients built on it, but it isn’t documented here: build on /v2.

View as MarkdownAssets

An asset is a single zoomable image. Create one from an image or a PDF on the web by its URL, or from a file on your computer through an upload. ZoomHub turns it into tiles you can zoom into, with a page to share and a script to embed. A PDF becomes a collection of its pages once it is processed.

Processing takes from seconds to minutes. Poll Retrieve an asset until its status is succeeded or failed, waiting longer between attempts as you go.

The asset object

A zoomable image, its settings, and how far processing has come.

Attributes

  • objectstring

    Always asset.

  • idstring

    The asset’s ID, which collections and viewers name it by.

  • titlestringNullable

    At most 250 characters, single line; surrounding whitespace is trimmed.

  • captionstringNullable

    At most 250 characters, single line; surrounding whitespace is trimmed.

  • descriptionstringNullable

    At most 10000 characters, line breaks allowed; surrounding whitespace is trimmed.

  • reference_idstringNullable

    The owner’s own identifier for the image, such as a catalog or accession number: unique among the owner’s assets. At most 100 characters, single line; surrounding whitespace is trimmed.

  • visibilityenum

    Who may view the asset.

    Possible values

    • publicAnyone may view it, and it may be listed on its owner’s profile.
    • unlistedAnyone with its link may view it; it is listed nowhere.
    • protectedViewing it needs its password, or its owner’s key.
  • statusenum

    How processing went. succeeded and failed are final.

    Possible values

    • unverifiedSubmitted on ZoomHub’s site with an email address, and waiting for the link ZoomHub emailed to be followed. Assets created with a key skip this.
    • queuedWaiting to be processed.
    • processingBeing turned into tiles.
    • succeededDone: kind describes the tiles.
    • failedProcessing failed, and the asset has no tiles. Delete it and create it again to retry.
  • progressnumber

    How far processing has come, from 0 to 1.

  • sourceobjectNullable

    Where the image came from, or null for a few images from Zoom.it’s time, whose source isn’t known.

    Show child attributesHide child attributes
    • typeenum

      The kind of source, which is also the key its details are under.

      Possible values

      • urlAn image or PDF fetched from the web; its details are under url.
      • uploadA file uploaded through an upload. upload is an empty object: an upload’s file isn’t exposed.
    • urlobjectOptional

      Present when type is url.

      Show child attributesHide child attributes
      • original_urlstring

        The URL the asset was created from.

    • uploadobjectOptional

      Present, and empty, when type is upload.

  • kindobject

    What kind of asset it is, known once it is processed.

    Show child attributesHide child attributes
    • typeenum

      What kind of asset it is, which is also the key its details are under.

      Possible values

      • unknownNot processed yet, or processing failed.
      • imageA zoomable image; its tiles are under image.
    • imageobjectOptional

      Present when type is image.

      Show child attributesHide child attributes
      • dziobject

        The Deep Zoom Image to view.

        Show child attributesHide child attributes
        • urlstring

          The DZI manifest’s URL. Read the tiles through it, but don’t store it: it changes when the asset’s protection does.

        • widthinteger

          The image’s width in pixels.

        • heightinteger

          The image’s height in pixels.

        • tile_sizeinteger

          The edge of a tile in pixels, overlap excluded.

        • tile_overlapinteger

          How many pixels each tile overlaps its neighbors.

        • tile_formatenum

          The tiles’ image format, which is also their file extension.

          Possible values

          • jpegJPEG tiles.
          • jpgJPEG tiles, named with the older extension.
          • pngPNG tiles.
          • webpWebP tiles.
  • linksobject

    Where the asset lives.

    Show child attributesHide child attributes
    • selfstring

      This asset in the API.

    • sharestring

      The asset’s page on ZoomHub.

    • embedstring

      A script that embeds a viewer for the image where its <script> tag is.

    • collectionstringNullable

      The collection a PDF’s pages were split into, in the API, or null for anything else.

  • created_atstring

    When the asset was created, in RFC 3339 UTC.

The asset object
{
  "caption": null,
  "created_at": "2026-09-30T00:00:00Z",
  "description": null,
  "id": "smpAsset1",
  "kind": {
    "image": {
      "dzi": {
        "height": 3072,
        "tile_format": "jpeg",
        "tile_overlap": 1,
        "tile_size": 254,
        "url": "https://cache.zoomhub.net/content/smpAsset1.dzi",
        "width": 4096
      }
    },
    "type": "image"
  },
  "links": {
    "collection": null,
    "embed": "https://zoomhub.net/a/smpAsset1.js",
    "self": "https://api.zoomhub.net/v2/assets/smpAsset1",
    "share": "https://zoomhub.net/a/smpAsset1"
  },
  "object": "asset",
  "progress": 1,
  "reference_id": null,
  "source": {
    "type": "url",
    "url": {
      "original_url": "https://example.com/photo.jpg"
    }
  },
  "status": "succeeded",
  "title": "Sample photo",
  "visibility": "unlisted"
}
{
  "caption": null,
  "created_at": "2026-09-30T00:00:00Z",
  "description": null,
  "id": "smpAsset1",
  "kind": {
    "type": "unknown"
  },
  "links": {
    "collection": null,
    "embed": "https://zoomhub.net/a/smpAsset1.js",
    "self": "https://api.zoomhub.net/v2/assets/smpAsset1",
    "share": "https://zoomhub.net/a/smpAsset1"
  },
  "object": "asset",
  "progress": 0.5,
  "reference_id": null,
  "source": {
    "type": "upload",
    "upload": {}
  },
  "status": "processing",
  "title": "Sample photo",
  "visibility": "unlisted"
}

Create an asset

POST/v2/assetsAPI key

Creates an asset from an image or a PDF on the web, and starts processing it.

The asset is yours and starts processing at once. A file that isn’t on the web goes through an upload instead.

Creating is safe to repeat: the URL of one of your assets answers with that asset as it is, and the settings you sent are ignored. A URL someone else submitted is refused, and the answer names no asset. Deleting an asset frees its URL to be submitted again.

Protected takes a password, so visibility can’t be protected here: create the asset, then protect it with Update an asset.

Body parameters

  • sourceobjectRequired

    Where the image comes from.

    Show child parametersHide child parameters
    • typeenumRequired

      Always url: files come in through uploads.

      Possible values

      • urlAn image or PDF on the web.
    • urlobjectRequired

      Where to fetch it.

      Show child parametersHide child parameters
      • original_urlstringRequired

        The image’s or PDF’s URL, starting with http:// or https://.

  • titlestringOptional

    At most 250 characters, single line; surrounding whitespace is trimmed.

  • captionstringOptional

    At most 250 characters, single line; surrounding whitespace is trimmed.

  • descriptionstringOptional

    At most 10000 characters, line breaks allowed; surrounding whitespace is trimmed.

  • reference_idstringOptional

    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.

  • visibilityenumOptional

    Who may view the asset. To protect it with a password, update it once it is created.

    Possible values

    • publicAnyone may view it, and it may be listed on its owner’s profile.
    • unlistedAnyone with its link may view it; it is listed nowhere. The default.
curl "https://api.zoomhub.net/v2/assets" \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "source": {
      "type": "url",
      "url": {
        "original_url": "https://example.com/photo.jpg"
      }
    },
    "title": "Sample photo"
  }'
const response = await fetch("https://api.zoomhub.net/v2/assets", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "source": {
      "type": "url",
      "url": {
        "original_url": "https://example.com/photo.jpg"
      }
    },
    "title": "Sample photo"
  }),
})
console.log(await response.json())
import os

import requests

response = requests.post(
    "https://api.zoomhub.net/v2/assets",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "source": {
            "type": "url",
            "url": {
                "original_url": "https://example.com/photo.jpg"
            }
        },
        "title": "Sample photo"
    },
)
print(response.json())
Response
{
  "caption": null,
  "created_at": "2026-09-30T00:00:00Z",
  "description": null,
  "id": "smpAsset1",
  "kind": {
    "type": "unknown"
  },
  "links": {
    "collection": null,
    "embed": "https://zoomhub.net/a/smpAsset1.js",
    "self": "https://api.zoomhub.net/v2/assets/smpAsset1",
    "share": "https://zoomhub.net/a/smpAsset1"
  },
  "object": "asset",
  "progress": 0,
  "reference_id": null,
  "source": {
    "type": "url",
    "url": {
      "original_url": "https://example.com/photo.jpg"
    }
  },
  "status": "queued",
  "title": "Sample photo",
  "visibility": "unlisted"
}
{
  "caption": null,
  "created_at": "2026-09-30T00:00:00Z",
  "description": null,
  "id": "smpAsset1",
  "kind": {
    "image": {
      "dzi": {
        "height": 3072,
        "tile_format": "jpeg",
        "tile_overlap": 1,
        "tile_size": 254,
        "url": "https://cache.zoomhub.net/content/smpAsset1.dzi",
        "width": 4096
      }
    },
    "type": "image"
  },
  "links": {
    "collection": null,
    "embed": "https://zoomhub.net/a/smpAsset1.js",
    "self": "https://api.zoomhub.net/v2/assets/smpAsset1",
    "share": "https://zoomhub.net/a/smpAsset1"
  },
  "object": "asset",
  "progress": 1,
  "reference_id": null,
  "source": {
    "type": "url",
    "url": {
      "original_url": "https://example.com/photo.jpg"
    }
  },
  "status": "succeeded",
  "title": "Sample photo",
  "visibility": "unlisted"
}

Responses

  • 201Created

    The new asset object, queued. Its links.self is also in the Location header.

  • 200OK

    The URL is one of your assets: that asset object, as it is.

  • 400Bad Requestbad_request

    The body isn’t JSON, misses source, has a key the endpoint doesn’t know, or breaks a limit; or visibility is protected. The detail says which.

  • 400Bad Requestinvalid_url

    The original_url isn’t a URL ZoomHub can fetch, or points into ZoomHub’s own uploads: upload the file instead.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The account’s email address may not submit content.

  • The account’s plan doesn’t include reference IDs.

  • Someone else already submitted this URL. The answer doesn’t say who, or which asset.

  • Another of the owner’s assets already uses this reference_id.

  • 429Too Many Requestsupload_limit_reached

    The account has reached its upload limit.

  • 503Service Unavailableuploads_disabled

    ZoomHub isn’t accepting new content right now. Retry later.

Retrieve an asset

GET/v2/assets/{id}API key optional

Returns the asset with the given ID.

Anyone may read an asset that isn’t Protected. Send your key to read your own Protected assets: the answer then carries Cache-Control: no-store, private. Poll it until status is succeeded or failed.

Reading what isn’t Protected needs no key: leave out the Authorization header. A key that fails to verify counts as no key: what’s public answers as usual, and what’s Protected answers 403 forbidden.

Path parameters

  • idstringRequired

    The asset’s ID.

curl "https://api.zoomhub.net/v2/assets/smpAsset1"
const apiKey = process.env.ZOOMHUB_API_KEY
const response = await fetch("https://api.zoomhub.net/v2/assets/smpAsset1", {
  headers: {
    ...(apiKey && { Authorization: `Bearer ${apiKey}` }),
  },
})
console.log(await response.json())
import os

import requests

api_key = os.environ.get("ZOOMHUB_API_KEY")
response = requests.get(
    "https://api.zoomhub.net/v2/assets/smpAsset1",
    headers={"Authorization": "Bearer " + api_key} if api_key else {},
)
print(response.json())
Response200
{
  "caption": null,
  "created_at": "2026-09-30T00:00:00Z",
  "description": null,
  "id": "smpAsset1",
  "kind": {
    "image": {
      "dzi": {
        "height": 3072,
        "tile_format": "jpeg",
        "tile_overlap": 1,
        "tile_size": 254,
        "url": "https://cache.zoomhub.net/content/smpAsset1.dzi",
        "width": 4096
      }
    },
    "type": "image"
  },
  "links": {
    "collection": null,
    "embed": "https://zoomhub.net/a/smpAsset1.js",
    "self": "https://api.zoomhub.net/v2/assets/smpAsset1",
    "share": "https://zoomhub.net/a/smpAsset1"
  },
  "object": "asset",
  "progress": 1,
  "reference_id": null,
  "source": {
    "type": "url",
    "url": {
      "original_url": "https://example.com/photo.jpg"
    }
  },
  "status": "succeeded",
  "title": "Sample photo",
  "visibility": "unlisted"
}

Responses

  • 200OK

    The asset object.

  • 403Forbiddenforbidden

    The asset is Protected and the key’s account may not view it. The answer is the same whatever the reason, so it doesn’t reveal which rule refused.

  • 404Not Foundcontent_not_found

    No asset has this ID, or it was deleted.

Update an asset

PATCH/v2/assets/{id}API key

Changes an asset’s title, caption, description, reference ID, visibility or password.

Only the asset’s owner, or an admin, may update it, and the owner’s plan decides what may be set. Send only what changes: a key you leave out keeps its value, and null clears it.

To protect an asset, set a password. To open it again, set password to null, which makes it Unlisted, or set visibility to the tier you want. A PDF’s pages follow their document: change the document’s protection instead.

Path parameters

  • idstringRequired

    The asset’s ID.

Body parameters

  • titlestringOptionalNullable

    At most 250 characters, single line; surrounding whitespace is trimmed.

  • captionstringOptionalNullable

    At most 250 characters, single line; surrounding whitespace is trimmed.

  • descriptionstringOptionalNullable

    At most 10000 characters, line breaks allowed; surrounding whitespace is trimmed.

  • reference_idstringOptionalNullable

    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.

  • visibilityenumOptional

    Who may view the asset. It can’t be null.

    Possible values

    • publicAnyone may view it, and it may be listed on its owner’s profile.
    • unlistedAnyone with its link may view it; it is listed nowhere.
    • protectedViewing it needs its password, or its owner’s key. Needs a password, sent now or set before.
  • passwordstringOptionalNullable

    The password that opens a Protected asset: 8 to 256 characters. Setting one protects the asset, so it can’t come with visibility public or unlisted. null removes it.

curl "https://api.zoomhub.net/v2/assets/smpAsset1" \
  --request PATCH \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "caption": null,
    "reference_id": "SMP-0447",
    "title": "Sample photo"
  }'
const response = await fetch("https://api.zoomhub.net/v2/assets/smpAsset1", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "caption": null,
    "reference_id": "SMP-0447",
    "title": "Sample photo"
  }),
})
console.log(await response.json())
import os

import requests

response = requests.patch(
    "https://api.zoomhub.net/v2/assets/smpAsset1",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "caption": None,
        "reference_id": "SMP-0447",
        "title": "Sample photo"
    },
)
print(response.json())
Response200
{
  "caption": null,
  "created_at": "2026-09-30T00:00:00Z",
  "description": null,
  "id": "smpAsset1",
  "kind": {
    "image": {
      "dzi": {
        "height": 3072,
        "tile_format": "jpeg",
        "tile_overlap": 1,
        "tile_size": 254,
        "url": "https://cache.zoomhub.net/content/smpAsset1.dzi",
        "width": 4096
      }
    },
    "type": "image"
  },
  "links": {
    "collection": null,
    "embed": "https://zoomhub.net/a/smpAsset1.js",
    "self": "https://api.zoomhub.net/v2/assets/smpAsset1",
    "share": "https://zoomhub.net/a/smpAsset1"
  },
  "object": "asset",
  "progress": 1,
  "reference_id": null,
  "source": {
    "type": "url",
    "url": {
      "original_url": "https://example.com/photo.jpg"
    }
  },
  "status": "succeeded",
  "title": "Sample photo",
  "visibility": "unlisted"
}

Responses

  • 200OK

    The updated asset object.

  • 400Bad Requestbad_request

    The body isn’t JSON, has a key the endpoint doesn’t know, or breaks a limit; or the protection change isn’t allowed, such as protecting a PDF’s page or an asset without an owner. The detail says which.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns the asset nor is an admin.

  • The owner’s plan doesn’t include password protection or reference IDs.

  • 404Not Foundcontent_not_found

    No asset has this ID, or it was deleted.

  • Another of the owner’s assets already uses this reference_id.

  • The asset is a member of a collection someone else owns, so it can’t be protected. Ask them to remove it first.

  • 503Service Unavailableprotection_unavailable

    Protection can’t be changed right now; nothing was changed.

  • 503Service Unavailableprotection_pending

    The asset is Protected, but the CDN wasn’t updated yet; ZoomHub finishes within a minute, and retrying is safe.

Delete an asset

DELETE/v2/assets/{id}API key

Deletes an asset, its tiles, and its membership in every collection.

Only the asset’s owner, or an admin, may delete it. Its page, share link and embeds stop working, and its URL is free to be submitted again. Deleting a PDF deletes its pages and the collection made of them too.

A deletion can’t be undone through the API.

Path parameters

  • idstringRequired

    The asset’s ID.

curl "https://api.zoomhub.net/v2/assets/smpAsset1" \
  --request DELETE \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY"
const response = await fetch("https://api.zoomhub.net/v2/assets/smpAsset1", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
  },
})
console.log(response.status)
import os

import requests

response = requests.delete(
    "https://api.zoomhub.net/v2/assets/smpAsset1",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
)
print(response.status_code)
Response204

204 No Content

Deleted.

Responses

  • 204No Content

    Deleted.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns the asset nor is an admin.

  • 404Not Foundcontent_not_found

    No asset has this ID, or it was deleted.

View as MarkdownUploads

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 that names the file, and get back a URL for each of its parts; PUT each part to its URL; then complete the upload, which makes it an asset 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, an upload in one part is three commands, which read your key from the ZOOMHUB_API_KEY environment variable:

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 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:

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 gives fresh URLs for the parts.

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

  • objectstring

    Always upload.

  • idstring

    The upload’s ID. Only its creator can use it.

  • statusenum

    Where the upload is. Only a pending one takes parts.

    Possible values

    • pendingWaiting for its parts, and to be completed.
    • completedMade an asset, which asset_id names.
    • cancelledCancelled; its parts are gone.
    • expiredNot completed by expires_at. Start a new upload.
  • filenamestring

    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_typestring

    The file’s media type.

  • size_bytesinteger

    The file’s size in bytes.

  • part_size_bytesinteger

    The size of every part but the last, which takes the rest.

  • expires_atstring

    When the upload expires unless completed, a day after it was created, in RFC 3339 UTC. The parts’ URLs work until then.

  • partsarray of objectsNullable

    The parts to send, while the upload is pending; null after.

    Show child attributesHide child attributes
    • numberinteger

      The part’s number, from 1, in file order.

    • size_bytesinteger

      How many bytes of the file it holds.

    • urlstring

      Where to PUT the part’s bytes. It is storage’s, signed for this part alone.

  • asset_idstringNullable

    The asset the upload became, once it is completed; null before.

  • created_atstring

    When the upload was created, in RFC 3339 UTC.

The upload object
{
  "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"
}
{
  "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/uploadsAPI 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

  • filenamestringRequired

    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_typestringRequired

    The file’s media type: any image/ type, or application/pdf.

  • size_bytesintegerRequired

    The file’s size in bytes.

  • part_size_bytesintegerOptional

    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.

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
  }'
const response = await fetch("https://api.zoomhub.net/v2/uploads", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "content_type": "image/tiff",
    "filename": "scan-0042.tif",
    "part_size_bytes": 8388608,
    "size_bytes": 12582912
  }),
})
console.log(await response.json())
import os

import requests

response = requests.post(
    "https://api.zoomhub.net/v2/uploads",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "content_type": "image/tiff",
        "filename": "scan-0042.tif",
        "part_size_bytes": 8388608,
        "size_bytes": 12582912
    },
)
print(response.json())
Response201
{
  "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"
}

Responses

  • 201Created

    The new upload object, with its parts.

  • 200OK

    An Idempotency-Key this account sent before: that upload object, as it now is.

  • 400Bad Requestbad_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.

  • 400Bad Requestfile_too_large

    The file is larger than the account’s plan takes.

  • The content_type is neither an image type nor application/pdf.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The account’s email address may not upload.

  • 422Unprocessable Entityidempotency_key_reused

    The Idempotency-Key named an upload of another file.

  • 429Too Many Requestsupload_limit_reached

    The account has reached its upload limit, counting its pending uploads.

  • 503Service Unavailableuploads_disabled

    ZoomHub isn’t accepting new content right now. Retry later.

Retrieve an upload

GET/v2/uploads/{id}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

  • idstringRequired

    The upload’s ID.

curl "https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG" \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY"
const response = await fetch("https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG", {
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
  },
})
console.log(await response.json())
import os

import requests

response = requests.get(
    "https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
)
print(response.json())
Response200
{
  "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"
}

Responses

  • 200OK

    The upload object.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 404Not Foundnot_found

    No upload with this ID that the key’s account may use: its own, or any for an admin.

Complete an upload

POST/v2/uploads/{id}/completeAPI 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 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

  • idstringRequired

    The upload’s ID.

Body parameters

  • assetobjectOptionalNullable

    The settings of the asset the upload becomes.

    Show child parametersHide child parameters
    • titlestringOptional

      At most 250 characters, single line; surrounding whitespace is trimmed.

    • captionstringOptional

      At most 250 characters, single line; surrounding whitespace is trimmed.

    • descriptionstringOptional

      At most 10000 characters, line breaks allowed; surrounding whitespace is trimmed.

    • reference_idstringOptional

      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.

    • visibilityenumOptional

      Who may view the asset. To protect it with a password, update it once it is created.

      Possible values

      • publicAnyone may view it, and it may be listed on its owner’s profile.
      • unlistedAnyone with its link may view it; it is listed nowhere. The default.
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"
    }
  }'
const response = await fetch("https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG/complete", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "asset": {
      "title": "Scan 42"
    }
  }),
})
console.log(await response.json())
import os

import requests

response = requests.post(
    "https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG/complete",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "asset": {
            "title": "Scan 42"
        }
    },
)
print(response.json())
Response200
{
  "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"
}

Responses

  • 200OK

    The completed upload object, with its asset_id.

  • 400Bad Requestbad_request

    The body isn’t JSON, has a key the endpoint doesn’t know, or breaks a limit. The detail says which.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The account’s email address may not upload. The upload is cancelled.

  • The account’s plan doesn’t include reference IDs.

  • 404Not Foundnot_found

    No upload with this ID that the key’s account may use: its own, or any for an admin.

  • Another of your assets already uses this reference_id.

  • 409Conflictupload_cancelled

    The upload was cancelled.

  • 409Conflictupload_expired

    The upload expired before it was completed.

  • 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.

  • Another asset already holds this upload’s file. Start a new upload.

  • 503Service Unavailableuploads_disabled

    ZoomHub isn’t accepting new content right now. Retry later.

Cancel an upload

POST/v2/uploads/{id}/cancelAPI 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

  • idstringRequired

    The upload’s ID.

curl "https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG/cancel" \
  --request POST \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY"
const response = await fetch("https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG/cancel", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
  },
})
console.log(await response.json())
import os

import requests

response = requests.post(
    "https://api.zoomhub.net/v2/uploads/Smp7UpXAb12CdEfG/cancel",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
)
print(response.json())
Response200
{
  "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"
}

Responses

  • 200OK

    The cancelled upload object.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 404Not Foundnot_found

    No upload with this ID that the key’s account may use: its own, or any for an admin.

  • 409Conflictupload_completed

    The upload is completed and its asset made. Delete the asset instead.

View as MarkdownCollections

A collection is an ordered set of images that zoom together as one Deep Zoom Collection: ZoomHub composites every member’s tiles into one pyramid, so a viewer can show hundreds of images at once. A PDF becomes a collection of its pages, and you can make your own from images you uploaded.

A collection holds only its owner’s images, whoever adds them, and as many as the owner’s plan holds: up to 1,000 on Business. Add, remove and order members with the collection item endpoints, and show the collection with a gallery viewer.

The collection object

An ordered set of images, and the composite they zoom in.

Attributes

  • objectstring

    Always collection.

  • idstring

    The collection’s ID.

  • titlestringNullable

    The collection’s title, or null for an untitled one.

  • sourceobjectNullable

    Where the collection came from, such as the PDF its pages were split from, or null for a collection made of images.

    Show child attributesHide child attributes
    • typeenum

      The kind of source, which is also the key its details are under.

      Possible values

      • urlA document fetched from the web; its details are under url.
      • uploadA document uploaded as a file. upload is an empty object: an upload’s file isn’t exposed.
    • urlobjectOptional

      Present when type is url.

      Show child attributesHide child attributes
      • original_urlstring

        The document’s URL.

    • uploadobjectOptional

      Present, and empty, when type is upload.

  • statusenum

    How processing its members went.

    Possible values

    • queuedThe collection has no members yet.
    • processingSome members are still being processed.
    • succeededEvery member was processed.
    • failedEvery member finished, and at least one failed.
  • num_itemsinteger

    How many members it has.

  • dzcobject

    The Deep Zoom Collection that composites the members’ tiles, for viewers that read DZC.

    Show child attributesHide child attributes
    • urlstring

      The DZC manifest’s URL. It stays the same as members come and go; don’t store it, since it changes when the collection’s protection does.

    • tile_sizeinteger

      The edge of a tile in pixels.

    • max_levelinteger

      The deepest level of the composite. Down to it, viewers draw members from the composite’s tiles; deeper, from each member’s own.

    • tile_formatstring

      The tiles’ image format, which is also their file extension, such as webp.

    • statusenum

      Whether the composite is up to date with the members.

      Possible values

      • processingTiles are still being composited, after a member was added or removed.
      • succeededThe composite is up to date.
  • linksobject

    Where the collection lives.

    Show child attributesHide child attributes
    • selfstring

      This collection in the API.

    • sharestring

      The collection’s page on ZoomHub.

The collection object
{
  "dzc": {
    "max_level": 9,
    "status": "succeeded",
    "tile_format": "webp",
    "tile_size": 512,
    "url": "https://cache.zoomhub.net/collections/Smp7Set9Abc.dzc"
  },
  "id": "Smp7Set9Abc",
  "links": {
    "self": "https://api.zoomhub.net/v2/collections/Smp7Set9Abc",
    "share": "https://zoomhub.net/c/Smp7Set9Abc"
  },
  "num_items": 2,
  "object": "collection",
  "source": {
    "type": "url",
    "url": {
      "original_url": "https://example.com/doc.pdf"
    }
  },
  "status": "succeeded",
  "title": null
}
{
  "dzc": {
    "max_level": 9,
    "status": "succeeded",
    "tile_format": "webp",
    "tile_size": 512,
    "url": "https://cache.zoomhub.net/collections/Smp7Set9Abc.dzc"
  },
  "id": "Smp7Set9Abc",
  "links": {
    "self": "https://api.zoomhub.net/v2/collections/Smp7Set9Abc",
    "share": "https://zoomhub.net/c/Smp7Set9Abc"
  },
  "num_items": 0,
  "object": "collection",
  "source": {
    "type": "upload",
    "upload": {}
  },
  "status": "queued",
  "title": "Sample set"
}
{
  "dzc": {
    "max_level": 9,
    "status": "succeeded",
    "tile_format": "webp",
    "tile_size": 512,
    "url": "https://cache.zoomhub.net/collections/Smp7Set9Abc.dzc"
  },
  "id": "Smp7Set9Abc",
  "links": {
    "self": "https://api.zoomhub.net/v2/collections/Smp7Set9Abc",
    "share": "https://zoomhub.net/c/Smp7Set9Abc"
  },
  "num_items": 0,
  "object": "collection",
  "source": null,
  "status": "queued",
  "title": "Sample set"
}

Create a collection

POST/v2/collectionsAPI key

Creates a collection, with its first members if you list them.

The create is all or nothing: every listed image is checked before anything is written, so a refused one leaves no collection behind. Images still processing join too, and are composited once they are done.

Body parameters

  • titlestringOptional

    The collection’s title. At most 250 characters on a single line, and not empty; surrounding whitespace is trimmed.

  • source_urlstringOptional

    The URL of the document the collection was made from, starting with http:// or https://. It is recorded as the collection’s source; ZoomHub doesn’t fetch it.

  • itemsarray of objectsOptional

    The first members, in display order. Without it, the collection starts empty. At most 10,000 entries, none listed twice. An image that is also a member of a listed collection, or of two, joins once.

    Show child parametersHide child parameters
    • asset_idstringOptional

      An image to add, by its content ID. Send this or collection_id, not both.

    • collection_idstringOptional

      A collection whose members to add, in its order, as they are now: a PDF’s collection adds its pages. Members it gains or loses later don’t follow. A listed collection’s members that failed to process are left out.

curl "https://api.zoomhub.net/v2/collections" \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "items": [
      {
        "asset_id": "smpAsset1"
      },
      {
        "asset_id": "smpAsset2"
      }
    ],
    "source_url": "https://example.com/doc.pdf"
  }'
const response = await fetch("https://api.zoomhub.net/v2/collections", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "items": [
      {
        "asset_id": "smpAsset1"
      },
      {
        "asset_id": "smpAsset2"
      }
    ],
    "source_url": "https://example.com/doc.pdf"
  }),
})
console.log(await response.json())
import os

import requests

response = requests.post(
    "https://api.zoomhub.net/v2/collections",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "items": [
            {
                "asset_id": "smpAsset1"
            },
            {
                "asset_id": "smpAsset2"
            }
        ],
        "source_url": "https://example.com/doc.pdf"
    },
)
print(response.json())
Response201
{
  "dzc": {
    "max_level": 9,
    "status": "succeeded",
    "tile_format": "webp",
    "tile_size": 512,
    "url": "https://cache.zoomhub.net/collections/Smp7Set9Abc.dzc"
  },
  "id": "Smp7Set9Abc",
  "links": {
    "self": "https://api.zoomhub.net/v2/collections/Smp7Set9Abc",
    "share": "https://zoomhub.net/c/Smp7Set9Abc"
  },
  "num_items": 2,
  "object": "collection",
  "source": {
    "type": "url",
    "url": {
      "original_url": "https://example.com/doc.pdf"
    }
  },
  "status": "succeeded",
  "title": null
}

Responses

  • 201Created

    The new collection object.

  • 400Bad Requestbad_request

    The body isn’t JSON, misses a required key, has a key the endpoint doesn’t know, or breaks a limit. The detail says which.

  • A listed image failed to process.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account doesn’t own a listed image.

  • 403Forbiddenforbidden

    A listed image belongs to someone other than the collection’s owner. Only the owner’s images can join.

  • 404Not Foundcontent_not_found

    A listed image doesn’t exist.

  • 404Not Foundnot_found

    A listed collection_id isn’t a collection the key’s account owns.

  • 422Unprocessable Entitycollection_full

    The collection would hold more images than its owner’s plan allows (1,000 on Business). The detail names the limit and the plan that holds more.

  • 503Service Unavailableprotection_pending

    The change involves a Protected image and the CDN couldn’t be updated; nothing was saved. Retry.

  • 503Service Unavailableservice_unavailable

    The collection is busy updating its protection; nothing was saved. Retry after the seconds the Retry-After header gives.

Retrieve a collection

GET/v2/collections/{id}API key optional

Returns the collection with the given ID.

Anyone may read a collection unless one of its members is Protected. Send your key to read your own: the answer then carries Cache-Control: no-store, private. Poll it while status or dzc.status is processing.

Reading what isn’t Protected needs no key: leave out the Authorization header. A key that fails to verify counts as no key: what’s public answers as usual, and what’s Protected answers 403 forbidden.

Path parameters

  • idstringRequired

    The collection’s ID.

curl "https://api.zoomhub.net/v2/collections/Smp7Set9Abc"
const apiKey = process.env.ZOOMHUB_API_KEY
const response = await fetch("https://api.zoomhub.net/v2/collections/Smp7Set9Abc", {
  headers: {
    ...(apiKey && { Authorization: `Bearer ${apiKey}` }),
  },
})
console.log(await response.json())
import os

import requests

api_key = os.environ.get("ZOOMHUB_API_KEY")
response = requests.get(
    "https://api.zoomhub.net/v2/collections/Smp7Set9Abc",
    headers={"Authorization": "Bearer " + api_key} if api_key else {},
)
print(response.json())
Response200
{
  "dzc": {
    "max_level": 9,
    "status": "succeeded",
    "tile_format": "webp",
    "tile_size": 512,
    "url": "https://cache.zoomhub.net/collections/Smp7Set9Abc.dzc"
  },
  "id": "Smp7Set9Abc",
  "links": {
    "self": "https://api.zoomhub.net/v2/collections/Smp7Set9Abc",
    "share": "https://zoomhub.net/c/Smp7Set9Abc"
  },
  "num_items": 2,
  "object": "collection",
  "source": {
    "type": "url",
    "url": {
      "original_url": "https://example.com/doc.pdf"
    }
  },
  "status": "succeeded",
  "title": null
}

Responses

  • 200OK

    The collection object.

  • 403Forbiddenforbidden

    The collection is Protected and the key’s account may not view it. The answer is the same whatever the reason, so it doesn’t reveal which rule refused.

  • 404Not Foundnot_found

    No collection has this ID, or it was deleted.

Update a collection

PATCH/v2/collections/{id}API key

Renames a collection.

Only the collection’s owner, or an admin, may update it.

Path parameters

  • idstringRequired

    The collection’s ID.

Body parameters

  • titlestringOptionalNullable

    The new title; null removes it. At most 250 characters on a single line, and not empty; surrounding whitespace is trimmed.

curl "https://api.zoomhub.net/v2/collections/Smp7Set9Abc" \
  --request PATCH \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "title": "Sample set"
  }'
const response = await fetch("https://api.zoomhub.net/v2/collections/Smp7Set9Abc", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "title": "Sample set"
  }),
})
console.log(await response.json())
import os

import requests

response = requests.patch(
    "https://api.zoomhub.net/v2/collections/Smp7Set9Abc",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "title": "Sample set"
    },
)
print(response.json())
Response200
{
  "dzc": {
    "max_level": 9,
    "status": "succeeded",
    "tile_format": "webp",
    "tile_size": 512,
    "url": "https://cache.zoomhub.net/collections/Smp7Set9Abc.dzc"
  },
  "id": "Smp7Set9Abc",
  "links": {
    "self": "https://api.zoomhub.net/v2/collections/Smp7Set9Abc",
    "share": "https://zoomhub.net/c/Smp7Set9Abc"
  },
  "num_items": 0,
  "object": "collection",
  "source": null,
  "status": "queued",
  "title": "Sample set"
}

Responses

  • 200OK

    The updated collection object.

  • 400Bad Requestbad_request

    The body isn’t JSON, misses a required key, has a key the endpoint doesn’t know, or breaks a limit. The detail says which.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns the collection nor is an admin.

  • 404Not Foundnot_found

    No collection has this ID, or it was deleted.

Delete a collection

DELETE/v2/collections/{id}API key

Deletes a collection, but not its members.

Only the collection’s owner, or an admin, may delete it. From then on the collection answers 404, here and on its page. Its member images stay as they are.

Path parameters

  • idstringRequired

    The collection’s ID.

curl "https://api.zoomhub.net/v2/collections/Smp7Set9Abc" \
  --request DELETE \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY"
const response = await fetch("https://api.zoomhub.net/v2/collections/Smp7Set9Abc", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
  },
})
console.log(response.status)
import os

import requests

response = requests.delete(
    "https://api.zoomhub.net/v2/collections/Smp7Set9Abc",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
)
print(response.status_code)
Response204

204 No Content

Deleted.

Responses

  • 204No Content

    Deleted.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns the collection nor is an admin.

  • 404Not Foundnot_found

    No collection has this ID, or it was deleted.

View as MarkdownCollection items

A collection item is an image’s membership in a collection: its place in the order and, for a PDF’s page, its page number. Removing an item takes the image out of the collection; the image itself stays.

Only a collection made with Create a collection takes new members: a PDF’s collection holds its pages in page order, and answers 404 to an add. Every write here requires the collection’s owner, or an admin.

The collection item object

An image’s place in a collection.

Attributes

  • objectstring

    Always collection_item.

  • asset_idstring

    The image’s asset ID. Fetch the image with Retrieve an asset.

  • positioninteger

    Its place in the display order, counting from 0. Positions have no gaps, so a member’s position changes as others are added, removed or moved before it.

  • page_numberintegerNullable

    Its page in the PDF the collection was made from, counting from 1, or null for an image added on its own.

The collection item object1
{
  "asset_id": "smpAsset1",
  "object": "collection_item",
  "page_number": 1,
  "position": 0
}

The added items object

What a batch add did.

Attributes

  • objectstring

    Always added_items.

  • itemsarray of objects

    The members this batch added, in the order they joined. To take the batch back, remove exactly these.

    Show child attributesHide child attributes
    • objectstring

      Always collection_item.

    • asset_idstring

      The image’s asset ID. Fetch the image with Retrieve an asset.

    • positioninteger

      Its place in the display order, counting from 0. Positions have no gaps, so a member’s position changes as others are added, removed or moved before it.

    • page_numberintegerNullable

      Its page in the PDF the collection was made from, counting from 1, or null for an image added on its own.

  • num_skippedinteger

    How many listed images were members already, a listed collection’s members counted one by one.

  • num_failedinteger

    How many of a listed collection’s members were left out because they failed to process.

The added items object1
{
  "items": [
    {
      "asset_id": "smpAsset1",
      "object": "collection_item",
      "page_number": null,
      "position": 0
    }
  ],
  "num_failed": 1,
  "num_skipped": 2,
  "object": "added_items"
}

List a collection’s items

GET/v2/collections/{id}/itemsAPI key optional

Returns every member of a collection, in display order.

The list isn’t paginated: a collection holds at most 10,000 members. Like Retrieve a collection, it needs no key unless a member is Protected.

Reading what isn’t Protected needs no key: leave out the Authorization header. A key that fails to verify counts as no key: what’s public answers as usual, and what’s Protected answers 403 forbidden.

Path parameters

  • idstringRequired

    The collection’s ID.

curl "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items"
const apiKey = process.env.ZOOMHUB_API_KEY
const response = await fetch("https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items", {
  headers: {
    ...(apiKey && { Authorization: `Bearer ${apiKey}` }),
  },
})
console.log(await response.json())
import os

import requests

api_key = os.environ.get("ZOOMHUB_API_KEY")
response = requests.get(
    "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items",
    headers={"Authorization": "Bearer " + api_key} if api_key else {},
)
print(response.json())
Response200
{
  "data": [
    {
      "asset_id": "smpAsset1",
      "object": "collection_item",
      "page_number": 1,
      "position": 0
    },
    {
      "asset_id": "smpAsset2",
      "object": "collection_item",
      "page_number": 2,
      "position": 1
    }
  ],
  "has_more": false,
  "object": "list"
}

Responses

  • 200OK

    A list object whose data holds the collection item objects.

  • 403Forbiddenforbidden

    The collection is Protected and the key’s account may not view it. The answer is the same whatever the reason, so it doesn’t reveal which rule refused.

  • 404Not Foundnot_found

    No collection has this ID, or it was deleted.

Add an item

POST/v2/collections/{id}/itemsAPI key

Adds one image to a collection.

Adding an image that is already a member changes nothing and answers 200 with its membership, so a retry is safe. The image must be done processing: add a batch to add images that are still processing.

Path parameters

  • idstringRequired

    The collection’s ID.

Body parameters

  • asset_idstringRequired

    The image’s content ID.

  • positionintegerOptional

    Where to insert it, counting from 0: at most the number of members, which appends it. Without it, the image is appended.

curl "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items" \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "asset_id": "smpAsset1",
    "position": 0
  }'
const response = await fetch("https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "asset_id": "smpAsset1",
    "position": 0
  }),
})
console.log(await response.json())
import os

import requests

response = requests.post(
    "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "asset_id": "smpAsset1",
        "position": 0
    },
)
print(response.json())
Response
{
  "asset_id": "smpAsset1",
  "object": "collection_item",
  "page_number": null,
  "position": 0
}
{
  "asset_id": "smpAsset1",
  "object": "collection_item",
  "page_number": null,
  "position": 0
}

Responses

  • 201Created

    The new membership.

  • 200OK

    The image was a member already; its membership, unchanged.

  • 400Bad Requestbad_request

    The body isn’t JSON, misses a required key, has a key the endpoint doesn’t know, or breaks a limit. The detail says which.

  • 400Bad Requestasset_not_ready

    The image is still processing.

  • The image’s processing failed.

  • 400Bad Requestbad_request

    position is past the end.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns the collection nor is an admin.

  • 403Forbiddenforbidden

    The key’s account doesn’t own the image.

  • 403Forbiddenforbidden

    The image belongs to someone other than the collection’s owner. Only the owner’s images can join.

  • 404Not Foundnot_found

    No collection has this ID, it was deleted or was made from a PDF.

  • 404Not Foundcontent_not_found

    The image doesn’t exist.

  • 422Unprocessable Entitycollection_full

    The collection is full.

  • 503Service Unavailableprotection_pending

    The change involves a Protected image and the CDN couldn’t be updated; nothing was saved. Retry.

  • 503Service Unavailableservice_unavailable

    The collection is busy updating its protection; nothing was saved. Retry after the seconds the Retry-After header gives.

Add items

POST/v2/collections/{id}/items/batchAPI key

Appends images, or the members of other collections, to a collection in one step.

The batch is all or nothing: every image is checked before anything is written. New members are appended after the last member, in the order the entries name them. Members already there are skipped and counted, so a retry after a timeout adds nothing twice. Images still processing join too, and are composited once they are done.

Name a collection to add its members as they are now: a PDF’s collection adds its pages, in page order. A collection can’t add its own members.

The answer is 200, not 201: a batch whose images are all members already creates nothing.

Path parameters

  • idstringRequired

    The collection’s ID.

Body parameters

  • itemsarray of objectsRequired

    The images to add, or collections whose members to add. At least one. At most 10,000 entries, none listed twice. An image that is also a member of a listed collection, or of two, joins once.

    Show child parametersHide child parameters
    • asset_idstringOptional

      An image to add, by its content ID. Send this or collection_id, not both.

    • collection_idstringOptional

      A collection whose members to add, in its order, as they are now: a PDF’s collection adds its pages. Members it gains or loses later don’t follow. A listed collection’s members that failed to process are left out.

curl "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/batch" \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "items": [
      {
        "asset_id": "smpAsset1"
      },
      {
        "collection_id": "Smp7Pdf9Doc"
      }
    ]
  }'
const response = await fetch("https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/batch", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "items": [
      {
        "asset_id": "smpAsset1"
      },
      {
        "collection_id": "Smp7Pdf9Doc"
      }
    ]
  }),
})
console.log(await response.json())
import os

import requests

response = requests.post(
    "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/batch",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "items": [
            {
                "asset_id": "smpAsset1"
            },
            {
                "collection_id": "Smp7Pdf9Doc"
            }
        ]
    },
)
print(response.json())
Response200
{
  "items": [
    {
      "asset_id": "smpAsset1",
      "object": "collection_item",
      "page_number": null,
      "position": 0
    }
  ],
  "num_failed": 1,
  "num_skipped": 2,
  "object": "added_items"
}

Responses

  • 200OK

    The added items object.

  • 400Bad Requestbad_request

    The body isn’t JSON, misses a required key, has a key the endpoint doesn’t know, or breaks a limit. The detail says which.

  • 400Bad Requestbad_request

    The batch names the collection itself.

  • A listed image failed to process.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns the collection nor is an admin.

  • 403Forbiddenforbidden

    The key’s account doesn’t own a listed image.

  • 403Forbiddenforbidden

    The image belongs to someone other than the collection’s owner. Only the owner’s images can join.

  • 404Not Foundnot_found

    No collection has this ID, it was deleted or was made from a PDF, or a listed collection_id isn’t a collection the key’s account owns.

  • 404Not Foundcontent_not_found

    A listed image doesn’t exist.

  • 422Unprocessable Entitycollection_full

    The batch would take the collection past its limit. Nothing was added; the detail says how many fit.

  • 503Service Unavailableprotection_pending

    The change involves a Protected image and the CDN couldn’t be updated; nothing was saved. Retry.

  • 503Service Unavailableservice_unavailable

    The collection is busy updating its protection; nothing was saved. Retry after the seconds the Retry-After header gives.

Remove items

POST/v2/collections/{id}/items/removalAPI key

Removes images from a collection in one step.

An image that isn’t a member is skipped, so a retry after a timeout is harmless. To undo Add items, send the items it answered with. The images themselves stay.

Path parameters

  • idstringRequired

    The collection’s ID.

Body parameters

  • asset_idsarray of stringsRequired

    The images to remove, by content ID. At least one and at most 10,000, none listed twice.

curl "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/removal" \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "asset_ids": [
      "smpAsset1",
      "smpAsset2"
    ]
  }'
const response = await fetch("https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/removal", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "asset_ids": [
      "smpAsset1",
      "smpAsset2"
    ]
  }),
})
console.log(response.status)
import os

import requests

response = requests.post(
    "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/removal",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "asset_ids": [
            "smpAsset1",
            "smpAsset2"
        ]
    },
)
print(response.status_code)
Response204

204 No Content

Removed.

Responses

  • 204No Content

    Removed.

  • 400Bad Requestbad_request

    The body isn’t JSON, misses a required key, has a key the endpoint doesn’t know, or breaks a limit. The detail says which.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns the collection nor is an admin.

  • 404Not Foundnot_found

    No collection has this ID, or it was deleted.

  • 503Service Unavailableservice_unavailable

    The collection is busy updating its protection; nothing was saved. Retry after the seconds the Retry-After header gives.

Order items

PUT/v2/collections/{id}/items/orderAPI key

Moves the listed members to the front of a collection, in the order listed.

Every member you leave out follows, in its current order, so listing the members you show is enough. An image that isn’t a member is skipped. Only positions change: no tile is redrawn.

Path parameters

  • idstringRequired

    The collection’s ID.

Body parameters

  • asset_idsarray of stringsRequired

    The members to put first, in their new order, by content ID. At least one and at most 10,000, none listed twice.

curl "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/order" \
  --request PUT \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "asset_ids": [
      "smpAsset2",
      "smpAsset1"
    ]
  }'
const response = await fetch("https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/order", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "asset_ids": [
      "smpAsset2",
      "smpAsset1"
    ]
  }),
})
console.log(response.status)
import os

import requests

response = requests.put(
    "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/order",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "asset_ids": [
            "smpAsset2",
            "smpAsset1"
        ]
    },
)
print(response.status_code)
Response204

204 No Content

Ordered.

Responses

  • 204No Content

    Ordered.

  • 400Bad Requestbad_request

    The body isn’t JSON, misses a required key, has a key the endpoint doesn’t know, or breaks a limit. The detail says which.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns the collection nor is an admin.

  • 404Not Foundnot_found

    No collection has this ID, or it was deleted.

Remove an item

DELETE/v2/collections/{id}/items/{assetId}API key

Removes one image from a collection.

The members after it move up one place. The image itself stays.

Path parameters

  • idstringRequired

    The collection’s ID.

  • assetIdstringRequired

    The member image’s content ID.

curl "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/smpAsset1" \
  --request DELETE \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY"
const response = await fetch("https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/smpAsset1", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
  },
})
console.log(response.status)
import os

import requests

response = requests.delete(
    "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/smpAsset1",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
)
print(response.status_code)
Response204

204 No Content

Removed.

Responses

  • 204No Content

    Removed.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns the collection nor is an admin.

  • 404Not Foundnot_found

    No collection has this ID or it was deleted, or the image isn’t a member.

  • 503Service Unavailableservice_unavailable

    The collection is busy updating its protection; nothing was saved. Retry after the seconds the Retry-After header gives.

Move an item

PATCH/v2/collections/{id}/items/{assetId}API key

Moves one member to a new position.

The members in between shift by one place. Only positions change: no tile is redrawn. To reorder many members, order items instead.

Path parameters

  • idstringRequired

    The collection’s ID.

  • assetIdstringRequired

    The member image’s content ID.

Body parameters

  • positionintegerRequired

    Its position once moved, counting from 0: at most the last member’s.

curl "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/smpAsset1" \
  --request PATCH \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "position": 0
  }'
const response = await fetch("https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/smpAsset1", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "position": 0
  }),
})
console.log(await response.json())
import os

import requests

response = requests.patch(
    "https://api.zoomhub.net/v2/collections/Smp7Set9Abc/items/smpAsset1",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "position": 0
    },
)
print(response.json())
Response200
{
  "asset_id": "smpAsset1",
  "object": "collection_item",
  "page_number": 1,
  "position": 0
}

Responses

  • 200OK

    The moved membership.

  • 400Bad Requestbad_request

    The body isn’t JSON, misses a required key, has a key the endpoint doesn’t know, or breaks a limit. The detail says which.

  • 400Bad Requestbad_request

    position is past the last member’s.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns the collection nor is an admin.

  • 404Not Foundnot_found

    No collection has this ID or it was deleted, or the image isn’t a member.

View as MarkdownViewers

A viewer is a saved, configured embed: a gallery that shows a collection, or an image viewer that shows one image. Its links give a page to share, an <iframe> URL and a <script> URL to embed. Change a viewer’s options and every embed of it follows.

A viewer belongs to the owner of what it shows, and can only ever show that owner’s collections and images. Its type is fixed when it is created.

The viewer object

A saved embed of a collection or an image, with its options.

Attributes

  • objectstring

    Always viewer.

  • idstring

    The viewer’s ID.

  • typeenum

    What the viewer shows, which is also the key its options are under.

    Possible values

    • galleryShows a collection.
    • imageShows one image.
  • titlestringNullable

    The viewer’s title, or null.

  • galleryobjectOptional

    Present when type is gallery: the collection it shows and how.

    Show child attributesHide child attributes
    • collection_idstring

      The collection the gallery shows. It must belong to the viewer’s owner.

    • layoutenum

      How the gallery lays out its images. Every layout keeps the collection’s order.

      Possible values

      • justifiedRows of equal height, filled edge to edge.
      • gridEqual cells.
      • stripOne row to scroll through.
    • backgroundstring

      The gallery’s backdrop: black, white, or a hex color such as #1c1c1f.

    • captionsenum

      Whether each image’s title shows beneath it.

      Possible values

      • shown
      • hidden
    • controlsenum

      Which controls the gallery shows.

      Possible values

      • fullEvery control, including the layout toggle.
      • minimalOnly the essential controls.
      • fixed-layoutEvery control but the layout toggle: opening an image frames it closely, and going home steps back to the wall.
    • navigationenum

      How visitors move between images.

      Possible values

      • arrowsPrevious and next arrows.
      • numbersA ribbon of numbered pills on top, one per image.
    • arrowsenum

      Where the previous and next arrows sit.

      Possible values

      • controlsWith the other controls.
      • sidesAs large buttons on the gallery’s left and right edges, along the bottom on a narrow or touch screen.
    • autoplayintegerNullable

      Plays the gallery as a slideshow, holding each image this many seconds: 3 to 60. null is off. Slideshows are part of paid plans.

    • motionenum

      How a slideshow holds each image. It matters only while the slideshow plays.

      Possible values

      • driftEach image drifts slowly in toward its center.
      • stillEach image holds still.
    • accent_colorstringOptional

      The color the viewer highlights with, such as hover outlines, active controls and focus rings, as #rgb or #rrggbb. Left out, the viewer keeps ZoomHub’s own.

  • imageobjectOptional

    Present when type is image: the image it shows and how.

    Show child attributesHide child attributes
    • content_idstring

      The image the viewer shows, by content ID. It must belong to the viewer’s owner.

    • headingenum

      What shows in the top-left corner.

      Possible values

      • markThe ZoomHub logo.
      • hiddenNothing. Hiding the logo is part of paid plans.
    • controlsarray of strings

      The controls the viewer shows, each at most once; an empty array shows none.

      Possible values

      • zoomZoom in and out.
      • resetReturn to the whole image.
      • fullscreenFill the screen.
      • shareShare the image.
      • view_originalOpen the original file.
    • minimapenum

      Whether the navigator shows in the top-right corner.

      Possible values

      • shown
      • hidden
    • metadataenum

      Whether the image’s pixel dimensions show in the top-right corner.

      Possible values

      • shown
      • hidden
    • backgroundenum

      The backdrop behind the image.

      Possible values

      • blackBlack.
      • whiteWhite.
      • noneTransparent: the page shows through.
    • fitenum

      How the image sits in its frame before anyone zooms.

      Possible values

      • containThe whole image, letterboxed.
      • coverThe frame filled, the image cropped.
    • accent_colorstringOptional

      The color the viewer highlights with, such as hover outlines, active controls and focus rings, as #rgb or #rrggbb. Left out, the viewer keeps ZoomHub’s own.

    • constrainenumOptional

      How far visitors may zoom out and pan. Left out, they may zoom out a little past the fitted view and pan loosely.

      Possible values

      • zoomNo further out than the fitted view.
      • fullNo further out than the fitted view, and never past the image’s edges.
  • linksobject

    Where the viewer lives.

    Show child attributesHide child attributes
    • selfstring

      This viewer in the API.

    • sharestring

      The viewer’s page on ZoomHub.

    • embedstring

      A page made for an <iframe>.

    • scriptstring

      A script that puts the viewer where its <script> tag is.

The viewer object
{
  "gallery": {
    "arrows": "controls",
    "autoplay": null,
    "background": "black",
    "captions": "shown",
    "collection_id": "Smp7Set9Abc",
    "controls": "full",
    "layout": "justified",
    "motion": "drift",
    "navigation": "arrows"
  },
  "id": "Smp7View9Ab",
  "links": {
    "embed": "https://zoomhub.net/v/Smp7View9Ab/embed",
    "script": "https://zoomhub.net/v/Smp7View9Ab.js",
    "self": "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
    "share": "https://zoomhub.net/v/Smp7View9Ab"
  },
  "object": "viewer",
  "title": "Sample gallery",
  "type": "gallery"
}
{
  "id": "Smp7View9Ab",
  "image": {
    "background": "white",
    "constrain": "full",
    "content_id": "Smp7Pic",
    "controls": [
      "zoom",
      "view_original"
    ],
    "fit": "cover",
    "heading": "hidden",
    "metadata": "hidden",
    "minimap": "shown"
  },
  "links": {
    "embed": "https://zoomhub.net/v/Smp7View9Ab/embed",
    "script": "https://zoomhub.net/v/Smp7View9Ab.js",
    "self": "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
    "share": "https://zoomhub.net/v/Smp7View9Ab"
  },
  "object": "viewer",
  "title": "Press kit",
  "type": "image"
}
{
  "gallery": {
    "accent_color": "#3a8fd9",
    "arrows": "controls",
    "autoplay": 8,
    "background": "black",
    "captions": "shown",
    "collection_id": "Smp7Set9Abc",
    "controls": "full",
    "layout": "justified",
    "motion": "drift",
    "navigation": "arrows"
  },
  "id": "Smp7View9Ab",
  "links": {
    "embed": "https://zoomhub.net/v/Smp7View9Ab/embed",
    "script": "https://zoomhub.net/v/Smp7View9Ab.js",
    "self": "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
    "share": "https://zoomhub.net/v/Smp7View9Ab"
  },
  "object": "viewer",
  "title": "Sample gallery",
  "type": "gallery"
}
{
  "id": "Smp7View9Ab",
  "image": {
    "accent_color": "#3a8fd9",
    "background": "white",
    "constrain": "full",
    "content_id": "Smp7Pic",
    "controls": [
      "zoom",
      "view_original"
    ],
    "fit": "cover",
    "heading": "hidden",
    "metadata": "hidden",
    "minimap": "shown"
  },
  "links": {
    "embed": "https://zoomhub.net/v/Smp7View9Ab/embed",
    "script": "https://zoomhub.net/v/Smp7View9Ab.js",
    "self": "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
    "share": "https://zoomhub.net/v/Smp7View9Ab"
  },
  "object": "viewer",
  "title": "Press kit",
  "type": "image"
}

Create a viewer

POST/v2/viewersAPI key

Creates a gallery for a collection, or a viewer for an image.

Only the owner of the collection or image, or an admin, may create a viewer for it, and the viewer belongs to that owner. Options you leave out take their defaults.

Body parameters

  • typeenumRequired

    What the viewer shows. Send its options under the key of the same name.

    Possible values

    • galleryShows a collection.
    • imageShows one image.
  • titlestringOptionalNullable

    The viewer’s title. At most 250 characters on a single line, and not empty; surrounding whitespace is trimmed.

  • galleryobjectOptional

    Required when type is gallery.

    Show child parametersHide child parameters
    • collection_idstringRequired

      The collection the gallery shows. It must belong to the viewer’s owner.

    • layoutenumOptional

      How the gallery lays out its images. Every layout keeps the collection’s order. Defaults to justified.

      Possible values

      • justifiedRows of equal height, filled edge to edge.
      • gridEqual cells.
      • stripOne row to scroll through.
    • backgroundstringOptional

      The gallery’s backdrop: black, white, or a hex color such as #1c1c1f. Defaults to black.

    • captionsenumOptional

      Whether each image’s title shows beneath it. Defaults to shown.

      Possible values

      • shown
      • hidden
    • controlsenumOptional

      Which controls the gallery shows. Defaults to full.

      Possible values

      • fullEvery control, including the layout toggle.
      • minimalOnly the essential controls.
      • fixed-layoutEvery control but the layout toggle: opening an image frames it closely, and going home steps back to the wall.
    • navigationenumOptional

      How visitors move between images. Defaults to arrows.

      Possible values

      • arrowsPrevious and next arrows.
      • numbersA ribbon of numbered pills on top, one per image.
    • arrowsenumOptional

      Where the previous and next arrows sit. Defaults to controls.

      Possible values

      • controlsWith the other controls.
      • sidesAs large buttons on the gallery’s left and right edges, along the bottom on a narrow or touch screen.
    • autoplayintegerOptionalNullable

      Plays the gallery as a slideshow, holding each image this many seconds: 3 to 60. null is off. Slideshows are part of paid plans. Defaults to null.

    • motionenumOptional

      How a slideshow holds each image. It matters only while the slideshow plays. Defaults to drift.

      Possible values

      • driftEach image drifts slowly in toward its center.
      • stillEach image holds still.
    • accent_colorstringOptional

      The color the viewer highlights with, such as hover outlines, active controls and focus rings, as #rgb or #rrggbb. Left out, the viewer keeps ZoomHub’s own.

  • imageobjectOptional

    Required when type is image.

    Show child parametersHide child parameters
    • content_idstringRequired

      The image the viewer shows, by content ID. It must belong to the viewer’s owner.

    • headingenumOptional

      What shows in the top-left corner. Defaults to mark.

      Possible values

      • markThe ZoomHub logo.
      • hiddenNothing. Hiding the logo is part of paid plans.
    • controlsarray of stringsOptional

      The controls the viewer shows, each at most once; an empty array shows none. Defaults to every control.

      Possible values

      • zoomZoom in and out.
      • resetReturn to the whole image.
      • fullscreenFill the screen.
      • shareShare the image.
      • view_originalOpen the original file.
    • minimapenumOptional

      Whether the navigator shows in the top-right corner. Defaults to hidden.

      Possible values

      • shown
      • hidden
    • metadataenumOptional

      Whether the image’s pixel dimensions show in the top-right corner. Defaults to shown.

      Possible values

      • shown
      • hidden
    • backgroundenumOptional

      The backdrop behind the image. Defaults to black.

      Possible values

      • blackBlack.
      • whiteWhite.
      • noneTransparent: the page shows through.
    • fitenumOptional

      How the image sits in its frame before anyone zooms. Defaults to contain.

      Possible values

      • containThe whole image, letterboxed.
      • coverThe frame filled, the image cropped.
    • accent_colorstringOptional

      The color the viewer highlights with, such as hover outlines, active controls and focus rings, as #rgb or #rrggbb. Left out, the viewer keeps ZoomHub’s own.

    • constrainenumOptional

      How far visitors may zoom out and pan. Left out, they may zoom out a little past the fitted view and pan loosely.

      Possible values

      • zoomNo further out than the fitted view.
      • fullNo further out than the fitted view, and never past the image’s edges.
curl "https://api.zoomhub.net/v2/viewers" \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "gallery": {
      "collection_id": "Smp7Set9Abc"
    },
    "title": "Sample gallery",
    "type": "gallery"
  }'
const response = await fetch("https://api.zoomhub.net/v2/viewers", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "gallery": {
      "collection_id": "Smp7Set9Abc"
    },
    "title": "Sample gallery",
    "type": "gallery"
  }),
})
console.log(await response.json())
import os

import requests

response = requests.post(
    "https://api.zoomhub.net/v2/viewers",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "gallery": {
            "collection_id": "Smp7Set9Abc"
        },
        "title": "Sample gallery",
        "type": "gallery"
    },
)
print(response.json())
Response201
{
  "gallery": {
    "arrows": "controls",
    "autoplay": null,
    "background": "black",
    "captions": "shown",
    "collection_id": "Smp7Set9Abc",
    "controls": "full",
    "layout": "justified",
    "motion": "drift",
    "navigation": "arrows"
  },
  "id": "Smp7View9Ab",
  "links": {
    "embed": "https://zoomhub.net/v/Smp7View9Ab/embed",
    "script": "https://zoomhub.net/v/Smp7View9Ab.js",
    "self": "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
    "share": "https://zoomhub.net/v/Smp7View9Ab"
  },
  "object": "viewer",
  "title": "Sample gallery",
  "type": "gallery"
}

Responses

  • 201Created

    The new viewer object.

  • 400Bad Requestbad_request

    The body isn’t JSON, misses a required key, has a key the endpoint doesn’t know, or breaks a limit. The detail says which.

  • 400Bad Requestbad_request

    The collection or image has no owner, so it can’t have a viewer.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns what the viewer shows nor is an admin.

  • The owner’s plan doesn’t include an option you set: hiding the logo, or a slideshow.

  • 404Not Foundnot_found

    The collection doesn’t exist.

  • 404Not Foundcontent_not_found

    The image doesn’t exist.

Retrieve a viewer

GET/v2/viewers/{id}API key optional

Returns the viewer with the given ID.

Anyone may read a viewer whose collection or image they may view. Its owner may always read it: send the owner’s key.

Reading what isn’t Protected needs no key: leave out the Authorization header. A key that fails to verify counts as no key: what’s public answers as usual, and what’s Protected answers 403 forbidden.

Path parameters

  • idstringRequired

    The viewer’s ID.

curl "https://api.zoomhub.net/v2/viewers/Smp7View9Ab"
const apiKey = process.env.ZOOMHUB_API_KEY
const response = await fetch("https://api.zoomhub.net/v2/viewers/Smp7View9Ab", {
  headers: {
    ...(apiKey && { Authorization: `Bearer ${apiKey}` }),
  },
})
console.log(await response.json())
import os

import requests

api_key = os.environ.get("ZOOMHUB_API_KEY")
response = requests.get(
    "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
    headers={"Authorization": "Bearer " + api_key} if api_key else {},
)
print(response.json())
Response200
{
  "gallery": {
    "arrows": "controls",
    "autoplay": null,
    "background": "black",
    "captions": "shown",
    "collection_id": "Smp7Set9Abc",
    "controls": "full",
    "layout": "justified",
    "motion": "drift",
    "navigation": "arrows"
  },
  "id": "Smp7View9Ab",
  "links": {
    "embed": "https://zoomhub.net/v/Smp7View9Ab/embed",
    "script": "https://zoomhub.net/v/Smp7View9Ab.js",
    "self": "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
    "share": "https://zoomhub.net/v/Smp7View9Ab"
  },
  "object": "viewer",
  "title": "Sample gallery",
  "type": "gallery"
}

Responses

  • 200OK

    The viewer object.

  • 403Forbiddenforbidden

    The collection or image the viewer shows is Protected and the key’s account may not view it. The answer is the same whatever the reason, so it doesn’t reveal which rule refused.

  • 404Not Foundnot_found

    No viewer has this ID, it was deleted, or what it shows was.

List viewers

GET/v2/viewersAPI key optional

Returns the viewers of a collection or of an image.

Name exactly one of collection_id and content_id. A collection or image that doesn’t exist has no viewers, so it answers an empty list rather than 404.

Reading what isn’t Protected needs no key: leave out the Authorization header. A key that fails to verify counts as no key: what’s public answers as usual, and what’s Protected answers 403 forbidden.

Query parameters

  • collection_idstringOptional

    List the galleries of this collection.

  • content_idstringOptional

    List the viewers of this image.

curl "https://api.zoomhub.net/v2/viewers?collection_id=Smp7Set9Abc"
const apiKey = process.env.ZOOMHUB_API_KEY
const response = await fetch("https://api.zoomhub.net/v2/viewers?collection_id=Smp7Set9Abc", {
  headers: {
    ...(apiKey && { Authorization: `Bearer ${apiKey}` }),
  },
})
console.log(await response.json())
import os

import requests

api_key = os.environ.get("ZOOMHUB_API_KEY")
response = requests.get(
    "https://api.zoomhub.net/v2/viewers?collection_id=Smp7Set9Abc",
    headers={"Authorization": "Bearer " + api_key} if api_key else {},
)
print(response.json())
Response200
{
  "data": [
    {
      "gallery": {
        "arrows": "controls",
        "autoplay": null,
        "background": "black",
        "captions": "shown",
        "collection_id": "Smp7Set9Abc",
        "controls": "full",
        "layout": "justified",
        "motion": "drift",
        "navigation": "arrows"
      },
      "id": "Smp7View9Ab",
      "links": {
        "embed": "https://zoomhub.net/v/Smp7View9Ab/embed",
        "script": "https://zoomhub.net/v/Smp7View9Ab.js",
        "self": "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
        "share": "https://zoomhub.net/v/Smp7View9Ab"
      },
      "object": "viewer",
      "title": "Sample gallery",
      "type": "gallery"
    }
  ],
  "has_more": false,
  "object": "list"
}

Responses

  • 200OK

    A list object whose data holds the viewer objects; empty when there are none.

  • 400Bad Requestbad_request

    Both collection_id and content_id were sent, or neither.

  • 400Bad Requestbad_request

    The collection_id or content_id can’t be an ID.

  • 403Forbiddenforbidden

    The collection or image is Protected and the key’s account may not view it. The answer is the same whatever the reason, so it doesn’t reveal which rule refused.

Update a viewer

PATCH/v2/viewers/{id}API key

Changes a viewer’s title, options, or what it shows.

Only the viewer’s owner, or an admin, may update it. Send only what changes: an option you leave out keeps its value. Send options under the viewer’s own type; its type can’t change.

A downgraded owner keeps a slideshow or a hidden logo set before, and can still change other options; turning either on again takes a paid plan.

Path parameters

  • idstringRequired

    The viewer’s ID.

Body parameters

  • typeenumOptional

    Accepted and ignored when it names a viewer type, since a viewer’s type can’t change; any other value is refused.

    Possible values

    • galleryShows a collection.
    • imageShows one image.
  • titlestringOptionalNullable

    The new title; null removes it. At most 250 characters on a single line, and not empty; surrounding whitespace is trimmed.

  • galleryobjectOptional

    A gallery’s options to change.

    Show child parametersHide child parameters
    • collection_idstringOptional

      The collection the gallery shows. It must belong to the viewer’s owner.

    • layoutenumOptional

      How the gallery lays out its images. Every layout keeps the collection’s order.

      Possible values

      • justifiedRows of equal height, filled edge to edge.
      • gridEqual cells.
      • stripOne row to scroll through.
    • backgroundstringOptional

      The gallery’s backdrop: black, white, or a hex color such as #1c1c1f.

    • captionsenumOptional

      Whether each image’s title shows beneath it.

      Possible values

      • shown
      • hidden
    • controlsenumOptional

      Which controls the gallery shows.

      Possible values

      • fullEvery control, including the layout toggle.
      • minimalOnly the essential controls.
      • fixed-layoutEvery control but the layout toggle: opening an image frames it closely, and going home steps back to the wall.
    • navigationenumOptional

      How visitors move between images.

      Possible values

      • arrowsPrevious and next arrows.
      • numbersA ribbon of numbered pills on top, one per image.
    • arrowsenumOptional

      Where the previous and next arrows sit.

      Possible values

      • controlsWith the other controls.
      • sidesAs large buttons on the gallery’s left and right edges, along the bottom on a narrow or touch screen.
    • autoplayintegerOptionalNullable

      Plays the gallery as a slideshow, holding each image this many seconds: 3 to 60. null is off. Slideshows are part of paid plans.

    • motionenumOptional

      How a slideshow holds each image. It matters only while the slideshow plays.

      Possible values

      • driftEach image drifts slowly in toward its center.
      • stillEach image holds still.
    • accent_colorstringOptionalNullable

      The color the viewer highlights with, such as hover outlines, active controls and focus rings, as #rgb or #rrggbb. Left out, the viewer keeps ZoomHub’s own. null removes it.

  • imageobjectOptional

    An image viewer’s options to change.

    Show child parametersHide child parameters
    • content_idstringOptional

      The image the viewer shows, by content ID. It must belong to the viewer’s owner.

    • headingenumOptional

      What shows in the top-left corner.

      Possible values

      • markThe ZoomHub logo.
      • hiddenNothing. Hiding the logo is part of paid plans.
    • controlsarray of stringsOptional

      The controls the viewer shows, each at most once; an empty array shows none.

      Possible values

      • zoomZoom in and out.
      • resetReturn to the whole image.
      • fullscreenFill the screen.
      • shareShare the image.
      • view_originalOpen the original file.
    • minimapenumOptional

      Whether the navigator shows in the top-right corner.

      Possible values

      • shown
      • hidden
    • metadataenumOptional

      Whether the image’s pixel dimensions show in the top-right corner.

      Possible values

      • shown
      • hidden
    • backgroundenumOptional

      The backdrop behind the image.

      Possible values

      • blackBlack.
      • whiteWhite.
      • noneTransparent: the page shows through.
    • fitenumOptional

      How the image sits in its frame before anyone zooms.

      Possible values

      • containThe whole image, letterboxed.
      • coverThe frame filled, the image cropped.
    • accent_colorstringOptionalNullable

      The color the viewer highlights with, such as hover outlines, active controls and focus rings, as #rgb or #rrggbb. Left out, the viewer keeps ZoomHub’s own. null removes it.

    • constrainenumOptionalNullable

      How far visitors may zoom out and pan. Left out, they may zoom out a little past the fitted view and pan loosely. null removes it.

      Possible values

      • zoomNo further out than the fitted view.
      • fullNo further out than the fitted view, and never past the image’s edges.
curl "https://api.zoomhub.net/v2/viewers/Smp7View9Ab" \
  --request PATCH \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "gallery": {
      "accent_color": null,
      "captions": "shown"
    },
    "title": "Sample gallery"
  }'
const response = await fetch("https://api.zoomhub.net/v2/viewers/Smp7View9Ab", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "gallery": {
      "accent_color": null,
      "captions": "shown"
    },
    "title": "Sample gallery"
  }),
})
console.log(await response.json())
import os

import requests

response = requests.patch(
    "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
    json={
        "gallery": {
            "accent_color": None,
            "captions": "shown"
        },
        "title": "Sample gallery"
    },
)
print(response.json())
Response200
{
  "gallery": {
    "arrows": "controls",
    "autoplay": null,
    "background": "black",
    "captions": "shown",
    "collection_id": "Smp7Set9Abc",
    "controls": "full",
    "layout": "justified",
    "motion": "drift",
    "navigation": "arrows"
  },
  "id": "Smp7View9Ab",
  "links": {
    "embed": "https://zoomhub.net/v/Smp7View9Ab/embed",
    "script": "https://zoomhub.net/v/Smp7View9Ab.js",
    "self": "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
    "share": "https://zoomhub.net/v/Smp7View9Ab"
  },
  "object": "viewer",
  "title": "Sample gallery",
  "type": "gallery"
}

Responses

  • 200OK

    The updated viewer object.

  • 400Bad Requestbad_request

    The body isn’t JSON, misses a required key, has a key the endpoint doesn’t know, or breaks a limit. The detail says which.

  • 400Bad Requestbad_request

    The options were sent under the other viewer type.

  • The new collection or image belongs to someone other than the viewer’s owner.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns what the viewer shows nor is an admin.

  • The owner’s plan doesn’t include an option you turned on: hiding the logo, or a slideshow.

  • 404Not Foundnot_found

    No viewer has this ID, or the new collection doesn’t exist.

  • 404Not Foundcontent_not_found

    The new image doesn’t exist.

Delete a viewer

DELETE/v2/viewers/{id}API key

Deletes a viewer, but not what it shows.

Only the viewer’s owner, or an admin, may delete it, whatever the owner’s plan. Its page and embeds answer 404 from then on.

Path parameters

  • idstringRequired

    The viewer’s ID.

curl "https://api.zoomhub.net/v2/viewers/Smp7View9Ab" \
  --request DELETE \
  --header "Authorization: Bearer $ZOOMHUB_API_KEY"
const response = await fetch("https://api.zoomhub.net/v2/viewers/Smp7View9Ab", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}`,
  },
})
console.log(response.status)
import os

import requests

response = requests.delete(
    "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
    headers={"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]},
)
print(response.status_code)
Response204

204 No Content

Deleted.

Responses

  • 204No Content

    Deleted.

  • 401Unauthorizednot_authenticated

    No Authorization header.

  • 401Unauthorizedinvalid_api_key

    The key is unknown or revoked, or the header isn’t a Bearer key.

  • 403Forbiddenplan_required

    The key is valid, but its account’s plan doesn’t include the API.

  • 403Forbiddenforbidden

    The key’s account neither owns what the viewer shows nor is an admin.

  • 404Not Foundnot_found

    No viewer has this ID, or it was deleted.

View as MarkdownGuides

Each guide is a complete program, in curl, JavaScript and Python, that you can save and run as it is. Its steps walk through the program part by part, and link to the endpoints each part calls.

The programs read your key from the ZOOMHUB_API_KEY environment variable. Paste a key into “Use my key” to write it into the programs instead, then keep the copies to yourself.

Upload an image and embed it

Uploads an image from your computer, waits until it is zoomable, and prints the snippet that embeds it.

You need an API key and an image of at most 5 GB: GIF, JPEG, PNG, TIFF or WebP. Run the program with the image’s path.

The curl program needs bash, curl and jq; the JavaScript one, Node.js 20 or later; the Python one, Python 3.8 or later and Requests. The first lines of each say how to run it.

  1. Set up
  2. Create an upload
  3. Send the file to storage
  4. Complete the upload
  5. Wait until it is zoomable
  6. Embed it
Program
#!/usr/bin/env bash
# Uploads a local image to ZoomHub, waits until it is zoomable and
# prints the snippet that embeds it.
#
#   ZOOMHUB_API_KEY=<your key> bash upload-and-embed.sh photo.jpg
#
# Needs curl and jq.

set -euo pipefail

api="https://api.zoomhub.net"
auth="Authorization: Bearer $ZOOMHUB_API_KEY"

if [ $# -ne 1 ]; then
  echo "Usage: bash upload-and-embed.sh <image file>" >&2
  exit 1
fi
file=$1

# Prints the body of a 2xx answer; prints any other answer and fails.
request() {
  local response status
  response=$(curl --silent --show-error --write-out '\n%{http_code}' "$@") || return
  status=${response##*$'\n'}
  if [ "$status" -lt 200 ] || [ "$status" -gt 299 ]; then
    echo "HTTP $status: ${response%$'\n'*}" >&2
    return 1
  fi
  printf '%s\n' "${response%$'\n'*}"
}

case "$(printf '%s' "${file##*.}" | tr '[:upper:]' '[:lower:]')" in
  gif) content_type="image/gif" ;;
  jpeg | jpg) content_type="image/jpeg" ;;
  png) content_type="image/png" ;;
  tif | tiff) content_type="image/tiff" ;;
  webp) content_type="image/webp" ;;
  *)
    echo "Not an image ZoomHub takes: $file" >&2
    exit 1
    ;;
esac
filename=$(basename "$file")

body=$(jq --null-input --arg filename "$filename" --arg content_type "$content_type" \
  --argjson size_bytes "$(wc -c <"$file")" \
  '{filename: $filename, content_type: $content_type, size_bytes: $size_bytes}')
upload=$(request --header "$auth" --header "Content-Type: application/json" \
  --data "$body" "$api/v2/uploads")

echo "Uploading $filename ($content_type)…" >&2
request --upload-file "$file" "$(jq --raw-output '.parts[0].url' <<<"$upload")" >/dev/null

echo "Uploaded. Completing the upload…" >&2
upload=$(request --request POST --header "$auth" \
  "$api/v2/uploads/$(jq --raw-output .id <<<"$upload")/complete")
id=$(jq --raw-output .asset_id <<<"$upload")

deadline=$((SECONDS + 30 * 60))
delay=1
asset=$(request --header "$auth" "$api/v2/assets/$id")
until [ "$(jq --raw-output .status <<<"$asset")" = succeeded ]; do
  if [ "$(jq --raw-output .status <<<"$asset")" = failed ]; then
    echo "Processing $id failed." >&2
    exit 1
  fi
  if [ "$SECONDS" -gt "$deadline" ]; then
    echo "$id is still processing. Check on it later." >&2
    exit 1
  fi
  echo "Processing $id: $(jq '.progress * 100 | floor' <<<"$asset")%" >&2
  sleep "$delay"
  delay=$((delay * 2 > 30 ? 30 : delay * 2))
  asset=$(request --header "$auth" "$api/v2/assets/$id")
done

echo "Ready: $(jq --raw-output .links.share <<<"$asset")"
echo
echo "Embed it with:"
echo "<script src=\"$(jq --raw-output .links.embed <<<"$asset")\"></script>"
// Uploads a local image to ZoomHub, waits until it is zoomable and
// prints the snippet that embeds it.
//
//   ZOOMHUB_API_KEY=<your key> node upload-and-embed.mjs photo.jpg
//
// Needs Node.js 20 or later.

import { readFile } from "node:fs/promises"
import { basename, extname } from "node:path"

const api = "https://api.zoomhub.net"
const headers = { Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}` }
const jsonHeaders = { ...headers, "Content-Type": "application/json" }

if (process.argv.length !== 3) {
  console.error("Usage: node upload-and-embed.mjs <image file>")
  process.exit(1)
}
const file = process.argv[2]

// Returns a 2xx answer; throws on any other.
async function request(url, options) {
  const response = await fetch(url, options)
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${await response.text()}`)
  }
  return response
}

const mediaTypes = {
  ".gif": "image/gif",
  ".jpeg": "image/jpeg",
  ".jpg": "image/jpeg",
  ".png": "image/png",
  ".tif": "image/tiff",
  ".tiff": "image/tiff",
  ".webp": "image/webp",
}
const contentType = mediaTypes[extname(file).toLowerCase()]
if (!contentType) {
  console.error(`Not an image ZoomHub takes: ${file}`)
  process.exit(1)
}
const filename = basename(file)
const bytes = await readFile(file)

let upload = await (
  await request(`${api}/v2/uploads`, {
    method: "POST",
    headers: jsonHeaders,
    body: JSON.stringify({
      filename,
      content_type: contentType,
      size_bytes: bytes.length,
    }),
  })
).json()

console.error(`Uploading ${filename} (${contentType})…`)
await request(upload.parts[0].url, { method: "PUT", body: bytes })

console.error("Uploaded. Completing the upload…")
upload = await (
  await request(`${api}/v2/uploads/${upload.id}/complete`, {
    method: "POST",
    headers,
  })
).json()
const id = upload.asset_id

const deadline = Date.now() + 30 * 60 * 1000
let delay = 1000
let asset = await (await request(`${api}/v2/assets/${id}`, { headers })).json()
while (asset.status !== "succeeded") {
  if (asset.status === "failed") {
    console.error(`Processing ${id} failed.`)
    process.exit(1)
  }
  if (Date.now() > deadline) {
    console.error(`${id} is still processing. Check on it later.`)
    process.exit(1)
  }
  console.error(`Processing ${id}: ${Math.floor(asset.progress * 100)}%`)
  await new Promise((resolve) => setTimeout(resolve, delay))
  delay = Math.min(delay * 2, 30 * 1000)
  asset = await (await request(`${api}/v2/assets/${id}`, { headers })).json()
}

console.log(`Ready: ${asset.links.share}`)
console.log()
console.log("Embed it with:")
console.log(`<script src="${asset.links.embed}"></script>`)
# Uploads a local image to ZoomHub, waits until it is zoomable and
# prints the snippet that embeds it.
#
#   ZOOMHUB_API_KEY=<your key> python3 upload_and_embed.py photo.jpg
#
# Needs Python 3.8 or later and Requests (pip install requests).

import os
import sys
import time
from pathlib import Path

import requests

API = "https://api.zoomhub.net"
HEADERS = {"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]}
# Seconds to wait on the network for each answer; without a timeout,
# Requests waits forever on a stalled connection.
TIMEOUT = 60

if len(sys.argv) != 2:
    sys.exit("Usage: python3 upload_and_embed.py <image file>")
file = Path(sys.argv[1])


def check(response):
    """Returns a 2xx answer; exits on any other."""
    if not 200 <= response.status_code < 300:
        sys.exit(f"HTTP {response.status_code}: {response.text}")
    return response


MEDIA_TYPES = {
    ".gif": "image/gif",
    ".jpeg": "image/jpeg",
    ".jpg": "image/jpeg",
    ".png": "image/png",
    ".tif": "image/tiff",
    ".tiff": "image/tiff",
    ".webp": "image/webp",
}
content_type = MEDIA_TYPES.get(file.suffix.lower())
if content_type is None:
    sys.exit(f"Not an image ZoomHub takes: {file}")

upload = check(
    requests.post(
        f"{API}/v2/uploads",
        json={
            "filename": file.name,
            "content_type": content_type,
            "size_bytes": file.stat().st_size,
        },
        headers=HEADERS,
        timeout=TIMEOUT,
    )
).json()

print(f"Uploading {file.name} ({content_type})…", file=sys.stderr)
# Sent from the open file, with its length, which storage needs.
with file.open("rb") as image:
    check(requests.put(upload["parts"][0]["url"], data=image, timeout=TIMEOUT))

print("Uploaded. Completing the upload…", file=sys.stderr)
upload = check(
    requests.post(
        f"{API}/v2/uploads/{upload['id']}/complete",
        headers=HEADERS,
        timeout=TIMEOUT,
    )
).json()
asset_id = upload["asset_id"]

deadline = time.monotonic() + 30 * 60
delay = 1
while True:
    asset = check(
        requests.get(
            f"{API}/v2/assets/{asset_id}",
            headers=HEADERS,
            timeout=TIMEOUT,
        )
    ).json()
    if asset["status"] == "succeeded":
        break
    if asset["status"] == "failed":
        sys.exit(f"Processing {asset_id} failed.")
    if time.monotonic() > deadline:
        sys.exit(f"{asset_id} is still processing. Check on it later.")
    print(
        f"Processing {asset_id}: {int(asset['progress'] * 100)}%",
        file=sys.stderr,
    )
    time.sleep(delay)
    delay = min(delay * 2, 30)

print(f"Ready: {asset['links']['share']}")
print()
print("Embed it with:")
print(f'<script src="{asset["links"]["embed"]}"></script>')
OutputExample run
Uploading photo.jpg (image/jpeg)…
Uploaded. Completing the upload…
Processing smpAsset1: 0%
Processing smpAsset1: 40%
Ready: https://zoomhub.net/a/smpAsset1

Embed it with:
<script src="https://zoomhub.net/a/smpAsset1.js"></script>
  1. Set up

    Every request sends your key in the Authorization header (see Authentication). A helper turns any answer but a 2xx into an error, so the program stops at the first refusal and shows it; the error object says what went wrong.

    set -euo pipefail
    
    api="https://api.zoomhub.net"
    auth="Authorization: Bearer $ZOOMHUB_API_KEY"
    
    if [ $# -ne 1 ]; then
      echo "Usage: bash upload-and-embed.sh <image file>" >&2
      exit 1
    fi
    file=$1
    
    # Prints the body of a 2xx answer; prints any other answer and fails.
    request() {
      local response status
      response=$(curl --silent --show-error --write-out '\n%{http_code}' "$@") || return
      status=${response##*$'\n'}
      if [ "$status" -lt 200 ] || [ "$status" -gt 299 ]; then
        echo "HTTP $status: ${response%$'\n'*}" >&2
        return 1
      fi
      printf '%s\n' "${response%$'\n'*}"
    }
    import { readFile } from "node:fs/promises"
    import { basename, extname } from "node:path"
    
    const api = "https://api.zoomhub.net"
    const headers = { Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}` }
    const jsonHeaders = { ...headers, "Content-Type": "application/json" }
    
    if (process.argv.length !== 3) {
      console.error("Usage: node upload-and-embed.mjs <image file>")
      process.exit(1)
    }
    const file = process.argv[2]
    
    // Returns a 2xx answer; throws on any other.
    async function request(url, options) {
      const response = await fetch(url, options)
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${await response.text()}`)
      }
      return response
    }
    import os
    import sys
    import time
    from pathlib import Path
    
    import requests
    
    API = "https://api.zoomhub.net"
    HEADERS = {"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]}
    # Seconds to wait on the network for each answer; without a timeout,
    # Requests waits forever on a stalled connection.
    TIMEOUT = 60
    
    if len(sys.argv) != 2:
        sys.exit("Usage: python3 upload_and_embed.py <image file>")
    file = Path(sys.argv[1])
    
    
    def check(response):
        """Returns a 2xx answer; exits on any other."""
        if not 200 <= response.status_code < 300:
            sys.exit(f"HTTP {response.status_code}: {response.text}")
        return response
  2. Create an upload

    Create an upload with the file’s name, media type and size. The upload it answers with lists the file’s parts: one, by default, with the URL to send the whole file to. The URL works for a day.

    case "$(printf '%s' "${file##*.}" | tr '[:upper:]' '[:lower:]')" in
      gif) content_type="image/gif" ;;
      jpeg | jpg) content_type="image/jpeg" ;;
      png) content_type="image/png" ;;
      tif | tiff) content_type="image/tiff" ;;
      webp) content_type="image/webp" ;;
      *)
        echo "Not an image ZoomHub takes: $file" >&2
        exit 1
        ;;
    esac
    filename=$(basename "$file")
    
    body=$(jq --null-input --arg filename "$filename" --arg content_type "$content_type" \
      --argjson size_bytes "$(wc -c <"$file")" \
      '{filename: $filename, content_type: $content_type, size_bytes: $size_bytes}')
    upload=$(request --header "$auth" --header "Content-Type: application/json" \
      --data "$body" "$api/v2/uploads")
    const mediaTypes = {
      ".gif": "image/gif",
      ".jpeg": "image/jpeg",
      ".jpg": "image/jpeg",
      ".png": "image/png",
      ".tif": "image/tiff",
      ".tiff": "image/tiff",
      ".webp": "image/webp",
    }
    const contentType = mediaTypes[extname(file).toLowerCase()]
    if (!contentType) {
      console.error(`Not an image ZoomHub takes: ${file}`)
      process.exit(1)
    }
    const filename = basename(file)
    const bytes = await readFile(file)
    
    let upload = await (
      await request(`${api}/v2/uploads`, {
        method: "POST",
        headers: jsonHeaders,
        body: JSON.stringify({
          filename,
          content_type: contentType,
          size_bytes: bytes.length,
        }),
      })
    ).json()
    MEDIA_TYPES = {
        ".gif": "image/gif",
        ".jpeg": "image/jpeg",
        ".jpg": "image/jpeg",
        ".png": "image/png",
        ".tif": "image/tiff",
        ".tiff": "image/tiff",
        ".webp": "image/webp",
    }
    content_type = MEDIA_TYPES.get(file.suffix.lower())
    if content_type is None:
        sys.exit(f"Not an image ZoomHub takes: {file}")
    
    upload = check(
        requests.post(
            f"{API}/v2/uploads",
            json={
                "filename": file.name,
                "content_type": content_type,
                "size_bytes": file.stat().st_size,
            },
            headers=HEADERS,
            timeout=TIMEOUT,
        )
    ).json()
  3. Send the file to storage

    PUT the file’s bytes to the part’s url, with its length. Storage answers 200.

    The part’s url is storage’s, not ZoomHub’s: don’t send your key there.

    echo "Uploading $filename ($content_type)…" >&2
    request --upload-file "$file" "$(jq --raw-output '.parts[0].url' <<<"$upload")" >/dev/null
    console.error(`Uploading ${filename} (${contentType})…`)
    await request(upload.parts[0].url, { method: "PUT", body: bytes })
    print(f"Uploading {file.name} ({content_type})…", file=sys.stderr)
    # Sent from the open file, with its length, which storage needs.
    with file.open("rb") as image:
        check(requests.put(upload["parts"][0]["url"], data=image, timeout=TIMEOUT))
  4. Complete the upload

    Complete an upload once the file is in storage. That makes it an asset, which is yours and starts processing at once; the completed upload names it as asset_id. A body could set the asset’s title and other settings; this program sends none.

    echo "Uploaded. Completing the upload…" >&2
    upload=$(request --request POST --header "$auth" \
      "$api/v2/uploads/$(jq --raw-output .id <<<"$upload")/complete")
    id=$(jq --raw-output .asset_id <<<"$upload")
    console.error("Uploaded. Completing the upload…")
    upload = await (
      await request(`${api}/v2/uploads/${upload.id}/complete`, {
        method: "POST",
        headers,
      })
    ).json()
    const id = upload.asset_id
    print("Uploaded. Completing the upload…", file=sys.stderr)
    upload = check(
        requests.post(
            f"{API}/v2/uploads/{upload['id']}/complete",
            headers=HEADERS,
            timeout=TIMEOUT,
        )
    ).json()
    asset_id = upload["asset_id"]
  5. Wait until it is zoomable

    Poll Retrieve an asset until its status is succeeded or failed, waiting twice as long after each attempt, up to 30 seconds. progress goes from 0 to 1 on the way.

    Processing takes from seconds to minutes. The program gives up after 30 minutes; the asset stays, so you can look it up later by its ID.

    deadline=$((SECONDS + 30 * 60))
    delay=1
    asset=$(request --header "$auth" "$api/v2/assets/$id")
    until [ "$(jq --raw-output .status <<<"$asset")" = succeeded ]; do
      if [ "$(jq --raw-output .status <<<"$asset")" = failed ]; then
        echo "Processing $id failed." >&2
        exit 1
      fi
      if [ "$SECONDS" -gt "$deadline" ]; then
        echo "$id is still processing. Check on it later." >&2
        exit 1
      fi
      echo "Processing $id: $(jq '.progress * 100 | floor' <<<"$asset")%" >&2
      sleep "$delay"
      delay=$((delay * 2 > 30 ? 30 : delay * 2))
      asset=$(request --header "$auth" "$api/v2/assets/$id")
    done
    const deadline = Date.now() + 30 * 60 * 1000
    let delay = 1000
    let asset = await (await request(`${api}/v2/assets/${id}`, { headers })).json()
    while (asset.status !== "succeeded") {
      if (asset.status === "failed") {
        console.error(`Processing ${id} failed.`)
        process.exit(1)
      }
      if (Date.now() > deadline) {
        console.error(`${id} is still processing. Check on it later.`)
        process.exit(1)
      }
      console.error(`Processing ${id}: ${Math.floor(asset.progress * 100)}%`)
      await new Promise((resolve) => setTimeout(resolve, delay))
      delay = Math.min(delay * 2, 30 * 1000)
      asset = await (await request(`${api}/v2/assets/${id}`, { headers })).json()
    }
    deadline = time.monotonic() + 30 * 60
    delay = 1
    while True:
        asset = check(
            requests.get(
                f"{API}/v2/assets/{asset_id}",
                headers=HEADERS,
                timeout=TIMEOUT,
            )
        ).json()
        if asset["status"] == "succeeded":
            break
        if asset["status"] == "failed":
            sys.exit(f"Processing {asset_id} failed.")
        if time.monotonic() > deadline:
            sys.exit(f"{asset_id} is still processing. Check on it later.")
        print(
            f"Processing {asset_id}: {int(asset['progress'] * 100)}%",
            file=sys.stderr,
        )
        time.sleep(delay)
        delay = min(delay * 2, 30)
  6. Embed it

    links.embed is a script that puts a viewer for the image where its <script> tag is: paste the tag where the image should appear on your page. links.share is the image’s page on ZoomHub.

    The script takes options for its size, fit, background and more: see the embed options.

    echo "Ready: $(jq --raw-output .links.share <<<"$asset")"
    echo
    echo "Embed it with:"
    echo "<script src=\"$(jq --raw-output .links.embed <<<"$asset")\"></script>"
    console.log(`Ready: ${asset.links.share}`)
    console.log()
    console.log("Embed it with:")
    console.log(`<script src="${asset.links.embed}"></script>`)
    print(f"Ready: {asset['links']['share']}")
    print()
    print("Embed it with:")
    print(f'<script src="{asset["links"]["embed"]}"></script>')
    LiveWhat the snippet shows, here with ZoomHub’s demo image and sized to this column; the snippet itself uses width=auto. Drag to pan, and scroll or pinch to zoom.

Configure and embed a viewer

Creates a viewer for one of your images, changes one of its options, lists the image’s viewers, and prints the snippet that embeds the new one.

You need an API key and the ID of an image of yours, such as the one Upload an image and embed it prints. Run the program with the ID.

The curl program needs bash, curl and jq; the JavaScript one, Node.js 20 or later; the Python one, Python 3.8 or later and Requests. The first lines of each say how to run it.

  1. Set up
  2. Create an image viewer
  3. Change an option
  4. List the image’s viewers
  5. Embed it
Program
#!/usr/bin/env bash
# Creates a viewer for one of your images, changes one of its options,
# lists the image’s viewers and prints the snippet that embeds the new
# one.
#
#   ZOOMHUB_API_KEY=<your key> bash configure-viewer.sh <content ID>
#
# Needs curl and jq.

set -euo pipefail

api="https://api.zoomhub.net"
auth="Authorization: Bearer $ZOOMHUB_API_KEY"

if [ $# -ne 1 ]; then
  echo "Usage: bash configure-viewer.sh <content ID>" >&2
  exit 1
fi
content_id=$1

# Prints the body of a 2xx answer; prints any other answer and fails.
request() {
  local response status
  response=$(curl --silent --show-error --write-out '\n%{http_code}' "$@") || return
  status=${response##*$'\n'}
  if [ "$status" -lt 200 ] || [ "$status" -gt 299 ]; then
    echo "HTTP $status: ${response%$'\n'*}" >&2
    return 1
  fi
  printf '%s\n' "${response%$'\n'*}"
}

body=$(jq --null-input --arg content_id "$content_id" '{
  type: "image",
  title: "My viewer",
  image: {
    content_id: $content_id,
    fit: "cover",
    minimap: "shown",
    controls: ["zoom", "reset", "fullscreen"],
    accent_color: "#3a8fd9"
  }
}')
viewer=$(request --header "$auth" --header "Content-Type: application/json" \
  --data "$body" "$api/v2/viewers")
viewer_id=$(jq --raw-output .id <<<"$viewer")
echo "Created viewer $viewer_id" >&2

viewer=$(request --request PATCH --header "$auth" --header "Content-Type: application/json" \
  --data '{"image": {"background": "white"}}' "$api/v2/viewers/$viewer_id")
echo "Background: $(jq --raw-output .image.background <<<"$viewer")" >&2

echo "Viewers of $content_id:" >&2
request --header "$auth" "$api/v2/viewers?content_id=$content_id" |
  jq --raw-output '.[] | "  \(.id)  \(.title // "(untitled)")"' >&2

echo "Ready: $(jq --raw-output .links.share <<<"$viewer")"
echo
echo "Embed it with:"
echo "<script src=\"$(jq --raw-output .links.script <<<"$viewer")\"></script>"
// Creates a viewer for one of your images, changes one of its options,
// lists the image’s viewers and prints the snippet that embeds the new
// one.
//
//   ZOOMHUB_API_KEY=<your key> node configure-viewer.mjs <content ID>
//
// Needs Node.js 20 or later.

const api = "https://api.zoomhub.net"
const headers = { Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}` }
const jsonHeaders = { ...headers, "Content-Type": "application/json" }

if (process.argv.length !== 3) {
  console.error("Usage: node configure-viewer.mjs <content ID>")
  process.exit(1)
}
const contentId = process.argv[2]

// Returns the JSON of a 2xx answer; throws on any other.
async function request(url, options) {
  const response = await fetch(url, options)
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${await response.text()}`)
  }
  return response.json()
}

let viewer = await request(`${api}/v2/viewers`, {
  method: "POST",
  headers: jsonHeaders,
  body: JSON.stringify({
    type: "image",
    title: "My viewer",
    image: {
      content_id: contentId,
      fit: "cover",
      minimap: "shown",
      controls: ["zoom", "reset", "fullscreen"],
      accent_color: "#3a8fd9",
    },
  }),
})
console.error(`Created viewer ${viewer.id}`)

viewer = await request(`${api}/v2/viewers/${viewer.id}`, {
  method: "PATCH",
  headers: jsonHeaders,
  body: JSON.stringify({ image: { background: "white" } }),
})
console.error(`Background: ${viewer.image.background}`)

const query = new URLSearchParams({ content_id: contentId })
const viewers = await request(`${api}/v2/viewers?${query}`, { headers })
console.error(`Viewers of ${contentId}:`)
for (const { id, title } of viewers) {
  console.error(`  ${id}  ${title ?? "(untitled)"}`)
}

console.log(`Ready: ${viewer.links.share}`)
console.log()
console.log("Embed it with:")
console.log(`<script src="${viewer.links.script}"></script>`)
# Creates a viewer for one of your images, changes one of its options,
# lists the image’s viewers and prints the snippet that embeds the new
# one.
#
#   ZOOMHUB_API_KEY=<your key> python3 configure_viewer.py <content ID>
#
# Needs Python 3.8 or later and Requests (pip install requests).

import os
import sys

import requests

API = "https://api.zoomhub.net"
HEADERS = {"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]}
# Seconds to wait on the network for each answer; without a timeout,
# Requests waits forever on a stalled connection.
TIMEOUT = 60

if len(sys.argv) != 2:
    sys.exit("Usage: python3 configure_viewer.py <content ID>")
content_id = sys.argv[1]


def check(response):
    """Returns the JSON of a 2xx answer; exits on any other."""
    if not 200 <= response.status_code < 300:
        sys.exit(f"HTTP {response.status_code}: {response.text}")
    return response.json()


viewer = check(
    requests.post(
        f"{API}/v2/viewers",
        json={
            "type": "image",
            "title": "My viewer",
            "image": {
                "content_id": content_id,
                "fit": "cover",
                "minimap": "shown",
                "controls": ["zoom", "reset", "fullscreen"],
                "accent_color": "#3a8fd9",
            },
        },
        headers=HEADERS,
        timeout=TIMEOUT,
    )
)
print(f"Created viewer {viewer['id']}", file=sys.stderr)

viewer = check(
    requests.patch(
        f"{API}/v2/viewers/{viewer['id']}",
        json={"image": {"background": "white"}},
        headers=HEADERS,
        timeout=TIMEOUT,
    )
)
print(f"Background: {viewer['image']['background']}", file=sys.stderr)

viewers = check(
    requests.get(
        f"{API}/v2/viewers",
        params={"content_id": content_id},
        headers=HEADERS,
        timeout=TIMEOUT,
    )
)
print(f"Viewers of {content_id}:", file=sys.stderr)
for each in viewers:
    print(f"  {each['id']}  {each['title'] or '(untitled)'}", file=sys.stderr)

print(f"Ready: {viewer['links']['share']}")
print()
print("Embed it with:")
print(f'<script src="{viewer["links"]["script"]}"></script>')
OutputExample run
Created viewer Smp7View9Ab
Background: white
Viewers of Smp7Pic:
  Smp7View9Ab  My viewer
Ready: https://zoomhub.net/v/Smp7View9Ab

Embed it with:
<script src="https://zoomhub.net/v/Smp7View9Ab.js"></script>
  1. Set up

    Every request sends your key in the Authorization header (see Authentication). A helper turns any answer but a 2xx into an error, so the program stops at the first refusal and shows it; the error object says what went wrong.

    set -euo pipefail
    
    api="https://api.zoomhub.net"
    auth="Authorization: Bearer $ZOOMHUB_API_KEY"
    
    if [ $# -ne 1 ]; then
      echo "Usage: bash configure-viewer.sh <content ID>" >&2
      exit 1
    fi
    content_id=$1
    
    # Prints the body of a 2xx answer; prints any other answer and fails.
    request() {
      local response status
      response=$(curl --silent --show-error --write-out '\n%{http_code}' "$@") || return
      status=${response##*$'\n'}
      if [ "$status" -lt 200 ] || [ "$status" -gt 299 ]; then
        echo "HTTP $status: ${response%$'\n'*}" >&2
        return 1
      fi
      printf '%s\n' "${response%$'\n'*}"
    }
    const api = "https://api.zoomhub.net"
    const headers = { Authorization: `Bearer ${process.env.ZOOMHUB_API_KEY}` }
    const jsonHeaders = { ...headers, "Content-Type": "application/json" }
    
    if (process.argv.length !== 3) {
      console.error("Usage: node configure-viewer.mjs <content ID>")
      process.exit(1)
    }
    const contentId = process.argv[2]
    
    // Returns the JSON of a 2xx answer; throws on any other.
    async function request(url, options) {
      const response = await fetch(url, options)
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${await response.text()}`)
      }
      return response.json()
    }
    import os
    import sys
    
    import requests
    
    API = "https://api.zoomhub.net"
    HEADERS = {"Authorization": "Bearer " + os.environ["ZOOMHUB_API_KEY"]}
    # Seconds to wait on the network for each answer; without a timeout,
    # Requests waits forever on a stalled connection.
    TIMEOUT = 60
    
    if len(sys.argv) != 2:
        sys.exit("Usage: python3 configure_viewer.py <content ID>")
    content_id = sys.argv[1]
    
    
    def check(response):
        """Returns the JSON of a 2xx answer; exits on any other."""
        if not 200 <= response.status_code < 300:
            sys.exit(f"HTTP {response.status_code}: {response.text}")
        return response.json()
  2. Create an image viewer

    Create a viewer of type image with the image’s ID and the options to set; the rest take their defaults. This one fills its frame with the image, shows the navigator, offers three controls, and highlights in blue.

    body=$(jq --null-input --arg content_id "$content_id" '{
      type: "image",
      title: "My viewer",
      image: {
        content_id: $content_id,
        fit: "cover",
        minimap: "shown",
        controls: ["zoom", "reset", "fullscreen"],
        accent_color: "#3a8fd9"
      }
    }')
    viewer=$(request --header "$auth" --header "Content-Type: application/json" \
      --data "$body" "$api/v2/viewers")
    viewer_id=$(jq --raw-output .id <<<"$viewer")
    echo "Created viewer $viewer_id" >&2
    let viewer = await request(`${api}/v2/viewers`, {
      method: "POST",
      headers: jsonHeaders,
      body: JSON.stringify({
        type: "image",
        title: "My viewer",
        image: {
          content_id: contentId,
          fit: "cover",
          minimap: "shown",
          controls: ["zoom", "reset", "fullscreen"],
          accent_color: "#3a8fd9",
        },
      }),
    })
    console.error(`Created viewer ${viewer.id}`)
    viewer = check(
        requests.post(
            f"{API}/v2/viewers",
            json={
                "type": "image",
                "title": "My viewer",
                "image": {
                    "content_id": content_id,
                    "fit": "cover",
                    "minimap": "shown",
                    "controls": ["zoom", "reset", "fullscreen"],
                    "accent_color": "#3a8fd9",
                },
            },
            headers=HEADERS,
            timeout=TIMEOUT,
        )
    )
    print(f"Created viewer {viewer['id']}", file=sys.stderr)
  3. Change an option

    Update a viewer with only what changes, under the viewer’s type: every other option keeps its value. Every embed of the viewer follows.

    viewer=$(request --request PATCH --header "$auth" --header "Content-Type: application/json" \
      --data '{"image": {"background": "white"}}' "$api/v2/viewers/$viewer_id")
    echo "Background: $(jq --raw-output .image.background <<<"$viewer")" >&2
    viewer = await request(`${api}/v2/viewers/${viewer.id}`, {
      method: "PATCH",
      headers: jsonHeaders,
      body: JSON.stringify({ image: { background: "white" } }),
    })
    console.error(`Background: ${viewer.image.background}`)
    viewer = check(
        requests.patch(
            f"{API}/v2/viewers/{viewer['id']}",
            json={"image": {"background": "white"}},
            headers=HEADERS,
            timeout=TIMEOUT,
        )
    )
    print(f"Background: {viewer['image']['background']}", file=sys.stderr)
  4. List the image’s viewers

    List viewers with content_id answers with every viewer of the image, the new one among them.

    echo "Viewers of $content_id:" >&2
    request --header "$auth" "$api/v2/viewers?content_id=$content_id" |
      jq --raw-output '.[] | "  \(.id)  \(.title // "(untitled)")"' >&2
    const query = new URLSearchParams({ content_id: contentId })
    const viewers = await request(`${api}/v2/viewers?${query}`, { headers })
    console.error(`Viewers of ${contentId}:`)
    for (const { id, title } of viewers) {
      console.error(`  ${id}  ${title ?? "(untitled)"}`)
    }
    viewers = check(
        requests.get(
            f"{API}/v2/viewers",
            params={"content_id": content_id},
            headers=HEADERS,
            timeout=TIMEOUT,
        )
    )
    print(f"Viewers of {content_id}:", file=sys.stderr)
    for each in viewers:
        print(f"  {each['id']}  {each['title'] or '(untitled)'}", file=sys.stderr)
  5. Embed it

    links.script is a script that puts the viewer where its <script> tag is, and links.share is the viewer’s page on ZoomHub.

    echo "Ready: $(jq --raw-output .links.share <<<"$viewer")"
    echo
    echo "Embed it with:"
    echo "<script src=\"$(jq --raw-output .links.script <<<"$viewer")\"></script>"
    console.log(`Ready: ${viewer.links.share}`)
    console.log()
    console.log("Embed it with:")
    console.log(`<script src="${viewer.links.script}"></script>`)
    print(f"Ready: {viewer['links']['share']}")
    print()
    print("Embed it with:")
    print(f'<script src="{viewer["links"]["script"]}"></script>')
    Example answer
    {
      "id": "Smp7View9Ab",
      "image": {
        "accent_color": "#3a8fd9",
        "background": "white",
        "content_id": "Smp7Pic",
        "controls": [
          "zoom",
          "reset",
          "fullscreen"
        ],
        "fit": "cover",
        "heading": "mark",
        "metadata": "shown",
        "minimap": "shown"
      },
      "links": {
        "embed": "https://zoomhub.net/v/Smp7View9Ab/embed",
        "script": "https://zoomhub.net/v/Smp7View9Ab.js",
        "self": "https://api.zoomhub.net/v2/viewers/Smp7View9Ab",
        "share": "https://zoomhub.net/v/Smp7View9Ab"
      },
      "object": "viewer",
      "title": "My viewer",
      "type": "image"
    }
    Embed
    <script src="https://zoomhub.net/v/Smp7View9Ab.js"></script>