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.

Zenodo license configuration and expansion flow A supplied license label is normalized to the lowercase Open Definition id cc-by-4.0 and written to the metadata.license field. A decision node asks whether the id is a known Zenodo license. If recognized, Zenodo expands it into a title and URL and publishes the record. If not, the API returns a 400 validation error routed to remediation. Supplied label any casing Normalize to id cc-by-4.0 Known id? yes Expand + publish title & URL no 400 validation error Verify via GET on the record confirm stored id matches intent

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.

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

python
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.0 is accepted; the DataCite/SPDX form CC-BY-4.0 is 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 DataCite rightsList output, as the CC-BY automated publishing guide handles it.
  • A 200 on 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 or rights[].id, never trust the create-call status alone.
  • A versionless cc-by is silently different from cc-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.

See the Open Science Infrastructure Planning overview for how license configuration fits the wider governance and deposit workflow.