DOI Minting with DataCite: Engineering Reliable Persistent-Identifier Registration

A dataset without a resolvable, citable identifier is unfindable in practice, and the moment a research group tries to automate the minting of those identifiers at scale it discovers that a Digital Object Identifier (DOI) is not a string you generate — it is a record you register, validate, and transition through a lifecycle at a registration agency. DataCite is that agency for research data, and its registration surface has two moving parts that a pipeline must satisfy simultaneously: a strict metadata schema that governs what a DOI must describe, and a REST API that governs how a DOI moves from a private draft to a globally findable, resolvable identifier. Getting either one wrong produces the failure modes that plague hand-rolled deposit scripts — DOIs that resolve to nothing, identifiers double-minted on retry, or records rejected at publish time because a mandatory property was never attached. This guide treats DOI registration as an engineering problem with exact contracts: the mandatory properties of the DataCite Metadata Schema, the JSON:API shape of the /dois endpoint, the two-phase reserve-then-publish mint that makes registration idempotent, and the error handling that separates a transient failure from a permanent one. It sits within Persistent Identifiers & Repository Integration as the identifier-registration layer, feeding the deposit and reconciliation stages that surround it.

Two companion walkthroughs take the pieces here down to complete, runnable clients. The step-by-step guide to registering DOIs with the DataCite REST API builds the single-record reserve-then-publish client end to end, and batch updating DataCite metadata for existing DOIs covers bounded-concurrency PUT updates across thousands of DOIs with rate-limit and partial-failure handling. This overview is the schema-and-lifecycle foundation both of them assume.

Where DOI Registration Sits in the Deposit Flow

Minting a DOI is rarely the first thing a pipeline does with a dataset. By the time a record reaches the registration layer it has usually already been ingested, validated, and enriched, and the internal metadata has been crosswalked into the shape DataCite expects — the field-by-field translation that the Metadata Schema Mapping guide covers in full. The registration layer’s single job is to take a well-formed metadata document, attach it to a DOI, and drive that DOI to the findable state without ever creating a duplicate or leaving a half-registered record behind. Everything else — deposit of the actual files to a repository, later reconciliation of DOI state across systems — happens on either side of this boundary.

DataCite DOI minting sub-pipeline A DOI advances left to right through four stages. First a draft DOI is reserved under a prefix. Metadata is then attached. A decision node checks whether the schema validates. If it validates, the record is published and becomes findable. If it does not, the record is routed downward to a quarantine queue for remediation rather than published. Reserve draft POST prefix only Attach metadata creators, titles, types Schema valid? yes Publish · findable event: publish no Quarantine queue 422 remediation

The reason this is drawn as a pipeline rather than a single call is the two-phase mint. A DOI is reserved first as a private draft, and only promoted to findable once its metadata has been attached and accepted. That separation is what makes the whole operation idempotent and safe to retry, and it is the single most important design decision in the entire registration layer.

The DataCite Metadata Schema: Mandatory Properties

The DataCite Metadata Schema (currently version 4.5, with 4.x broadly compatible) defines the properties a DOI record may carry and, critically, the small set that it must carry before it can become findable. A pipeline that understands only these mandatory properties can already mint a compliant, resolvable DOI; everything else — subjects, funding references, related identifiers, geolocations — is optional enrichment layered on top. There are six mandatory properties, and every one of them maps to a field in the REST API’s JSON:API attributes object.

  • Identifier — the DOI string itself, with an identifierType of DOI. In the REST API this is expressed as the doi attribute, or omitted in favour of a prefix when you want DataCite to auto-generate a suffix.
  • Creators — at least one creator (a person or organisation responsible for the resource), each with a name. This maps to the creators array.
  • Titles — at least one title for the resource, mapping to the titles array.
  • Publisher — the entity that holds or publishes the resource, mapping to the publisher attribute.
  • PublicationYear — the year the resource was or will be made public, as a four-digit integer, mapping to publicationYear.
  • ResourceType — the general category of the resource (for example Dataset), expressed through types.resourceTypeGeneral with an optional free-text types.resourceType.

The important subtlety is that these properties are only mandatory for the findable state. A draft DOI requires nothing but a prefix — you can reserve it with an otherwise empty record. This is precisely what enables reserve-then-publish: you claim the identifier cheaply, then build up its metadata, and the mandatory-property contract is enforced only at the moment you try to publish. Attempting to publish a record that is missing any of the six produces an HTTP 422 with a per-field breakdown of what is absent.

The DataCite REST API and the DOI Lifecycle

The DataCite REST API models every DOI as a JSON:API resource of type dois. A registration request POSTs a single top-level data object to the /dois collection endpoint; an update PUTs to /dois/{id}. The request body is always wrapped in the JSON:API envelope — a data object carrying a type, an optional id, and an attributes object that holds the metadata. Authentication is HTTP Basic auth using a repository account’s ID and password, and the same credentials scope every request to the prefixes that account controls.

