Skirnir API

Build exhibits from your own systems

The Exhibitions workspace over HTTP, for developers and for their assistants. Read it in order the first time; the reference is at the end.

Chapter 1

Start here

The API does what the Exhibitions workspace does: your captures, their exhibits and drafts, collections, and the webhooks that tell your own systems when something changes.

Everything is at https://heimdall3d.com/api/v1, answers JSON, and is described in full as OpenAPI 3.1 at /api/v1/openapi.json. The exhibit format has its own JSON Schema at /api/v1/schema/exhibit.json, every field explained.

A change you make is a draft that visitors do not see until it is published. Publishing is a permission of its own, so a program or an assistant can do the work while a person decides when it goes out.

The server stores and serves. A capture reaches it already converted, in the browser of whoever chose the file, and nothing is converted on the server.

Chapter 2

Make a key

A key belongs to your organisation, or to you, and carries what it may do.

In the Exhibitions workspace, open Keys and make one. Choose what it may do: read sees your pieces, exhibits and collections; write changes them, every exhibit change a draft; publish makes a draft what visitors see. The key is shown once; keep it where you keep other secrets.

Send it on every request as Authorization: Bearer h3d_…. A key makes at most 120 requests a minute; past that the answer is 429 with Retry-After.

GET/api/v1/piecesread

List the pieces

Every capture and picture of the account, in the order of its page.

curl -X GET https://heimdall3d.com/api/v1/pieces \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "pieces": [
    {
      "bytes": 48210044,
      "embed": "https://heimdall3d.com/u/trogir-museum/…",
      "exhibit_changed_at": "2026-09-30T09:12:00Z",
      "has_draft": true,
      "has_exhibit": true,
      "id": 128,
      "kind": "model",
      "note": "The old town, captured in 2026.",
      "page": "https://heimdall3d.com/u/trogir-museum/…",
      "title": "Trogir, Croatia",
      "triangles": 0,
      "views": 3412
    }
  ]
}

401 no key · 403 a key without the read scope · 429 past 120 a minute

Chapter 3

Your first exhibit

Read what is there, save a zone as a draft, share the draft for checking, then publish.

Send the whole exhibit. If a field does not match the format, the answer names it, as zones[3].title, so you can put it right.

A preview link shows the draft, read only, to whoever has it, for 1 to 90 days. Publishing keeps the version, with who published it, and tells your webhooks.

GET/api/v1/pieces/{id}/exhibitread

Read a piece's exhibit

The exhibit as its editors see it: the draft when there is one, otherwise what readers see. ?state=published reads what readers see. The X-Exhibit-State header says which it is.

curl -X GET https://heimdall3d.com/api/v1/pieces/128/exhibit \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "version": 1,
  "zones": [
    {
      "center": [
        48.77,
        -0.43,
        -169.34
      ],
      "id": "west-portal",
      "radius": 6,
      "shape": "cylinder",
      "story": {
        "sources": [
          {
            "label": "Trogir Cathedral",
            "url": "https://en.wikipedia.org/wiki/Trogir_Cathedral"
          }
        ],
        "title": "Radovan"
      },
      "text": "Master Radovan signed the west portal in 1240.",
      "title": "The west portal"
    }
  ]
}

401 no key · 403 a key without the read scope · 429 past 120 a minute · 404 not the key's

PUT/api/v1/pieces/{id}/exhibitwrite

Save a piece's exhibit as a draft

The whole exhibit, as /api/v1/schema/exhibit.json describes it. It is kept as a draft that readers do not see until it is published. A field that does not match the format is named in the answer.

