Files
A file is an object you uploaded, registered in your team's Drive. Files are the input to mass imports: you upload a spreadsheet, preview its sheets to learn their shape, then start an import against it. Files are also what file and image passport property values point at.
Files are team-global — a file belongs to no ontology. Writes need files:write, reads need files:read.
Because files are team-global, files:read cannot be restricted to
specific ontologies. A key whose files:read carries an ontology scope is
rejected with 403 ability_not_scoped_to_resource on the read endpoints;
grant it outright instead. A file owned by another team reads as 404 resource_missing rather than 403.
The file model
- Name
object- Type
- string
- Description
Always
file.
- Name
id- Type
- string
- Description
Unique identifier, e.g.
file_00a1b2c3d4e5f607.
- Name
filename- Type
- string
- Description
The stored name of the uploaded file, e.g.
units.xlsx. This may differ from the name you sent: characters the Drive can't hold become_, and a name already taken in the directory is de-duplicated (units.xlsx→units (2).xlsx). Read it back rather than assuming it matches. Nullable.
- Name
extension- Type
- string
- Description
File extension without the dot, e.g.
xlsx. Nullable.
- Name
size- Type
- integer
- Description
Size of the file in bytes. Nullable.
- Name
metadata- Type
- object
- Description
Caller-owned metadata bag. Files are immutable once uploaded, so it is set at upload time or not at all.
- Name
created_at- Type
- string
- Description
ISO 8601 timestamp of the upload. Nullable.
The file preview model
- Name
object- Type
- string
- Description
Always
file.preview.
- Name
file- Type
- string
- Description
The id of the previewed file, e.g.
file_00a1b2c3d4e5f607.
- Name
sheets- Type
- array
- Description
One entry per worksheet in the spreadsheet (see below).
The sheet object
Each entry in sheets describes one worksheet — enough to build an import mapping and rows value without downloading the file.
- Name
index- Type
- integer
- Description
Zero-based position of the sheet in the workbook.
- Name
name- Type
- string
- Description
The sheet's tab name.
- Name
header- Type
- array
- Description
The column labels taken from the first row.
- Name
examples- Type
- array
- Description
Up to two example data rows, each an array of cell values.
- Name
rows- Type
- integer
- Description
Data row count, with the header row excluded.
Upload a file
Uploads a file as multipart/form-data in a single shot. Spreadsheets can then be previewed and imported. Requires files:write. Accepts an optional Idempotency-Key header.
The 50 MB ceiling
This is a single-shot upload, which is what puts a ceiling on it at all. The default is 50 MB — and treat that as an upper bound the deployment must also honour, not a guarantee.
A size rule can only reject a request it actually received, so a body over PHP's own upload_max_filesize / post_max_size never reaches the validator and surfaces as a confusing 400 parameter_invalid with param: "file" — "file is required" — rather than a size error. An ingress body-size limit rejects it earlier still, with a bare 413 that is not in the API's error envelope at all. The three limits are configured in different places and nothing enforces that they agree.
Anything larger goes through multipart uploads, which do not send the bytes through the API at all, so none of the three limits apply.
The stored filename may differ from the name you send — unsupported
characters become _, and a name already used in the directory is
de-duplicated (report.csv → report (2).csv), so an upload never fails
on a name clash alone. Read the returned filename back rather than
assuming it. The one exception is a concurrent upload that wins the race
to the same name, which returns 409 file_already_exists — retry to
take the next free name.
Required attributes
- Name
file- Type
- string
- Description
The file to upload, sent as a binary
multipart/form-datapart. Do not set a JSON content type.
Optional attributes
- Name
folder_path- Type
- string
- Description
The directory to upload into. The Drive root when omitted.
- Name
metadata- Type
- string
- Description
A metadata bag as a JSON-encoded string — a multipart body cannot hold a nested object, so send the form field with text like
{"source_system":"erp-prod"}. It is parsed and then validated identically to the JSON form; text that is not valid JSON, or that decodes to something other than an object, is400 metadata_invalid.
Request
curl https://api.synexcloud.com/v1/files \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-F "file=@units.xlsx"
Response
{
"object": "file",
"id": "file_00a1b2c3d4e5f607",
"filename": "units.xlsx",
"extension": "xlsx",
"size": 20480,
"created_at": "2026-07-12T09:31:00Z"
}
Retrieve a file
Retrieves a file's metadata by id. Requires files:read.
Request
curl https://api.synexcloud.com/v1/files/file_00a1b2c3d4e5f607 \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "file",
"id": "file_00a1b2c3d4e5f607",
"filename": "units.xlsx",
"extension": "xlsx",
"size": 20480,
"created_at": "2026-07-12T09:31:00Z"
}
Preview a spreadsheet
Parses a previously uploaded spreadsheet and returns, per sheet, the header labels, up to two example rows, and the data row count — everything you need to build an import mapping and rows value. Requires files:read.
Sheets come back in workbook order, which is the order a multi-level import consumes them: levels[0] reads the first sheet, levels[1] the second, and so on.
A file that exists but is not a spreadsheet this endpoint can parse returns 404 file_not_previewable — distinct from a missing id, which is 404 resource_missing.
Preview is not free. The row count is exact, which means the whole object is downloaded and every row of every sheet is parsed, synchronously, to produce it. On a large upload that is a multi-second call. Preview once and keep the result rather than calling it per import attempt.
Request
curl https://api.synexcloud.com/v1/files/file_00a1b2c3d4e5f607/preview \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "file.preview",
"file": "file_00a1b2c3d4e5f607",
"sheets": [
{
"index": 0,
"name": "Units",
"header": ["Capacity", "Alias"],
"examples": [
["4500", "unit-01"],
["4800", "unit-02"]
],
"rows": 120
}
]
}
List a directory
Lists one page of a directory's direct children, files and folders interleaved. Requires files:read.
Switch on object to tell the two apart: a folder carries a path and no id, a file carries an id and no path.
Paths are normalized, so /exports/2026, exports/2026 and /exports/2026/ all name the same directory. . and .. segments, empty segments, and the | character are 400 parameter_invalid.
path joins the cursor's scope, so a cursor minted in one directory and replayed against another is 400 invalid_cursor.
Entries carry no download URL, unlike the application's own file browser. Presigning every file in a page eagerly mints bearer credentials you will mostly not use, and pins the page's usefulness to the shortest of their lifetimes. Mint one on demand instead.
There is no ?search= on files. The drive search underneath is unbounded, so exposing it would put an unpredictable scan behind a public endpoint. Until that is fixed, list a directory and filter client-side, or keep your own index of the ids you uploaded — the id is the stable handle, not the name.
Optional attributes
- Name
path- Type
- string
- Description
The directory to list. The Drive root when omitted.
- Name
limit- Type
- integer
- Description
Page size, 1–100 (default 10).
- Name
cursor- Type
- string
- Description
A pagination cursor from a previous response's
next_cursor.
Request
curl -G https://api.synexcloud.com/v1/files \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-d "path=/exports/2026/"
Response
{
"object": "list",
"data": [
{
"object": "folder",
"name": "q3",
"path": "/exports/2026/q3/"
},
{
"object": "file",
"id": "file_00a1b2c3d4e5f607",
"filename": "units.xlsx",
"extension": "xlsx",
"size": 20480,
"metadata": {},
"created_at": "2026-07-10T12:00:00Z"
}
],
"has_more": false,
"next_cursor": null,
"url": "/v1/files"
}
Generate a download link
Returns a link, not the bytes. Requires files:read.
Objects run to tens of megabytes, and proxying one through the API would hold a worker for the whole transfer, pay egress twice, and put a request timeout between you and a download that is otherwise object storage's problem. Fetch url directly; it needs no Authorization header.
That is also the catch: the URL carries its own authorization, so it is a bearer credential for that one object until it expires. Don't log it, don't persist it in a query string, and don't hand it to a browser you don't trust.
The lifetime is 15 minutes by default — deliberately much shorter than the links the web app mints for a logged-in session, so a URL leaked through a log or a referrer is not a standing grant. expires_at is absolute, so cache the URL for its lifetime and re-request rather than calling per download.
Request
curl https://api.synexcloud.com/v1/files/file_00a1b2c3d4e5f607/download \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "file.download",
"file": "file_00a1b2c3d4e5f607",
"url": "https://s3.eu-west-1.amazonaws.com/…?X-Amz-Signature=…",
"expires_at": "2026-07-26T14:25:43Z"
}
Multipart uploads
POST /v1/files is single-shot, and therefore capped by what a request body can carry — 50 MB by default. POST /v1/uploads is the same object-storage multipart pipeline the internal uploader runs, with one deliberate difference: the parts are presigned, so you PUT each one straight to object storage and the bytes never touch an API worker.
That is what lifts the ceiling — a 4 GiB upload costs the API four small JSON calls, not 4 GiB of proxying — and it is what makes an upload resumable, since a failed part is one re-signed PUT rather than a restarted request. The maximum size is 5 GiB.
The id is the file's from the start. POST /v1/uploads returns a file_…
id, and on completion that is the id of the file. There is no separate upload
id to exchange for one, and nothing to map afterwards.
Three calls, plus one PUT per part:
- Open the upload — returns
part_size,part_countand the file id. - Sign a batch of part numbers, at most 100 per call.
- PUT each part straight to object storage, keeping the ETag each returns.
- Complete with the full list of part numbers and ETags.
No per-part checksums
The internal uploader checks a SHA-256 per chunk and this does not, because nothing passes through PHP here and object storage will only accept a checksum it was told to expect when the upload was created. Integrity is what the store itself enforces: each part's ETag is returned by the PUT, you send the list back, and completion fails the whole upload if any ETag does not match the part the store holds.
For you as the caller that reduces to one rule: send back exactly the ETags you were given, and a corrupted part fails the whole completion rather than landing silently inside the finished file.
Open a multipart upload
Opens the upload and returns the part plan. Requires files:write.
part_size is 8 MiB, part_count is ceil(size / part_size), and part numbers run 1…part_count. Both come back on this call rather than being yours to choose — two different answers for one upload would let you sign a part the completion then refuses.
filename is rejected here, not sanitized — a deliberate divergence from POST /v1/files, which substitutes characters the Drive key layout cannot hold. There the name comes off a multipart part header the client may not control; here it is a field you typed, and silently storing an upload under a different name than the one you will complete against is worse than a 400. De-duplication still applies (export.csv → export (2).csv), so read the returned filename back.
Required attributes
- Name
filename- Type
- string
- Description
The name to store the object under.
- Name
size- Type
- integer
- Description
Total size in bytes, at most 5 GiB.
Optional attributes
- Name
folder_path- Type
- string
- Description
The directory to upload into. The Drive root when omitted.
- Name
metadata- Type
- object
- Description
Caller-owned metadata bag.
Request
curl https://api.synexcloud.com/v1/uploads \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filename": "cells_q3.csv",
"size": 734003200,
"folder_path": "/exports/2026/"
}'
Response
{
"object": "upload",
"id": "file_5e2a9c7d1b4f0836",
"filename": "cells_q3.csv",
"size": 734003200,
"part_size": 8388608,
"part_count": 88,
"created_at": "2026-07-28T10:14:02Z"
}
Sign part URLs
Presigns a batch of part numbers. Requires files:write.
Part URLs come in batches of at most 100, and each one lives 15 minutes. That is why they are requested a batch at a time instead of all at once on the create call: an upload with 600 parts would see the first batch expire long before you reached the last.
Re-signing costs nothing — the call computes signatures locally and never talks to object storage — so request the next batch as you go, and simply re-request a URL that expired before you got to it.
Each URL is a bearer credential that can write one part of one object. Treat them the way you treat download links: don't log them, don't persist them.
Required attributes
- Name
part_numbers- Type
- array
- Description
The part numbers to sign, at most 100 per call, each within 1…
part_count.
Request
curl https://api.synexcloud.com/v1/uploads/file_5e2a9c7d1b4f0836/part_urls \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"part_numbers": [1, 2, 3]}'
Response
{
"object": "upload.part_urls",
"upload": "file_5e2a9c7d1b4f0836",
"parts": [
{
"part_number": 1,
"url": "https://s3.eu-west-1.amazonaws.com/…partNumber=1&X-Amz-Signature=…"
},
{
"part_number": 2,
"url": "https://s3.eu-west-1.amazonaws.com/…partNumber=2&X-Amz-Signature=…"
},
{
"part_number": 3,
"url": "https://s3.eu-west-1.amazonaws.com/…partNumber=3&X-Amz-Signature=…"
}
],
"expires_at": "2026-07-28T10:29:02Z"
}
Complete a multipart upload
Assembles the parts into the finished file. Requires files:write.
Send every part number with the ETag its PUT returned. Completion emits file.created on the change feed — the same event the single-shot upload emits, because a file is a file regardless of how its bytes arrived.
A second complete against the same id, or a DELETE after completing, is 409 upload_completed.
Required attributes
- Name
parts- Type
- array
- Description
Every part, as
{ part_number, etag }. A mismatched ETag fails the whole completion.
Request
curl https://api.synexcloud.com/v1/uploads/file_5e2a9c7d1b4f0836/complete \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"parts": [
{"part_number": 1, "etag": "\"9f8c1e0a…\""},
{"part_number": 2, "etag": "\"4b7d2f61…\""}
]
}'
Response
{
"object": "file",
"id": "file_5e2a9c7d1b4f0836",
"filename": "cells_q3.csv",
"extension": "csv",
"size": 734003200,
"metadata": {},
"created_at": "2026-07-28T10:31:44Z"
}
Abort a multipart upload
Drops the parts and the Drive row, and frees the filename. Requires files:write.
That last part is the point: a client that gives up on a failed export.csv and retries should get export.csv back, not export (2).csv shadowed by a dead entry it can no longer see.
The cost of that choice, stated plainly: nothing about the upload survives, so a later call against the id is 404 resource_missing rather than a distinct "aborted" state.
Abort what you abandon. There is no expires_at on an upload and no
lifecycle rule reaping abandoned ones, so an upload you stop working on
stays open and its parts stay billed until you delete it.
Request
curl -X DELETE https://api.synexcloud.com/v1/uploads/file_5e2a9c7d1b4f0836 \
-H "Authorization: Bearer $SYNEX_API_KEY"
Response
{
"object": "upload",
"id": "file_5e2a9c7d1b4f0836",
"deleted": true
}
Create a folder
Creates a directory. Requires files:write.
A folder is a stored row, not an object-storage prefix. The Drive keys items by directory and name, so a directory exists because something says it does — which is why uploading into a folder does not create it, and why this endpoint exists at all.
. and .. are rejected as names: they pass the character rule but could never be addressed again, because path parameters reject them as segments — so such a folder would be created unlistable. A name already taken in that directory is 409 folder_already_exists.
Folders emit no event.
Required attributes
- Name
name- Type
- string
- Description
The folder name.
.and..are rejected.
Optional attributes
- Name
parent_path- Type
- string
- Description
The directory to create it in. The Drive root when omitted.
Request
curl https://api.synexcloud.com/v1/folders \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "2026", "parent_path": "/exports/"}'
Response
{
"object": "folder",
"name": "2026",
"path": "/exports/2026/",
"created_at": "2026-07-28T10:40:11Z"
}