A DOI moves through exactly three states, and the transition between them is driven by the event attribute on a write request rather than by a separate endpoint:

  • draft — the default state of a newly created DOI. It is private, deletable, requires only a prefix, and does not resolve. Drafts are the reservation half of a two-phase mint.
  • registered — the DOI is registered with the global Handle System and resolves, but is not indexed in DataCite’s public search. Reached with event: register. This state is useful for identifiers that must resolve but should not yet appear in discovery.
  • findable — the DOI resolves and is indexed for discovery and citation. Reached with event: publish. This is the terminal state for a published dataset.

The transitions are one-directional in normal operation: a draft can be promoted to registered or findable, and a findable DOI can be pulled back to registered with event: hide, but a DOI that has ever been registered or findable can never return to draft and can never be deleted. That irreversibility is why the draft phase matters so much — the draft is the only stage at which a mistake is cheap to correct by simply deleting the record and starting over.

Step-by-Step Implementation

The implementation below builds a mint client in stages: a typed model of the DataCite attributes so a malformed record is caught before any network call, a payload builder that wraps that model in the JSON:API envelope, and a client that performs the two-phase mint with idempotency and error classification. Every block is runnable against the test endpoint with a Fabrica test account.

Step 1 — Model the DataCite attributes with Pydantic V2

The first defence against a 422 is to never send a request that could produce one. A Pydantic V2 model of the mandatory attributes validates the record locally — checking that at least one creator and title exist, that publicationYear is a plausible four-digit year, and that resourceTypeGeneral is one of the values DataCite accepts — so a structurally invalid record fails loudly in your own process rather than as an opaque server rejection. This is the same validation-at-the-boundary discipline described in the Pydantic schema validation guide, aimed here at the DataCite contract.

python
from __future__ import annotations

from datetime import datetime, timezone
from typing import Literal

from pydantic import BaseModel, Field, field_validator

ResourceTypeGeneral = Literal[
    "Dataset", "Text", "Software", "Collection", "Image",
    "Audiovisual", "Model", "Workflow", "Other",
]


class Creator(BaseModel):
    name: str
    nameType: Literal["Personal", "Organizational"] | None = None


class Title(BaseModel):
    title: str


class Types(BaseModel):
    resourceTypeGeneral: ResourceTypeGeneral
    resourceType: str | None = None


class DataCiteAttributes(BaseModel):
    """The mandatory DataCite Metadata Schema 4.x properties, JSON:API-named."""

    prefix: str = Field(pattern=r"^10\.\d{4,9}$")
    doi: str | None = None
    url: str | None = None
    creators: list[Creator] = Field(min_length=1)
    titles: list[Title] = Field(min_length=1)
    publisher: str = Field(min_length=1)
    publicationYear: int
    types: Types

    @field_validator("publicationYear")
    @classmethod
    def year_is_plausible(cls, v: int) -> int:
        current = datetime.now(timezone.utc).year
        if not 1000 <= v <= current + 1:
            raise ValueError(f"publicationYear {v} is outside the plausible range")
        return v

    @field_validator("url")
    @classmethod
    def url_is_http(cls, v: str | None) -> str | None:
        if v is not None and not v.startswith(("http://", "https://")):
            raise ValueError("url must be an absolute http(s) landing-page URL")
        return v

A record that passes this model carries every property DataCite needs to reach findable, so the only remaining failure modes are transport-level, not schema-level.

Step 2 — Build the JSON:API payload for each phase

The two phases send different bodies. The reserve phase sends a minimal record — just the prefix and type — so DataCite creates a draft and assigns a suffix. The publish phase sends the full attributes plus event: publish and the landing-page url, which is required for a findable DOI. Keeping payload construction in one place guarantees the two requests stay consistent.

python
from typing import Any


def reserve_payload(prefix: str) -> dict[str, Any]:
    """Minimal JSON:API body that creates a draft DOI under a prefix."""
    return {"data": {"type": "dois", "attributes": {"prefix": prefix}}}


def publish_payload(doi: str, attrs: DataCiteAttributes) -> dict[str, Any]:
    """Full JSON:API body that attaches metadata and promotes to findable."""
    body = attrs.model_dump(exclude_none=True)
    body["event"] = "publish"
    body["doi"] = doi  # bind the update to the reserved identifier
    return {"data": {"type": "dois", "id": doi, "attributes": body}}

Because the publish body carries the reserved doi as the JSON:API id, the same builder works whether you PUT it to /dois/{doi} to update the existing draft or replay it after a network timeout — the target is always the identifier you already reserved.

Step 3 — Perform the two-phase mint idempotently