curl -X PUT https://heimdall3d.com/api/v1/pieces/128/exhibit \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"version":1,"zones":[{"center":[48.77,-0.43,-169.34],"id":"west-portal","radius":6,"shape":"cylinder","story":{"sources":[{"label":"Trogir Cathedral","url":"https://en.wikipedia.org/wiki/Trogir_Cathedral"}],"title":"Radovan"},"text":"Master Radovan signed the west portal in 1240.","title":"The west portal"}]}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit", {
  method: "PUT",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "version": 1,
    "zones": [
      {
        "center": [
          48.77,
          -0.43,
          -169.34
        ],
        "id": "west-portal",
        "radius": 6,
        "shape": "cylinder",
        "story": {
          "sources": [
            {
              "label": "Trogir Cathedral",
              "url": "https://en.wikipedia.org/wiki/Trogir_Cathedral"
            }
          ],
          "title": "Radovan"
        },
        "text": "Master Radovan signed the west portal in 1240.",
        "title": "The west portal"
      }
    ]
  }),
});
const answer = await response.json();
Example answer · 200
{
  "draft": true
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's · 400 a wrong body, the field named

POST/api/v1/pieces/{id}/previewswrite

Make a preview link

A link that shows the draft, read only, to whoever has it, for 1 to 90 days.

curl -X POST https://heimdall3d.com/api/v1/pieces/128/previews \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"days":7,"label":"the director"}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/previews", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "days": 7,
    "label": "the director"
  }),
});
const answer = await response.json();
Example answer · 201
{
  "created": "2026-09-30T10:02:00Z",
  "expires": "2026-10-07T10:02:00Z",
  "id": 128,
  "label": "the director",
  "state": "open",
  "url": "https://heimdall3d.com/u/trogir-museum/…"
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's · 400 a wrong body, the field named

POST/api/v1/pieces/{id}/exhibit/publishpublish

Publish the draft

Readers see the draft from now on; it is kept as a version, and the webhooks told. 409 when there is nothing new to publish.

curl -X POST https://heimdall3d.com/api/v1/pieces/128/exhibit/publish \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit/publish", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key" },
});
// 204, with no body, when it worked
Example answer · 204
(no body)

401 no key · 403 a key without the publish scope · 429 past 120 a minute · 404 not the key's

Chapter 4

Drafts and history

Every publish is kept. Throw a draft away, look at an earlier version, or bring one back as the draft.

DELETE/api/v1/pieces/{id}/exhibit/draftwrite

Discard the draft

Throws away changes readers have not seen. 409 when there are none.

curl -X DELETE https://heimdall3d.com/api/v1/pieces/128/exhibit/draft \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit/draft", {
  method: "DELETE",
  headers: { Authorization: "Bearer h3d_your_key" },
});
// 204, with no body, when it worked
Example answer · 204
(no body)

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's

GET/api/v1/pieces/{id}/exhibit/versionsread

List the published versions

curl -X GET https://heimdall3d.com/api/v1/pieces/128/exhibit/versions \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit/versions", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "versions": [
    {
      "at": "2026-09-29T16:40:00Z",
      "by": "ana-kovac",
      "bytes": 48210044,
      "id": 128,
      "removed": false
    }
  ]
}

401 no key · 403 a key without the read scope · 429 past 120 a minute · 404 not the key's

POST/api/v1/pieces/{id}/exhibit/versions/{version}/restorewrite

Restore a version as the draft

The version becomes the draft, to be published or changed further.

curl -X POST https://heimdall3d.com/api/v1/pieces/128/exhibit/versions/6/restore \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit/versions/6/restore", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key" },
});
// 204, with no body, when it worked
Example answer · 204
(no body)

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's

Chapter 5

Captures, and replacing them

Upload a capture already converted, and swap in a better one without losing its exhibit.

A new capture of the same place takes the old one's place: the piece keeps its id, and so its exhibit, links, embeds, collections and views. If the new capture sits in another frame, the exhibit editor moves every zone at once.

POST/api/v1/pieceswrite

Upload a piece

A multipart form with one file field, "file": a glb, a streamed package (.ssog, .smesh, .spoints) or a picture, already converted on your side. The server stores it and converts nothing. Larger files go in parts, through the resumable upload road.

