Publishing Datasets to Figshare via the API
Figshare’s upload model is the one most likely to trip an automation script, because a file does not go to the article endpoint at all — it goes to a separate upload service in numbered parts, each verified against an MD5 the API computed for you up front. Get the sequence wrong and the article publishes with a zero-byte or half-written file that no reviewer notices until download. This walkthrough runs the Figshare articles API end to end: create an article, initiate a file upload, PUT each part to the upload service, complete the upload so Figshare validates the MD5, reserve a Digital Object Identifier, and publish. It is the concrete Figshare backend behind the Repository Deposit Automation guide, and it follows the same create-upload-metadata-publish lifecycle defined there. You need a Figshare personal token, Python 3.10+, and httpx; the reserved DOI is registered through DataCite, whose model the DOI minting with DataCite guide details.
Figshare Article Fields
An article is created with POST /account/articles, and its body sets the descriptive metadata that will accompany the published dataset. Figshare identifies a license by a numeric id rather than an SPDX string — 1 is Creative Commons Attribution 4.0 (CC-BY 4.0) on figshare.com — and a resource type by defined_type. The file-upload call is separate and takes only name, md5, and size.
| Field | Required | Type | Example | Notes |
|---|---|---|---|---|
title |
yes | string | Reef Temperature Series 2019–2024 |
Plain text |
description |
recommended | string | <p>Hourly sea-surface temperatures…</p> |
HTML permitted |
defined_type |
no | string | dataset |
dataset, figure, software, poster, others |
authors |
no | array | [{"name": "Curie, Marie"}] |
Use {"id": N} for existing Figshare authors |
keywords |
no | array | ["oceanography", "SST"] |
Free-text tags |
categories |
no | array | [23] |
Numeric Figshare category ids |
license |
no | integer | 1 |
Numeric license id; 1 = CC-BY 4.0 |
references |
no | array | ["https://doi.org/10.5281/…"] |
Related links |
funding_list |
no | array | [{"title": "NSF OCE-1234567"}] |
Grant attributions |
Production Implementation
The client below runs the full sequence. The upload URL Figshare returns points at a separate host, so the part PUTs go there; each part is a byte range read straight from the file. Completing the upload is what triggers Figshare’s server-side MD5 check against the digest supplied at initiation, so a corrupted part fails at complete_upload rather than silently publishing.
from __future__ import annotations
import hashlib
from pathlib import Path
from typing import Any
import httpx
FIGSHARE_API = "https://api.figshare.com/v2"
def compute_md5(path: Path, chunk: int = 1 << 20) -> str:
"""Stream the file through MD5 so large uploads stay memory-constant."""
digest = hashlib.md5()
with path.open("rb") as fh:
for block in iter(lambda: fh.read(chunk), b""):
digest.update(block)
return digest.hexdigest()
class FigshareError(RuntimeError):
"""Raised on an unrecoverable Figshare API response."""
def _id_from_location(location: str) -> int:
return int(location.rstrip("/").rsplit("/", 1)[-1])
def create_article(client: httpx.Client, metadata: dict[str, Any]) -> int:
"""POST an article; the response location carries the new article id."""
resp = client.post("/account/articles", json=metadata)
resp.raise_for_status()
return _id_from_location(resp.json()["location"])
def initiate_file(client: httpx.Client, article_id: int, path: Path, md5: str) -> int:
"""Register a file with its md5 and size; returns the file id."""
body = {"name": path.name, "md5": md5, "size": path.stat().st_size}
resp = client.post(f"/account/articles/{article_id}/files", json=body)
resp.raise_for_status()
return _id_from_location(resp.json()["location"])
def upload_parts(client: httpx.Client, article_id: int, file_id: int, path: Path) -> None:
"""Fetch the part plan from the upload service and PUT each byte range."""
info = client.get(f"/account/articles/{article_id}/files/{file_id}")
info.raise_for_status()
upload_url = info.json()["upload_url"] # separate host, token in the path
plan = client.get(upload_url)
plan.raise_for_status()
with path.open("rb") as fh:
for part in plan.json()["parts"]:
fh.seek(part["startOffset"])
chunk = fh.read(part["endOffset"] - part["startOffset"] + 1)
put = client.put(f"{upload_url}/{part['partNo']}", content=chunk)
put.raise_for_status()
def complete_upload(client: httpx.Client, article_id: int, file_id: int) -> None:
"""Signal completion; Figshare now validates the whole-file MD5."""
resp = client.post(f"/account/articles/{article_id}/files/{file_id}")
if resp.status_code >= 400:
raise FigshareError(f"Upload completion failed (MD5 mismatch?): {resp.text}")
def reserve_doi(client: httpx.Client, article_id: int) -> str:
"""Reserve a DOI before publication so it can be cited in the record."""
resp = client.post(f"/account/articles/{article_id}/reserve_doi")
resp.raise_for_status()
return resp.json()["doi"]
def publish(client: httpx.Client, article_id: int) -> str:
"""Publish the article; the reserved DOI becomes findable."""
resp = client.post(f"/account/articles/{article_id}/publish")
resp.raise_for_status()
return resp.json()["location"]
def deposit_dataset(token: str, files: list[Path], metadata: dict[str, Any]) -> dict[str, str]:
"""Full flow: create -> upload parts -> complete -> reserve DOI -> publish."""
headers = {"Authorization": f"token {token}"}
with httpx.Client(base_url=FIGSHARE_API, headers=headers, timeout=120.0) as client:
article_id = create_article(client, metadata)
for path in files:
file_id = initiate_file(client, article_id, path, compute_md5(path))
upload_parts(client, article_id, file_id, path)
complete_upload(client, article_id, file_id)
doi = reserve_doi(client, article_id)
record_url = publish(client, article_id)
return {"article_id": str(article_id), "doi": doi, "url": record_url}
if __name__ == "__main__":
meta = {
"title": "Reef Temperature Series 2019–2024",
"description": "<p>Hourly sea-surface temperatures from 12 reef stations.</p>",
"defined_type": "dataset",
"authors": [{"name": "Curie, Marie"}],
"keywords": ["oceanography", "sea-surface temperature"],
"license": 1, # CC-BY 4.0
}
result = deposit_dataset("YOUR_TOKEN", [Path("reef_sst.csv")], meta)
print(result["doi"])
Why the Part-Based Upload Exists
The multipart mechanism looks heavyweight for a small file, but it is what makes large, unreliable transfers survivable. When you initiate a file, Figshare records its total MD5 and computes a part plan sized to the byte length; each part is then an independent PUT that can be retried in isolation. A dropped connection on part 7 of 40 costs one part, not the whole upload — which is exactly the resumable behaviour the Repository Deposit Automation guide relies on. Re-fetching the part plan after a failure shows each part’s status, so a resume uploads only the parts still marked PENDING.
Completion is the integrity checkpoint. When you POST to the file endpoint, Figshare concatenates the stored parts, computes the whole-file MD5, and compares it to the digest you supplied at initiation. A mismatch fails the completion call rather than corrupting the article, so the checksum you sent up front is doing real work — it is not advisory metadata. Because publishing an already-published article creates a new version with a version-suffixed DOI, an update follows the same path: edit the article, add or replace files through the part flow, and publish again. The reserved DOI, once activated, persists across those versions, so citations remain stable while the underlying files evolve.
Verification
Assert the sequencing offline with httpx.MockTransport, which lets you prove the client fetches the part plan and issues one PUT per part before completing. The test simulates a two-part file and counts the part uploads.
from pathlib import Path
import httpx
def test_uploads_every_part(tmp_path: Path) -> None:
f = tmp_path / "data.bin"
f.write_bytes(b"x" * 2048)
put_calls: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if request.method == "GET" and path.endswith("/files/9"):
return httpx.Response(200, json={"upload_url": "https://up.figshare.test/u/tok"})
if request.method == "GET" and path == "/u/tok":
return httpx.Response(200, json={"parts": [
{"partNo": 1, "startOffset": 0, "endOffset": 1023},
{"partNo": 2, "startOffset": 1024, "endOffset": 2047},
]})
if request.method == "PUT":
put_calls.append(path)
return httpx.Response(200)
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
with httpx.Client(base_url="https://api.figshare.com/v2", transport=transport) as client:
upload_parts(client, article_id=5, file_id=9, path=f)
assert put_calls == ["/u/tok/1", "/u/tok/2"] # one PUT per part, in order
Run it with pytest -q. Against a live token, deposit_dataset returns a DOI under Figshare’s 10.6084 prefix and a public article URL once publication completes.
Gotchas
- Skipping
complete_upload. Uploading every part but never posting completion leaves the file in a pending state and the article publishes without it. Fix: always callcomplete_upload, which is also the step where Figshare validates the MD5 you supplied at initiation. - Sending the file to the API host instead of the upload service. The part
PUTs go to theupload_urlreturned by the file record, on a different host, not toapi.figshare.com. Fix: readupload_urlfrom the file GET and issue the parts there, exactly asupload_partsdoes. - Passing an SPDX string as the license. Figshare rejects
"CC-BY-4.0"where it expects the numeric id1. Fix: map your SPDX label to Figshare’s numeric license id before building the article body.
Related Guides
- Repository Deposit Automation — the parent guide whose adapter Protocol and lifecycle this Figshare backend implements.
- Automating Zenodo batch uploads with the REST API — the same deposit lifecycle against Zenodo’s bucket upload for cross-archive comparison.
- DOI minting with DataCite — the registration agency behind the DOI reserved before publication.