Skip to main content

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:

  • uploadFile attaches a file to a record, such as a project or a contract.
  • uploadAccountLogo sets the account's logo.
  • uploadUserAvatar sets 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
}
}
Response
{
"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 in query and its variables, with the file's variable set to null.
  • 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
}
}
operations
{
"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, uploadAccountLogo and uploadUserAvatar. Send every other query and mutation to https://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​

StatuscategoryWhat it meansWhat to do
403UPLOAD_TICKET_INVALIDThe ticket is missing, has expired or is not valid.Get a new ticket and send the upload again.
403noneThe 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.
403IP_NOT_ALLOWEDThe access token is restricted to certain IP addresses and yours is not among them.Do not retry.
415UPLOAD_ROUTE_NOT_MULTIPARTThe request is not multipart/form-data.Send uploads as multipart, and everything else to /graphql.
400UPLOAD_ROUTE_REJECTEDThe request is not a single upload mutation. The message names the rule it broke.Change the request; it never succeeds as sent.
400noneX-Requested-With: XMLHttpRequest is missing.Add the header.
405noneThe request was not a POST.Use POST.
413OPERATIONS_TOO_LARGE, MAP_TOO_LARGE, QUERY_TOO_LARGEThe operations or map field, or the query text in it, is too large; see query size.Send less alongside the file.
429see rate limitsA 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.