curl -X POST https://heimdall3d.com/api/v1/pieces \
  -H "Authorization: Bearer h3d_your_key" \
  -F "file=@trogir.ssog.zip"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key" },
  body: form, // a FormData holding the file under "file"
});
const answer = await response.json();
Example answer · 200
{
  "allowance": 107374182400,
  "edit": "https://heimdall3d.com/u/trogir-museum/…",
  "id": 128,
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "used": 1210044221
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 400 a wrong body, the field named

POST/api/v1/pieces/{id}/replacewrite

Replace a piece's file

Upload the new file as a piece first, then name it here: it takes this piece's place, which keeps its id, exhibit, links, embeds, collections and view counts, and the uploaded piece goes. A model is replaced by a model, a picture by a picture.

curl -X POST https://heimdall3d.com/api/v1/pieces/128/replace \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"from":131}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/replace", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "from": 131
  }),
});
const answer = await response.json();
Example answer · 200
{
  "bytes": 48210044,
  "embed": "https://heimdall3d.com/u/trogir-museum/…",
  "exhibit_changed_at": "2026-09-30T09:12:00Z",
  "has_draft": true,
  "has_exhibit": true,
  "id": 128,
  "kind": "model",
  "note": "The old town, captured in 2026.",
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "title": "Trogir, Croatia",
  "triangles": 0,
  "views": 3412
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's · 400 a wrong body, the field named

Chapter 6

Collections

Gather pieces on a page of their own: private, unlisted with a link, or public.

POST/api/v1/collectionswrite

Make a collection

A new collection, private until its visibility is changed.

curl -X POST https://heimdall3d.com/api/v1/collections \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"title":"Trogir, Croatia"}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/collections", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "title": "Trogir, Croatia"
  }),
});
const answer = await response.json();
Example answer · 201
{
  "id": 128,
  "intro": "Radovan's portal and the chapels.",
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "pieces": [
    128,
    131
  ],
  "slug": "the-cathedral",
  "title": "Trogir, Croatia",
  "updated": "2026-09-30T10:05:00Z",
  "visibility": "unlisted"
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 400 a wrong body, the field named

PUT/api/v1/collections/{id}/pieceswrite

Set a collection's pieces

The pieces it holds, in order. A piece that is not the account's is left out.

curl -X PUT https://heimdall3d.com/api/v1/collections/14/pieces \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"pieces":[128,131]}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/collections/14/pieces", {
  method: "PUT",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "pieces": [
      128,
      131
    ]
  }),
});
const answer = await response.json();
Example answer · 200
{
  "id": 128,
  "intro": "Radovan's portal and the chapels.",
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "pieces": [
    128,
    131
  ],
  "slug": "the-cathedral",
  "title": "Trogir, Croatia",
  "updated": "2026-09-30T10:05:00Z",
  "visibility": "unlisted"
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's · 400 a wrong body, the field named

PATCH/api/v1/collections/{id}write

Change a collection

Any of its title, introduction and visibility; what is left out stays.

curl -X PATCH https://heimdall3d.com/api/v1/collections/14 \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"intro":"Radovan's portal and the chapels.","title":"Trogir, Croatia","visibility":"unlisted"}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/collections/14", {
  method: "PATCH",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "intro": "Radovan's portal and the chapels.",
    "title": "Trogir, Croatia",
    "visibility": "unlisted"
  }),
});
const answer = await response.json();
Example answer · 200
{
  "id": 128,
  "intro": "Radovan's portal and the chapels.",
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "pieces": [
    128,
    131
  ],
  "slug": "the-cathedral",
  "title": "Trogir, Croatia",
  "updated": "2026-09-30T10:05:00Z",
  "visibility": "unlisted"
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's · 400 a wrong body, the field named

Chapter 7

Webhooks

Tell your own systems when an exhibit is published or a capture uploaded.

Every delivery is a POST signed with the webhook's secret: X-Heimdall-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">. Check it before you trust the body. A delivery not answered 2xx within ten seconds is tried again, eight times over about two days.

POST/api/v1/webhookswrite

Add a webhook

An https address told of events by a signed POST: X-Heimdall-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>" under the secret>. The secret is answered once. A delivery that is not answered 2xx within ten seconds is tried again, eight times over about two days.

curl -X POST https://heimdall3d.com/api/v1/webhooks \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"events":["exhibit.published"],"url":"https://museum.example/hooks/heimdall"}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/webhooks", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "events": [
      "exhibit.published"
    ],
    "url": "https://museum.example/hooks/heimdall"
  }),
});
const answer = await response.json();
Example answer · 201
{
  "secret": "whsec_…",
  "webhook": {
    "created": "2026-09-30T10:02:00Z",
    "events": [
      "exhibit.published"
    ],
    "id": 128,
    "last_at": "2026-09-30T10:06:00Z",
    "last_status": "200 OK",
    "url": "https://heimdall3d.com/u/trogir-museum/…"
  }
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 400 a wrong body, the field named

Chapter 8

Assistants

Let an assistant build exhibits through the MCP server, with the same keys.

The MCP server is https://heimdall3d.com/api/v1/mcp, over Streamable HTTP. Connect any MCP client with your key; what it changes is a draft unless the key may publish. The builder's guide, with worked prompts, is /api/v1/guide.md.

claude mcp add --transport http skirnir https://heimdall3d.com/api/v1/mcp --header "Authorization: Bearer h3d_your_key"

Chapter 9

Reference

Every route, with a request and an example answer.

GET/api/v1/piecesList the piecesPOST/api/v1/piecesUpload a pieceGET/api/v1/pieces/{id}Read a piecePOST/api/v1/pieces/{id}/replaceReplace a piece's fileGET/api/v1/pieces/{id}/exhibitRead a piece's exhibitPUT/api/v1/pieces/{id}/exhibitSave a piece's exhibit as a draftDELETE/api/v1/pieces/{id}/exhibit/draftDiscard the draftPOST/api/v1/pieces/{id}/exhibit/publishPublish the draftGET/api/v1/pieces/{id}/exhibit/versionsList the published versionsGET/api/v1/pieces/{id}/exhibit/versions/{version}Read a published versionPOST/api/v1/pieces/{id}/exhibit/versions/{version}/restoreRestore a version as the draftGET/api/v1/pieces/{id}/previewsList the preview linksPOST/api/v1/pieces/{id}/previewsMake a preview linkDELETE/api/v1/pieces/{id}/previews/{preview}Withdraw a preview linkGET/api/v1/collectionsList the collectionsPOST/api/v1/collectionsMake a collectionGET/api/v1/collections/{id}Read a collectionPATCH/api/v1/collections/{id}Change a collectionPUT/api/v1/collections/{id}/piecesSet a collection's piecesDELETE/api/v1/collections/{id}Delete a collectionGET/api/v1/webhooksList the webhooksPOST/api/v1/webhooksAdd a webhookDELETE/api/v1/webhooks/{id}Remove a webhook
GET/api/v1/piecesread

List the pieces

Every capture and picture of the account, in the order of its page.

curl -X GET https://heimdall3d.com/api/v1/pieces \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "pieces": [
    {
      "bytes": 48210044,
      "embed": "https://heimdall3d.com/u/trogir-museum/…",
      "exhibit_changed_at": "2026-09-30T09:12:00Z",
      "has_draft": true,
      "has_exhibit": true,
      "id": 128,
      "kind": "model",
      "note": "The old town, captured in 2026.",
      "page": "https://heimdall3d.com/u/trogir-museum/…",
      "title": "Trogir, Croatia",
      "triangles": 0,
      "views": 3412
    }
  ]
}

