Automating Zenodo Batch Uploads with the REST API

Zenodo is the default landing zone for grant-funded datasets and software releases, and its REST API is well suited to automation — but the deposition flow has enough moving parts that a naive script uploads files to the wrong endpoint, sends metadata Zenodo silently rejects, or re-runs into duplicate records. This walkthrough takes the Zenodo deposition API end to end: create an empty deposition, stream each file to the per-deposition bucket URL with a PUT, attach the required metadata, publish to mint a Digital Object Identifier, and cut a new version when the data changes. It is the concrete Zenodo backend behind the Repository Deposit Automation guide, so the idempotency ledger and checksum discipline described there apply here unchanged. You need a Zenodo personal access token with the deposit:write and deposit:actions scopes, Python 3.10+, and httpx; test against https://sandbox.zenodo.org/api before pointing at production. The DOI you capture at the end is registered through DataCite, whose metadata model the DOI minting with DataCite guide covers.

Zenodo deposition REST API flow Five stages run left to right: POST create deposition, PUT files to the bucket URL, PUT deposition metadata, POST publish which mints the DOI, and the minted-DOI terminal box. A dashed new-version action routes from the DOI box back to create deposition for the next release. POST create deposition PUT bucket stream files PUT metadata title, creators POST publish action Minted DOI + concept DOI newversion action · next release

Zenodo Deposition Metadata Fields

The metadata object sent on the PUT /api/deposit/depositions/{id} request is validated server-side, and a missing required field returns an HTTP 400 with a per-field error list rather than a generic failure. The table is the exact contract for a dataset deposit; the license field takes a Zenodo/SPDX license identifier such as cc-by-4.0.

Field Required Type Example Notes
upload_type yes string dataset One of dataset, publication, software, image, poster, others
title yes string Reef Temperature Series 2019–2024 Plain text
creators yes array [{"name": "Curie, Marie", "orcid": "0000-0002-1825-0097"}] name is Family, Given; affiliation and orcid optional
description yes string <p>Hourly sea-surface temperatures…</p> HTML permitted
access_right no string open open, embargoed, restricted, closed; defaults to open
license conditional string cc-by-4.0 Required when access_right is open or embargoed
publication_date no string 2026-07-16 ISO 8601; defaults to today
keywords no array ["oceanography", "SST"] Free-text tags
version no string 1.0.0 Human-readable version label
related_identifiers no array [{"identifier": "10.5281/…", "relation": "isNewVersionOf"}] Links to related DOIs

Production Implementation

The client below runs the full flow. Files are uploaded to the bucket URL concurrently under an asyncio.Semaphore, and each upload’s returned MD5 is compared against the locally computed digest before the deposition is allowed to publish. The new_version function performs the update flow: it calls the newversion action, follows the returned draft link, and hands back a fresh deposition ready for changed files.

python
from __future__ import annotations

import asyncio
import hashlib
from pathlib import Path
from typing import Any

import httpx

ZENODO_API = "https://sandbox.zenodo.org/api"  # swap for https://zenodo.org/api in production


def compute_md5(path: Path, chunk: int = 1 << 20) -> str:
    """Stream the file through MD5 so large uploads stay memory-constant."""
    digest = hashlib.md5()
    with path.open("rb") as fh:
        for block in iter(lambda: fh.read(chunk), b""):
            digest.update(block)
    return digest.hexdigest()


class ZenodoError(RuntimeError):
    """Raised on an unrecoverable Zenodo API response."""


async def create_deposition(client: httpx.AsyncClient) -> dict[str, Any]:
    """POST an empty deposition; the response carries links.bucket and the id."""
    resp = await client.post("/api/deposit/depositions", json={})
    resp.raise_for_status()
    return resp.json()


async def _put_file(
    client: httpx.AsyncClient, bucket_url: str, path: Path, sem: asyncio.Semaphore
) -> None:
    """Stream one file to the bucket and verify Zenodo's returned checksum."""
    local_md5 = compute_md5(path)
    async with sem:
        with path.open("rb") as body:
            resp = await client.put(f"{bucket_url}/{path.name}", content=body)
    resp.raise_for_status()
    remote = resp.json().get("checksum", "").removeprefix("md5:")
    if remote.lower() != local_md5.lower():
        raise ZenodoError(f"Checksum mismatch for {path.name}: {remote} != {local_md5}")


async def upload_files(
    client: httpx.AsyncClient, bucket_url: str, files: list[Path], concurrency: int = 4
) -> None:
    """Upload a batch of files with bounded concurrency."""
    sem = asyncio.Semaphore(concurrency)
    await asyncio.gather(*(_put_file(client, bucket_url, f, sem) for f in files))


async def set_metadata(
    client: httpx.AsyncClient, deposition_id: int, metadata: dict[str, Any]
) -> None:
    """PUT the deposition metadata; a 400 lists the offending fields."""
    resp = await client.put(
        f"/api/deposit/depositions/{deposition_id}", json={"metadata": metadata}
    )
    if resp.status_code == 400:
        raise ZenodoError(f"Metadata rejected: {resp.json()}")
    resp.raise_for_status()


