From minting to resolving, with curl¶
One identifier, followed from the moment it is minted to the moment the object behind it is gone. Every response below is a real one, copied from a running instance.
Two processes answer, and neither can do the other's job:
$M |
http://127.0.0.1:8110 |
minting and updating. Needs a credential |
$R |
http://127.0.0.1:8111 |
resolution. Needs none, and has no minting endpoint |
Standing them up is the quickstart; the rest of this page assumes
a NAAN, an organisation with the shoulder /x9, and an API key in $KEY.
1. Mint¶
curl -X POST $M/api/mint \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"url": "https://repo.example.ac.jp/records/1",
"title": "Rainfall in the Kanto plain, 1991-2020",
"who": "Yamada, Taro",
"when": "2026"}'
{
"ark": "ark:99999/x9tn1qkq2g7",
"url": "https://repo.example.ac.jp/records/1",
"title": "Rainfall in the Kanto plain, 1991-2020",
"who": "Yamada, Taro",
"when": "2026",
"created_at": "2026-09-07T09:54:10.571278Z",
"published_at": "2026-09-07T09:54:10.571278Z",
"hold_until": null
}
201, and the name is now spoken for for good. x9 is the shoulder the
organisation was given; tn1qkq2g came from a random minter; the final 7 is a
check digit, which is why a mistyped ARK can be told apart from
an ARK that simply is not here.
Reserve it first if the object is still a draft
-d '{"reserve": true, "url": "…"}' # published_at is null
curl -X POST $M/api/publish -d '{"ark": "ark:99999/x9tn1qkq2g7"}'
curl -X POST $M/api/delete -d '{"ark": "ark:99999/x9tn1qkq2g7"}'
ARKHE_RESOLVE_UNPUBLISHED resolves it.
Send a request_id for anything at scale
request_id returns the ARK minted the first time (200
instead of 201) rather than minting a second one. A batch of ten thousand is
interrupted more often than not, and an ARK nobody points at cannot be taken
back.
2. Resolve¶
Resolution needs no credential — that is the whole point of the second process.
A child of that object needs no identifier of its own. Anything after the name is handed to the target — suffix passthrough:
$ curl -o /dev/null -w '%{http_code} %{redirect_url}\n' $R/ark:99999/x9tn1qkq2g7/page/3
302 https://repo.example.ac.jp/records/1/page/3
The tail is appended to the target as it stands
It is a plain concatenation, so a target that carries a query string ends up with the tail inside it:
target https://repo.example.ac.jp/view?id=1
request …/x9tn1qkq2g7/page/3
result https://repo.example.ac.jp/view?id=1/page/3 ← inside the query value
A target ending in / likewise yields //, which most servers absorb. If the
objects behind an ARK are addressed by query string, either point the ARK at a path
form, or register the parts you need explicitly (next section) instead of relying on
passthrough.
3. Ask about the identifier instead of following it¶
Appending ?? asks what the resolver promises about the name. The answer is
ERC/ANVL — the format ARK has answered in for twenty years.
$ curl -i "$R/ark:99999/x9tn1qkq2g7??"
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
thump-status: 0.6 200 OK
link: </ark:99999/x9tn1qkq2g7>; rel="describes"
erc:
who: Yamada, Taro
what: Rainfall in the Kanto plain, 1991-2020
when: 2026
where: ark:99999/x9tn1qkq2g7
redirect: https://repo.example.ac.jp/records/1
about: ark:99999/x9tn1qkq2g7
policy: (:unav)
commitment-level: permanent-dynamic
Three things in there are worth pausing on.
where is the ARK, not the target. The specification defines it as "the long-term
identifier as opposed to a transient redirect target", so repointing the ARK does not
change it. Where the object sits today is redirect, outside the kernel.
(:unav) is not an empty field. ERC requires a reason when a value cannot be
given, so "we have not recorded a namespace policy" and "there is no such thing" stay
distinguishable. A namespace policy is set when the NAAN is registered
(arkhe naan add … --policy, or the NAAN form in the admin interface).
Link: …; rel="describes" tells a client that knows nothing about inflections that
this response describes the ARK rather than being the thing itself.
The answer survives the object either way: section 6 comes back to that.
?info answers in whichever medium you ask for — the same description, a different
content type:
$ curl -o /dev/null -w '%{content_type}\n' "$R/ark:99999/x9tn1qkq2g7?info"
text/html; charset=utf-8
$ curl -H 'Accept: application/json' "$R/ark:99999/x9tn1qkq2g7?info" # same as ?json
$ curl -H 'Accept: text/plain' "$R/ark:99999/x9tn1qkq2g7?info" # same as ??
The page is translated too. ?lang= will not work here — the query string is the
inflection — so the language goes after an &, or comes from Accept-Language:
4. Point one part somewhere else¶
Suffix passthrough covers the general case. When one part genuinely lives elsewhere — a IIIF canvas, a subtree in another store — register that one point:
curl -X POST $M/api/register \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"ark": "ark:99999/x9tn1qkq2g7", "qualifier": "/page/3",
"url": "https://iiif.example.ac.jp/records/1/canvas/3"}'
$ curl -o /dev/null -w '%{http_code} %{redirect_url}\n' $R/ark:99999/x9tn1qkq2g7/page/3
302 https://iiif.example.ac.jp/records/1/canvas/3
The registered row wins over the ancestor; everything else under the name still passes
through. Note that this needs ark:mint, not ark:update — a new resolvable
identifier appears, even though nothing was minted.
5. Move the object¶
curl -X PATCH $M/api/update \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"ark": "ark:99999/x9tn1qkq2g7",
"url": "https://newrepo.example.ac.jp/datasets/1"}'
{
"ark": "ark:99999/x9tn1qkq2g7",
"url": "https://newrepo.example.ac.jp/datasets/1",
"title": "Rainfall in the Kanto plain, 1991-2020",
"who": "Yamada, Taro",
"when": "2026"
}
$ curl -o /dev/null -w '%{http_code} %{redirect_url}\n' $R/ark:99999/x9tn1qkq2g7
302 https://newrepo.example.ac.jp/datasets/1
PATCH writes what you sent and leaves the rest alone, which is what moving an
object actually calls for. Sending a field as "" clears it, so removing a value is
still possible — the two cases have to stay distinguishable.
PUT on the same path replaces the whole record
PUT /api/update is a replacement: send only ark and url and title, who,
when and the rest are emptied, because every omitted field carries its default.
That is what a PUT means, and it is the right verb when you can say "the record is
now exactly this" — but for repointing an object, use PATCH.
6. Stop the redirect without killing the name¶
A delegate is down, or a wrong target went out, and you need it to stop now. 404
would be a lie — the identifier exists — and 503 makes a permanent identifier look
broken. A hold answers 200 with the description instead:
curl -X PUT $M/api/hold \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"ark": "ark:99999/x9tn1qkq2g7", "until": "2026-09-14T00:00:00Z",
"reason": "verifying the new target"}'
$ curl "$R/ark:99999/x9tn1qkq2g7??"
erc:
where: ark:99999/x9tn1qkq2g7
redirect: https://newrepo.example.ac.jp/datasets/1
commitment-level: permanent-dynamic
hold: verifying the new target
hold-until: 2026-09-14T00:00:00+00:00
The reason is published, so do not put anything in it you would not say in public.
until is required and capped by ARKHE_HOLD_MAX_DAYS: "temporary" left to memory
becomes permanent, and an expired hold lifts itself by the clock alone. To lift it
early:
curl -X PUT $M/api/hold/release -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"ark": "ark:99999/x9tn1qkq2g7"}'
7. When the object is gone¶
A published ARK has no delete (only one that never went out does). When the thing itself is gone, say so:
curl -X PUT $M/api/tombstone \
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"ark": "ark:99999/x9tn1qkq2g7",
"commitment": "The dataset was withdrawn by the depositor in 2026-09."}'
$ curl -o /dev/null -w '%{http_code}\n' $R/ark:99999/x9tn1qkq2g7
200
$ curl "$R/ark:99999/x9tn1qkq2g7??"
erc:
where: ark:99999/x9tn1qkq2g7
about: ark:99999/x9tn1qkq2g7
commitment: The dataset was withdrawn by the depositor in 2026-09.
commitment-level: permanent-dynamic
The redirect is gone; the identifier and the record are not. A citation written in
2026 still leads somewhere that says what the thing was and what happened to it, which
is what FAIR A2 asks for and what a 404 cannot do.
8. When something is wrong¶
Errors carry a code. Match on the code, not on the wording — the full list is the errors reference.
$ curl -X POST $M/api/mint -H 'Content-Type: application/json' -d '{}'
{"code": "ARKHE-1201", "message": "No credentials."}
$ curl -X PUT $M/api/update -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{"ark": "not-an-ark", "url": "https://x/1"}'
{"code": "ARKHE-1001",
"message": "Not readable as an ARK: missing name part",
"detail": {"reason": "missing name part"}}
Resolution answers in plain text, with the code first:
$ curl $R/ark:99999/x9tn1qkq2g8
ARKHE-1403 ark:99999/x9tn1qkq2g8 — Check digit mismatch: the identifier looks mistranscribed.
That is a different answer from "no such ARK". The check digit says the string was mistyped or mis-transcribed on its way here, which is worth telling a person who is staring at a printed identifier.
A NAAN this resolver knows nothing about is handed upwards rather than refused:
$ curl -o /dev/null -w '%{http_code} %{redirect_url}\n' $R/ark:12345/abcde
302 https://n2t.net/ark:12345/abcde
9. What the resolver says about itself¶
That is the specification's own answer: the resolver's root path, so that appending a compact ARK to it gives a resolution request. arkhe's own inventory — the namespaces it holds, and where minting happens when it happens elsewhere — is the JSON representation of the same URL:
$ curl -H 'Accept: application/json' $R/.well-known/ark
{"resolver_path": "/",
"resolver": "arkhe",
"global_resolver": "https://n2t.net",
"naans": [{"naan": "99999", "authoritative": true,
"redirect": null, "minter": null, "na_policy": null}],
"delegated_shoulders": [],
"held": []}
10. Two ledgers: one public, one closed¶
Everything above was one ledger. The case this design exists for has two, run separately: a closed arkhe inside a network the outside cannot reach, and a public one that answers the world. They share no database, no replication and no sync — the only thing that crosses is a namespace allocation a person sets on each side, and later the descriptions an operator chooses to hand over.
The identifier is the same on both. That is the whole point: a name given out while the object was closed has to keep working when it opens.
$P / $PR |
public minter / resolver | authoritative for 99999. /c7 is delegated here |
$C / $CR |
closed minter / resolver | inside the network. Authoritative for /c7 there |
Build the two ledgers¶
Namespaces are not part of the REST API. Minting, updating and importing are; carving out a NAAN or a shoulder is done with the CLI (or the admin interface), because it is an act of allocation rather than of identification.
# ── Public side ──────────────────────────────────────────────
$ arkhe naan add 99999 "Example RA" --policy "NP | NR, OP, CC | 2026"
Registered NAAN 99999 (Example RA)
$ arkhe onboard 99999 "Example University" --shoulder /s7 # open PIDs
Onboarded Example University and delegated 99999/s7 (shoulder id 1)
$ arkhe shoulder add 99999 /c7 --manager 1 --note "closed PIDs" # closed PIDs
Carved out 99999/c7 (id 2)
# That id is what the other shoulder commands take. `arkhe shoulder list` shows it again.
# /c7 is minted elsewhere: record that, and give the outside an explanation
$ arkhe shoulder status 2 delegated --about https://ark.example.ac.jp/closed/99999
99999/c7 → delegated
$ arkhe shoulder redirect 2 '303 https://ark.example.ac.jp/closed-namespace'
resolution for 99999/c7 now goes to 303 https://ark.example.ac.jp/closed-namespace
$ arkhe shoulder list
2 99999/c7 delegated Example University
1 99999/s7 active Example University
The closed side is a separate ledger that holds the same NAAN and the same shoulder, and is authoritative for it there:
# ── Inside the closed network ─────────────────────────────────
$ arkhe naan add 99999 "Example RA (closed)"
Registered NAAN 99999 (Example RA (closed))
$ arkhe onboard 99999 "Closed unit" --shoulder /c7
Onboarded Closed unit and delegated 99999/c7
Nothing connects the two. 303 https://…/closed-namespace is not a default — it is
the value just written into shoulder.redirect, and without it an unregistered name
under /c7 simply answers 404.
Mint on each side¶
The open shoulder is the ordinary path from section 1:
$ curl -X POST $P/api/mint -H "Authorization: Bearer $PK" \
-d '{"shoulder": "/s7", "url": "https://repo.example.ac.jp/records/7",
"title": "Open dataset"}'
{"ark": "ark:99999/s75h5rdvnm2", …}
$ curl -o /dev/null -w '%{http_code} %{redirect_url}\n' $PR/ark:99999/s75h5rdvnm2
302 https://repo.example.ac.jp/records/7
The public side cannot mint in /c7, and says so:
$ curl -i -X POST $P/api/mint -H "Authorization: Bearer $PK" -d '{"shoulder": "/c7"}'
HTTP/1.1 403 Forbidden
{"code": "ARKHE-1309",
"message": "Minting for shoulder /c7 happens elsewhere and is not reachable from here.
See https://ark.example.ac.jp/closed/99999",
"detail": {"shoulder": "/c7", "about": "https://ark.example.ac.jp/closed/99999",
"note": "closed PIDs"}}
403, and no Location. A delegate that a caller can reach is answered with a
307 at its minter instead — arkhe never proxies the call either way, because minting
on someone's behalf means that when the response is lost, a name exists over there that
nobody here knows about, and under NR that cannot be cleaned up. But a closed minter is
not reachable, and Location means "send the same request here": pointing it at a page
for people would have clients POST to it. The page belongs in the body, as about.
# ── Inside the closed network ─────────────────────────────────
$ curl -X POST $C/api/mint -H "Authorization: Bearer $CK" \
-d '{"url": "https://inside.closed.example/dataset/42",
"title": "(a title that only exists inside)"}'
{"ark": "ark:99999/c7w545sj4z5", …}
Resolve the same name on each side¶
$ curl -o /dev/null -w '%{http_code} %{redirect_url}\n' $CR/ark:99999/c7w545sj4z5
302 https://inside.closed.example/dataset/42 # inside: straight to the object
$ curl -o /dev/null -w '%{http_code} %{redirect_url}\n' $PR/ark:99999/c7w545sj4z5
303 https://ark.example.ac.jp/closed-namespace # outside: "this namespace is closed"
A name that exists and one that does not are answered identically — the public side does not hold the names, so it cannot tell them apart. That is the point of this level: existence does not leak, and nothing was configured per identifier.
A typo is a different matter. The check digit is verified before the shoulder is
consulted, so a mistyped string answers 404 ARKHE-1403 rather than the explanation
page. Well-formed names all look alike; malformed ones are told they are malformed.
Hand it over, description only¶
$ curl -X POST $P/api/import -H "Authorization: Bearer $PK" \
-d '{"ark": "ark:99999/c7w545sj4z5",
"title": "Soil moisture, Kanto plain (restricted)",
"who": "Yamada, Taro",
"when": "2026",
"commitment": "Restricted access; use requires an application"}'
{"ark": "ark:99999/c7w545sj4z5", "url": "", …}
url stays empty, and that is not an omission — it is the statement. The public
resolver now describes an identifier it cannot send anyone to:
$ curl -H 'Accept: text/plain' "$PR/ark:99999/c7w545sj4z5?info"
erc:
who: Yamada, Taro
what: Soil moisture, Kanto plain (restricted)
when: 2026
where: ark:99999/c7w545sj4z5
about: ark:99999/c7w545sj4z5
policy: NP | NR, OP, CC | 2026
commitment: Restricted access; use requires an application
commitment-level: permanent-dynamic
What crossed the boundary is exactly what an operator typed into that request. Do not
build an automatic sync upward: a confidential title will eventually arrive in ?info,
and having no path out is stronger than having a filter on the way out.
Raise it when you can¶
$ curl -X PATCH $P/api/update -d '{"ark": "ark:99999/c7w545sj4z5",
"url": "https://apply.example.ac.jp/dataset/42"}'
→ 302 https://apply.example.ac.jp/dataset/42 # available on application
$ curl -X PATCH $P/api/update -d '{"ark": "ark:99999/c7w545sj4z5",
"url": "https://repo.example.ac.jp/records/42"}'
→ 302 https://repo.example.ac.jp/records/42 # the embargo lifts
The description survives both moves — what and who are still there — and the name
was identical at every step. Use PATCH, not PUT: as in section 5,
the description is exactly what you do not want to lose here.
A whole namespace at once¶
$ curl -X POST $P/api/import/bulk -H "Authorization: Bearer $PK" \
-d '{"data": [{"ark": "ark:99999/c7p31k8g8hn", "title": "batch 1"},
{"ark": "ark:99999/c7vtrvkbmfw", "title": "batch 2"}]}'
{"count": 2, "imported": [...]}
One row that fails any check and nothing is created. Names that did land cannot be taken back, so a half-imported namespace is worse than none.
What gets refused¶
$ curl -X POST $P/api/import -d '{"ark": "ark:99999/s71sqcdz09r"}' # not delegated
ARKHE-1307 Shoulder /s7 has status=active; only a delegated shoulder can be imported into.
$ curl -X POST $P/api/import -d '{"ark": "ark:99999/c7w545sj4zz"}' # check digit
ARKHE-1012 Check digit mismatch: ark:99999/c7w545sj4zz was not minted by a NOID minter, or was mistyped.
$ curl -X POST $P/api/import -d '{"ark": "ark:99999/c7w545sj4z5"}' # already here
ARKHE-1005 ark:99999/c7w545sj4z5 is already registered.
The check digit is the only evidence a public ledger has that a name arriving from outside was not mistyped, which is why it cannot be waived. Reach follows the usual rule — higher authority covers lower — and the NAAN must be one this ledger is authoritative for; taking custody of names in a namespace you merely forward would be claiming to be its keeper.
The two side by side¶
$ curl -o /dev/null -w '%{http_code} %{redirect_url}\n' $PR/ark:99999/s75h5rdvnm2
302 https://repo.example.ac.jp/records/7 # was open all along
$ curl -o /dev/null -w '%{http_code} %{redirect_url}\n' $PR/ark:99999/c7w545sj4z5
302 https://repo.example.ac.jp/records/42 # was closed for a year
Same NAAN, same shape, one resolver. The only difference is the shoulder, and by the time an outsider sees either of them, that difference has stopped mattering.
The shape of it¶
flowchart LR
M["POST /api/mint<br/><small>ark:mint</small>"] --> A(["ark:99999/x9tn1qkq2g7"])
A -->|"GET"| T["302 → the object"]
A -->|"GET …/page/3"| P["302 → the object/page/3<br/><small>suffix passthrough</small>"]
A -->|"?? · ?info · ?json"| D["200 the description<br/><small>answers even when the object cannot</small>"]
U["PUT /api/update"] -.->|"the target moves"| A
H["PUT /api/hold"] -.->|"the redirect stops, dated"| A
X["PUT /api/tombstone"] -.->|"the object is gone"| A
Every dotted arrow changes where the name leads, or whether it leads anywhere at all. None of them changes what the name means, and none of them can take it back.
Next¶
- The API reference — every endpoint, and what resolution answers
- Errors — every code
- Invariants — why a published ARK has no delete
- Running several arkhe — the closed/public arrangement of section 10 in full