Skip to content

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"}'
A reserved ARK does not resolve on a public resolver and can still be deleted; publishing is the point of no return, and the name of a withdrawn one is never assigned again. Inside a closed network, ARKHE_RESOLVE_UNPUBLISHED resolves it.

Send a request_id for anything at scale

-d '{"request_id": "ingest-2026-09-07-0001", "url": "…"}'
Resending the same 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.

curl -i $R/ark:99999/x9tn1qkq2g7
HTTP/1.1 302 Found
location: https://repo.example.ac.jp/records/1

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:

$ curl "$R/ark:99999/x9tn1qkq2g7?info&lang=en"

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:updatea 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

$ curl $R/.well-known/ark
/

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