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 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.
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.
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 …/filesmultipart endpoint still exists, but the modern, resumable path isPUT {bucket_url}/{filename}using thelinks.bucketvalue from the create response. Fix: always read the bucket URL from the deposition, never construct it by hand. - Forgetting the
licensefield whenaccess_rightisopen. Zenodo returns a 400 pointing atlicense, and a batch loop that ignores the body treats it as a generic failure. Fix: setlicenseto a valid identifier such ascc-by-4.0whenever access is open or embargoed, and surface the 400 body. - Creating a fresh deposition for an update. A new
POSTdeposition gets a brand-new DOI with no lineage to the original. Fix: use thenewversionaction so the concept DOI is preserved and the versions are linked, asnew_versiondoes.
Related Guides
- Repository Deposit Automation — the parent guide whose adapter Protocol, idempotency ledger, and checksum discipline this Zenodo backend implements.
- Depositing to Dataverse via the Native API — the same deposit lifecycle expressed against a Dataverse installation for cross-archive comparison.
- DOI minting with DataCite — the registration agency behind the DOI that the publish action returns.