File uploads
Files are uploaded with a GraphQL mutation sent as a multipart request, following the GraphQL multipart request specification. Three mutations take a file:
uploadFileattaches a file to a record, such as a project or a contract.uploadAccountLogosets the account's logo.uploadUserAvatarsets the signed-in user's avatar.
File uploads have an endpoint of their own. You get its address from the uploadTicket query, then send the multipart request there.
Step 1: get an upload ticket
Ask for a ticket with an ordinary query to https://api.kavitro.com/graphql:
query {
uploadTicket {
url
expiresAt
}
}
{
"data": {
"uploadTicket": {
"url": "/graphql/upload?verify=1791473400-qL3n7kXw0bS2...",
"expiresAt": "2026-10-08T18:20:00+03:00"
}
}
}
url is relative to the API host, so the upload goes to https://api.kavitro.com followed by it. Treat it as opaque: use it exactly as you received it, and do not build or change it yourself.
A ticket works for as many uploads as you like until expiresAt, currently ten minutes after you asked for it. What counts is when an upload starts: one that starts before expiresAt may finish after it, so a large file on a slow connection is not refused once it has been sent. Ask for a new ticket once the current one is close to expiring, rather than one per upload.
Step 2: send the upload
POST the multipart request to the ticket's URL. The request has three parts, in this order:
operations- JSON with the mutation inqueryand itsvariables, with the file's variable set tonull.map- JSON saying which variable the file belongs to, here{"0": ["variables.input.file"]}.0- the file itself.
mutation UploadFile($input: UploadFileInput!) {
uploadFile(input: $input) {
uuid
fileName
}
}
{
"query": "mutation UploadFile($input: UploadFileInput!) { uploadFile(input: $input) { uuid fileName } }",
"variables": {
"input": {
"file": null,
"model": { "type": "PROJECT", "id": "42" }
}
}
}
For the logo and the avatar the file is a top-level variable, so the map is {"0": ["variables.file"]}:
mutation UploadUserAvatar($file: Upload!) {
uploadUserAvatar(file: $file) {
avatar
}
}
Required headers
Authorization: Bearer YOUR_ACCESS_TOKEN
X-Requested-With: XMLHttpRequest
Let your HTTP client set Content-Type itself: it has to carry the multipart boundary. Without X-Requested-With, a multipart request is refused with a 400 before it is read (see request format).
What the upload endpoint accepts
The upload endpoint runs file uploads and nothing else:
- Only
uploadFile,uploadAccountLogoanduploadUserAvatar. Send every other query and mutation tohttps://api.kavitro.com/graphql. - One operation, with one upload field, per request. Batches, a second operation in the document and aliased uploads that send several files at once are all refused. To upload several files, send one request for each.
- The upload's result, read with plain fields. Select what you need from what the mutation returns, but give those fields no arguments and use no fragments, at the top of the mutation or anywhere below it. Query anything more on
/graphql. - The full query text. Persisted query hashes are not accepted here.
- Multipart only, and only
POST.
Everything else works as it does on /graphql: the same access token, the same IP restrictions on it, and the same rate limits. An upload is a mutation, so it counts against the mutation rate limit, 40 mutations per user per minute by default. uploadTicket is a query, so asking for a ticket does not.
Errors
| Status | category | What it means | What to do |
|---|---|---|---|
403 | UPLOAD_TICKET_INVALID | The ticket is missing, has expired or is not valid. | Get a new ticket and send the upload again. |
403 | none | The ticket was refused. The body may not be JSON, and a browser cannot read it at all: it has no CORS headers, so it looks like a network error. | Get a new ticket and send the upload again. |
403 | IP_NOT_ALLOWED | The access token is restricted to certain IP addresses and yours is not among them. | Do not retry. |
415 | UPLOAD_ROUTE_NOT_MULTIPART | The request is not multipart/form-data. | Send uploads as multipart, and everything else to /graphql. |
400 | UPLOAD_ROUTE_REJECTED | The request is not a single upload mutation. The message names the rule it broke. | Change the request; it never succeeds as sent. |
400 | none | X-Requested-With: XMLHttpRequest is missing. | Add the header. |
405 | none | The request was not a POST. | Use POST. |
413 | OPERATIONS_TOO_LARGE, MAP_TOO_LARGE, QUERY_TOO_LARGE | The operations or map field, or the query text in it, is too large; see query size. | Send less alongside the file. |
429 | see rate limits | A rate limit. | Wait for Retry-After. |
Retry a refused ticket (UPLOAD_TICKET_INVALID, or a 403 with no category) once with a fresh ticket. If the new ticket is refused too, stop and look at the request rather than retrying again. Errors from the mutation itself, such as a record you cannot add files to, arrive as usual with a 200 and an errors array; see errors.
Examples
Both examples upload a file to a project, asking for a new ticket whenever the current one is within a minute of expiring.
Python
Uses requests:
import json
from datetime import datetime, timedelta, timezone
import requests
API = "https://api.kavitro.com"
HEADERS = {
"Authorization": "Bearer YOUR_ACCESS_TOKEN",
"X-Requested-With": "XMLHttpRequest",
}
UPLOAD_FILE = """
mutation UploadFile($input: UploadFileInput!) {
uploadFile(input: $input) {
uuid
fileName
}
}
"""
_ticket = None
def upload_url():
"""The upload endpoint's URL, asking for a new ticket when the last one is about to expire."""
global _ticket
now = datetime.now(timezone.utc)
if _ticket is None or datetime.fromisoformat(_ticket["expiresAt"]) - now < timedelta(minutes=1):
response = requests.post(
f"{API}/graphql",
headers=HEADERS,
json={"query": "query { uploadTicket { url expiresAt } }"},
)
response.raise_for_status()
result = response.json()
if "errors" in result:
raise RuntimeError(result["errors"][0]["message"])
_ticket = result["data"]["uploadTicket"]
return API + _ticket["url"]
def upload_file(path, model_type, model_id):
operations = {
"query": UPLOAD_FILE,
"variables": {"input": {"file": None, "model": {"type": model_type, "id": model_id}}},
}
with open(path, "rb") as file:
response = requests.post(
upload_url(),
headers=HEADERS,
# requests sends the form fields before the file, as the specification requires.
data={
"operations": json.dumps(operations),
"map": json.dumps({"0": ["variables.input.file"]}),
},
files={"0": file},
)
response.raise_for_status()
result = response.json()
if "errors" in result:
raise RuntimeError(result["errors"][0]["message"])
return result["data"]["uploadFile"]
print(upload_file("report.pdf", "PROJECT", "42"))
JavaScript
Runs in Node.js 20 or later, which has fetch, FormData and openAsBlob built in:
import { openAsBlob } from 'node:fs'
const API = 'https://api.kavitro.com'
const HEADERS = {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-Requested-With': 'XMLHttpRequest',
}
const UPLOAD_FILE = `
mutation UploadFile($input: UploadFileInput!) {
uploadFile(input: $input) {
uuid
fileName
}
}
`
let ticket = null
// The upload endpoint's URL, asking for a new ticket when the last one is about to expire.
async function uploadUrl() {
if (!ticket || Date.parse(ticket.expiresAt) - Date.now() < 60_000) {
const response = await fetch(`${API}/graphql`, {
method: 'POST',
headers: { ...HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'query { uploadTicket { url expiresAt } }' }),
})
const { data, errors } = await response.json()
if (errors) throw new Error(errors[0].message)
ticket = data.uploadTicket
}
return API + ticket.url
}
async function uploadFile(path, fileName, model) {
const body = new FormData()
body.append(
'operations',
JSON.stringify({ query: UPLOAD_FILE, variables: { input: { file: null, model } } }),
)
body.append('map', JSON.stringify({ 0: ['variables.input.file'] }))
body.append('0', await openAsBlob(path), fileName)
// No Content-Type here: fetch sets it, with the multipart boundary.
const response = await fetch(await uploadUrl(), { method: 'POST', headers: HEADERS, body })
if (!response.ok) throw new Error(`Upload failed with status ${response.status}`)
const { data, errors } = await response.json()
if (errors) throw new Error(errors[0].message)
return data.uploadFile
}
console.log(await uploadFile('report.pdf', 'report.pdf', { type: 'PROJECT', id: '42' }))
In a browser, append the File from a file input in place of openAsBlob.