Praxsuite

Files

Vincent Depassier · September 17, 2026

Every file URL Praxsuite hands out points at the gateway, never at the storage provider underneath it. The content is streamed through these routes, so nothing in your product is tied to whichever cloud is holding the bytes, and a "copy image address" never leaks a provider URL.

Auth: API key or end-user JWT — with two exceptions on download, below.

Who may reach a file

Being authenticated is not the same as being allowed to open one particular blob. An API key reaches every file in the workspace. An end user (JWT) is judged per file, in this order:

  1. A grant of resource type `file` on that blob settles it, in both directions — someone outside the list is refused even if they could read a row the file sits on.

  2. The uploader always reads their own file, and a public file is readable by anyone.

  3. Otherwise the referencing rows decide. The check replays the caller's own scopes, column permissions and row filter as an ordinary read. Only File-type columns create that link: a blob id kept in a text or JSON column references nothing.

  4. If none of the above applies — no grant, no row references the blob, or the caller has no scope at all on the table that does — reading by id is allowed. The caller already holds the id, and who gave it to them is outside what the gateway can see.

Case 4 is the one that surprises people, so it is worth stating what it does not open:

  • GET /files still hides the blob — that route hands out ids nobody gave the caller.

  • Writing, and therefore DELETE, still needs a grant or a writable row. A blob no row references can be deleted only by whoever uploaded it.

To have a file refused by id as well, declare a grant on it or put its id in a File column of a table the caller is scoped to. A refusal reads 404, never 403.

List

GET /{workspaceId}/files
{
  "files": [
    { "id": "…", "name": "invoice-2026-01", "extension": ".pdf", "createdDate": "2026-01-14T09:22:11Z" }
  ]
}

Newest first, capped at 500, and there is no pagination. For anything larger, keep your own index in a table.

For an end-user JWT the result is filtered to the files that user may read, and the cap is applied before the filter — so a short list does not mean there is nothing more behind it.

Upload

POST /{workspaceId}/files/upload
Content-Type: multipart/form-data

One field, named file.

curl -X POST https://gateway.praxsuite.com/{workspaceId}/files/upload \
  -H "Authorization: Bearer sk_live_..." \
  -F "file=@invoice.pdf"
{
  "id": "…",
  "name": "invoice",
  "extension": ".pdf",
  "size": 48213,
  "createdDate": "2026-01-14T09:22:11Z"
}

Keep the id — it is what a File-type column stores, as a uuid[] value in a mutation.

Size is capped by the workspace plan, and exceeding it is a 400 naming the limit in MB.

Extension is checked against a fixed allow-list, and anything else is a 400:

.pdf .doc .docx .xls .xlsx .ppt .pptx · .txt .csv .json .xml .html .htm · .png .jpg .jpeg .gif .webp .svg .ico · .zip .rar .7z .tar .gz · .mp4 .mov .avi .mp3 .wav .ogg · .py .js .ts .cs .java .go .rs .rb

The display name is derived from the uploaded filename, stripped of any path and truncated at 100 characters.

When an end user uploads, the blob is recorded as theirs, which is what rule 2 above reads.

Download

GET /{workspaceId}/files/{blobId}

Returns the bytes, streamed, with the stored content type and the original filename. Not a redirect.

Three ways to be allowed through, and any one of them is enough:

  1. The blob is marked public. Served with Cache-Control: public, max-age=300.

  2. The link is signed — ?exp=…&sig=… on the URL. This is what makes a file usable from an <img src>, an email or a webhook, none of which can send an authentication header.

  3. The caller is authenticated in the ordinary way and may reach this blob, by the rules above. Served with Cache-Control: private, max-age=60.

Otherwise 401 when there is no authentication at all, and 404 when there is but the file is not theirs to reach.

Get a shareable URL

GET /{workspaceId}/files/{blobId}/url?expiresMinutes=60
{
  "id": "…",
  "name": "invoice",
  "extension": "pdf",
  "url": "https://gateway.praxsuite.com/{workspaceId}/files/{blobId}?ttl=60&exp=…&sig=…",
  "expiresAt": "2026-01-14T10:22:11Z",
  "ttlMinutes": 60
}

expiresMinutes is clamped to 5–1440 (24 hours); the default is 60. A day is the ceiling on purpose: a signature that outlives a revoked access is not revocable in any practical sense.

The returned URL is a Praxsuite URL. You can store it in a row or paste it into a document — it is the file's stable address, and the signature on it is what expires.

This route works with an end-user JWT as well as an API key, and only for a caller who may already read the file. It is how you get a link an <img src> can use on behalf of an end user: the URL mode that puts signed links into query results is a property of an API key credential, and a JWT does not have one, so a query made with a JWT always returns the proxied form, which a browser cannot load without a header.

Delete

DELETE /{workspaceId}/files/{blobId}
{ "deleted": true, "id": "…" }

Removes both the stored object and its record. There is no undo, and no trash.

For an end user this is a write: it needs a manage grant on the file, or the ability to write a row that references it.

Errors

The flat shape — { "error": "…" }.

Status

Cause

400

No file in the request, file over the plan limit, or a disallowed extension

401

Not authenticated, and the blob is neither public nor signed for

404

No such blob in this workspace, its content is missing from storage, or the caller may not reach it

500

The upload could not be stored