async def publish(client: httpx.AsyncClient, deposition_id: int) -> dict[str, Any]:
    """POST the publish action; the response carries the minted DOI."""
    resp = await client.post(f"/api/deposit/depositions/{deposition_id}/actions/publish")
    resp.raise_for_status()
    return resp.json()


async def deposit_batch(
    token: str, files: list[Path], metadata: dict[str, Any]
) -> dict[str, str]:
    """Full flow: create -> upload -> set metadata -> publish -> capture DOI."""
    headers = {"Authorization": f"Bearer {token}"}
    async with httpx.AsyncClient(base_url=ZENODO_API, headers=headers, timeout=120.0) as client:
        deposition = await create_deposition(client)
        dep_id = deposition["id"]
        bucket = deposition["links"]["bucket"]
        await upload_files(client, bucket, files)
        await set_metadata(client, dep_id, metadata)
        published = await publish(client, dep_id)
        return {"doi": published["doi"], "concept_doi": published.get("conceptdoi", "")}


async def new_version(
    client: httpx.AsyncClient, published_deposition_id: int
) -> dict[str, Any]:
    """Start a new version from a published record and return the fresh draft."""
    resp = await client.post(
        f"/api/deposit/depositions/{published_deposition_id}/actions/newversion"
    )
    resp.raise_for_status()
    draft_url = resp.json()["links"]["latest_draft"]
    draft = await client.get(draft_url)
    draft.raise_for_status()
    return draft.json()  # replace changed files on its bucket, then publish again


if __name__ == "__main__":
    meta = {
        "upload_type": "dataset",
        "title": "Reef Temperature Series 2019–2024",
        "creators": [{"name": "Curie, Marie", "orcid": "0000-0002-1825-0097"}],
        "description": "<p>Hourly sea-surface temperatures from 12 reef stations.</p>",
        "access_right": "open",
        "license": "cc-by-4.0",
        "keywords": ["oceanography", "sea-surface temperature"],
    }
    result = asyncio.run(
        deposit_batch("YOUR_TOKEN", [Path("reef_sst.csv"), Path("stations.json")], meta)
    )
    print(result["doi"])

The New-Version Flow in Practice

Zenodo’s versioning is built around two related identifiers, and understanding the split is what keeps an automated updater from fragmenting a dataset’s citation history. When a deposition is first published, Zenodo mints a concept DOI that always resolves to the latest version, plus a version DOI that is frozen to that specific release. Every subsequent version gets its own version DOI while the concept DOI stays constant, so a paper that cites the concept DOI keeps pointing at the current data and a paper that cites a version DOI keeps pointing at the exact bytes it was reviewed against.

The newversion action is the only correct entry point for an update. It clones the published record into a fresh draft — carrying the metadata and file list forward — and returns a links.latest_draft URL. From there the flow is identical to a first deposit: replace the files that changed on the draft’s bucket, adjust the metadata if needed, and publish. Because the draft inherits the concept DOI, the new version is automatically linked to its predecessors, and Zenodo records the isNewVersionOf and isPreviousVersionOf relations for you. The idempotency ledger from the Repository Deposit Automation guide should key each version on its own content hash, so a re-run of an update resumes the existing draft rather than opening a second one.

Verification

Confirm each stage against the Zenodo sandbox without publishing anything permanent by driving create_deposition, upload_files, and set_metadata, then asserting the draft is publish-ready before you commit. The test below uses httpx.MockTransport so it runs offline in CI and proves the checksum guard rejects a corrupted upload.

python
import asyncio
from pathlib import Path

import httpx
import pytest


def _handler(request: httpx.Request) -> httpx.Response:
    if request.method == "PUT" and "/files/" in request.url.path:
        # Simulate Zenodo returning a wrong checksum.
        return httpx.Response(200, json={"checksum": "md5:" + "0" * 32})
    return httpx.Response(200, json={})


@pytest.mark.asyncio
async def test_bad_checksum_is_rejected(tmp_path: Path) -> None:
    f = tmp_path / "data.csv"
    f.write_bytes(b"a,b\n1,2\n")
    transport = httpx.MockTransport(_handler)
    async with httpx.AsyncClient(base_url="https://sandbox.zenodo.org/api",
                                 transport=transport) as client:
        with pytest.raises(ZenodoError):
            await upload_files(client, "https://sandbox.zenodo.org/api/files/abc", [f])

Run it with pytest -q. Against a live sandbox token, a successful deposit_batch call prints a DOI under Zenodo’s 10.5281 prefix, and the deposition appears in your sandbox dashboard as published.

Gotchas

  • Uploading to the deposition URL instead of the bucket. The legacy POST …/files multipart endpoint still exists, but the modern, resumable path is PUT {bucket_url}/{filename} using the links.bucket value from the create response. Fix: always read the bucket URL from the deposition, never construct it by hand.
  • Forgetting the license field when access_right is open. Zenodo returns a 400 pointing at license, and a batch loop that ignores the body treats it as a generic failure. Fix: set license to a valid identifier such as cc-by-4.0 whenever access is open or embargoed, and surface the 400 body.
  • Creating a fresh deposition for an update. A new POST deposition gets a brand-new DOI with no lineage to the original. Fix: use the newversion action so the concept DOI is preserved and the versions are linked, as new_version does.