Crossref Metadata Deposit: Registering and Enriching Scholarly Metadata

A research group that publishes a data paper alongside its dataset lives across two registration agencies at once: the article is registered with Crossref, the dataset carries a DataCite DOI, and the scholarly record is only complete when the two are linked in both directions. Crossref metadata deposit is the machine-to-machine channel that registers article-level identifiers and, just as importantly, enriches them with the relation metadata that ties a publication to its underlying data, its preprint, and its supplements. Done by hand through a web form, this is tolerable for one article and untenable for a journal backlist or an automated deposit pipeline. This guide treats the deposit as an engineering problem: you build an XML document against the Crossref deposit schema, POST it over HTTPS to the deposit endpoint, and then follow a submission through its queued-to-completed lifecycle by polling with the submission identifier the server hands back. It sits within Persistent Identifiers & Repository Integration as the article-metadata registration path that runs parallel to data-DOI minting, and it assumes you already operate a deposit workflow and want the exact request shapes, status semantics, and error branches. For the narrower task of attaching a dataset to a parent publication, work through Depositing Crossref Metadata for Datasets.

Where Crossref Sits Relative to DataCite

Crossref and DataCite are both DOI registration agencies operating under the same International DOI Foundation resolution infrastructure, but they serve different content and expose different metadata models. Crossref centers on the scholarly literature — journal articles, conference papers, books, preprints, and the citation and relation graph that connects them — and every deposit is expressed in the Crossref deposit XML schema. DataCite centers on research outputs — datasets, software, samples, and instruments — and expresses records against the DataCite Metadata Schema. In practice a dataset is minted a DataCite DOI, not a Crossref one; the Crossref side of the relationship is the article, and the dataset appears there only as a cross-reference. The full data-DOI path, including the DataCite REST API and metadata shape, is covered in DOI Minting with DataCite; this guide is the mirror image of it, registering the article and pointing it at the data.

The two schemas are not interchangeable, and the crosswalk between an internal record and either target is a mapping problem in its own right, detailed in Metadata Schema Mapping. What matters here is the division of labor: Crossref owns the article identifier and the relation assertion; DataCite owns the dataset identifier and its own reciprocal assertion. A robust pipeline deposits both sides so the link resolves whether a reader arrives from the paper or from the data.

Crossref deposit submission lifecycle A deposit XML document is built from a typed model, then POSTed to the Crossref deposit endpoint, which returns a submission identifier. A poll step queries submission status. A first decision asks whether the batch has finished; if not, the flow loops back to polling for queued or in-process batches. When finished, a second decision asks whether every record succeeded, branching up to a completed terminal or down to a failed terminal. Build deposit XML typed doi_batch POST to endpoint doMDUpload Poll status submissionDownload Finished? no · queued yes All ok? yes Completed Success no Failed record errors

The Crossref Deposit Model