401 no key · 403 a key without the read scope · 429 past 120 a minute

POST/api/v1/pieceswrite

Upload a piece

A multipart form with one file field, "file": a glb, a streamed package (.ssog, .smesh, .spoints) or a picture, already converted on your side. The server stores it and converts nothing. Larger files go in parts, through the resumable upload road.

curl -X POST https://heimdall3d.com/api/v1/pieces \
  -H "Authorization: Bearer h3d_your_key" \
  -F "file=@trogir.ssog.zip"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key" },
  body: form, // a FormData holding the file under "file"
});
const answer = await response.json();
Example answer · 200
{
  "allowance": 107374182400,
  "edit": "https://heimdall3d.com/u/trogir-museum/…",
  "id": 128,
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "used": 1210044221
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 400 a wrong body, the field named

GET/api/v1/pieces/{id}read

Read a piece

curl -X GET https://heimdall3d.com/api/v1/pieces/128 \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "bytes": 48210044,
  "embed": "https://heimdall3d.com/u/trogir-museum/…",
  "exhibit_changed_at": "2026-09-30T09:12:00Z",
  "has_draft": true,
  "has_exhibit": true,
  "id": 128,
  "kind": "model",
  "note": "The old town, captured in 2026.",
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "title": "Trogir, Croatia",
  "triangles": 0,
  "views": 3412
}

401 no key · 403 a key without the read scope · 429 past 120 a minute · 404 not the key's

POST/api/v1/pieces/{id}/replacewrite

Replace a piece's file

Upload the new file as a piece first, then name it here: it takes this piece's place, which keeps its id, exhibit, links, embeds, collections and view counts, and the uploaded piece goes. A model is replaced by a model, a picture by a picture.

