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 multipart upload flow Five stages run left to right: initiate file registers the name, md5, and size; get parts fetches the upload URL and part list; PUT parts uploads each numbered part with a per-part loop shown above the box; complete upload triggers Figshare's MD5 validation; and reserve DOI then publish finalizes the article. Initiate file name, md5, size Get parts upload URL PUT parts byte ranges for each part Complete MD5 validated Reserve DOI & publish

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.

python
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.

python
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 call complete_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 the upload_url returned by the file record, on a different host, not to api.figshare.com. Fix: read upload_url from the file GET and issue the parts there, exactly as upload_parts does.
  • Passing an SPDX string as the license. Figshare rejects "CC-BY-4.0" where it expects the numeric id 1. Fix: map your SPDX label to Figshare’s numeric license id before building the article body.