Table Files
A table file is a file a user attaches to a table — audio, an image, a video, or a PDF. It is the third thing a user can contribute to a table, beside a table link and a table document.
The bytes never pass through Shapecaster. You ask the API for somewhere to put a file, upload it straight to the storage service, then tell the API the upload is done. That is three requests for one file, and the middle one does not touch this API at all:
- Authorize the upload. You declare the file's name, size, checksum, and content type, and get back a
signed_idand a URL to upload to. PUTthe bytes to that URL, with the headers the response gave you. This goes to the storage service, not to Shapecaster.- Create the file, sending the
signed_idfrom step 1 and an optional label. This is the request that returns a table file.
You need an MD5 checksum of the whole file before step 1. It is a required part of authorizing, so the file has to be read and digested in full before you can ask where to put it — real time and real memory on a large video, and spent again on a retry. Browsers have no native MD5, so a hand-written uploader needs a library for it.
If your front end can use @rails/activestorage, its DirectUpload client already speaks the shapes step 1 sends and returns, checksum included, and can be pointed at this endpoint rather than reimplemented.
Files are owned per contribution, the same way links and documents are. Any authenticated user may attach a file to any table, and the file belongs to whoever attached it, so one table can carry files from several different user tags. Only that owner can delete it, not the table's owner.
Files are immutable. There is no update endpoint: replacing one means deleting it and attaching another.
Like links and documents, files are not nested into the table object.
The table file object
| Attribute | Type | Description |
|---|---|---|
| id | uuid | The unique identifier of the file. |
| table_id | uuid | The unique identifier of the table the file is attached to. |
| label | string (optional) | A human-readable label for the file. Up to 100 characters. null when none was given. |
| filename | string | The file's name, as declared when the upload was authorized. |
| byte_size | integer | The file's size in bytes, as declared when the upload was authorized. |
| content_type | string | The file's content type, as declared when the upload was authorized. |
| url | string | A URL to fetch the bytes from. Only present when fetching a single file. |
| user_tag | uuid | The opaque user tag of the user that attached this file. Fixed when the file is created and never changes after. A user session token owns what it attaches, so it never sends this. An administrator names the owner with it. |
{
"id": "7d31f8ac-52b6-4e09-8c74-1a9f3e60b5d2",
"table_id": "984c03a9-7a64-4046-a26f-57cc773c7ec5",
"label": "Opening statements",
"filename": "deliberation-round-one.mp4",
"byte_size": 18446320,
"content_type": "video/mp4",
"url": "https://storage.example.com/shapecaster/9f3e60b5d2a1?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=1f0c8b7a",
"user_tag": "6f2bd2cb-3ca5-47bd-a680-64cd7cd11312"
}filename, byte_size, and content_type are what the client declared when it authorized the upload. Nothing inspects the bytes to confirm them, so a content_type of video/mp4 means a client said so, not that the file is one.
url is the one attribute that is not always there. Listing a table's files returns metadata only — everything above except the URL — so listing a table mints no fetch URLs. Fetch a file on its own to get one.
The URL is short-lived. Treat it as fetch-now rather than store-and-reuse: request the file again when you need the bytes again, rather than keeping the URL.
Authorizing an upload
POST https://api.cxipgroup.com/tables/:table_id/table_files/upload
User token required
To authorize an upload, send an authenticated request to the above endpoint declaring what you are about to upload. Nothing is created here — the response tells you where to put the bytes and gives you a signed_id to create the file with afterwards.
This is the one endpoint in this API that takes an envelope. The four attributes go inside a blob object rather than at the top level, and the response wraps its upload details in a direct_upload object. Both shapes are what @rails/activestorage's DirectUpload client already sends and expects. Every other endpoint on this site, this resource's own create included, keeps its attributes bare at the top level — an envelope anywhere else is a mistake.
checksum is a base64-encoded MD5 of the entire file. It has to be computed before this call, because the storage service binds it into the URL it hands back.
The declared byte_size is bound into that URL too, which is how the size limit is enforced without any bytes reaching Shapecaster: a PUT that sends more than it declared is refused by the storage service.
Parameters:
| Parameter | Value | Description |
|---|---|---|
| table_id | uuid (required) | The unique identifier of the table the file will be attached to. |
| blob | object (required) | The envelope holding the four attributes below. |
| blob.filename | string (required) | The file's name. |
| blob.byte_size | integer (required) | The file's size in bytes. At least 1 byte, and at most 100 MB. |
| blob.checksum | string (required) | A base64-encoded MD5 of the whole file. |
| blob.content_type | string (required) | The file's content type. Audio, image, and video types are accepted, along with application/pdf. SVG is not. |
Request:
curl -i "https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_files/upload" \
-X POST \
-H "Authorization: Bearer ustk_b3d9f0a2-1c47-4e8a-9f65-0d2b8c7e3a41" \
-H "Content-Type: application/json" \
-d '{
"blob": {
"filename": "deliberation-round-one.mp4",
"byte_size": 18446320,
"checksum": "kR9mTz1QpX4vN7cLbA2eYw==",
"content_type": "video/mp4"
}
}'const response = await fetch("https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_files/upload", {
method: "POST",
body: JSON.stringify({
blob: {
filename: "deliberation-round-one.mp4",
byte_size: 18446320,
checksum: "kR9mTz1QpX4vN7cLbA2eYw==",
content_type: "video/mp4"
}
}),
headers: {
"Content-type": "application/json; charset=UTF-8",
"Authorization": "Bearer ustk_b3d9f0a2-1c47-4e8a-9f65-0d2b8c7e3a41"
},
});Response:
{
"signed_id": "eyJfcmFpbHMiOnsiZGF0YSI6IjNmNzJhOTFlIiwicHVyIjoiYmxvYl9pZCJ9fQ==--8c41e0a7",
"direct_upload": {
"url": "https://storage.example.com/shapecaster/9f3e60b5d2a1?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=1f0c8b7a",
"headers": {
"Content-Type": "video/mp4",
"Content-MD5": "kR9mTz1QpX4vN7cLbA2eYw=="
}
}
}Status codes:
| Status | When |
|---|---|
200 OK | The body carries a signed_id and a direct_upload object. Nothing was created. |
401 Unauthorized | Missing or invalid token. |
404 Not Found | No table with that table_id. |
422 Unprocessable Content | Validation failed. A missing or empty filename or checksum, a byte_size below 1 byte or past 100 MB, and a content_type that is missing or not accepted all land here. A request with no blob envelope reports all four attributes as missing. |
Uploading the bytes
Send the file to the url from the response with a PUT, carrying the headers the response listed. This request goes to the storage service, not to Shapecaster, and it takes no Shapecaster token — the URL carries its own authorization.
curl -i "https://storage.example.com/shapecaster/9f3e60b5d2a1?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=1f0c8b7a" \
-X PUT \
-H "Content-Type: video/mp4" \
-H "Content-MD5: kR9mTz1QpX4vN7cLbA2eYw==" \
--data-binary @deliberation-round-one.mp4Use the url exactly as you received it and send exactly the headers you were given. The URL is signed over both, so a changed header or a rebuilt URL is refused.
The URL is short-lived, and what expires is the start of the upload, not the upload itself. A transfer that begins before the URL lapses runs to completion afterwards, so a large file on a slow connection is safe. What is not safe is minting several URLs up front and uploading them one after another — the later ones can lapse while the earlier ones transfer. Ask for one authorization per file, at the moment you upload it.
Creating a file
POST https://api.cxipgroup.com/tables/:table_id/table_files
User token required
To attach an uploaded file to a table, send an authenticated request to the above endpoint with the signed_id you were given when the upload was authorized, and an optional label. Both are sent at the top level — this endpoint is bare, like every other one but authorizing.
Send this only after the bytes have been uploaded. The signed_id names the file you were authorized to store, and each one attaches exactly one file: sending the same one a second time is a 422, not a second file. That matters on a retry, which is the way you are likely to meet it — if a create times out or fails partway, do not resend the same signed_id hoping it takes. Go back to authorizing and upload again.
A signed_id does not expire, so nothing else stops a replay.
Every file has an owner. Sent with a user session token, the file is owned by that session's user tag and a user_tag in the body is ignored. Sent with an administrator token, which is bound to nobody, the owner is named with user_tag, and that tag must be one of your own user tags. The rule is the same on tables, links, and documents, and is explained once in the Authenticating guide.
You can attach a file to any table, including one owned by a different user tag.
Parameters:
| Parameter | Value | Description |
|---|---|---|
| table_id | uuid (required) | The unique identifier of the table to attach the file to. |
| signed_id | string (required) | The signed_id from authorizing the upload. |
| label | string (optional) | A human-readable label for the file. Up to 100 characters. |
| user_tag | uuid (admin only) | The user tag that will own the file. Required when you send an administrator token, ignored when you send a user session token. |
Request:
curl -i "https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_files" \
-X POST \
-H "Authorization: Bearer ustk_b3d9f0a2-1c47-4e8a-9f65-0d2b8c7e3a41" \
-H "Content-Type: application/json" \
-d '{
"signed_id": "eyJfcmFpbHMiOnsiZGF0YSI6IjNmNzJhOTFlIiwicHVyIjoiYmxvYl9pZCJ9fQ==--8c41e0a7",
"label": "Opening statements"
}'const response = await fetch("https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_files", {
method: "POST",
body: JSON.stringify({
signed_id: "eyJfcmFpbHMiOnsiZGF0YSI6IjNmNzJhOTFlIiwicHVyIjoiYmxvYl9pZCJ9fQ==--8c41e0a7",
label: "Opening statements"
}),
headers: {
"Content-type": "application/json; charset=UTF-8",
"Authorization": "Bearer ustk_b3d9f0a2-1c47-4e8a-9f65-0d2b8c7e3a41"
},
});Response:
{
"id": "7d31f8ac-52b6-4e09-8c74-1a9f3e60b5d2",
"table_id": "984c03a9-7a64-4046-a26f-57cc773c7ec5",
"label": "Opening statements",
"filename": "deliberation-round-one.mp4",
"byte_size": 18446320,
"content_type": "video/mp4",
"url": "https://storage.example.com/shapecaster/9f3e60b5d2a1?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=1f0c8b7a",
"user_tag": "6f2bd2cb-3ca5-47bd-a680-64cd7cd11312"
}Status codes:
| Status | When |
|---|---|
201 Created | The file was created. Its URL is in the Location header. |
401 Unauthorized | Missing or invalid token. |
404 Not Found | No table with that table_id. |
422 Unprocessable Content | Validation failed. A missing signed_id, a signed_id this API did not issue, one naming a file that no longer exists, one already used to create a file, and a label past 100 characters all land here. So does an administrator token sending no user_tag, or one naming a tag that isn't yours. |
A signed_id this API did not issue and one naming a file that has since gone are reported the same way, as a missing file — both mean there is nothing to attach. One that has already been used reports on file as well, but distinguishes itself: has already been attached to another file.
Listing a table's files
GET https://api.cxipgroup.com/tables/:table_id/table_files
User token required
To list the files on a table, send an authenticated request to the above endpoint. This endpoint is paginated — the response is a bare array, with pagination metadata in the response headers.
No URLs are returned here. Each entry carries everything but url. Fetch a file on its own when you want its bytes, which keeps listing a table's files free of credentials you did not ask for.
Files come back in the order they were attached, oldest first. Reads are not owner-scoped: the response carries every file on the table, whichever user tag attached it.
Parameters:
| Parameter | Value | Description |
|---|---|---|
| table_id | uuid (required) | The unique identifier of the table whose files you want. |
| limit | integer (optional) | The number of records to return per page. Defaults to 20, with a maximum of 100. |
| page | integer (optional) | The page of records you want returned. 0 or 1 returns the first page. A value beyond the total number of pages returns nothing. |
Request:
curl -i -G "https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_files" \
-H "Authorization: Bearer ustk_b3d9f0a2-1c47-4e8a-9f65-0d2b8c7e3a41" \
--data-urlencode "limit=20"const response = await fetch("https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_files?limit=20", {
method: "GET",
headers: {
"Content-type": "application/json; charset=UTF-8",
"Authorization": "Bearer ustk_b3d9f0a2-1c47-4e8a-9f65-0d2b8c7e3a41"
},
});Response:
[
{
"id": "7d31f8ac-52b6-4e09-8c74-1a9f3e60b5d2",
"table_id": "984c03a9-7a64-4046-a26f-57cc773c7ec5",
"label": "Opening statements",
"filename": "deliberation-round-one.mp4",
"byte_size": 18446320,
"content_type": "video/mp4",
"user_tag": "6f2bd2cb-3ca5-47bd-a680-64cd7cd11312"
},
{
"id": "e0b4a71c-9d38-4f52-a613-8c2705fd9e41",
"table_id": "984c03a9-7a64-4046-a26f-57cc773c7ec5",
"label": null,
"filename": "scoring-sheet.pdf",
"byte_size": 204817,
"content_type": "application/pdf",
"user_tag": "c41a7e39-8d52-4b06-9f18-2e7bd05a6c83"
}
// ...
]Status codes:
| Status | When |
|---|---|
200 OK | The body is a bare array of the table's files, without their URLs. |
401 Unauthorized | Missing or invalid token. |
404 Not Found | No table with that table_id. |
422 Unprocessable Content | A negative limit. Other unusable pagination values resolve rather than failing. |
Getting a file
GET https://api.cxipgroup.com/tables/:table_id/table_files/:id
User token required
To retrieve a single file with a URL to fetch its bytes from, send an authenticated request to the above endpoint. This is the only place url comes back. Reads are not owner-scoped — any valid token can retrieve any file, and the file's own owner gets no privileged view of it.
The response carries the file's metadata and a URL, not the bytes. Fetching the bytes is a second request, to that URL, and it takes no Shapecaster token.
The file is looked up within the table, so a file id that exists but belongs to a different table is a 404 here.
Parameters:
| Parameter | Value | Description |
|---|---|---|
| table_id | uuid (required) | The unique identifier of the table the file is attached to. |
| id | uuid (required) | The unique identifier of the file to retrieve. |
Request:
curl -i "https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_files/7d31f8ac-52b6-4e09-8c74-1a9f3e60b5d2" \
-H "Authorization: Bearer ustk_b3d9f0a2-1c47-4e8a-9f65-0d2b8c7e3a41"const response = await fetch("https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_files/7d31f8ac-52b6-4e09-8c74-1a9f3e60b5d2", {
method: "GET",
headers: {
"Content-type": "application/json; charset=UTF-8",
"Authorization": "Bearer ustk_b3d9f0a2-1c47-4e8a-9f65-0d2b8c7e3a41"
},
});Response:
{
"id": "7d31f8ac-52b6-4e09-8c74-1a9f3e60b5d2",
"table_id": "984c03a9-7a64-4046-a26f-57cc773c7ec5",
"label": "Opening statements",
"filename": "deliberation-round-one.mp4",
"byte_size": 18446320,
"content_type": "video/mp4",
"url": "https://storage.example.com/shapecaster/9f3e60b5d2a1?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=300&X-Amz-Signature=1f0c8b7a",
"user_tag": "6f2bd2cb-3ca5-47bd-a680-64cd7cd11312"
}Status codes:
| Status | When |
|---|---|
200 OK | The body is the file, with a URL to fetch its bytes from. |
401 Unauthorized | Missing or invalid token. |
404 Not Found | No table with that table_id, or no file with that id on it. |
Deleting a file
DELETE https://api.cxipgroup.com/tables/:table_id/table_files/:id
User token required
To delete a file, send an authenticated request to the above endpoint. A successful delete returns an empty body, and takes the stored bytes with it.
Deletion is scoped to the file's own owner, not the table's. A user session token's tag must be the tag that attached the file — the table's owner has no say over files other users attached to their table, and gets a 403 for trying. An administrator token acts by id, and may name a user_tag alongside only if that tag is the file's owner.
Because files are immutable, this is also how you replace one: delete it and attach its replacement, which means going through the upload sequence again. The replacement is a new file with a new id.
A table's files are deleted along with the table itself, so deleting a table needs no cleanup pass over its files first.
Parameters:
| Parameter | Value | Description |
|---|---|---|
| table_id | uuid (required) | The unique identifier of the table the file is attached to. |
| id | uuid (required) | The unique identifier of the file to delete. |
Request:
curl -i "https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_files/7d31f8ac-52b6-4e09-8c74-1a9f3e60b5d2" \
-X DELETE \
-H "Authorization: Bearer ustk_b3d9f0a2-1c47-4e8a-9f65-0d2b8c7e3a41"const response = await fetch("https://api.cxipgroup.com/tables/984c03a9-7a64-4046-a26f-57cc773c7ec5/table_files/7d31f8ac-52b6-4e09-8c74-1a9f3e60b5d2", {
method: "DELETE",
headers: {
"Authorization": "Bearer ustk_b3d9f0a2-1c47-4e8a-9f65-0d2b8c7e3a41"
},
});Status codes:
| Status | When |
|---|---|
204 No Content | The file was deleted. The body is empty. |
401 Unauthorized | Missing or invalid token. |
403 Forbidden | The file was attached by a different user tag. |
404 Not Found | No table with that table_id, or no file with that id on it. |