Skip to content

Your First Table

Getting Started leaves you with an administrator token and a frame. This guide takes it from there to a table carrying positions, in the order the API requires, and says what each step commits you to.

The short version: mint a user tag, mint a session for it, create a table on your frame, and put positions on it. Everything else — links, documents, reading it all back — hangs off the table once it exists.

First, decide which token your code holds

This is the fork that shapes everything below, and it is easier to choose now than to migrate later.

Your administrator token (adtk_) can reach every endpoint on the API. It is bound to your client rather than to any person, so it is a server-side credential: it belongs in your backend, never in a browser, a mobile app, or anything a user can read.

A user session token (ustk_) is bound to one user tag and reaches the tables, links, and documents endpoints only. It is what you hand to the code acting for a particular person, and it is what makes that person the owner of what they create.

Most integrations use both — the administrator token on your server to mint tags and sessions, and a session token in whatever your users touch. The Authenticating guide covers both tiers in full.

Mint a user tag

Your users do not sign up here. Your application knows who they are, and this API only needs a stable, opaque handle for each of them — a user tag.

bash
curl -i "https://api.cxipgroup.com/user_tags" \
  -X POST \
  -H "Authorization: Bearer adtk_c1f4e2a7-9b3d-4e6a-8f21-0a5c7d9e1b34"
javascript
const response = await fetch("https://api.cxipgroup.com/user_tags", {
  method: "POST",
  headers: {
    "Authorization": "Bearer adtk_c1f4e2a7-9b3d-4e6a-8f21-0a5c7d9e1b34"
  },
});

The request takes no body at all. The tag is minted for you, comes back as tag, and is the value every later step means when it says "the user tag" — not the object's id, which is a separate UUID you will rarely need.

Store the tag against your own user record. Listing tags and fetching one back are planned rather than available, so today a tag you do not keep is a tag you have to replace — and a replacement is a different person as far as this API is concerned.

Mint a session for that tag

A session turns a user tag into a token that can act for that user.

bash
curl -i "https://api.cxipgroup.com/sessions" \
  -X POST \
  -H "Authorization: Bearer adtk_c1f4e2a7-9b3d-4e6a-8f21-0a5c7d9e1b34" \
  -H "Content-Type: application/json" \
  -d '{
    "user_tag": "a9f201c2-36eb-41e1-b30a-7bd180f02e91"
  }'
javascript
const response = await fetch("https://api.cxipgroup.com/sessions", {
  method: "POST",
  body: JSON.stringify({
    user_tag: "a9f201c2-36eb-41e1-b30a-7bd180f02e91"
  }),
  headers: {
    "Content-type": "application/json; charset=UTF-8",
    "Authorization": "Bearer adtk_c1f4e2a7-9b3d-4e6a-8f21-0a5c7d9e1b34"
  },
});

The response carries tokenustk_80573e65-c815-49d8-b7b9-75cf2f143876 in this example — and it is the only response that ever will. Reading a session back does not include it. A token you fail to capture cannot be recovered, only replaced by minting another session.

Sessions expire, and they renew themselves as they are used, so a user working steadily stays signed in without you doing anything. One that has been idle long enough comes back rejected, and the answer is to mint a new session rather than to refresh the old one.

Create a table

A table is where the discussion happens, and it needs the frame_id of the frame you created in Getting Started — a table cannot exist without a frame to plot against. A session token can read frames but never make one, so your users can see the values they are plotting against — and, where the frame carries them, the question and end labels for each — while the set of frames stays yours to curate. The table's frame is fixed once the table exists, so this is the one chance to choose it.

bash
curl -i "https://api.cxipgroup.com/tables" \
  -X POST \
  -H "Authorization: Bearer ustk_80573e65-c815-49d8-b7b9-75cf2f143876" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "research",
    "title": "Ice Cream Scoop Showdown",
    "purpose": "Rank freezer-case pints across flavor, texture, and value",
    "frame_id": "09a8e2c1-4e6f-438a-a603-1cdc7ccfa2b3"
  }'
javascript
const response = await fetch("https://api.cxipgroup.com/tables", {
  method: "POST",
  body: JSON.stringify({
    type: "research",
    title: "Ice Cream Scoop Showdown",
    purpose: "Rank freezer-case pints across flavor, texture, and value",
    frame_id: "09a8e2c1-4e6f-438a-a603-1cdc7ccfa2b3"
  }),
  headers: {
    "Content-type": "application/json; charset=UTF-8",
    "Authorization": "Bearer ustk_80573e65-c815-49d8-b7b9-75cf2f143876"
  },
});

Who owns it depends on the token you just sent. A session token owns what it creates, so the table belongs to that session's user tag and a user_tag in the body is ignored. An administrator token is bound to nobody, so it has to name the owner with user_tag — the same request without one is a 422. Ownership is fixed at creation and no update can reassign it.