The client reserves a draft, reads back the assigned DOI, then publishes. The idempotency key is the DOI itself: once reserved, all further writes target /dois/{doi}, so a retried publish is a repeat PUT to the same resource rather than a second POST that would mint a duplicate. The client distinguishes a 422 (permanent — the record is wrong) from a 5xx or timeout (transient — retry the same idempotent write).

python
from __future__ import annotations

import httpx

TEST_BASE = "https://api.test.datacite.org"  # Fabrica test; prod is api.datacite.org


class DataCiteError(RuntimeError):
    """A non-retryable DataCite rejection (validation or conflict)."""


class DataCiteClient:
    def __init__(self, client_id: str, password: str, base_url: str = TEST_BASE) -> None:
        self._auth = (client_id, password)
        self._base = base_url.rstrip("/")

    def reserve(self, prefix: str) -> str:
        """Create a draft DOI and return the assigned identifier."""
        with httpx.Client(timeout=30.0) as http:
            resp = http.post(
                f"{self._base}/dois",
                json=reserve_payload(prefix),
                auth=self._auth,
                headers={"Content-Type": "application/vnd.api+json"},
            )
        if resp.status_code == 422:
            raise DataCiteError(f"reserve rejected: {resp.json()['errors']}")
        resp.raise_for_status()
        return resp.json()["data"]["id"]

    def publish(self, doi: str, attrs: DataCiteAttributes) -> dict[str, object]:
        """Attach metadata and promote the draft to findable (idempotent PUT)."""
        with httpx.Client(timeout=30.0) as http:
            resp = http.put(
                f"{self._base}/dois/{doi}",
                json=publish_payload(doi, attrs),
                auth=self._auth,
                headers={"Content-Type": "application/vnd.api+json"},
            )
        if resp.status_code == 422:
            raise DataCiteError(f"publish rejected: {resp.json()['errors']}")
        resp.raise_for_status()
        return resp.json()["data"]

    def mint(self, attrs: DataCiteAttributes) -> str:
        """Full reserve-then-publish mint. Returns the findable DOI."""
        doi = attrs.doi or self.reserve(attrs.prefix)
        self.publish(doi, attrs)
        return doi

The mint method captures the whole discipline: reserve to obtain a stable identifier, then publish against it. If publish fails transiently, the caller re-invokes publish(doi, attrs) with the DOI it already holds, and no duplicate is ever created. The single-record client is developed further, including retry loops and response parsing, in the registering DOIs with the DataCite REST API walkthrough.

Reference: Mandatory Properties to JSON:API Fields

This table is the authoritative mapping from each mandatory DataCite Metadata Schema property to its JSON:API attribute and a concrete example value. Every row is required for a DOI to reach the findable state.

DataCite property JSON:API attribute Example value
Identifier (type DOI) doi (or prefix to auto-assign) 10.5072/example-reef-2026
Creators creators[].name [{"name": "Okafor, Amara", "nameType": "Personal"}]
Titles titles[].title [{"title": "Great Barrier Reef Temperature Series 2015–2025"}]
Publisher publisher "Marine Data Archive"
PublicationYear publicationYear 2026
ResourceType (general) types.resourceTypeGeneral "Dataset"
Landing-page URL (findable only) url https://data.example.org/reef/2026
Lifecycle transition event publish (draft → findable)

The url and event rows are not schema properties in the strict sense, but they are mandatory at the REST layer: a findable DOI with no url resolves to nothing, and without event: publish the record stays a draft no matter how complete its metadata.

Error Handling and Edge Cases

DataCite returns a small, well-defined set of status codes, and the correct handling of each is what separates a resilient minting pipeline from one that silently double-mints or gives up on transient faults. The governing rule is the same one the API Routing & Fallbacks guide applies across services: classify every response into permanent-reject, transient-retry, or success, and never retry a permanent rejection.

  • 422 Unprocessable Entity — the metadata failed schema validation. The errors array names each offending field (for example a missing titles or an out-of-vocabulary resourceTypeGeneral). This is permanent: retrying the identical body reproduces it. Route the record to a quarantine queue for remediation.
  • 409 Conflict — a DOI with this identifier already exists. In a reserve-then-publish flow this most often means a POST with an explicit doi was replayed. Treat it as evidence the identifier is already claimed and switch to a PUT against it rather than minting again.
  • 401 / 403 — the credentials are wrong or the account lacks rights to the prefix. Permanent; fail fast and alert, since no retry will succeed.
  • 429 Too Many Requests — the account has exceeded its rate limit. Transient: honour any Retry-After header and back off. This dominates batch workloads and is the central concern of the batch updating DataCite metadata for existing DOIs guide.
  • 5xx and connection timeouts — transient server or network faults. Because every write after reservation is an idempotent PUT against a known DOI, these are always safe to retry with exponential backoff.

