Skip to main content

File Service

The File Service owns all collaborative resources in Typograph: teams, projects, templates, media and fonts, plus sharing, invites, comments, favorites, trash, and search. The gateway routes everything under /v1/file/*.

Base URL: /v1/file/

Scopes: file (full), file:read, file:write, file:delete. All File Service endpoints require a user token.

Path structure

File Service paths follow a "list-by-parent, item-by-id" pattern. Collections are listed and created under their owning parent (projects under teams, templates under projects, media/uploads/fonts under projects), but individual resources are addressed by their own top-level ID once created. Top-level GET /v1/file/teams is an exception — it's scoped to the authenticated user's organization.

Teams

EndpointMethodScopes
/v1/file/teamsGETfile / file:read
/v1/file/teamsPOSTfile / file:write
/v1/file/teams/{id}GETfile / file:read
/v1/file/teams/{id}PATCHfile / file:write
/v1/file/teams/{id}DELETEfile / file:delete
/v1/file/teams/{teamId}/contactsGETfile / file:read
/v1/file/organizations/{organizationId}/teamsGETfile / file:read
curl https://api.typograph.nl/v1/file/teams \
-H "Authorization: Bearer $USER_TOKEN"

List responses follow standard pagination.

Projects

Projects are team-scoped — there's no top-level "list all projects" endpoint. Create and list projects on a specific team; once you have a project ID you can read, update, and delete it directly.

EndpointMethodScopes
/v1/file/teams/{teamId}/projectsGETfile / file:read
/v1/file/teams/{teamId}/projectsPOSTfile / file:write
/v1/file/projects/{id}GETfile / file:read
/v1/file/projects/{id}PATCHfile / file:write
/v1/file/projects/{id}DELETEfile / file:delete
/v1/file/projects/{id}/movePATCHfile / file:write
/v1/file/projects/{projectId}/templatesGET, POSTfile:read / file:write
/v1/file/projects/{projectId}/uploadsGET, POSTfile:read / file:write
/v1/file/projects/{projectId}/uploads/{id}PATCHfile:write
/v1/file/projects/{projectId}/mediaGETfile:read
/v1/file/projects/{projectId}/media/{id}PATCHfile:write / media:write
/v1/file/projects/{projectId}/media/presigned-urlsPOSTfile:read (app keys too)
/v1/file/media/{id}/movePATCHfile:write / media:write
/v1/file/projects/{projectId}/fontsGETfile:read
/v1/file/projects/{projectId}/fonts/{id}GETfile:read / font:read
/v1/file/projects/{projectId}/fonts/presigned-urlsPOSTfile:read (app keys too)
/v1/file/fonts/{id}/movePATCHfile:write / font:write
/v1/file/projects/{projectId}/clipsGET, POSTfile:read / clip:read, file:write / clip:write
/v1/file/projects/{projectId}/clips/{id}GETfile:read / clip:read
/v1/file/projects/{projectId}/clips/{id}PATCHfile:write / clip:write
/v1/file/projects/{projectId}/clips/{id}DELETEfile:write, file:delete / clip:write, clip:delete
/v1/file/projects/{projectId}/clips/{id}/presigned-urlPOSTfile:read / clip:read (read), file:write / clip:write (write)
/v1/file/projects/{projectId}/clips/{id}/movePATCHfile:write / clip:write
/v1/file/projects/{projectId}/clips/{id}/duplicatePOSTfile:write / clip:write
/v1/file/projects/{projectId}/clips/{id}/sync-sizePOSTfile:write / clip:write

A clip is a small, project-scoped file resource (same shape as a font, minus the font-specific metadata): id, project_id, team_id, creator_id, name, file, error, size, created_at, updated_at, touched_at, deleted_at. POST /projects/{projectId}/clips creates one from { "name": "..." }; PATCH /projects/{projectId}/clips/{id} renames it the same way. GET /projects/{projectId}/clips accepts limit (max 50, default 10), offset, and include_deleted and returns { "results": [...], "total": <int> }. DELETE /projects/{projectId}/clips/{id} soft-deletes (?action=delete, the default) or restores (?action=undelete) a clip, mirroring the template soft-delete pattern below.

A clip's document JSON is stored in cloud storage, addressed via a presigned URL: POST /projects/{projectId}/clips/{id}/presigned-url returns a URL to read (?action=GetObject, the default — project view permission) or write (?action=PutObject — project edit permission) the clip body. The storage object is created lazily on the first write, so a read against a never-written clip returns 404.

POST /projects/{projectId}/clips/{id}/sync-size re-reads the clip's stored size from object metadata and returns the updated clip. A clip's document is uploaded straight to storage, so its size is not known at write time; the uploader calls this once the upload succeeds.

PATCH /projects/{projectId}/clips/{id}/move re-parents a clip to another project (and, implicitly, that project's team), re-keying its stored document to the target folder — it requires edit permission on both projects. POST /projects/{projectId}/clips/{id}/duplicate instead creates a copy in the target project (view permission on the source, edit permission on the target): a new clip is returned (201) and the source's document JSON plus its thumbnail, when present, are copied to the new clip's own storage keys; the original stays put. Both take { "project_id": "<target>" }.

curl -X POST https://api.typograph.nl/v1/file/teams/019b.../projects \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Q4 Campaign" }'

Templates

Templates are the Typograph document files that the editor opens and the publisher renders. Templates are project-scoped on create/list — once you have a template ID you can read and update it directly.

EndpointMethodScopes
/v1/file/projects/{projectId}/templatesGETfile / file:read
/v1/file/projects/{projectId}/templatesPOSTfile / file:write
/v1/file/templates/{id}GETfile / file:read
/v1/file/templates/{id}PATCHfile / file:write
/v1/file/templates/{id}DELETEfile / file:delete
/v1/file/templates/{id}/touchPATCHfile:write
/v1/file/templates/{id}/movePATCHfile / file:write
/v1/file/templates/{id}/{fileType}/download-urlGETfile / file:read
/v1/file/templates/{id}/{fileType}/presigned-urlPOSTfile:write

fileType is one of thumbnail or document. GET .../download-url hands out a short-lived link to read the file and is the one an app key may call; POST .../presigned-url hands out write access and stays a person's job. The touch endpoint bumps the "last modified" timestamp without changing content — the editor calls it on open to drive recency lists. The move endpoints (fonts, media, templates) relocate a resource to another project: send { "project_id": "..." }; the caller must be able to edit both the source and target project, and a missing, malformed, or inaccessible resource id uniformly returns 404. PATCH /projects/{id}/move moves an entire project (including its fonts, media, and templates) to another team via { "team_id": "..." }; a project id the caller cannot edit also reads as 404. DELETE /templates/{id} soft-deletes a template (?action=delete) or restores it (?action=undelete); soft-deleted templates surface in the user's trash flow (see Trash) and are permanently removed — including their files in storage — by the scheduled deletion job once the retention window has passed.

Premade templates

The premade template catalog is a global, read-only set of starter templates curated by Typograph. Unlike project templates, the catalog is identical for every user and is not project-scoped. Items are addressed by a URL-safe code (lowercase letters, digits, hyphens) rather than a UUID.

EndpointMethodScopes
/v1/file/premade-templatesGETfile / file:read
/v1/file/premade-templates/categoriesGETfile / file:read
/v1/file/premade-templates/{code}GETfile / file:read

The list endpoint accepts limit (max 100, default 50), offset, category (filter by category code), and featured (boolean) query parameters. List responses are wrapped as { "results": [...], "total": <int> }.

Template comments

EndpointMethodScopes
/v1/file/templates/{templateId}/commentsGETfile:read
/v1/file/templates/{templateId}/commentsPOSTfile:write
/v1/file/templates/{templateId}/comments/{commentId}PUTfile:write
/v1/file/templates/{templateId}/comments/{commentId}DELETEfile:delete
/v1/file/templates/{templateId}/comments/{commentId}/resolvePATCHfile:write
/v1/file/templates/{templateId}/comments/{commentId}/repliesGET, POSTfile:read / file:write
/v1/file/templates/{templateId}/comments/{commentId}/replies/{replyId}PUTfile:write
/v1/file/templates/{templateId}/comments/{commentId}/replies/{replyId}DELETEfile:delete

A comment is anchored to a spot on a page, not attached to the template as a whole. Its position carries page_uuid — the page the pin belongs to — plus x and y in native millimetres. Millimetres rather than screen pixels, so a pin placed at one zoom level is in the same place at another; the page uuid rather than a page number, so inserting or reordering pages ahead of it does not move it. A comment whose page_uuid is an empty string predates anchoring and clients skip it rather than drawing it on page one.

GET /comments returns a bare array, not an envelope. It accepts limit, offset, and only_unresolved=true, which is what drives the editor's default view — resolved threads are hidden behind a filter rather than deleted.

Who may do what:

ActionRequired
Read commentsview on the template
Create a comment or replycomment on the template
Edit or delete a commentIts author
Edit or delete a replyIts author
Resolve or reopen a commentIts author, or anyone with edit on the template

Resolving is deliberately not author-only: the person who acts on the feedback is usually the designer, not the reviewer who wrote it, and a thread only they could close would stay open.

Replying requires comment permission on the template, not authorship of the parent comment — otherwise nobody could answer anyone else's feedback.

PUT on a comment or a reply changes the body and nothing else. Moving a pin and resolving a thread are separate operations, and resolving carries a different permission.

Forms

A form turns a template into something an end user can personalise: each field writes a value into one element of the linked template. Forms are project-scoped, and a form is draft until it is published — a draft is only reachable by someone who can edit the project.

EndpointMethodScopes
/v1/file/templates/{templateId}/formsGETfile / file:read
/v1/file/templates/{templateId}/formsPOSTfile / file:write
/v1/file/projects/{projectId}/formsGETfile / file:read
/v1/file/forms/{id}GETfile / file:read
/v1/file/forms/{id}PATCHfile / file:write
/v1/file/forms/{id}DELETEfile / file:delete
/v1/file/forms/{id}/duplicatePOSTfile / file:write
/v1/file/forms/{id}/templates/{templateId}POSTfile / file:write
/v1/file/forms/{id}/templates/{templateId}DELETEfile / file:delete
/v1/file/forms/{formId}/values/meGETfile / file:read
/v1/file/forms/{formId}/values/mePOSTfile / file:write

A form carries id, project_id, team_id, creator_id, name, status (draft or published), fields, template_ids, created_at, updated_at and deleted_at. Both list endpoints accept limit (max 50, default 10), offset and include_deleted, and return { "results": [...], "total": <int> }.

POST /templates/{templateId}/forms creates a form and links it to that template in one step, inheriting the template's project and team. PATCH /forms/{id} is a partial update — only the properties present in the body change, and template_ids replaces the whole link set. POST /forms/{id}/duplicate copies the form, its fields and its template links as a draft; the template itself is not duplicated, so the copy points at the same template and the fields' element_id references stay valid.

Each field carries an id, a type (text, textarea, number, color, date, dropdown, radio, checkbox, toggle or image), and optionally label, description, required, default_value, element_id, canvas_action and image_source. element_id names the template element the value is written to, and canvas_action says how: TEXT into a text element's runs, URL into an image, COLOR into a fill (or a line's stroke), HIDDEN to leave the element alone. It defaults to URL for an image field, COLOR for a colour field, and TEXT otherwise — a mismatch between the action and the element's kind means the value is silently not applied, so the portal's form builder blocks those combinations up front. An element locked in the editor, or nested inside a clip, cannot be personalised at all.

GET|POST /forms/{formId}/values/me reads and writes the calling user's own answers, as { "values": { "<field id>": <value> } }. A user who has not filled the form in yet gets an empty values object rather than a 404. Reading takes a read scope and writing a write scope, so a read-only integration can fetch answers without being able to change them.

The write accepts complete beside values. A draft is stored as it comes, so a form being typed into can be saved at any moment; complete: true marks the finished answer, and a required field left empty is then rejected with a 400.

Sharing: Invites, Roles, Permissions

Sharing is resource-scoped: you invite someone to a specific team, project, or template. The resource type is part of the URL.

EndpointMethodScopes
/v1/file/{resourceType}/{resourceId}/permissionsGETfile:read
/v1/file/{resourceType}/{resourceId}/invitesGET, POSTfile:read / file:write
/v1/file/{resourceType}/{resourceId}/invites/{id}GETfile:read
/v1/file/{resourceType}/{resourceId}/invites/{id}PATCHfile:write
/v1/file/{resourceType}/{resourceId}/invites/{id}DELETEfile:delete
/v1/file/{resourceType}/{resourceId}/roles/{id}PATCHfile:write
/v1/file/{resourceType}/{resourceId}/roles/{id}DELETEfile:delete

resourceType is one of team, project, or template.

curl -X POST https://api.typograph.nl/v1/file/template/019b.../invites \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "email": "colleague@example.com", "role": "editor" }'

GET /{resourceType}/{resourceId}/permissions returns what the calling user may do with the resource: can_view, can_comment, can_edit and can_administer. The flags cascade — admin implies edit, edit implies comment, comment implies view — so a client can test the one flag it cares about without recombining them.

The four permission levels, in increasing order of access:

PermissionCan
viewOpen and download the resource.
commentThe above, plus leave and reply to comments, and resolve comment threads. Cannot change the design.
editThe above, plus change the resource and invite others.
adminEverything, including managing access and deleting.

The authoritative list of roles and their capabilities lives on the Identity Service — see Identity Service.

Favorites, Shared, Trash, Search, Recents

EndpointMethodScopes
/v1/file/favosGETfile / file:read
/v1/file/favosPUTfile / file:write
/v1/file/sharedGETfile / file:read
/v1/file/trashGETfile / file:read
/v1/file/searchGETfile / file:read
/v1/file/recent/searchedGETfile / file:read
/v1/file/recent/searchedPOSTfile / file:write
/v1/file/recent/searchedDELETEfile / file:write
/v1/file/recent/templatesGETfile / file:read

PUT /v1/file/favos toggles the favorite state for a resource (pass the resource type and id in the body).

GET /v1/file/shared?limit=&offset= replaces the old /v1/file/shared/projects and /v1/file/shared/templates endpoints with a single feed: projects and templates shared with the authenticated user (roles shared with one of the user's teams, and expired roles, are excluded), newest-first. limit defaults to 10 and caps at 50; exceeding it returns 400. Pass resource_type=project or resource_type=template to narrow the feed to one kind (any other value is a 400; teams never appear here). The response is { "results": [...], "total": n }. Each row in results carries resource_type (project or template), the resource's id/name/team_id, project_id and thumbnail (template-only, otherwise null), total_templates (project-only, otherwise 0), shared_by_name/shared_by_email of the person who shared it (null if that user has since been deleted), shared_at, permission (view / comment / edit / admin), and the shared resource itself as a nested project or template object (whichever matches resource_type; the other is null) so clients can render it with the same card they use elsewhere. total counts every matching role; rows whose resource has since been deleted are silently skipped, so a page can legitimately contain fewer than limit items.

GET /v1/file/search matches the query against templates, projects and teams the user has access to, returning each group plus a total. Pass ?save=0 to run the search without recording it as a recent search (used for search-as-you-type); the default save=1 records the query. Pass ?trash=1 to include soft-deleted items in the results: the response will then also contain trashed_teams, trashed_projects, and trashed_templates arrays (same shape as their non-trashed counterparts); omitting the flag (default 0) excludes trashed items. POST /v1/file/recent/searched saves a query ({ "query": "..." }) and DELETE /v1/file/recent/searched clears the current user's recent searches; both return 204 No Content.

Users, Roles & Invites (Current User)

EndpointMethodScopesNotes
/v1/file/usersPATCHfile / file:writeUpdates the authenticated user's profile in the file service.
/v1/file/users/{id}GETfile / file:readFetch a specific user by UUID.
/v1/file/users/me/invitesGETfile / file:readList invites addressed to the current user.
/v1/file/rolesGETfile / file:readCatalog of resource roles (owner/editor/commenter/viewer).

Notifications

EndpointMethodScopes
/v1/file/notificationsGETfile / file:read
/v1/file/notificationsPATCHfile / file:write
/v1/file/notifications/{id}PATCHfile / file:write

Mark a single notification read/unread with PATCH /notifications/{id}. Mark all notifications read in one call with PATCH /notifications (no body fields required).

Uploads

Large uploads use presigned URLs — you request one from the File Service, then PUT the file bytes directly to cloud storage, bypassing the API. The same pattern applies for project media, project fonts, and template thumbnails/documents (endpoints listed in the relevant sections above).

Flow:

POST /v1/file/projects/{projectId}/media/presigned-urls   # get upload URLs
PUT <signed_url> # upload bytes to R2/S3
PATCH /v1/file/projects/{projectId}/uploads/{id} # finalize metadata

Template uploads

Uploading a Typograph template export (.zip with embedded fonts/images, or a bare .json body) follows the same flow, but the finalize call processes the file synchronously: fonts and images are extracted, deduplicated against the project, and a new template is created. On success the upload's message field contains the new template id and warnings lists any assets the template references that could not be resolved. Size limits: 5 MB for .json, 50 MB for .zip (413 when exceeded). Structurally invalid templates are rejected with 422 schema_violations.

Error Responses

Standard envelope (see API Overview → Error Handling):

StatusErrorDescription
401unauthorizedMissing or invalid token
403access_deniedMissing scope or resource permission
404not_foundResource does not exist or not accessible to caller
409conflictDuplicate slug / concurrent edit
422validation_errorField validation failed
{
"error": "validation_error",
"error_description": "Request validation failed",
"request_id": "019397ab-cdef-7890-abcd-ef1234567890",
"details": [
{ "field": "name", "code": "required", "message": "The name field is required." }
]
}