curl -X POST https://heimdall3d.com/api/v1/pieces/128/replace \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"from":131}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/replace", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "from": 131
  }),
});
const answer = await response.json();
Example answer · 200
{
  "bytes": 48210044,
  "embed": "https://heimdall3d.com/u/trogir-museum/…",
  "exhibit_changed_at": "2026-09-30T09:12:00Z",
  "has_draft": true,
  "has_exhibit": true,
  "id": 128,
  "kind": "model",
  "note": "The old town, captured in 2026.",
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "title": "Trogir, Croatia",
  "triangles": 0,
  "views": 3412
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's · 400 a wrong body, the field named

GET/api/v1/pieces/{id}/exhibitread

Read a piece's exhibit

The exhibit as its editors see it: the draft when there is one, otherwise what readers see. ?state=published reads what readers see. The X-Exhibit-State header says which it is.

curl -X GET https://heimdall3d.com/api/v1/pieces/128/exhibit \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "version": 1,
  "zones": [
    {
      "center": [
        48.77,
        -0.43,
        -169.34
      ],
      "id": "west-portal",
      "radius": 6,
      "shape": "cylinder",
      "story": {
        "sources": [
          {
            "label": "Trogir Cathedral",
            "url": "https://en.wikipedia.org/wiki/Trogir_Cathedral"
          }
        ],
        "title": "Radovan"
      },
      "text": "Master Radovan signed the west portal in 1240.",
      "title": "The west portal"
    }
  ]
}

401 no key · 403 a key without the read scope · 429 past 120 a minute · 404 not the key's

PUT/api/v1/pieces/{id}/exhibitwrite

Save a piece's exhibit as a draft

The whole exhibit, as /api/v1/schema/exhibit.json describes it. It is kept as a draft that readers do not see until it is published. A field that does not match the format is named in the answer.

curl -X PUT https://heimdall3d.com/api/v1/pieces/128/exhibit \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"version":1,"zones":[{"center":[48.77,-0.43,-169.34],"id":"west-portal","radius":6,"shape":"cylinder","story":{"sources":[{"label":"Trogir Cathedral","url":"https://en.wikipedia.org/wiki/Trogir_Cathedral"}],"title":"Radovan"},"text":"Master Radovan signed the west portal in 1240.","title":"The west portal"}]}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit", {
  method: "PUT",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "version": 1,
    "zones": [
      {
        "center": [
          48.77,
          -0.43,
          -169.34
        ],
        "id": "west-portal",
        "radius": 6,
        "shape": "cylinder",
        "story": {
          "sources": [
            {
              "label": "Trogir Cathedral",
              "url": "https://en.wikipedia.org/wiki/Trogir_Cathedral"
            }
          ],
          "title": "Radovan"
        },
        "text": "Master Radovan signed the west portal in 1240.",
        "title": "The west portal"
      }
    ]
  }),
});
const answer = await response.json();
Example answer · 200
{
  "draft": true
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's · 400 a wrong body, the field named

DELETE/api/v1/pieces/{id}/exhibit/draftwrite

Discard the draft

Throws away changes readers have not seen. 409 when there are none.

curl -X DELETE https://heimdall3d.com/api/v1/pieces/128/exhibit/draft \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit/draft", {
  method: "DELETE",
  headers: { Authorization: "Bearer h3d_your_key" },
});
// 204, with no body, when it worked
Example answer · 204
(no body)

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's

POST/api/v1/pieces/{id}/exhibit/publishpublish

Publish the draft

Readers see the draft from now on; it is kept as a version, and the webhooks told. 409 when there is nothing new to publish.

curl -X POST https://heimdall3d.com/api/v1/pieces/128/exhibit/publish \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit/publish", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key" },
});
// 204, with no body, when it worked
Example answer · 204
(no body)

401 no key · 403 a key without the publish scope · 429 past 120 a minute · 404 not the key's

GET/api/v1/pieces/{id}/exhibit/versionsread

List the published versions

curl -X GET https://heimdall3d.com/api/v1/pieces/128/exhibit/versions \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit/versions", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "versions": [
    {
      "at": "2026-09-29T16:40:00Z",
      "by": "ana-kovac",
      "bytes": 48210044,
      "id": 128,
      "removed": false
    }
  ]
}

401 no key · 403 a key without the read scope · 429 past 120 a minute · 404 not the key's

GET/api/v1/pieces/{id}/exhibit/versions/{version}read

Read a published version

curl -X GET https://heimdall3d.com/api/v1/pieces/128/exhibit/versions/6 \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit/versions/6", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "version": 1,
  "zones": [
    {
      "center": [
        48.77,
        -0.43,
        -169.34
      ],
      "id": "west-portal",
      "radius": 6,
      "shape": "cylinder",
      "story": {
        "sources": [
          {
            "label": "Trogir Cathedral",
            "url": "https://en.wikipedia.org/wiki/Trogir_Cathedral"
          }
        ],
        "title": "Radovan"
      },
      "text": "Master Radovan signed the west portal in 1240.",
      "title": "The west portal"
    }
  ]
}