The most dangerous edge case is a reservation that succeeds but whose response is lost to a timeout. The client believes no draft was created and reserves again, producing an orphan draft. Two defences neutralise it: reserve under a deterministic explicit doi (a content-derived suffix) so a replay collides with a 409 you can recognise, or run a periodic sweep that deletes drafts older than a threshold — drafts are deletable, so orphans are cheap to reap.

Verification and Testing

The mint client is verified against the Fabrica test endpoint with a test prefix (commonly 10.5072), which mints real, resolvable-in-test DOIs without touching production or the global Handle System. The test below asserts the two invariants that matter: local validation rejects an incomplete record before any network call, and the payload builder emits a correctly enveloped publish body bound to the reserved DOI.

python
import pytest
from pydantic import ValidationError


def _valid_attrs() -> DataCiteAttributes:
    return DataCiteAttributes(
        prefix="10.5072",
        creators=[Creator(name="Okafor, Amara", nameType="Personal")],
        titles=[Title(title="Reef Temperature Series")],
        publisher="Marine Data Archive",
        publicationYear=2026,
        types=Types(resourceTypeGeneral="Dataset"),
        url="https://data.example.org/reef/2026",
    )


def test_missing_creator_is_rejected_locally() -> None:
    with pytest.raises(ValidationError):
        DataCiteAttributes(
            prefix="10.5072",
            creators=[],  # violates min_length=1
            titles=[Title(title="x")],
            publisher="Marine Data Archive",
            publicationYear=2026,
            types=Types(resourceTypeGeneral="Dataset"),
        )


def test_publish_payload_binds_the_reserved_doi() -> None:
    body = publish_payload("10.5072/example-reef-2026", _valid_attrs())
    assert body["data"]["id"] == "10.5072/example-reef-2026"
    assert body["data"]["attributes"]["event"] == "publish"
    assert body["data"]["attributes"]["url"].startswith("https://")

Run the suite with pytest -q. Wire it into CI so a change to the attribute model or the payload envelope fails the build rather than surfacing as a 422 against the live registry. For an end-to-end check, point DataCiteClient at the test base URL and assert mint() returns a DOI whose record reports "state": "findable" when fetched back.

Gotchas and Known Pitfalls

  • Publishing before metadata is complete. Sending event: publish on a record missing any mandatory property returns a 422 and the DOI stays a draft. Root cause: treating publish as a state flip rather than a validated transition. Fix: validate with the Pydantic model first, and only ever publish a record that passed it.
  • Minting duplicates on retry. Re-POSTing to /dois after a lost response creates a second draft. Root cause: using POST as the retry unit. Fix: reserve once, then make every subsequent write an idempotent PUT to /dois/{doi}.
  • Testing against production prefixes. Development runs that hit api.datacite.org with a real prefix create permanent, undeletable findable DOIs. Root cause: a single hard-coded base URL. Fix: default to api.test.datacite.org with a test prefix and require an explicit flag to target production.
  • Assuming drafts resolve. A reserved draft does not resolve and is invisible in search, so a landing page linking to it 404s. Root cause: conflating reservation with publication. Fix: only expose a DOI publicly once it reaches findable.
  • Sending the wrong content type. DataCite’s JSON:API endpoints expect application/vnd.api+json; a plain application/json header can be rejected. Fix: set the JSON:API media type on every write, as the client above does.

Frequently Asked Questions

What is the difference between a draft, registered, and findable DOI?

A draft DOI is private, requires only a prefix, does not resolve, and can be deleted — it is a reservation. A registered DOI resolves through the global Handle System but is not indexed in DataCite’s public search, reached with event: register. A findable DOI both resolves and is indexed for discovery and citation, reached with event: publish. Only draft is reversible; once a DOI is registered or findable it can never be deleted or returned to draft.

Why reserve a draft first instead of minting in one call?

Reserving a draft first makes the whole mint idempotent and cheap to correct. The draft claims a stable identifier that every later write targets by ID, so a retried publish is a repeat PUT rather than a second mint that would create a duplicate. It also lets you build and validate metadata against a real DOI before committing to the irreversible findable state, and a mistaken draft can simply be deleted.

Which DataCite properties are mandatory to mint a findable DOI?

Six properties are mandatory: Identifier (the DOI, type DOI), Creators (at least one name), Titles (at least one title), Publisher, PublicationYear (a four-digit year), and ResourceType (via resourceTypeGeneral). At the REST layer a landing-page url is also required for the DOI to actually resolve. A draft requires none of these — only a prefix — because the mandatory-property contract is enforced only at the transition to findable.

How do I test DOI minting without creating real production identifiers?

Use the DataCite Fabrica test environment at api.test.datacite.org with a test account and a test prefix such as 10.5072. It exercises the identical API and metadata schema and mints DOIs that are resolvable within the test system, but nothing is registered with the production Handle System or DataCite’s public index. Never run development against api.datacite.org, because production findable DOIs are permanent and cannot be deleted.