Registering DOIs with the DataCite REST API

This page builds one thing end to end: a Python client that registers a single Digital Object Identifier (DOI) against the DataCite REST API by reserving a draft, then publishing it to the findable state, handling validation rejections and transient faults without ever minting a duplicate. It is the concrete, runnable companion to the DOI Minting with DataCite overview, which explains the DataCite Metadata Schema and the draft→registered→findable lifecycle this client drives. Here the goal is narrower: exact request shapes, a full httpx implementation you can run against the test endpoint today, and the error handling that makes it safe in production.

The prerequisites are a DataCite Fabrica test account (a repository client ID and password), a test prefix such as 10.5072, and Python 3.10+ with httpx installed. The client authenticates with HTTP Basic auth, targets the test base URL https://api.test.datacite.org, and speaks the JSON:API media type application/vnd.api+json. Everything is a single-record operation; for updating many existing DOIs at once with bounded concurrency and rate-limit handling, see batch updating DataCite metadata for existing DOIs.

DOI registration request flow Four stages left to right. An internal metadata record is transformed into a JSON:API envelope. That envelope is POSTed to the dois endpoint to reserve a draft. The draft is then published with a PUT carrying event publish, producing a findable DOI. Metadata record Python dict JSON:API body data.attributes POST /dois reserve draft PUT · publish findable DOI

The JSON:API Request Contract

Every DataCite write is a JSON:API document: a top-level data object with a type of dois, an optional id (the DOI), and an attributes object carrying the metadata. The reserve request sends only a prefix; the publish request sends the full mandatory metadata plus event: publish and a landing-page url. The table below is the deterministic field contract for a findable dataset DOI — each attribute, whether it is required, and a concrete value.

JSON:API attribute Required? Example value
prefix Reserve only (or supply doi) 10.5072
doi On publish / update 10.5072/reef-2026-0001
event Publish only publish
url Yes (findable) https://data.example.org/reef/2026
creators[].name Yes Okafor, Amara
titles[].title Yes Reef Temperature Series 2015–2025
publisher Yes Marine Data Archive
publicationYear Yes 2026
types.resourceTypeGeneral Yes Dataset
types.resourceType No Sensor time series

A reserve request omits everything from url downward — DataCite creates a draft from the prefix alone. Those fields become mandatory only when event: publish promotes the draft to findable, which is why the two requests carry different bodies.

Authentication is HTTP Basic auth on every request, using the repository client ID and password issued by DataCite. That credential also scopes what you can do: it authorises writes only under the prefixes the account owns, so a POST carrying a prefix outside that grant returns a 403 no matter how well-formed the metadata is. Two request headers matter as much as the body. Content-Type: application/vnd.api+json tells DataCite the payload is a JSON:API document; a plain application/json can be rejected before the metadata is ever inspected. Accept: application/vnd.api+json asks for the response in the same envelope, so the reserved DOI comes back at a predictable data.id path rather than in a format you have to guess at.

The reserve response is the pivot of the whole flow. DataCite replies with the created resource, and its data.id field holds the DOI it assigned — for example 10.5072/abcd-1234 when you supplied only the 10.5072 prefix. Every subsequent write, including the publish and any later correction, addresses that exact identifier, so capturing data.id accurately is what binds the two phases into one idempotent operation.

Production Implementation

The client below is complete and runnable. It reserves a draft, extracts the assigned DOI from the JSON:API response, then publishes against that DOI. Retries target the reserved identifier with an idempotent PUT, so a network fault after reservation never mints a second DOI. A 422 is raised as a permanent error, a 429 honours Retry-After, and 5xx and timeouts back off exponentially.

python
from __future__ import annotations

import time
from typing import Any

import httpx

TEST_BASE = "https://api.test.datacite.org"   # Fabrica test; prod: api.datacite.org
JSONAPI = "application/vnd.api+json"
_RETRYABLE_STATUS = {429, 500, 502, 503, 504}


class DataCiteValidationError(RuntimeError):
    """A permanent 422 rejection; the metadata must be fixed before retrying."""


def _mandatory_attributes(record: dict[str, Any]) -> dict[str, Any]:
    """Shape an internal record into DataCite mandatory attributes."""
    return {
        "url": record["url"],
        "creators": [{"name": n} for n in record["creators"]],
        "titles": [{"title": record["title"]}],
        "publisher": record["publisher"],
        "publicationYear": int(record["year"]),
        "types": {"resourceTypeGeneral": record["resource_type"]},
    }


