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.
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
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/teams | GET | file / file:read |
/v1/file/teams | POST | file / file:write |
/v1/file/teams/{id} | GET | file / file:read |
/v1/file/teams/{id} | PATCH | file / file:write |
/v1/file/teams/{id} | DELETE | file / file:delete |
/v1/file/teams/{teamId}/contacts | GET | file / file:read |
/v1/file/organizations/{organizationId}/teams | GET | file / 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.
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/teams/{teamId}/projects | GET | file / file:read |
/v1/file/teams/{teamId}/projects | POST | file / file:write |
/v1/file/projects/{id} | GET | file / file:read |
/v1/file/projects/{id} | PATCH | file / file:write |
/v1/file/projects/{id} | DELETE | file / file:delete |
/v1/file/projects/{id}/move | PATCH | file / file:write |
/v1/file/projects/{projectId}/templates | GET, POST | file:read / file:write |
/v1/file/projects/{projectId}/uploads | GET, POST | file:read / file:write |
/v1/file/projects/{projectId}/uploads/{id} | PATCH | file:write |
/v1/file/projects/{projectId}/media | GET | file:read |
/v1/file/projects/{projectId}/media/{id} | PATCH | file:write / media:write |
/v1/file/projects/{projectId}/media/presigned-urls | POST | file:read (app keys too) |
/v1/file/media/{id}/move | PATCH | file:write / media:write |
/v1/file/projects/{projectId}/fonts | GET | file:read |
/v1/file/projects/{projectId}/fonts/{id} | GET | file:read / font:read |
/v1/file/projects/{projectId}/fonts/presigned-urls | POST | file:read (app keys too) |
/v1/file/fonts/{id}/move | PATCH | file:write / font:write |
/v1/file/projects/{projectId}/clips | GET, POST | file:read / clip:read, file:write / clip:write |
/v1/file/projects/{projectId}/clips/{id} | GET | file:read / clip:read |
/v1/file/projects/{projectId}/clips/{id} | PATCH | file:write / clip:write |
/v1/file/projects/{projectId}/clips/{id} | DELETE | file:write, file:delete / clip:write, clip:delete |
/v1/file/projects/{projectId}/clips/{id}/presigned-url | POST | file:read / clip:read (read), file:write / clip:write (write) |
/v1/file/projects/{projectId}/clips/{id}/move | PATCH | file:write / clip:write |
/v1/file/projects/{projectId}/clips/{id}/duplicate | POST | file:write / clip:write |
/v1/file/projects/{projectId}/clips/{id}/sync-size | POST | file: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.
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/projects/{projectId}/templates | GET | file / file:read |
/v1/file/projects/{projectId}/templates | POST | file / file:write |
/v1/file/templates/{id} | GET | file / file:read |
/v1/file/templates/{id} | PATCH | file / file:write |
/v1/file/templates/{id} | DELETE | file / file:delete |
/v1/file/templates/{id}/touch | PATCH | file:write |
/v1/file/templates/{id}/move | PATCH | file / file:write |
/v1/file/templates/{id}/{fileType}/download-url | GET | file / file:read |
/v1/file/templates/{id}/{fileType}/presigned-url | POST | file: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.
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/premade-templates | GET | file / file:read |
/v1/file/premade-templates/categories | GET | file / file:read |
/v1/file/premade-templates/{code} | GET | file / 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
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/templates/{templateId}/comments | GET | file:read |
/v1/file/templates/{templateId}/comments | POST | file:write |
/v1/file/templates/{templateId}/comments/{commentId} | PUT | file:write |
/v1/file/templates/{templateId}/comments/{commentId} | DELETE | file:delete |
/v1/file/templates/{templateId}/comments/{commentId}/resolve | PATCH | file:write |
/v1/file/templates/{templateId}/comments/{commentId}/replies | GET, POST | file:read / file:write |
/v1/file/templates/{templateId}/comments/{commentId}/replies/{replyId} | PUT | file:write |
/v1/file/templates/{templateId}/comments/{commentId}/replies/{replyId} | DELETE | file: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:
| Action | Required |
|---|---|
| Read comments | view on the template |
| Create a comment or reply | comment on the template |
| Edit or delete a comment | Its author |
| Edit or delete a reply | Its author |
| Resolve or reopen a comment | Its 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.
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/templates/{templateId}/forms | GET | file / file:read |
/v1/file/templates/{templateId}/forms | POST | file / file:write |
/v1/file/projects/{projectId}/forms | GET | file / file:read |
/v1/file/forms/{id} | GET | file / file:read |
/v1/file/forms/{id} | PATCH | file / file:write |
/v1/file/forms/{id} | DELETE | file / file:delete |
/v1/file/forms/{id}/duplicate | POST | file / file:write |
/v1/file/forms/{id}/templates/{templateId} | POST | file / file:write |
/v1/file/forms/{id}/templates/{templateId} | DELETE | file / file:delete |
/v1/file/forms/{formId}/values/me | GET | file / file:read |
/v1/file/forms/{formId}/values/me | POST | file / 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.
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/{resourceType}/{resourceId}/permissions | GET | file:read |
/v1/file/{resourceType}/{resourceId}/invites | GET, POST | file:read / file:write |
/v1/file/{resourceType}/{resourceId}/invites/{id} | GET | file:read |
/v1/file/{resourceType}/{resourceId}/invites/{id} | PATCH | file:write |
/v1/file/{resourceType}/{resourceId}/invites/{id} | DELETE | file:delete |
/v1/file/{resourceType}/{resourceId}/roles/{id} | PATCH | file:write |
/v1/file/{resourceType}/{resourceId}/roles/{id} | DELETE | file: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:
| Permission | Can |
|---|---|
view | Open and download the resource. |
comment | The above, plus leave and reply to comments, and resolve comment threads. Cannot change the design. |
edit | The above, plus change the resource and invite others. |
admin | Everything, 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
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/favos | GET | file / file:read |
/v1/file/favos | PUT | file / file:write |
/v1/file/shared | GET | file / file:read |
/v1/file/trash | GET | file / file:read |
/v1/file/search | GET | file / file:read |
/v1/file/recent/searched | GET | file / file:read |
/v1/file/recent/searched | POST | file / file:write |
/v1/file/recent/searched | DELETE | file / file:write |
/v1/file/recent/templates | GET | file / 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)
| Endpoint | Method | Scopes | Notes |
|---|---|---|---|
/v1/file/users | PATCH | file / file:write | Updates the authenticated user's profile in the file service. |
/v1/file/users/{id} | GET | file / file:read | Fetch a specific user by UUID. |
/v1/file/users/me/invites | GET | file / file:read | List invites addressed to the current user. |
/v1/file/roles | GET | file / file:read | Catalog of resource roles (owner/editor/commenter/viewer). |
Notifications
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/notifications | GET | file / file:read |
/v1/file/notifications | PATCH | file / file:write |
/v1/file/notifications/{id} | PATCH | file / 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):
| Status | Error | Description |
|---|---|---|
401 | unauthorized | Missing or invalid token |
403 | access_denied | Missing scope or resource permission |
404 | not_found | Resource does not exist or not accessible to caller |
409 | conflict | Duplicate slug / concurrent edit |
422 | validation_error | Field 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." }
]
}