Configuring a CC-BY-4.0 License Record in Zenodo
A Zenodo deposition rejects a license far more quietly than most APIs reject a malformed field: submit an unrecognized value to metadata.license and the deposit either errors with a terse validation message or, worse, publishes with no license expansion and an empty rights block that aggregators read as “all rights reserved”. The root cause is almost always a casing or format mismatch — the field does not want the SPDX-style CC-BY-4.0 you would put in a DataCite rightsList, it wants the lowercase Open Definition id cc-by-4.0. This is the concrete, Zenodo-specific worked example that sits beneath the broader Open License Configuration guide: where that overview defines resolution rules for any license and any repository, here we take a single Creative Commons Attribution 4.0 International (CC-BY 4.0) deposit to Zenodo end to end — the exact string the field accepts, how Zenodo expands it into a full record, how to verify the published result over the REST API, and the four wrong values that cause the most failures.
It assumes you already run deposits against the Zenodo REST API and are comfortable with Python 3.10+ and the Pydantic V2 API. The task here is narrow and precise: guarantee that the license you send is the one Zenodo recognizes, and that what it stored is the one you meant. The general SPDX-to-DataCite side of licensing — the CC-BY-4.0 capitalized identifier, the rightsList entry, canonical URI resolution — is covered in Configuring CC-BY Licenses for Automated Dataset Publishing; this page deliberately does not repeat it, because Zenodo’s field wants a different token entirely.
What Zenodo’s license Field Actually Accepts
Zenodo’s legacy deposition REST API carries the license inside the deposition metadata object as a single string, metadata.license. That string is not free text and it is not the SPDX identifier. It is a license id drawn from Zenodo’s controlled vocabulary, which is seeded from the Open Definition license list and uses lowercase, hyphenated tokens. For Creative Commons Attribution 4.0 International the one accepted value is cc-by-4.0. Zenodo stores that id verbatim and, at render and export time, expands it into a full license object: a human-readable title, a canonical URL, and — in the newer InvenioRDM-based data model exposed under metadata.rights — a structured entry keyed by the same id.
The critical distinction is casing and separator style. cc-by-4.0 is accepted; CC-BY-4.0 (the SPDX form) is not the id Zenodo indexes, and depending on the API version it is either normalized away or rejected outright. Treat the two representations as living in different namespaces: the SPDX CC-BY-4.0 belongs in a DataCite rightsList entry, while cc-by-4.0 is the token that belongs in a Zenodo deposition. Confusing the two is the single most common Zenodo licensing bug, and it is exactly why this operation deserves its own gate rather than being folded into a generic license resolver.
The table below is the authoritative reference for a CC-BY 4.0 Zenodo deposit — the field, the value it will accept, and the record Zenodo produces from it. Rows marked as rejected are the values engineers most often try by analogy with other systems.
| Zenodo license field / input | Value | Accepted? | Resulting record |
|---|---|---|---|
metadata.license |
cc-by-4.0 |
Yes — canonical id | title “Creative Commons Attribution 4.0 International”, URL https://creativecommons.org/licenses/by/4.0/ |
metadata.license |
CC-BY-4.0 |
No — SPDX casing, not a Zenodo id | 400 validation error or silent drop; no expansion |
metadata.license |
cc-by |
No — version missing | 400 validation error; ambiguous, never guessed |
metadata.license |
https://creativecommons.org/licenses/by/4.0/ |
No — URL is output, not input | 400 validation error |
metadata.rights[].id (InvenioRDM) |
cc-by-4.0 |
Yes — same id, structured form | rights object with id, title, props.url, props.scheme |
resolved title (read-only) |
Creative Commons Attribution 4.0 International |
n/a — server-populated | returned on GET, never sent by the client |
The rule that falls out of the table: you send exactly one token, cc-by-4.0, and you send it lowercase. Everything else in the record — the title, the URL, the scheme — is populated by Zenodo from that id, so a correct deposit never hand-writes those fields and a correct verification reads them back to confirm the expansion happened.
Production Implementation
The implementation is a small resolver that normalizes any reasonable input label onto the Zenodo id, plus a Pydantic V2 model that guards the deposition metadata so a wrong-cased or unversioned value can never reach the API. Because Zenodo owns the expansion, the model’s job is narrow: accept the id, reject everything that is not a known Zenodo license id, and expose the expected title and URL so the client can assert on them after publication. This is the same edge-validation discipline described in Pydantic Schema Validation, pointed specifically at Zenodo’s field contract.
from __future__ import annotations
import logging
from typing import Any, Final
from pydantic import BaseModel, Field, field_validator
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
# Zenodo license ids (Open Definition style, lowercase) -> the record Zenodo expands.
# The id is the ONLY value the client sends; title/url are what Zenodo returns.
_ZENODO_LICENSES: Final[dict[str, dict[str, str]]] = {
"cc-by-4.0": {
"title": "Creative Commons Attribution 4.0 International",
"url": "https://creativecommons.org/licenses/by/4.0/",
},
"cc-by-sa-4.0": {
"title": "Creative Commons Attribution-ShareAlike 4.0 International",
"url": "https://creativecommons.org/licenses/by-sa/4.0/",
},
"cc0-1.0": {
"title": "Creative Commons Zero v1.0 Universal",
"url": "https://creativecommons.org/publicdomain/zero/1.0/",
},
}
# Loose inputs an upstream system might emit -> the canonical Zenodo id.
_INPUT_ALIASES: Final[dict[str, str]] = {
"cc-by-4.0": "cc-by-4.0",
"cc by 4.0": "cc-by-4.0",
"attribution 4.0 international": "cc-by-4.0",
"cc-by-sa-4.0": "cc-by-sa-4.0",
"cc0-1.0": "cc0-1.0",
"cc0": "cc0-1.0",
}
class ZenodoLicenseError(ValueError):
"""Raised when a label cannot be mapped to a known Zenodo license id."""
def to_zenodo_id(label: str) -> str:
"""Normalize any supported label to the lowercase Zenodo license id.
Casing and internal whitespace are collapsed. An SPDX-cased 'CC-BY-4.0'
therefore resolves, but a versionless 'cc-by' does not and is rejected.
"""
key = " ".join(label.strip().lower().split())
zid = _INPUT_ALIASES.get(key)
if zid is None or zid not in _ZENODO_LICENSES:
raise ZenodoLicenseError(
f"Unrecognized license label {label!r}. "
f"Zenodo needs a versioned id such as 'cc-by-4.0'."
)
return zid
class ZenodoDepositionMetadata(BaseModel):
"""The license-bearing slice of a Zenodo deposition metadata object."""
title: str
upload_type: str = Field(default="dataset")
license: str
@field_validator("license")
@classmethod
def license_must_be_known_id(cls, v: str) -> str:
# Must already be a canonical lowercase Zenodo id, not an SPDX form.
if v not in _ZENODO_LICENSES:
raise ValueError(
f"license must be a Zenodo id (e.g. 'cc-by-4.0'), got {v!r}"
)
return v
def expected_record(self) -> dict[str, str]:
"""Title and URL Zenodo should expand this id into, for verification."""
return {"id": self.license, **_ZENODO_LICENSES[self.license]}
def build_deposition(title: str, license_label: str) -> dict[str, Any]:
"""Produce a deposition payload with a validated, correctly-cased license."""
meta = ZenodoDepositionMetadata(
title=title,
license=to_zenodo_id(license_label),
)
logging.info("Deposition license set to %s", meta.license)
return {"metadata": meta.model_dump()}
The payload build_deposition returns is ready to POST to the deposition endpoint. Nothing in it names a URL or a title; those are Zenodo’s to fill. That is deliberate — if you send a title or URL, Zenodo ignores it and expands from the id anyway, so the only field that carries meaning is the id you validated. The batch mechanics of dispatching many such depositions, including retries and rate limits, are covered in Automating Zenodo Batch Uploads with the REST API; this gate is what each of those uploads should run before it dispatches.
Verification
Configuration is not confirmed by a 200 on the deposit call. Zenodo can accept a deposition and still fail to expand a license if the id was subtly wrong under a data-model migration, so verify by reading the record back and asserting on the expanded fields. The snippet below validates the outgoing payload offline, then checks a fetched record against the id and URL the model expects.
def verify_published_license(record: dict[str, Any], expected: dict[str, str]) -> None:
"""Assert a fetched Zenodo record carries the intended, expanded license.
Works against both the legacy `metadata.license` string and the newer
`metadata.rights[].id` structure returned by InvenioRDM-based Zenodo.
"""
meta = record.get("metadata", {})
got_id = meta.get("license")
if got_id is None:
rights = meta.get("rights") or []
got_id = rights[0].get("id") if rights else None
if got_id != expected["id"]:
raise AssertionError(f"stored license {got_id!r} != {expected['id']!r}")
logging.info("Verified license %s -> %s", got_id, expected["url"])
if __name__ == "__main__":
payload = build_deposition("Reef Temperature Series 2011-2020", "CC BY 4.0")
meta = ZenodoDepositionMetadata.model_validate(payload["metadata"])
assert meta.license == "cc-by-4.0"
# Simulate the record Zenodo returns on GET after publication.
fetched = {"metadata": {"license": "cc-by-4.0"}}
verify_published_license(fetched, meta.expected_record())
try:
ZenodoDepositionMetadata(title="x", license="CC-BY-4.0")
except Exception as exc:
logging.info("Rejected SPDX casing as expected: %s", exc)
print("license configuration verified")
Running the block prints license configuration verified and logs the rejection of the SPDX-cased value. In a live pipeline, replace the simulated fetched object with a GET on /api/records/{id} (or the deposition endpoint) and run the same assertion, so a drifted or unexpanded license fails the job instead of shipping silently.
Gotchas
- The Zenodo id is not the SPDX identifier.
cc-by-4.0is accepted; the DataCite/SPDX formCC-BY-4.0is a different token in a different namespace and will not index. Fix: normalize on the way into Zenodo and keep the SPDX form only for DataCiterightsListoutput, as the CC-BY automated publishing guide handles it. - A
200on deposit does not prove the license expanded. Zenodo can store a string it later fails to render as a known license, leaving an empty rights block. Fix: always read the record back and assert on the expanded id orrights[].id, never trust the create-call status alone. - A versionless
cc-byis silently different fromcc-by-4.0. Some upstream metadata emits an unversioned Creative Commons label; Zenodo has no such id, so the field is dropped or errors. Fix: reject any label that does not resolve to a versioned id at the pipeline edge.
Frequently Asked Questions
Does Zenodo accept the SPDX identifier CC-BY-4.0 in the license field?
No. Zenodo’s metadata.license field expects its own controlled id, cc-by-4.0, in lowercase Open Definition style. The uppercase SPDX identifier CC-BY-4.0 belongs in a DataCite rightsList entry, not in a Zenodo deposition. Sending the SPDX form yields a validation error or a dropped field, so normalize to the lowercase id before you deposit and keep the SPDX form for DataCite output only.
Should I set the license title and URL myself when depositing to Zenodo?
No. You send only the id, and Zenodo expands it into the title “Creative Commons Attribution 4.0 International” and the URL https://creativecommons.org/licenses/by/4.0/. Any title or URL you include in the request is ignored in favor of the server-side expansion. Because those fields are Zenodo’s to populate, the correct way to confirm the license is to read the published record back and assert on the expanded values.
How do I verify the license actually took effect after publishing?
Perform a GET on the record and inspect metadata.license (legacy) or metadata.rights[].id (InvenioRDM), then assert it equals the id you intended, such as cc-by-4.0. A successful create response is not sufficient proof, because Zenodo can store an id it fails to expand into a known license. Reading the record back and checking the expanded id turns a silent licensing failure into a job that fails loudly.
Related Guides
- Open License Configuration — the parent guide defining license resolution rules this Zenodo case specializes.
- Configuring CC-BY Licenses for Automated Dataset Publishing — the SPDX-and-DataCite side of the same license, with the
rightsListmapping this page deliberately omits. - Automating Zenodo Batch Uploads with the REST API — dispatching many depositions, each of which should run this license gate first.
See the Open Science Infrastructure Planning overview for how license configuration fits the wider governance and deposit workflow.