Positions can travel with this request. Send a positions array alongside the attributes, exactly as creating a table shows, and the table arrives with them already on it. The request below is the other case — what a client sends when somebody plots a position on a table that already exists.

Put positions on it

A position is one person's reading of the table's big question, plotted as four paired values against the frame's quadrants. A table carries up to three.

Positions have no endpoints of their own. They arrive nested inside the table, so adding one to a table that exists means updating the table:

bash
curl -i "https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5" \
  -X PUT \
  -H "Authorization: Bearer ustk_80573e65-c815-49d8-b7b9-75cf2f143876" \
  -H "Content-Type: application/json" \
  -d '{
    "positions": [
      {
        "headline": "Flavor Intensity — cocoa, vanilla bean, fruit punch",
        "val1_x": 88, "val1_y": 72,
        "val2_x": 40, "val2_y": 65,
        "val3_x": 22, "val3_y": 18,
        "val4_x": 70, "val4_y": 30
      }
    ]
  }'
javascript
const tableId = "984c03a9-7a64-4046-a26f-57cc773c7ec5";

const response = await fetch(`https://api.cxipgroup.com/tables/${tableId}`, {
  method: "PUT",
  body: JSON.stringify({
    positions: [
      {
        headline: "Flavor Intensity — cocoa, vanilla bean, fruit punch",
        val1_x: 88, val1_y: 72,
        val2_x: 40, val2_y: 65,
        val3_x: 22, val3_y: 18,
        val4_x: 70, val4_y: 30
      }
    ]
  }),
  headers: {
    "Content-type": "application/json; charset=UTF-8",
    "Authorization": "Bearer ustk_80573e65-c815-49d8-b7b9-75cf2f143876"
  },
});

That update sends only positions, and every other attribute keeps the value it already has — a partial body is a partial update in spite of the PUT.

Two things about that array are worth knowing before you build against it. It replaces rather than merges — whatever you send becomes the table's whole set, so adding a fourth position means sending the three that are already there alongside it. And leaving the key out is different from sending an empty one: omit positions and the existing ones are untouched, send [] and they are all destroyed.

With a table in hand, a user can attach a link or save a document on it. Both hang off the table's own path, and both are optional — a table is complete without them.

bash
curl -i "https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_links" \
  -X POST \
  -H "Authorization: Bearer ustk_80573e65-c815-49d8-b7b9-75cf2f143876" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/freezer-case-taste-test-2026",
    "label": "Consumer taste-test writeup"
  }'
javascript
const tableId = "984c03a9-7a64-4046-a26f-57cc773c7ec5";

const response = await fetch(`https://api.cxipgroup.com/tables/${tableId}/table_links`, {
  method: "POST",
  body: JSON.stringify({
    url: "https://example.com/freezer-case-taste-test-2026",
    label: "Consumer taste-test writeup"
  }),
  headers: {
    "Content-type": "application/json; charset=UTF-8",
    "Authorization": "Bearer ustk_80573e65-c815-49d8-b7b9-75cf2f143876"
  },
});

A document is the same shape at table_documents, carrying your JSON payload in data rather than a url. The top level of that payload must be an object, and nothing below it is interpreted — it comes back exactly as it was saved.

The ownership rule is not the table's. A link or a document belongs to whoever added it, not to whoever owns the table. A user can delete what they attached and nothing else, and the table's owner has no authority over what other people put on their table.

Read it back

Every read is open to any valid token — reads are not owner-scoped anywhere in this API, so a user can see tables and attachments that are not theirs. Listing the collection is where to start, and it takes an ids filter when you already know which tables you want:

bash
curl -i -G "https://api.cxipgroup.com/tables" \
  -H "Authorization: Bearer ustk_80573e65-c815-49d8-b7b9-75cf2f143876" \
  --data-urlencode "limit=100" \
  --data-urlencode "page=1"
javascript
const response = await fetch("https://api.cxipgroup.com/tables?limit=100&page=1", {
  method: "GET",
  headers: {
    "Content-type": "application/json; charset=UTF-8",
    "Authorization": "Bearer ustk_80573e65-c815-49d8-b7b9-75cf2f143876"
  },
});

Tables come back newest first, and two variations on that read are worth knowing:

List endpoints are paginated with metadata in the response headers rather than in the body. Reads also carry an etag, so a client that polls should send it back and take the 304 instead of the body. Everything is rate limited per token, and a 304 costs the same allowance as a full response.

Where to go next

The Resources section documents every endpoint in full — every parameter, every status code, and the request and response for each. Two things you may want that this walkthrough passed over: a table can be branched from another, which records where it came from, and the positions average aggregates the positions across a set of tables into one result.

Shapecaster API Documentation