A Crossref deposit is a single XML document validated against the Crossref deposit schema (the schema family published under the http://www.crossref.org/schema/5.3.1 namespace). The document root is a <doi_batch> element carrying a version attribute and the schema namespace, and it always has two children: a <head> and a <body>.

The <head> is the envelope. It identifies the submission with a <doi_batch_id> — a value you choose, which becomes the handle you poll on — a <timestamp> that Crossref uses to order competing deposits for the same DOI, and a <depositor> block with a <depositor_name> and an <email_address> where the asynchronous result is emailed. A <registrant> names the organization that owns the prefix.

The <body> carries the actual content records. For a journal article the body holds a <journal> element with <journal_metadata>, <journal_issue>, and one or more <journal_article> records; each record ends in a <doi_data> block pairing the <doi> with its <resource> landing-page URL. Crossref also defines top-level record types for <book>, <conference>, <posted_content> (preprints), and <database>/<dataset> for data citations, but datasets registered for their own sake are minted through DataCite rather than deposited here. The Crossref deposit is where the article record lives and where the relationship to the data is asserted.

Relation metadata: linking datasets to articles

The enrichment that makes Crossref deposits valuable for data-intensive work is the relations program. Inside a content record you attach a <program> element in the http://www.crossref.org/relations.xsd namespace (conventionally bound to the rel prefix). That program holds one or more <related_item> elements, each carrying an <inter_work_relation> with a relationship-type and an identifier-type. To state that an article is supplemented by a dataset, you deposit a related_item whose inter_work_relation has relationship-type="isSupplementedBy" and identifier-type="doi", with the dataset’s DataCite DOI as its text. Common relationship types for research data include isSupplementedBy, isSupplementTo, references, isReferencedBy, hasPreprint, and isPreprintOf. These assertions are what let discovery services and the DataCite–Crossref event data pipeline reconstruct the article-to-data graph.

The deposit endpoint and submission lifecycle

Deposits are POSTed as multipart/form-data to the HTTPS deposit endpoint at https://doi.crossref.org/servlet/deposit, with a parallel sandbox at https://test.crossref.org/servlet/deposit for rehearsal. The request carries three form fields alongside the file: operation=doMDUpload, a login_id, and a login_passwd. The endpoint does not validate and register synchronously. It accepts the batch, returns immediately, and processes it asynchronously, moving the submission through three states: queued on acceptance, in_process while records are validated and registered, and completed when the batch is finished. A completed batch is not necessarily a successful one — completion only means processing ended; each record inside carries its own Success or Failure diagnostic.

You observe that lifecycle by polling the submission-status servlet at https://doi.crossref.org/servlet/submissionDownload, passing your credentials, the doi_batch_id you submitted as file_name, and type=result. Until the batch finishes, the servlet returns a diagnostic whose status is queued or in_process; once finished it returns the full result document with per-record diagnostics. The engineering pattern, then, is submit-then-poll with backoff, keyed on the batch identifier.

Step-by-Step Implementation

The pipeline builds a typed representation of the deposit, serializes it to schema-valid XML, submits it, and polls until the batch reaches a terminal state. Each step is isolated so it can be unit-tested without a live endpoint.

Step 1 — Model the deposit as typed Python

Constructing XML by string concatenation is the root of most rejected deposits: an unescaped ampersand in a title, a missing required child, a misordered element. Model the deposit as typed objects first, so the invariants live in one place and validation happens before any bytes reach the network. The model below captures the envelope and a single article record with an optional dataset relation.

python
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timezone


@dataclass(frozen=True)
class DatasetRelation:
    """An inter-work relation from an article to a related dataset DOI."""

    dataset_doi: str
    relationship_type: str = "isSupplementedBy"

    def __post_init__(self) -> None:
        allowed = {"isSupplementedBy", "isSupplementTo", "references", "isReferencedBy"}
        if self.relationship_type not in allowed:
            raise ValueError(f"Unsupported relationship-type: {self.relationship_type!r}")
        if not self.dataset_doi.startswith("10."):
            raise ValueError(f"Not a DOI: {self.dataset_doi!r}")


@dataclass(frozen=True)
class ArticleRecord:
    title: str
    doi: str
    resource_url: str
    publication_year: int
    relations: tuple[DatasetRelation, ...] = ()


@dataclass(frozen=True)
class DepositHead:
    batch_id: str
    depositor_name: str
    depositor_email: str
    registrant: str
    timestamp: str = field(
        default_factory=lambda: datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
    )


@dataclass(frozen=True)
class Deposit:
    head: DepositHead
    journal_title: str
    articles: tuple[ArticleRecord, ...]

The __post_init__ checks reject a malformed relation before it can be serialized, turning a downstream Crossref Failure diagnostic into a local ValueError at construction time.

Step 2 — Serialize to schema-valid deposit XML

Serialize with xml.etree.ElementTree, which escapes text content for you — the single most common cause of hand-built rejects. Register the deposit and relations namespaces up front so the emitted document declares them correctly, and build the tree in schema order: <head> then <body>, and within each record <titles>, publication date, <doi_data>, and the optional relations <program> last.

python
from __future__ import annotations

import xml.etree.ElementTree as ET

CR_NS = "http://www.crossref.org/schema/5.3.1"
REL_NS = "http://www.crossref.org/relations.xsd"
ET.register_namespace("", CR_NS)
ET.register_namespace("rel", REL_NS)


def _q(tag: str) -> str:
    return f"{{{CR_NS}}}{tag}"


def build_deposit_xml(deposit: Deposit) -> bytes:
    """Serialize a Deposit into schema-valid Crossref deposit XML bytes."""
    batch = ET.Element(_q("doi_batch"), {"version": "5.3.1"})

    head = ET.SubElement(batch, _q("head"))
    ET.SubElement(head, _q("doi_batch_id")).text = deposit.head.batch_id
    ET.SubElement(head, _q("timestamp")).text = deposit.head.timestamp
    depositor = ET.SubElement(head, _q("depositor"))
    ET.SubElement(depositor, _q("depositor_name")).text = deposit.head.depositor_name
    ET.SubElement(depositor, _q("email_address")).text = deposit.head.depositor_email
    ET.SubElement(head, _q("registrant")).text = deposit.head.registrant

    body = ET.SubElement(batch, _q("body"))
    journal = ET.SubElement(body, _q("journal"))
    jmeta = ET.SubElement(journal, _q("journal_metadata"))
    ET.SubElement(jmeta, _q("full_title")).text = deposit.journal_title

    for article in deposit.articles:
        rec = ET.SubElement(journal, _q("journal_article"))
        titles = ET.SubElement(rec, _q("titles"))
        ET.SubElement(titles, _q("title")).text = article.title
        pub = ET.SubElement(rec, _q("publication_date"), {"media_type": "online"})
        ET.SubElement(pub, _q("year")).text = str(article.publication_year)

        if article.relations:
            program = ET.SubElement(rec, f"{{{REL_NS}}}program")
            for rel in article.relations:
                item = ET.SubElement(program, f"{{{REL_NS}}}related_item")
                iwr = ET.SubElement(
                    item,
                    f"{{{REL_NS}}}inter_work_relation",
                    {"relationship-type": rel.relationship_type, "identifier-type": "doi"},
                )
                iwr.text = rel.dataset_doi

        doi_data = ET.SubElement(rec, _q("doi_data"))
        ET.SubElement(doi_data, _q("doi")).text = article.doi
        ET.SubElement(doi_data, _q("resource")).text = article.resource_url

    return ET.tostring(batch, encoding="utf-8", xml_declaration=True)

Step 3 — Submit to the deposit endpoint

Submission is a multipart/form-data POST with the operation, credentials, and the XML file. Keep the deposit endpoint and credentials out of the code — read them from the environment — and set an explicit timeout so a hung connection cannot stall a batch run. The response body from doMDUpload is a human-readable confirmation that echoes the batch identifier; a non-2xx status or a body containing error means the batch was not even queued.

python
from __future__ import annotations

import os

import httpx

DEPOSIT_URL = os.environ.get("CROSSREF_DEPOSIT_URL", "https://test.crossref.org/servlet/deposit")


class CrossrefSubmitError(RuntimeError):
    """Raised when the deposit endpoint refuses to queue a batch."""


def submit_deposit(xml_bytes: bytes, batch_id: str, *, timeout: float = 30.0) -> str:
    """POST a deposit and return the batch_id used to poll its status."""
    login_id = os.environ["CROSSREF_LOGIN_ID"]
    login_passwd = os.environ["CROSSREF_LOGIN_PASSWD"]
    files = {"fname": (f"{batch_id}.xml", xml_bytes, "application/xml")}
    data = {"operation": "doMDUpload", "login_id": login_id, "login_passwd": login_passwd}

    with httpx.Client(timeout=timeout) as client:
        resp = client.post(DEPOSIT_URL, data=data, files=files)

    if resp.status_code >= 400 or "error" in resp.text.lower():
        raise CrossrefSubmitError(f"Deposit not queued ({resp.status_code}): {resp.text[:200]}")
    return batch_id

Step 4 — Poll the submission until it reaches a terminal state

Polling reads the submission-status servlet and parses the status attribute of the returned <doi_batch_diagnostic>. Treat queued and in_process as non-terminal and retry with capped exponential backoff; treat completed as terminal and then inspect each <record_diagnostic> for Success or Failure. Never poll in a tight loop — a batch can take minutes, and unbacked-off polling is both wasteful and rude to the endpoint.

python
from __future__ import annotations

import os
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass

import httpx

STATUS_URL = os.environ.get(
    "CROSSREF_STATUS_URL", "https://test.crossref.org/servlet/submissionDownload"
)


@dataclass(frozen=True)
class BatchResult:
    status: str                       # "completed"
    failures: tuple[str, ...]         # per-record failure messages, empty on full success


def _parse_result(xml_text: str) -> BatchResult | None:
    """Return a BatchResult once terminal, or None while still queued/in_process."""
    root = ET.fromstring(xml_text)
    status = root.attrib.get("status", "unknown")
    if status in {"queued", "in_process"}:
        return None
    failures = tuple(
        (msg.text or "unknown failure")
        for rec in root.iter("record_diagnostic")
        if rec.attrib.get("status") == "Failure"
        for msg in rec.iter("msg")
    )
    return BatchResult(status=status, failures=failures)


def poll_submission(batch_id: str, *, max_wait: float = 300.0) -> BatchResult:
    """Poll until the batch is terminal or max_wait elapses."""
    params = {
        "usr": os.environ["CROSSREF_LOGIN_ID"],
        "pwd": os.environ["CROSSREF_LOGIN_PASSWD"],
        "file_name": batch_id,
        "type": "result",
    }
    delay, waited = 5.0, 0.0
    with httpx.Client(timeout=30.0) as client:
        while waited < max_wait:
            resp = client.get(STATUS_URL, params=params)
            resp.raise_for_status()
            result = _parse_result(resp.text)
            if result is not None:
                return result
            time.sleep(delay)
            waited += delay
            delay = min(delay * 2, 60.0)
    raise TimeoutError(f"Batch {batch_id} did not finish within {max_wait}s")

Reference: Key Deposit Elements

Every element below is required or load-bearing in a working article deposit. The namespace column distinguishes the core deposit schema from the relations schema that carries dataset links.

Element Parent / namespace Required Purpose
doi_batch root · deposit schema yes Document root; carries version and the schema namespace
doi_batch_id head · deposit schema yes Your submission key; the value you poll on
timestamp head · deposit schema yes Orders competing deposits for the same DOI (higher wins)
depositor_name depositor · deposit schema yes Human/organization name recorded against the deposit
email_address depositor · deposit schema yes Address the asynchronous result email is sent to
registrant head · deposit schema yes Organization that owns the DOI prefix
journal_article journal · deposit schema yes One scholarly article record
doi doi_data · deposit schema yes The DOI being registered (prefix/suffix)
resource doi_data · deposit schema yes Landing-page URL the DOI resolves to
program record · relations schema no Container for relation assertions
related_item program · relations schema no One related work (e.g. a dataset)
inter_work_relation related_item · relations schema no relationship-type + identifier-type linking to the dataset DOI

Error Handling & Edge Cases

Two failure surfaces need distinct handling. The first is submission refusal: the endpoint returns a 4xx or an error body because credentials are wrong, the XML is not well-formed, or the prefix is not authorized. These are synchronous and terminal — retrying without fixing the cause is pointless, so surface them immediately as a CrossrefSubmitError. The second is per-record failure inside a completed batch: the batch reaches completed, but a record_diagnostic reports Failure because a DOI conflicts with a higher-timestamp deposit, a resource URL is malformed, or a relationship-type is invalid. These are only visible after polling, and they must be reconciled record by record rather than by resubmitting the whole batch blindly.

A subtle edge case is the timestamp race. Crossref resolves competing deposits for the same DOI by timestamp: a later, lower-timestamp deposit is silently ignored in favor of the existing higher one. If an automated pipeline hardcodes or reuses a timestamp, a legitimate metadata correction can appear to succeed at the batch level yet never take effect. Always derive the timestamp from a monotonic clock at build time, as DepositHead does. The dead-letter-and-replay discipline that quarantines the record-level failures this pipeline cannot auto-resolve is the same one described in API Routing & Fallbacks.

Verification & Testing

The build and parse steps are pure functions and can be pinned without any network. Assert that a dataset relation serializes into the relations namespace with the correct relationship-type, and that the status parser distinguishes a still-queued batch from a completed one with failures.

python
import xml.etree.ElementTree as ET


def test_dataset_relation_serializes_into_relations_namespace() -> None:
    deposit = Deposit(
        head=DepositHead("batch-001", "Data Team", "deposit@example.org", "Example U"),
        journal_title="Journal of Reproducible Data",
        articles=(
            ArticleRecord(
                title="A Reef Temperature Data Paper",
                doi="10.5555/article.001",
                resource_url="https://example.org/articles/001",
                publication_year=2026,
                relations=(DatasetRelation("10.82345/dataset.001"),),
            ),
        ),
    )
    xml_text = build_deposit_xml(deposit).decode("utf-8")
    assert "relations.xsd" in xml_text
    assert 'relationship-type="isSupplementedBy"' in xml_text
    assert "10.82345/dataset.001" in xml_text


def test_parser_treats_queued_as_non_terminal() -> None:
    assert _parse_result('<doi_batch_diagnostic status="queued"/>') is None


def test_parser_collects_record_failures() -> None:
    doc = (
        '<doi_batch_diagnostic status="completed">'
        '<record_diagnostic status="Failure"><msg>DOI conflict</msg></record_diagnostic>'
        "</doi_batch_diagnostic>"
    )
    result = _parse_result(doc)
    assert result is not None and result.failures == ("DOI conflict",)

Run the suite with pytest -q. A green run asserts that relation metadata lands in the right namespace and that the lifecycle parser will not mistake a queued batch for a finished one — the two mistakes most likely to silently corrupt a production deposit run.

Gotchas & Known Pitfalls

  • Completed is not the same as succeeded. A batch can reach completed with every record in Failure. Root cause: treating batch status as the success signal. Fix: after the batch is terminal, always inspect each record_diagnostic, as _parse_result does, and reconcile failures individually.
  • A stale or reused timestamp silently loses. Crossref keeps the deposit with the highest timestamp for a DOI; a lower one is ignored without error. Root cause: a hardcoded or cached timestamp. Fix: generate it from the current clock at build time on every deposit.
  • Datasets do not belong in a Crossref deposit as first-class records. Depositing a dataset here duplicates an identifier that should be a DataCite DOI. Root cause: conflating the two agencies. Fix: mint the dataset with DOI Minting with DataCite and reference it from the article via relation metadata.
  • Unescaped characters in titles reject the batch. A raw & or < in a title breaks well-formedness. Root cause: string-built XML. Fix: serialize with a real XML library so text is escaped automatically.
  • Polling in a tight loop looks like abuse. Unbacked-off status polls hammer the endpoint and can get throttled. Fix: use capped exponential backoff, as poll_submission does.

Frequently Asked Questions

Should I register my dataset with Crossref or DataCite?

Register the dataset with DataCite and register the article with Crossref. Datasets, software, and samples are DataCite’s domain and get DataCite DOIs; Crossref’s domain is the scholarly literature. The two are joined by relation metadata: the Crossref article deposit points at the dataset DOI with an isSupplementedBy inter-work relation, and the DataCite record points back. Depositing the dataset as a Crossref record instead would mint a competing identifier for something that already has a canonical one.

Why did my deposit reach “completed” but the DOI still isn’t registered?

Because completed describes the batch, not the record. Open the result document and read the record_diagnostic for that DOI. A Failure there names the reason — most often a timestamp conflict with an existing deposit, a malformed resource URL, or an unauthorized prefix. Fix the specific record and resubmit it with a fresh, higher timestamp.

How long does a submission take to process?

It is asynchronous and variable — seconds for a single record, minutes for a large batch under load. That is why the deposit endpoint returns immediately and you poll the submission servlet with the batch identifier. Poll with capped backoff and set an overall deadline, as the implementation does, rather than assuming a fixed processing time.

Can I update metadata for a DOI I already registered?

Yes. Resubmit the record with the same DOI and a higher timestamp; Crossref treats the highest-timestamp deposit as authoritative and overwrites the prior metadata. This is also how you add or correct a dataset relation after the fact — redeposit the article record with the relation program included and a current timestamp.