# datum.locker API

datum.locker records a claim that a record exists: a file (identified by its SHA-256 fingerprint), a web page (archived by the server), or a freetext note. Each claim gets a permanent citation URL. This document describes the HTTP API behind the web form, so agents can create claims directly.

Base URL: https://datum.locker

All bodies are JSON (`Content-Type: application/json`). Every request must send a non-empty `User-Agent` header; requests without one are dropped. Errors return a JSON body `{"error": "<reason>"}`.

## Authentication

Creating a claim requires the header `X-Datum-Magic-Word: <magic word>`. The magic word is the same word the web form asks for; it is not published here, so obtain it from the person you are working for or the site operator. A missing or wrong word returns 403. Every other endpoint below is public.

## Create a claim

`POST /api/datum/claims`

| Field | Type | Notes |
| --- | --- | --- |
| `claimant_name` | string, ≤300 chars | Who is making the claim, as free text. |
| `claimant_page` | string | Alternative to `claimant_name`: a thunderbay.wiki person page, from `GET /api/datum/people`. Takes precedence when both are sent. |
| `sha256` | string | File claim: SHA-256 of the file bytes as 64 lowercase hex characters. |
| `filename` | string, ≤500 chars | File claim: the file's name. |
| `bytes` | integer | File claim: file size in bytes. Required if you will upload in parts (see below). |
| `content_type` | string | File claim: the file's MIME type. |
| `source_url` | string, http(s), ≤2048 chars | URL claim: the server fetches and archives this page. |
| `note` | string, ≤5000 chars | A description of the record. For a note-only claim, the note is the record. |
| `observed_at` | string, ISO 8601 | When you observed the record. Optional. |

Rules:

- At least one of `claimant_name` or `claimant_page` is required.
- At least one of `sha256`, `source_url` or `note` is required.
- `sha256` and `source_url` are mutually exclusive: one claim archives at most one held copy. A `note` can accompany either.

Response `201`:

```json
{
  "source_id": "<sha256, or datum:<hash> for URL and note claims>",
  "url": "https://datum.locker/r/<source_id>",
  "source_raw_id": "<uuid of the held copy; absent for note-only claims>",
  "archive_status": "pending",
  "uploads": [{ "filename": "...", "key": "...", "put_url": "<presigned S3 PUT URL>" }]
}
```

`url` is the permanent citation link and is valid as soon as the response arrives. `uploads` is present only for file claims.

Claims are idempotent upserts. A file claim's `source_id` is the file's SHA-256. A URL or note claim's `source_id` is `datum:` followed by the SHA-256 of `note + "\n" + claimant` (plus `"\n" + source_url` when a URL is given), where claimant is `claimant_page` if sent, else `claimant_name`. Resubmitting the same content returns the same `source_id` and overwrites its note, claimant and `observed_at`.

Errors: `400` for an invalid body (malformed `sha256`, no content, no claimant, both file and URL, invalid URL, field too long); `403` for the magic word; `503` when storage is unavailable.

## Upload a file's bytes

For a file claim, the bytes go straight to storage; the API never receives them.

1. `PUT` the raw file bytes to `uploads[0].put_url`, sending the same `Content-Type` you declared. The URL expires after one hour.
2. `POST /api/datum/archive/<source_raw_id>/confirm` with no body. The server checks that the object landed and returns `{"source_raw_id": "...", "status": "registered" | "failed"}`. A `502` means the check could not run; retry.

Large files can go up in parts instead of step 1 (the web form does this above 100 MB). This needs `bytes` on the claim.

1. `POST /api/datum/archive/<source_raw_id>/multipart` with `{"filename": "<filename from the claim>"}`. Returns `{"upload_id", "part_size", "parts": [{"part_number", "put_url"}]}`.
2. For each part, `PUT` bytes `[(part_number - 1) * part_size, part_number * part_size)` of the file to its `put_url`, with no `Content-Type`. Part URLs expire after 12 hours.
3. `POST /api/datum/archive/<source_raw_id>/multipart/complete` with `{"filename", "upload_id"}`. A `409` reports how many parts are missing; re-send them and call again.
4. Call `/confirm` as above.

## Check archive status

`GET /api/datum/archive/<source_raw_id>` returns `{"source_raw_id", "status", "error"}`, where `status` is `pending`, `registered` (the held copy is stored) or `failed` (with a reason in `error`). URL claims are fetched in the background, so poll this every few seconds until it leaves `pending`. A failed archive is not retried; the citation stays valid without a held copy, and you can submit a new claim.

## Read a claim

- `GET /r/<source_id>/meta`: the claim as JSON (CORS-enabled).
- `GET /r/<source_id>`: the human citation page.

Reading the archived bytes themselves requires a separate content password; claim metadata does not.

## Find a claimant page

`GET /api/datum/people?q=<at least 2 characters>` returns `{"people": [{"page", "full_name"}]}`. Send the chosen `page` as `claimant_page`.

## Examples

Archive a web page:

```sh
curl -sS -A "my-agent/1.0" -X POST https://datum.locker/api/datum/claims \
  -H "Content-Type: application/json" \
  -H "X-Datum-Magic-Word: $DATUM_MAGIC_WORD" \
  -d '{"source_url": "https://example.com/obituary", "note": "Obituary of Jane Doe", "claimant_name": "Jane Researcher", "observed_at": "2026-10-30T10:00:00Z"}'
```

Claim a file, upload it, and confirm:

```sh
F=scan.pdf
SHA=$(sha256sum "$F" | cut -d' ' -f1)
RESP=$(curl -sS -A "my-agent/1.0" -X POST https://datum.locker/api/datum/claims \
  -H "Content-Type: application/json" \
  -H "X-Datum-Magic-Word: $DATUM_MAGIC_WORD" \
  -d "{\"sha256\": \"$SHA\", \"filename\": \"$F\", \"bytes\": $(stat -c%s "$F"), \"content_type\": \"application/pdf\", \"claimant_name\": \"Jane Researcher\"}")
curl -sS -X PUT -H "Content-Type: application/pdf" --upload-file "$F" "$(echo "$RESP" | jq -r '.uploads[0].put_url')"
curl -sS -A "my-agent/1.0" -X POST "https://datum.locker/api/datum/archive/$(echo "$RESP" | jq -r .source_raw_id)/confirm"
echo "$RESP" | jq -r .url
```
