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.
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.
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.
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
POSTsucceeds but the reply is lost to a timeout, a naive retryPOSTs again and orphans a second draft. Fix: keep the retry unit at thePUTlevel once a DOI exists, and reap stale drafts (they are deletable) on a schedule, or reserve under an explicit deterministicdoiso 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
DataCiteValidationErrordoes — and route the record to remediation instead of the retry loop. - Using
application/jsoninstead of the JSON:API type. DataCite’s write endpoints expectapplication/vnd.api+json; the wrongContent-Typecan be rejected before validation even runs. Fix: set the JSON:API media type on every request, as the client’s default headers do.
Related Guides
- DOI Minting with DataCite — the overview of the metadata schema and DOI lifecycle this client implements.
- Batch updating DataCite metadata for existing DOIs — scale the same PUT to thousands of DOIs with bounded concurrency and rate-limit handling.