File Service
The File Service owns all collaborative resources in Typograph: teams, projects, templates, media and fonts, plus sharing, invitations, 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. File Service endpoints require a user token, except the reads an integration needs — the teams of its organization, projects, templates, forms and the read-URL endpoints — which also accept a client token; Token Types lists them.
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 lists the user's teams in the organization the token reaches (which one).
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 |
GET /organizations/{organizationId}/teams lists the teams of the organization the token reaches (which one); any other organization is a 403. It answers { "data": [...], "meta": { "total", "limit", "offset", "has_more" } } and accepts client tokens too.
curl https://api.typograph.nl/v1/file/teams \
-H "Authorization: Bearer $USER_TOKEN"
List responses follow standard pagination.
Every team names the organization it belongs to: organization_id, and organization_name. organization_name is null for an organization whose name has not reached the file service yet. Through the API that is the organization the token reaches, with one exception: where another organization lets the apps of its guests read (Apps of guests), the teams the user has a role on there are listed too, for reading only. In the portal, someone invited to a team of an organization they are not a member of sees that team too, under its organization.
{
"id": "019b...",
"name": "Brand refresh",
"organization_id": "019a...",
"organization_name": "Studio Noord",
"creator_id": "019a...",
"metadata": { "user_count": 6, "admins": [] },
"created_at": "<ISO 8601 timestamp>",
"updated_at": "<ISO 8601 timestamp>",
"deleted_at": null
}
DELETE /v1/file/teams/{id} moves a team to the trash (?action=delete, the default) together with its projects and everything in them; ?action=undelete restores it and what went with it. Both return 204; restoring a team that is not in the trash is a 400, and deleting one that is already there a 409 with in_trash (see Nothing works on what is in the trash). A trashed team is permanently removed by the scheduled deletion job once the retention window has passed.
Names
The name of a team or project is 3 to 255 characters, when it is created (POST /v1/file/teams, POST /v1/file/teams/{teamId}/projects) and when it is renamed (PATCH). A name that is missing on create, blank, or outside that range is refused with 400 and validation_error, and nothing is created or changed:
{
"error": "validation_error",
"error_description": "Validation errors:\nname: This value is too short. It should have 3 characters or more.."
}
On create, name is the only field the request may hold. The id, the creator, the dates (created_at, updated_at), the trash (deleted_at) and the metadata are set by the server; a request that sends one of them, or any other field, is refused with 400 and invalid_request, and nothing is created. A rename (PATCH) likewise takes name alone and refuses any other field the same way.
The plan's number of teams
An organization's plan can limit its teams: max_teams in the plan's features (GET /v1/subscription/me). Free allows one team; Pro and Enterprise have no limit. A team in the trash does not count, but restoring it does. Beyond the limit, POST /v1/file/teams and DELETE /v1/file/teams/{id}?action=undelete answer 409 with team_limit_reached, and a message the user can act on:
{
"error": "team_limit_reached",
"error_description": "Free allows one team. Request another plan, or delete a team first."
}
Teams that exist already stay, also beyond the limit. When the plan cannot be checked, both answer 503 with subscription_service_unavailable and change nothing: try again in a moment. GET /v1/subscription/usage shows the teams in use against the limit, in plan_usage.teams.
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.
A project's name follows the same rule as a team's: see Names.
GET /v1/file/teams/{teamId}/projects lists the last edited project first, by updated_at. A project's updated_at moves when it is renamed or moved, and when one of its templates is created, saved, renamed or moved into it; opening a template moves neither. Projects edited at the same moment keep a fixed order; an edit while you page with limit and offset can still move a project to the front, so a later page may repeat or skip one (see Pagination).
| 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 | file / file:read |
/v1/file/projects/{projectId}/templates | POST | file / file:write |
/v1/file/projects/{projectId}/templates/presigned-urls | POST | file / file:read (app keys too) |
/v1/file/projects/{projectId}/uploads | GET | file / file:read |
/v1/file/projects/{projectId}/uploads | POST | file / file:write |
/v1/file/projects/{projectId}/uploads/{id} | GET | file / file:read |
/v1/file/projects/{projectId}/uploads/{id} | PATCH | file / file:write |
/v1/file/projects/{projectId}/media | GET | file / file:read / media / media:read |
/v1/file/projects/{projectId}/media/{id} | PATCH | file / file:write / media / media:write |
/v1/file/projects/{projectId}/media/tags | POST | file / file:write / media / media:write |
/v1/file/projects/{projectId}/tags | GET | file / file:read / media / media:read |
/v1/file/projects/{projectId}/tags/{id} | PATCH | file / file:write / media / media:write |
/v1/file/projects/{projectId}/tags/{id} | DELETE | file / file:write / file:delete / media / media:write / media:delete |
/v1/file/projects/{projectId}/media/{id} | DELETE | file / file:write / file:delete / media / media:write / media:delete |
/v1/file/projects/{projectId}/media/presigned-urls | POST | file / file:read / media / media:read (app keys too) |
/v1/file/media/{id}/move | PATCH | file / file:write / media / media:write |
/v1/file/projects/{projectId}/fonts | GET | file / file:read / font / font:read |
/v1/file/projects/{projectId}/fonts/lookup | GET | file / file:read / font / font:read |
/v1/file/projects/{projectId}/fonts/{id} | GET | file / file:read / font / font:read |
/v1/file/projects/{projectId}/fonts/{id} | PATCH | file / file:write / font / font:write |
/v1/file/projects/{projectId}/fonts/{id} | DELETE | file / file:write / file:delete / font / font:write / font:delete |
/v1/file/projects/{projectId}/fonts/presigned-urls | POST | file / file:read / font / font:read (app keys too) |
/v1/file/fonts/{id}/move | PATCH | file / file:write / font / font:write |
/v1/file/projects/{projectId}/clips | GET | file / file:read / clip / clip:read |
/v1/file/projects/{projectId}/clips | POST | file / file:write / clip / clip:write |
/v1/file/projects/{projectId}/clips/{id} | GET | file / file:read / clip / clip:read |
/v1/file/projects/{projectId}/clips/{id} | PATCH | file / file:write / clip / clip:write |
/v1/file/projects/{projectId}/clips/{id} | DELETE | file / file:write / file:delete / clip / clip:write / clip:delete |
/v1/file/projects/{projectId}/clips/{id}/presigned-url | POST | file / file:read / clip / clip:read to read |
/v1/file/projects/{projectId}/clips/{id}/presigned-url | POST | file / file:write / clip / clip:write to write (?action=PutObject) |
/v1/file/projects/{projectId}/clips/{id}/move | PATCH | file / file:write / clip / clip:write |
/v1/file/projects/{projectId}/clips/{id}/duplicate | POST | file / file:write / clip / clip:write |
/v1/file/projects/{projectId}/clips/{id}/sync-size | POST | file / file:write / clip / clip:write |
DELETE /v1/file/projects/{id} moves a project to the trash (?action=delete, the default) and ?action=undelete restores it; both return 204, and the same 400 and 409 apply as for teams; restoring a project whose team is still in the trash is a 409 with container_in_trash. Its templates go to the trash with it and its media, fonts, clips and forms stay with it; none of them is listed in the trash on its own while the project is there, and restoring the project brings them all back. Once the retention window has passed, the scheduled deletion job removes the project for good with everything in it, its forms and the answers entered in them included.
A media file is one image in up to four renditions, each { "filename", "size", "storage_object" }: file, the print file the editor and the PDF use; preview, a web copy of at most 2560 × 2560 pixels the editor draws; thumbnail, at most 1024 × 1024, for lists; and original, the file as it was uploaded, in its own format (a TIFF or PSD from a template package too). preview is null until the image is processed. original is null for media stored before originals were kept, and for a package image that came only as an editor rendition. width and height give the image's size in pixels, of the file a download saves (the original, else the print file) and as it is shown, so a photo stored on its side has the two swapped; both are null for a vector (SVG, AI, EPS, PDF) and for a format that cannot be measured.
POST /projects/{projectId}/media/presigned-urls reads despite the verb: it returns short-lived read links for up to 100 media files at once. Send { "media_ids": ["..."] }, plus include_thumbnail, include_preview and include_print (each true by default) and include_original and include_download (both false by default) to choose the renditions. download is the original, or the print file for media without one, served as an attachment under the media's name (its extension added when the name has none), so a browser saves it rather than opening it; like the original, it needs read access to the project. You get one entry per media file found, with its media_id and a thumbnail, preview, print, original and download link each, null for a rendition you did not ask for or the file does not have. As with templates, ids from another project are left out rather than reported as an error.
Media tags
Each project has its own tags for its media, and every media file carries its tags: [{ "id", "project_id", "name", "creator_id", "created_at" }], in name order. A name is the tag: two names that differ only in case or spacing are the same one.
POST /v1/file/projects/{projectId}/media/tags adds and removes tags on up to 100 media files at once, by name: send { "media_ids": ["..."], "add_tags": ["Logos"], "remove_tags": ["Draft"] }. A name the project has no tag of yet becomes a new tag, so tagging is also how a tag is made. It answers the media files with their tags. A media file holds at most 20 tags and a project 500; beyond that it answers 409 with tag_limit_reached. An id that is no media file of the project is a 404, and names that are empty or over 40 characters are a 422. It needs edit rights on the project, as renaming does.
GET /v1/file/projects/{projectId}/tags lists the project's tags in name order, each with media_count, the number of its media files out of the trash. PATCH /v1/file/projects/{projectId}/tags/{id} with { "name": "..." } renames a tag on every media file; a name another tag of the project has already merges the two, and the answer is that other tag. DELETE on the same path removes the tag from every media file, those in the trash too, and answers 204. Both need edit rights on the project.
The media list narrows to tags and names:
| Query parameter | What it does |
|---|---|
tag_ids | The ids of tags, comma separated: only media with these tags |
tag_match | all (the default): media with every tag in tag_ids; any: media with at least one |
untagged | true: only media without a tag; it takes no tag_ids |
search | Only media whose name contains this, in any case; at most 100 characters |
sort | newest (the default), oldest, name or largest |
total counts the media that match. Moving a media file to another project takes its tags along by name, made in that project when it has no tag of the name yet; media in the trash keeps its tags.
POST /projects/{projectId}/fonts/presigned-urls reads too: send { "font_ids": ["..."] } (up to 100) and get one entry per font file found, { "font_id", "presigned_url": { "url", "expires_at", "is_expired", "action" } }, with a read link valid for an hour, so a design can be drawn in its fonts. Ids from another project are left out.
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) and offset, 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.
GET /v1/file/projects/{projectId}/templates lists the last edited template first, by updated_at, which moves when a template is created, saved, renamed or moved; opening one moves only touched_at. Templates edited at the same moment keep a fixed order; an edit while you page can still move a template to the front.
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/projects/{projectId}/templates | GET | file / file:read |
/v1/file/projects/{projectId}/templates | POST | file / file:write |
/v1/file/projects/{projectId}/templates/presigned-urls | POST | file / file:read (app keys too) |
/v1/file/templates/{id} | GET | file / file:read |
/v1/file/templates/{id}/fields | GET | file / file:read (app keys too) |
/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 |
/v1/file/templates/{id}/collaboration-token | POST | file:write (people only) |
POST /projects/{projectId}/templates/presigned-urls reads despite the verb: it returns short-lived read links for the thumbnails of up to 100 templates at once, so a grid can show a whole page of cards without a request per card. Send { "template_ids": ["..."] } and you get one entry per template found, each with a template_id and a thumbnail that is null when the template has no thumbnail yet. Ids belonging to another project, or to a template in the trash, are left out of the response rather than reported as an error — the caller is authorized once for the project, not once per template.
POST /templates/{id}/collaboration-token returns { "token", "expires_at", "role" }: a two-minute token for the template's live room on the collaboration server, where everyone in the template sees each other's cursor and selection. The route needs a person's token with file / file:write, like saving; on the template itself, view permission is enough, and role says which the person has: edit for an owner or editor, view for a commenter or viewer. The editor asks for it through its host each time it connects, so a host never stores one. A caller who cannot view the template, or is an app rather than a person, gets 403, and so does an unknown id, as on every template route; a server without live collaboration answers 503. That 503 is not transient: fall back to single-user editing instead of retrying.
fileType is one of file (the template's document) or thumbnail. 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 template's recency, and after a save its updated_at too (see Touching after a save). 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 /projects/{projectId}/media/{id} and DELETE /projects/{projectId}/fonts/{id} work the same way: ?action=delete (the default) moves the file to the trash, ?action=undelete restores it, both return 204, and the file's admins get a notification. Restoring a file that is not in the trash is a 400; deleting one that is already there a 409 with in_trash, and restoring one whose project or team is still in the trash a 409 with container_in_trash. 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.
Publishing a stored template
To make a PDF or an image of a template stored in Typograph, start a job on the template itself. The job is billed to the organization that owns the template, as when someone publishes it in the portal, so it needs a role that may spend that organization's quota.
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/templates/{templateId}/jobs | POST | file / file:write and publisher / publisher:write |
/v1/file/templates/{templateId}/jobs/{jobId} | GET | file / file:read |
curl -X POST https://api.typograph.nl/v1/file/templates/{templateId}/jobs \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type": "publish", "format": "pdf"}'
typeispublish.formatispdf,jpg,jpeg,pngorwebp. The merge optionscsv_url,column_map,rows,merge_mode,filename_patternandoverflowwork as for a publisher job, and may each benullor left out.- It takes
editoradminon the template andcan_use_quotain the organization that owns it, so a guest cannot start one. A token of an app bound to another organization than the template's is refused with403organization_not_bound: such an app publishes through the publisher's own routes, on its own organization's quota. - It counts against the owning organization's
publisherlimit; at the limit it answers429rate_limit_exceeded.
It answers 202:
{
"job_id": "019b28fb-a11e-7641-a28f-e978f892ec20",
"template_id": "019b28fb-a11e-7641-a28f-e978f892ec21",
"organization_id": "019b28fb-a11e-7641-a28f-e978f892ec01",
"type": "publish",
"status": "queued",
"created_at": "<ISO 8601 timestamp>"
}
GET .../jobs/{jobId} answers for the person who started the job, with view on the template: the same fields plus files, error and finished_at. status is queued, processing, completed or failed. Each of files has a filename, a download_url that works for an hour, and its expires_at; error is {code, message} for a failed job and null otherwise.
Touching after a save
The document is written straight to storage through a presigned PUT, so PATCH /templates/{id}/touch is how the service learns of a save. The editor calls it on open, with no body, to drive recency lists. After writing the document, call it with { "is_saved": true }: updated_at moves, integrations get a template.updated event, and the stored document is normalised (asset URLs cleared, image keys filled in), which may rewrite it. Both answer 204 with no body.
To keep to the version you wrote, send the ETag header your PUT answered as etag, quotes included:
curl -X PATCH https://api.typograph.nl/v1/file/templates/019b.../touch \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "is_saved": true, "etag": "\"5d41402abc4b2a76b9719d911017c592\"" }'
The document is then read and rewritten only while it is still at that version, so a write someone made in between is never overwritten, and the response is 200 with the version the document has afterwards:
{ "etag": "\"7d793037a0760186574b0282f2f435e7\"" }
etag is a new ETag when the document was normalised, and the one you sent when there was nothing to clean or the document could not be normalised this time (it is then still as you wrote it): send it as If-Match on your next PUT. It is null when the document changed after your write: it was left alone, and your next PUT with If-Match on your own version answers 412, so you can load the newer document first. It is null as well when the document is no longer in storage, for instance because the template was deleted in the meantime. An etag that is not a non-empty string answers 400 with invalid_request.
Merge fields
GET /templates/{id}/fields lists what a row of data can fill in a template, so you can prepare data for it, or let an agent fill rows, without reading the template's document. A template that has no saved document yet answers 400 with invalid_request, and one whose document cannot be read as JSON, or is larger than 20 MB, answers 400 with invalid_template.
The fields come from the template itself:
| In the template | Field | type | Value |
|---|---|---|---|
| a text frame with a name | its name, e.g. naam | text | the frame's new text; a line break starts a paragraph |
| an image with a name | its name, e.g. photo | image | an https URL of the picture, placed with the frame's own fit |
| any element with a name, groups included | <name>.visible, e.g. photo.visible | visible | whether it prints: true/false, yes/no, ja/nee, y/n, j, 1/0 or x, in any case |
{{voornaam}} inside a text | the key, e.g. voornaam | text | the text in place of the placeholder |
- Only names someone gave count. An element the editor named after its id, or one the InDesign import called
Element 12, is no field: give the frames you want to fill a name in the editor's layers. Locked elements and master items are never fields. - A placeholder must be filled. Its field is
required: your data must name it, or{{voornaam}}would print as it stands. An empty value removes the placeholder, and its line when nothing else is on it, which is what an address block with an empty second line needs. - An empty value leaves an element as the template has it. That holds for every other field.
- One name, one field. Elements that share a name are filled together;
targetslists each element by itselement_uuid(the element'suuidin the template document), with its spread (counted from 1) and whether the field fills the element or a placeholder in it.
{
"template_id": "0199…",
"fields": [
{ "key": "naam", "label": "naam", "type": "text", "required": false, "max_length": null, "choices": null,
"targets": [{ "element_uuid": "9b1e…", "spread": 1, "kind": "element" }] },
{ "key": "voornaam", "label": "voornaam", "type": "text", "required": true, "max_length": null, "choices": null,
"targets": [{ "element_uuid": "41aa…", "spread": 2, "kind": "token" }] }
],
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["voornaam"],
"properties": {
"naam": { "type": "string", "title": "naam", "description": "The text. An empty value leaves the template's text." },
"voornaam": { "type": "string", "title": "voornaam", "description": "The text in place of {{voornaam}}. An empty value removes it, and its line when nothing else is on it." }
}
}
}
schema describes one row as a JSON object, keyed by field: the shape an agent can fill and validate before it sends data. Its keys follow the JSON Schema specification (additionalProperties), not this API's snake_case. max_length and choices are null for now; they will come from the personalisation rules a template's editors set.
Premade templates
The premade template catalog itself 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 |
/v1/file/projects/{projectId}/premade-templates/{code}/use | POST | file / file:write |
The list endpoint accepts limit (max 100, default 50), offset, category (filter by category code), and is_featured (boolean) query parameters. The categories endpoint lists every active category, by name; with has_templates=true only the ones that hold at least one active template, as the Portal's gallery shows them. List responses are wrapped as { "results": [...], "total": <int> }.
The use endpoint instantiates a catalog entry into a real, editable template: it downloads the entry's .typograph package, materialises its fonts and images as Font/Media records in projectId, and returns the new Template — the catalog entry itself is untouched. The package is imported exactly as a .typograph zip upload is: fonts already in the project are reused, and nothing is left in storage when the import fails. The caller must be able to edit projectId: a project the caller cannot edit (or that does not exist) returns 403, a catalog code that does not exist returns 404.
Only an entry with is_usable: true has a package. Using one that has none returns 400 with error: premade_template_not_ready; a package that cannot be downloaded returns premade_template_unavailable, and one that is not an importable zip premade_template_invalid.
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.
A comment's and a reply's user is its author as the pin shows them: id, user_id, handle,
first_name, last_name, full_name and image_url. Everyone who may read the template sees it,
so it never carries the author's email address, phone number or settings. user_id is the
author's account id, the one to compare with the signed-in user; id is their record in the
file service.
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.
A comment's is_resolved says whether its thread is resolved. PATCH .../comments/{commentId}/resolve?is_resolved=true
resolves it and ?is_resolved=false opens it again; it takes no body and answers 204 No Content.
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/projects/{projectId}/forms | POST | file / file:write |
/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) and offset, 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; POST /projects/{projectId}/forms creates one in a project without a template. Both take only { "name": "..." }, answer 201, and start the form as a draft without fields: fields and steps are set with PATCH /forms/{id}, as are further template links. 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_uuid 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_uuid, canvas_action and image_source. element_uuid is the uuid of 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.
DELETE /forms/{id} deletes a form at once: it is no longer read or listed. It cannot be restored, and the scheduled deletion job removes it for good once the retention window has passed, with the answers entered in it.
GET|POST /forms/{formId}/values/me reads and writes the calling user's own answers, as { "values": { "<key>": <value> } }. A field's key is <element_uuid>::<canvas_action> when it is bound to an element (9b1e…::URL), and its id when it is not. 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.
A form whose template is in the trash is not in the trash itself, but it fills in nothing until the template is restored. Its template_ids leave that template out, a duplicate does not link it, and filling the form in, or an app key reading it, answers 409 with in_trash, naming the template (see Nothing works on what is in the trash). Whoever builds the form still reads and changes it, to link another template or delete it. Restoring the template brings the link back.
The write accepts is_complete beside values. A draft is stored as it comes, so a form being typed into can be saved at any moment; is_complete: true marks the finished answer, and a required field left empty is then rejected with a 400. Only the keys of the form's own fields are stored: any other key is dropped, and the response shows what was kept.
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}/invitations | GET, POST | file:read / file:write |
/v1/file/{resourceType}/{resourceId}/invitations/{id} | GET | file:read |
/v1/file/{resourceType}/{resourceId}/invitations/{id} | PATCH | file:write |
/v1/file/{resourceType}/{resourceId}/invitations/{id} | DELETE | file:delete |
/v1/file/{resourceType}/{resourceId}/roles/{id} | PATCH | file:write |
/v1/file/{resourceType}/{resourceId}/roles/{id} | DELETE | file:delete |
/v1/file/{resourceType}/{resourceId}/access-requests | GET, POST | file:read / file:write |
/v1/file/{resourceType}/{resourceId}/access-requests/{id}/approve | POST | file:write |
/v1/file/{resourceType}/{resourceId}/access-requests/{id}/decline | POST | file:write |
/v1/file/{resourceType}/{resourceId}/access-requests/{id} | DELETE | file:delete |
resourceType is one of team, project, or template. Asking for access is described under Asking for access.
curl -X POST https://api.typograph.nl/v1/file/template/019b.../invitations \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "email": "colleague@example.com", "permission": "edit" }'
An editor invites with view, comment or edit; only an admin of the resource can invite
someone as admin, or change a pending invitation to it with
PATCH /{resourceType}/{resourceId}/invitations/{id} and { "permission": "admin" }. Anything
else is refused with a 403 and error: access_denied. Accepting checks this again against
whoever sent the invitation, as they are at that moment: an invitation its sender can no longer
grant (a demoted admin's admin invitation) is refused with a 403 and stays pending until it is
changed, withdrawn or sent again.
The invited user answers an invitation with PATCH /{resourceType}/{resourceId}/invitations/{id}?action=accept
or ?action=ignore; both return 204 No Content. Only the user whose email address the invitation was
sent to may answer it. Anyone else gets a 403 with error: invite_recipient_mismatch and the
invitation's address masked in recipient_hint, enough to tell which account to sign in with:
{
"error": "invite_recipient_mismatch",
"error_description": "This invitation was sent to j•••@example.com. Sign in with that address to respond to it.",
"recipient_hint": "j•••@example.com"
}
An invitation whose team, project or template has been deleted, or is in the trash or in a project or team in the trash, answers 404 to the invitee who accepts it; ignoring it still works (204), so the invitee can clear it. Whoever manages the invitations of something in the trash gets 409 with in_trash instead (see Nothing works on what is in the trash).
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. Beside them it says where that access comes from:
{
"can_view": true,
"can_comment": true,
"can_edit": false,
"can_administer": false,
"permission": "comment",
"granted_on": {
"resource_type": "project",
"resource_id": "0193a4b2-7c1e-7d40-9f2a-3b5c6d7e8f90",
"name": "Spring campaign"
},
"inherited_permission": "edit",
"expires_at": null,
"admins": [{ "name": "Anna de Vries" }]
}
permissionis the role that counts here (view,comment,editoradmin), andgranted_onthe team, project or template it is given on.inherited_permissionis the role from the place above, set only when it differs: here an editor of the team was givencommenton this project on purpose.expires_atis when the role ends, ornull.adminsnames whom to ask for more: the admins of the nearest place that has any, at most 10. Only the places the caller holds a role on, and those below them, count. So someone who shares one project is not told who the admins of its team are.
Without access, permission and granted_on are null and admins is empty.
The role closest to the resource counts, also when it is lower: a role on a template wins over one
on its project, and one on a project over one on its team, so an editor of a team can be given
comment on one project. An admin role is the exception: an admin of a team stays admin of every
project and template in it, and an admin of a project of every template in it, whatever role they
hold there. Giving such an admin less on a project or template is refused with 409 and
error: admin_inherited, by an invitation (POST .../invitations), a change of one
(PATCH .../invitations/{id}), accepting one (?action=accept, which leaves it pending) and a
change of a role (PATCH .../roles/{id}). admin_resource_type says whether they are admin of the
team or the project; neither is named, as a caller who shares one project may not see its team:
{
"error": "admin_inherited",
"error_description": "ada@example.com is admin of the team this project is in, and stays admin here. Make them admin here, or leave their access as it is.",
"admin_resource_type": "team"
}
To the invitee accepting, error_description reads "You are admin of the team this project is in,
and stay admin here. This invitation would give you less: decline it." Inviting or changing them to
admin is always allowed.
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.
Editor seats
Every plan has a number of editor seats: editor_seats in the plan's features (GET /v1/subscription/me), with the seats bought as add-ons included. Free has one, the owner's, so everyone it shares with can view or comment; Pro has 5 and Enterprise 10. An editor is a person who holds edit or admin anywhere in the organization, on a team or on anything in one, an open invitation included; a role on something in the trash counts until it is deleted for good. One person takes one seat, however many things they can edit. view and comment never take a seat and are never limited.
Granting edit or admin to someone who holds no seat yet is refused once the seats are all taken, with 409 and editor_seats_taken. That holds for an invitation (POST .../invitations), a change of one (PATCH .../invitations/{id}), accepting one (?action=accept), a change of a role (PATCH .../roles/{id}), approving a request for access (POST .../access-requests/{id}/approve) and creating a team (POST /v1/file/teams), which makes its creator the team's admin. Being the organization's owner or an admin takes no seat by itself: only creating a team or being granted edit or admin does. The message says what to do:
| When | error_description |
|---|---|
| Inviting or changing, on a plan with seats | Your plan has 5 editor seats, all taken. Invite to view or comment, free a seat, or request another plan. |
| Inviting or changing, on Free | Free has 1 editor seat, the owner's, so shares can view or comment. Invite to view or comment, or request another plan. |
| Creating a team | Creating a team makes you its admin, which takes an editor seat, and your plan's 5 editor seats are all taken. Free a seat, or request another plan. |
| Accepting | This invitation can't be accepted yet: the organization's editor seats are all taken. Ask whoever invited you to free a seat, or to invite you to view or comment. |
An invitation holds its seat only if there was room for it, also one sent before the plan had a limit: accepting it is refused while the seats are taken, and it stays pending. Editors who exist already keep their access, also beyond the plan. A seat comes free when its person no longer holds edit or admin anywhere, for example after a role is set to view or removed, or an invitation withdrawn. When the plan cannot be checked, these calls answer 503 with subscription_service_unavailable and change nothing. GET /v1/subscription/usage shows the seats in use against the limit, in plan_usage.editor_seats.
Who holds a seat already is said per person: is_editor on the roles of GET /v1/file/roles, for a caller who may manage access to the resource, and on the invitations of GET /v1/file/{resourceType}/{resourceId}/invitations. When it is true, edit or admin takes that person no new seat, so it is not refused when the seats are all taken. It is null where it is not said.
Asking for access
Someone who can open a team, project or template can ask its admins for a higher role there: comment, edit or admin. The admins are notified, and one of them, or an editor for a role up to edit, approves or declines it. Like the rest of sharing, these endpoints take a user token only.
curl -X POST https://api.typograph.nl/v1/file/project/0193a4b2-7c1e-7d40-9f2a-3b5c6d7e8f90/access-requests \
-H "Authorization: Bearer $USER_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "permission": "edit", "message": "The date of the campaign moved." }'
permission is required; message is optional, at most 500 characters, and trimmed. The answer is 201 Created with the request:
{
"id": "0193a4b2-8d2f-7e51-a03b-4c6d7e8f9a01",
"requester": {
"id": "0193a4b2-6b0d-7c3f-8e19-2a4b5c6d7e8f",
"user_id": "0193a4b2-5a9c-7b2e-9d08-1f3a4b5c6d7e",
"image_url": null,
"handle": "vera",
"first_name": "Vera",
"last_name": "Viewer",
"full_name": "Vera Viewer"
},
"resource_type": "project",
"resource_id": "0193a4b2-7c1e-7d40-9f2a-3b5c6d7e8f90",
"resource_name": "Spring campaign",
"permission": "edit",
"message": "The date of the campaign moved.",
"created_at": "<ISO 8601 timestamp>",
"expires_at": "<ISO 8601 timestamp>"
}
requester is the public profile, as a comment's author: whoever answers sees no email address. resource_name is the name the place had when it was asked for. expires_at is 30 days after created_at.
Asking is refused:
- with
403andaccess_deniedby someone who cannot open the place: only who sees it may ask for more on it; - with
400andinvalid_requestfor a role no higher than the caller holds there already; - with
400andvalidation_errorfor apermissionother thancomment,editoradmin(viewis what every requester has), or amessageover 500 characters; - with
409andaccess_request_openwhen the caller asked for this place already and that request is still open: withdraw it to ask for another role; - with
409andaccess_request_limit_reachedwhen the caller has 20 open requests.
The admins of the place are told with an access_request notification; when it has none, the admins of the nearest project or team above that has any, at most 10. Withdrawing and asking again for the same place tells them only once a day.
Who answers. GET /{resourceType}/{resourceId}/access-requests lists the open requests on the place that the caller may answer, oldest first, as a bare array of the requests above (limit, default 10, max 50, and offset). It needs edit on the place, or answers 403 with access_denied. An editor answers requests for up to edit from people who have no role on the place itself; a request for admin, and a request to raise a role someone holds on the place already (also one set lower there on purpose), takes an admin. A request the caller may not answer is left out of the list, and answering it is a 403.
POST .../access-requests/{id}/approve gives the requester the role on the place itself, where it counts as their closest role, and answers 200 with that role. Without a body it gives the role asked for; { "permission": "comment" } gives another one instead, lower or higher, within what the caller may give. A role the requester holds already on the place is raised; an end date it has stays. Approving is refused:
- with
400andinvalid_requestfor a role no higher than the requester holds by now; - with
403andaccess_deniedwhen the caller may not give the role; - with
409andeditor_seats_takenforeditoradminto someone who holds no editor seat yet, while the plan's seats are all taken (Editor seats);503withsubscription_service_unavailablewhen the plan cannot be checked.
POST .../access-requests/{id}/decline declines it, without a body, and answers 204 No Content. It takes what approving the request would take.
Once a request is approved, declined or withdrawn it is gone: answering it again is a 404 with not_found, as is a request id of another place. Two answers at the same moment get one through; the other is refused with 400 and invalid_request. A role given another way, such as an invitation, answers the request too: it is left out of every list from then on, and the requester can ask again.
The requester. GET /v1/file/users/me/access-requests lists the caller's own open requests, newest first (limit, default 10, max 50, and offset), leaving out one whose place is in the trash or deleted. DELETE /{resourceType}/{resourceId}/access-requests/{id} withdraws one and answers 204 No Content; only the requester may, anyone else gets 404 with not_found.
The requester is told the answer with an access_granted or access_declined notification. Its sender is who answered, or null when that is an admin of a team or project above that the requester does not share. A request that gets no answer lapses at expires_at: from then on it is in no list and cannot be answered, and within a day it is removed and the requester gets an access_request_expired notification, from Typograph itself.
Favorites, Shared, Trash, Search, Recents
| Endpoint | Method | Scopes |
|---|---|---|
/v1/file/favorites | GET | file / file:read |
/v1/file/favorites | PUT | file / file:write |
/v1/file/shared | GET | file / file:read |
/v1/file/shared/projects | GET | file / file:read |
/v1/file/shared/templates | GET | file / file:read |
/v1/file/trash | GET | file / file:read |
/v1/file/search | GET | file / file:read |
/v1/file/recent/searches | GET | file / file:read |
/v1/file/recent/searches | POST | file / file:write |
/v1/file/recent/searches | DELETE | file / file:write |
/v1/file/recent/templates | GET | file / file:read |
PUT /v1/file/favorites toggles the favorite state of a project or template: send { "resource_type": "project", "resource_id": "..." } (or "template"). GET /v1/file/favorites lists both, as { "projects": [...], "templates": [...] }, each favorite with the whole project or template; limit (default 10, max 50) and offset apply to each kind separately. A favorite of what is in the trash, or in a trashed project or team, is left out until it is restored, and none can be added: that is a 409 with in_trash.
GET /v1/file/shared?limit=&offset= is one feed of what others shared with the user: 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_filename (template-only, otherwise null; it holds the thumbnail's filename in storage, not a link: read the thumbnail through the template presigned URLs), template_count (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/shared/projects and GET /v1/file/shared/templates list one kind each: the projects the user was invited to and holds a role on directly (not through a team), or the templates they hold a role on directly (not through a team or a project), the last edited first (by updated_at, as the lists of a team and a project), as { "results": [...], "total": n } with the same project and template objects as elsewhere. They take limit (default 10, max 50) and offset. Where /v1/file/shared is the feed of what is shared with the user, these differ in two ways: they put the last edited first where the feed puts the newest share first, and they return the plain project or template objects, without who shared it, when, or the permission. Like that feed, they count a role that has expired as none, and leave out an item in the trash or in a project or team in the trash.
GET /v1/file/trash?limit=&offset= lists what is in the user's trash: { "limit": n, "offset": n, "teams": [...], "projects": [...], "templates": [...], "clips": [...], "media": [...], "fonts": [...] }. Each array holds the trashed items of that kind which the user holds a role on, directly or through their team, in the same shape as everywhere else plus deleted_at and can_restore. can_restore says whether this user may restore the item, by the same rule as its restore endpoint: a team takes a team admin, a project or template its admin (the most specific role counts, so a team admin unless the item has a role of its own), and a clip, media file or font anyone who can edit its project. The endpoint stays the gate, so check the flag before offering a restore, not instead of handling a 403. A template or media file also carries thumbnail_url, a read link to its thumbnail that is valid for 60 minutes, or null when it has none: the one way to show what it looks like while it is in the trash, as a template's own thumbnail endpoints answer 409 with in_trash and the media read links leave a trashed file out. A file inside a trashed project is not listed on its own: restoring the project brings it back. limit (default 10, max 50) and offset apply to each kind separately. Restore an item with the undelete action of its own DELETE endpoint.
GET /v1/file/search matches the query, 2 to 255 characters without the spaces around it (outside that is a 400 with invalid_request), against the names of the templates, projects, teams, media and fonts the user can open, returning each group (templates, projects, teams, media, fonts), the most recently updated first, plus a total, the query, limit and offset it ran with, and breadcrumbs: the project and team name of each template, media file, font and project, by its id. A media file or font is found where the user may open its project's media and fonts: a role on the project or its team, or on a template in the project. Five parameters narrow the search, and only narrow it: types[] lists only those types (templates, projects, teams, media, fonts; every type when left out); team_ids[] and project_ids[] keep what is in one of those teams or projects, in either when both are given, at most 100 of each (a team itself is listed only when named in team_ids[]); min_updated_at keeps what was updated at or after an ISO 8601 date-time or date; max_updated_at, in the same forms, keeps what was updated before it, not at it, so last month is min_updated_at the first of last month and max_updated_at the first of this month (an end at or before the start is a 400 with invalid_request). With one of these five the query may be left out: the search then lists everything the filters hold, whatever its name, and remembers no recent search (?types[]=templates&min_updated_at=… lists the templates changed since then, by anyone, and with &max_updated_at=… those changed in that period). Without a query and without a filter it is a 400. A team or project the user cannot see finds nothing, and an unknown type, an id that is no UUID or a date that cannot be read is a 400 with invalid_request. Beside the lists, template_count, project_count, team_count, media_count and font_count say how many of each type the search finds in its place and date, whatever types[] lists and however many fit in limit: the numbers behind a type filter. 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 ?include_deleted=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, the most recently deleted first); omitting the flag (default 0) excludes trashed items. GET /v1/file/recent/searches lists the current user's recent queries, the most recent first, as a bare array of { "id", "user_id", "query", "touched_at" } (limit, default 10, max 50, and offset). POST /v1/file/recent/searches saves a query ({ "query": "..." }, trimmed, 2 to 255 characters) and DELETE /v1/file/recent/searches clears the current user's recent searches; both return 204 No Content.
These lists, and the teams, shared items, favorites, recently opened templates and notifications, hold only the organization the token reaches, also when the user has roles in others (which one).
In GET /v1/file/teams, the shared lists, the trash, search, favorites and recently opened templates, every team, project, template, media file, font and clip carries organization_id and organization_name at its top level: the organization that owns it. For your app's token that is always the organization the token reaches. The fields are there for the portal's assistant, which works for a person in every organization they belong to and lists them side by side.
Nothing works on what is in the trash
Something is in the trash when it, its project or its team was deleted (deleted_at is set). Until it is restored, no call works on it: reading it, its file or its thumbnail, changing, sharing, commenting, favoriting, moving or copying it, and creating, uploading or moving anything into a project or team in the trash. Each answers 409 Conflict with in_trash, and names what is in the trash: the item itself, or the outermost project or team that is.
{
"error": "in_trash",
"error_description": "This template is in a project that is in the trash. Restore the project first.",
"resource_type": "project",
"resource_id": "019397ab-cdef-7890-abcd-ef1234567890",
"deleted_at": "<ISO 8601 timestamp>",
"request_id": "..."
}
resource_type is team, project, template, clip, media or font. Restore that one with the undelete action of its own DELETE endpoint. Only a caller who may reach the item learns that it is in the trash; anyone else gets the 403 or 404 they always got.
What still works: GET /v1/file/trash, and the restore. A restore of something whose project or team is still in the trash is refused with 409 and container_in_trash, naming that project or team, as its scheduled deletion would take the item with it: restore the project or team first.
Lists are not refused: they leave out what is in the trash, and what is in a project or team in the trash. GET /trash is where it is listed, and /search finds it with include_deleted=1, in trashed_*. The lists no longer read include_deleted: a request that still sends it gets what is out of the trash. POST /projects/{projectId}/templates/presigned-urls leaves out a template in the trash rather than refusing. An invitee who accepts an invitation into something in the trash gets 404, as for a deleted one.
Users, Roles & Invitations (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 user's profile by UUID (me for the caller). Only the user themself and people who share an organisation with them get it, and with a token bound to an organisation only the people in that one; anyone else gets a 404. |
/v1/file/users/me/invitations | GET | file / file:read | List invitations addressed to the current user, to teams, projects and templates of the token's organization. |
/v1/file/users/me/access-requests | GET | file / file:read | List the current user's own open requests for access, newest first (Asking for access). |
/v1/file/roles | GET | file / file:read | Who has access to a team, project or template: the roles people hold on it (resource_types[] and resource_ids[] in pairs), with is_editor for a caller who manages its access. |
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 |
A notification's sender is the person who caused it, with the same public fields as a comment's author: id, user_id, handle, first_name, last_name, full_name and image_url, never their email address, phone number or settings. It is null for a message from Typograph itself.
A notification's message is one English sentence, an HTML fragment with the names in bold. To show the notification in another language, word it yourself from its type and parameters: the values the message is built from, as plain text. Which keys a type carries:
| Type | parameters |
|---|---|
delete_*, undelete_*, purged_* | name |
delete_warning_* | name, days (an integer) |
invite_* | name, permission (view, comment, edit or admin) |
new_comment | name (the template's), template_id, comment_id, excerpt, reply (true for a reply in a thread) |
access_request | name, permission (the role asked for), resource_type, resource_id, access_request_id |
access_granted | name, permission (the role given), resource_type, resource_id |
access_declined, access_request_expired | name, permission (the role asked for), resource_type, resource_id |
Who caused it is sender, not a parameter. parameters is null on a notification it could not be read back for, and on a mention_comment: show the message then.
Mark a single notification read with PATCH /notifications/{id}?mark=read (the default), or unread again with ?mark=unread. Mark all notifications read in one call with PATCH /notifications, optionally only those of one team with ?team_id=. Neither takes a body; both answer 204 No Content.
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. A media file, font or template package is uploaded this way, through an upload; template thumbnails and documents have their own presigned-url endpoints (listed in the relevant sections above). The finalize call refuses a file over its limit with 413 and error payload_too_large: 75 MB for an image, 25 MB for an SVG, 50 MB for a font (template packages and PDFs below).
A thumbnail upload URL is signed for one Content-Type: image/webp for a template thumbnail, image/jpeg for a clip thumbnail. Send exactly that header with the PUT; storage refuses any other type with 403. A thumbnail read URL always serves the image with that type.
Flow:
POST /v1/file/projects/{projectId}/uploads # create the upload: upload_job, and presigned_url to PUT to
PUT <signed_url> # upload bytes to R2/S3
PATCH /v1/file/projects/{projectId}/uploads/{id} # finalize metadata
GET /v1/file/projects/{projectId}/uploads/{id} # follow it until it ends
An upload's status is awaiting_upload until the finalize call and received_upload after it,
processing_upload while the file is imported, and importing_assets while a template's fonts and
images are still being imported in the background (the template exists already). It ends success
or failed, with the reason in message. converting is for a PDF converted in the background,
before its import; today a .pdf is still converted inside the finalize call (below), so no upload
has it yet. Read status by value, and treat one you do not know as still running. GET /v1/file/projects/{projectId}/uploads/{id} reads one upload,
for anyone who can view the project (scope file or file:read).
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 with error payload_too_large when exceeded), and 250 MB for what a .zip holds once unpacked. Structurally invalid templates are rejected with 422 and error validation_error, the schema violations in error_description.
A package can mark a font of its manifest "source": "pdf": a copy a PDF embedded, cut down to the letters the design uses, with those letters in available_characters. Such a font is imported as a preview of its template, never as a font in its own right: it is stored with "source": "pdf" and its available_characters, the template shows the text in it, and it is still listed in warnings as an asset_missing font, with the preview's id as source_id. Upload the complete font to the project and it takes the preview's place in the template. When the project already has a font of that name, the template uses that one and the preview is not imported. A preview is never used for another template's font of the same name.
Canva PDF uploads
A PDF exported from Canva (.pdf, up to 50 MB) can be uploaded the same way and becomes a template. The finalize call converts the PDF to a template package first and then imports that package exactly like a .zip upload, so the response is the same: message holds the new template id, and the upload's filename comes back ending in .zip.
Each PDF page becomes one page of the template. Text, photos, vector shapes and solid colours are carried over as editable elements.
A PDF only holds its fonts cut down to the letters the design uses, so the font files are not carried over. Each font the text uses is looked up by family and style among the project's fonts. A font the project does not have and that Google Fonts has is added to the project in full. A font that is still missing is shown in the default font until you upload it; once it is uploaded, the template uses it.
warnings says what the import did and what it could not do, one entry per item, each with a code:
code | Other fields | Meaning |
|---|---|---|
pdf_image_unsupported | page, message | An image in a format, compression or colour space that is not read (CMYK pixels that are not a JPEG, 16-bit); the reason is in message. |
pdf_image_damaged | page, message | An image whose pixel data is broken. |
pdf_image_uncropped | page, message | An image cropped by more than one outline at once, placed uncropped. |
pdf_image_transparency_lost | page, message | A partly transparent image whose transparency could not be kept (a CMYK JPEG, or a mask that cannot be read), placed without it. |
pdf_gradient | page, message | Shapes filled with a gradient or a pattern, not carried over. |
pdf_spot_color | page, message | Shapes in a spot colour, not carried over. |
pdf_shape_unsupported | page, message | Shapes not carried over for another reason; the reason is in message. |
pdf_shape_uncropped | page, message | Shapes cropped in a way that could not be kept, shown whole. |
pdf_font_default | page, message | Text in a font the PDF gives no name (emoji, some text effects), shown in the default font. |
font_added | source (google), name | A font added to the project from Google Fonts, as a person reads it ("Montserrat Bold"). |
asset_missing | kind (font or image), name, source_id | A font or image the template uses that the project does not have, as for a .zip upload. |
The pdf_ warnings say what could not be carried over: page is the page it is on (counted from 1), and message says it in one English sentence, page included, to show as it is. Shapes with the same problem on one page are one warning, with the count in message.
"warnings": [
{"code": "pdf_gradient", "page": 1, "message": "Page 1: a shape could not be converted (it is a colour gradient or a pattern)."},
{"code": "font_added", "source": "google", "name": "Montserrat Bold"},
{"code": "asset_missing", "kind": "font", "name": "Helvetica World", "source_id": "..."}
]
Read warnings by code, and ignore a code you do not know: more may be added.
A file that is not a readable PDF is rejected with 422 in the standard error envelope, with the reason in error_description:
{
"error": "pdf_conversion_failed",
"error_description": "This PDF could not be converted. Upload a PDF exported from Canva, or a Typograph template package.",
"request_id": "..."
}
The conversion is built for Canva's PDF export. A PDF from another tool may convert, but is not supported.
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 |
403 | organization_not_bound | The resource, or the target of a move, belongs to another organization than the one the token reaches (details) |
403 | invite_recipient_mismatch | The invitation was sent to another address than the caller's; recipient_hint holds it, masked |
404 | not_found | Resource does not exist or not accessible to caller, such as a form of another organization than the token's |
409 | in_trash | What the call works on is in the trash, or in a project or team that is; resource_type, resource_id and deleted_at name it (details) |
409 | container_in_trash | A restore of something whose project or team is still in the trash; restore that first |
409 | team_limit_reached | A new or restored team beyond the plan's number of teams (details) |
409 | editor_seats_taken | edit or admin for someone who is no editor yet, or a new team by someone who is no editor yet, while the plan's editor seats are all taken (details) |
409 | admin_inherited | A role below admin on a project or template for someone who is admin of its team or project, by an invitation, a change of one, accepting one or a change of a role; admin_resource_type says which (details) |
409 | access_request_open | The caller asked for access to this team, project or template already, and that request is still open (details) |
409 | access_request_limit_reached | The caller has 20 open requests for access (details) |
409 | conflict | Duplicate slug / concurrent edit |
422 | validation_error | Field validation failed |
503 | subscription_service_unavailable | Creating or restoring a team, or granting edit or admin: the organization's plan could not be checked; try again in a moment |
{
"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." }
]
}