401 no key · 403 a key without the read scope · 429 past 120 a minute · 404 not the key's

POST/api/v1/pieces/{id}/exhibit/versions/{version}/restorewrite

Restore a version as the draft

The version becomes the draft, to be published or changed further.

curl -X POST https://heimdall3d.com/api/v1/pieces/128/exhibit/versions/6/restore \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/exhibit/versions/6/restore", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key" },
});
// 204, with no body, when it worked
Example answer · 204
(no body)

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's

GET/api/v1/pieces/{id}/previewsread

List the preview links

curl -X GET https://heimdall3d.com/api/v1/pieces/128/previews \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/previews", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "previews": [
    {
      "created": "2026-09-30T10:02:00Z",
      "expires": "2026-10-07T10:02:00Z",
      "id": 128,
      "label": "the director",
      "state": "open",
      "url": "https://heimdall3d.com/u/trogir-museum/…"
    }
  ]
}

401 no key · 403 a key without the read scope · 429 past 120 a minute · 404 not the key's

POST/api/v1/pieces/{id}/previewswrite

Make a preview link

A link that shows the draft, read only, to whoever has it, for 1 to 90 days.

curl -X POST https://heimdall3d.com/api/v1/pieces/128/previews \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"days":7,"label":"the director"}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/previews", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "days": 7,
    "label": "the director"
  }),
});
const answer = await response.json();
Example answer · 201
{
  "created": "2026-09-30T10:02:00Z",
  "expires": "2026-10-07T10:02:00Z",
  "id": 128,
  "label": "the director",
  "state": "open",
  "url": "https://heimdall3d.com/u/trogir-museum/…"
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's · 400 a wrong body, the field named

DELETE/api/v1/pieces/{id}/previews/{preview}write

Withdraw a preview link

curl -X DELETE https://heimdall3d.com/api/v1/pieces/128/previews/12 \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/pieces/128/previews/12", {
  method: "DELETE",
  headers: { Authorization: "Bearer h3d_your_key" },
});
// 204, with no body, when it worked
Example answer · 204
(no body)

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's

GET/api/v1/collectionsread

List the collections

curl -X GET https://heimdall3d.com/api/v1/collections \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/collections", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "collections": [
    {
      "id": 128,
      "intro": "Radovan's portal and the chapels.",
      "page": "https://heimdall3d.com/u/trogir-museum/…",
      "pieces": [
        128,
        131
      ],
      "slug": "the-cathedral",
      "title": "Trogir, Croatia",
      "updated": "2026-09-30T10:05:00Z",
      "visibility": "unlisted"
    }
  ]
}

401 no key · 403 a key without the read scope · 429 past 120 a minute

POST/api/v1/collectionswrite

Make a collection

A new collection, private until its visibility is changed.

curl -X POST https://heimdall3d.com/api/v1/collections \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"title":"Trogir, Croatia"}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/collections", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "title": "Trogir, Croatia"
  }),
});
const answer = await response.json();
Example answer · 201
{
  "id": 128,
  "intro": "Radovan's portal and the chapels.",
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "pieces": [
    128,
    131
  ],
  "slug": "the-cathedral",
  "title": "Trogir, Croatia",
  "updated": "2026-09-30T10:05:00Z",
  "visibility": "unlisted"
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 400 a wrong body, the field named

GET/api/v1/collections/{id}read

Read a collection

curl -X GET https://heimdall3d.com/api/v1/collections/14 \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/collections/14", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "id": 128,
  "intro": "Radovan's portal and the chapels.",
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "pieces": [
    128,
    131
  ],
  "slug": "the-cathedral",
  "title": "Trogir, Croatia",
  "updated": "2026-09-30T10:05:00Z",
  "visibility": "unlisted"
}

401 no key · 403 a key without the read scope · 429 past 120 a minute · 404 not the key's

PATCH/api/v1/collections/{id}write

Change a collection

Any of its title, introduction and visibility; what is left out stays.