class DataCiteRegistrar:
    def __init__(
        self,
        client_id: str,
        password: str,
        base_url: str = TEST_BASE,
        max_retries: int = 4,
        base_delay: float = 1.0,
    ) -> None:
        self._auth = (client_id, password)
        self._base = base_url.rstrip("/")
        self._max_retries = max_retries
        self._base_delay = base_delay
        self._http = httpx.Client(
            timeout=30.0,
            headers={"Content-Type": JSONAPI, "Accept": JSONAPI},
        )

    def close(self) -> None:
        self._http.close()

    def __enter__(self) -> "DataCiteRegistrar":
        return self

    def __exit__(self, *exc: object) -> None:
        self.close()

    def _request(self, method: str, path: str, body: dict[str, Any]) -> dict[str, Any]:
        """Send one write with retry on transient faults; raise on permanent ones."""
        url = f"{self._base}{path}"
        last_exc: Exception | None = None
        for attempt in range(self._max_retries + 1):
            try:
                resp = self._http.request(method, url, json=body, auth=self._auth)
            except (httpx.TransportError, httpx.TimeoutException) as exc:
                last_exc = exc  # transient: fall through to backoff
            else:
                if resp.status_code == 422:
                    raise DataCiteValidationError(resp.json()["errors"])
                if resp.status_code not in _RETRYABLE_STATUS:
                    resp.raise_for_status()
                    return resp.json()
                if resp.status_code == 429:
                    retry_after = float(resp.headers.get("Retry-After", "0"))
                    if retry_after > 0:
                        time.sleep(retry_after)
                        continue
            if attempt < self._max_retries:
                time.sleep(self._base_delay * (2 ** attempt))
        raise RuntimeError(f"exhausted retries for {method} {path}") from last_exc

    def reserve(self, prefix: str) -> str:
        """Create a draft DOI and return the assigned identifier."""
        body = {"data": {"type": "dois", "attributes": {"prefix": prefix}}}
        data = self._request("POST", "/dois", body)
        return data["data"]["id"]

    def publish(self, doi: str, record: dict[str, Any]) -> dict[str, Any]:
        """Attach mandatory metadata and promote the draft to findable."""
        attrs = _mandatory_attributes(record)
        attrs["event"] = "publish"
        body = {"data": {"type": "dois", "id": doi, "attributes": attrs}}
        return self._request("PUT", f"/dois/{doi}", body)["data"]

    def register(self, prefix: str, record: dict[str, Any]) -> str:
        """Full reserve-then-publish registration. Returns the findable DOI."""
        doi = self.reserve(prefix)
        self.publish(doi, record)  # idempotent: re-run publish(doi, record) on failure
        return doi


if __name__ == "__main__":
    record = {
        "url": "https://data.example.org/reef/2026",
        "creators": ["Okafor, Amara", "Marine Data Archive"],
        "title": "Great Barrier Reef Temperature Series 2015–2025",
        "publisher": "Marine Data Archive",
        "year": 2026,
        "resource_type": "Dataset",
    }
    with DataCiteRegistrar("YOUR.CLIENT", "your-password") as registrar:
        doi = registrar.register("10.5072", record)
        print(f"registered findable DOI: {doi}")

The separation of reserve and publish is deliberate: register calls each once, but if publish fails transiently the caller already holds the DOI and can simply call publish(doi, record) again. Because that call is a PUT against an existing resource, replaying it converges on the same findable record instead of creating a new one.

Verification

Verify the request-building logic without a live account by asserting the two payloads are shaped correctly, then run one real registration against the test endpoint and fetch the DOI back to confirm it reached findable.

python
def test_reserve_body_is_prefix_only() -> None:
    body = {"data": {"type": "dois", "attributes": {"prefix": "10.5072"}}}
    assert body["data"]["attributes"] == {"prefix": "10.5072"}
    assert "url" not in body["data"]["attributes"]


def test_publish_attributes_are_complete() -> None:
    record = {
        "url": "https://data.example.org/reef/2026",
        "creators": ["Okafor, Amara"],
        "title": "Reef Temperature Series",
        "publisher": "Marine Data Archive",
        "year": 2026,
        "resource_type": "Dataset",
    }
    attrs = _mandatory_attributes(record)
    attrs["event"] = "publish"
    assert attrs["event"] == "publish"
    assert attrs["creators"] == [{"name": "Okafor, Amara"}]
    assert attrs["types"]["resourceTypeGeneral"] == "Dataset"
    assert attrs["publicationYear"] == 2026

Run with pytest -q. For a live check against Fabrica, call register("10.5072", record) then GET /dois/{doi} and assert data.attributes.state == "findable" and that data.attributes.url matches the landing page you supplied. A green run plus a findable state is the end-to-end proof the client works.

Gotchas

  • Reserving twice after a lost response. If the POST succeeds but the reply is lost to a timeout, a naive retry POSTs again and orphans a second draft. Fix: keep the retry unit at the PUT level once a DOI exists, and reap stale drafts (they are deletable) on a schedule, or reserve under an explicit deterministic doi so a replay collides with a recognisable 409.
  • Retrying a 422. A 422 means the metadata is wrong; resending the identical body reproduces it forever. Fix: raise 422 as a permanent error — as DataCiteValidationError does — and route the record to remediation instead of the retry loop.
  • Using application/json instead of the JSON:API type. DataCite’s write endpoints expect application/vnd.api+json; the wrong Content-Type can be rejected before validation even runs. Fix: set the JSON:API media type on every request, as the client’s default headers do.