curl -X PATCH https://heimdall3d.com/api/v1/collections/14 \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"intro":"Radovan's portal and the chapels.","title":"Trogir, Croatia","visibility":"unlisted"}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/collections/14", {
  method: "PATCH",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "intro": "Radovan's portal and the chapels.",
    "title": "Trogir, Croatia",
    "visibility": "unlisted"
  }),
});
const answer = await response.json();
Example answer · 200
{
  "id": 128,
  "intro": "Radovan's portal and the chapels.",
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "pieces": [
    128,
    131
  ],
  "slug": "the-cathedral",
  "title": "Trogir, Croatia",
  "updated": "2026-09-30T10:05:00Z",
  "visibility": "unlisted"
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's · 400 a wrong body, the field named

PUT/api/v1/collections/{id}/pieceswrite

Set a collection's pieces

The pieces it holds, in order. A piece that is not the account's is left out.

curl -X PUT https://heimdall3d.com/api/v1/collections/14/pieces \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"pieces":[128,131]}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/collections/14/pieces", {
  method: "PUT",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "pieces": [
      128,
      131
    ]
  }),
});
const answer = await response.json();
Example answer · 200
{
  "id": 128,
  "intro": "Radovan's portal and the chapels.",
  "page": "https://heimdall3d.com/u/trogir-museum/…",
  "pieces": [
    128,
    131
  ],
  "slug": "the-cathedral",
  "title": "Trogir, Croatia",
  "updated": "2026-09-30T10:05:00Z",
  "visibility": "unlisted"
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's · 400 a wrong body, the field named

DELETE/api/v1/collections/{id}write

Delete a collection

The collection goes; its pieces stay.

curl -X DELETE https://heimdall3d.com/api/v1/collections/14 \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/collections/14", {
  method: "DELETE",
  headers: { Authorization: "Bearer h3d_your_key" },
});
// 204, with no body, when it worked
Example answer · 204
(no body)

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's

GET/api/v1/webhooksread

List the webhooks

curl -X GET https://heimdall3d.com/api/v1/webhooks \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/webhooks", {
  method: "GET",
  headers: { Authorization: "Bearer h3d_your_key" },
});
const answer = await response.json();
Example answer · 200
{
  "webhooks": [
    {
      "created": "2026-09-30T10:02:00Z",
      "events": [
        "exhibit.published"
      ],
      "id": 128,
      "last_at": "2026-09-30T10:06:00Z",
      "last_status": "200 OK",
      "url": "https://heimdall3d.com/u/trogir-museum/…"
    }
  ]
}

401 no key · 403 a key without the read scope · 429 past 120 a minute

POST/api/v1/webhookswrite

Add a webhook

An https address told of events by a signed POST: X-Heimdall-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>" under the secret>. The secret is answered once. A delivery that is not answered 2xx within ten seconds is tried again, eight times over about two days.

curl -X POST https://heimdall3d.com/api/v1/webhooks \
  -H "Authorization: Bearer h3d_your_key" \
  -H "Content-Type: application/json" \
  -d '{"events":["exhibit.published"],"url":"https://museum.example/hooks/heimdall"}'
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/webhooks", {
  method: "POST",
  headers: { Authorization: "Bearer h3d_your_key", "Content-Type": "application/json" },
  body: JSON.stringify({
    "events": [
      "exhibit.published"
    ],
    "url": "https://museum.example/hooks/heimdall"
  }),
});
const answer = await response.json();
Example answer · 201
{
  "secret": "whsec_…",
  "webhook": {
    "created": "2026-09-30T10:02:00Z",
    "events": [
      "exhibit.published"
    ],
    "id": 128,
    "last_at": "2026-09-30T10:06:00Z",
    "last_status": "200 OK",
    "url": "https://heimdall3d.com/u/trogir-museum/…"
  }
}

401 no key · 403 a key without the write scope · 429 past 120 a minute · 400 a wrong body, the field named

DELETE/api/v1/webhooks/{id}write

Remove a webhook

curl -X DELETE https://heimdall3d.com/api/v1/webhooks/3 \
  -H "Authorization: Bearer h3d_your_key"
In JavaScript
const response = await fetch("https://heimdall3d.com/api/v1/webhooks/3", {
  method: "DELETE",
  headers: { Authorization: "Bearer h3d_your_key" },
});
// 204, with no body, when it worked
Example answer · 204
(no body)

401 no key · 403 a key without the write scope · 429 past 120 a minute · 404 not the key's

The same routes as OpenAPI 3.1, for code generators and assistants: /api/v1/openapi.json. The exhibit format: /api/v1/schema/exhibit.json.