Upload links
The link at the bottom of every reminder. A client company opens it on a phone, drops in the invoices their accountant is waiting for, and that is the whole interaction: no account, no password, nothing to install.
Two of these routes are for the firm managing its links. Two are the public door the client uses, and they are the only routes in this API that take no credential.
Routes
| Method | Path | Needs |
|---|---|---|
| GET | /v1/profiles/{profileId}/upload-links |
SendReminders |
| POST | /v1/profiles/{profileId}/upload-links/{linkId}/revoke |
SendReminders |
| GET | /v1/uploads/{token} |
nothing |
| POST | /v1/uploads/{token} |
nothing |
There is no route that creates a link
Links are issued by the reminder engine when it sends a touch. There is no POST /v1/profiles/{id}/upload-links and no way to mint one on demand. If you want a client to have a link,
send them a reminder. See Reminders.
A link's expiry is computed from the client's own reminder settings: enough business days to cover the touches still to come, plus the out-of-office shift, plus 15 business days of grace, with a floor of 7 days. A link handed out on the last touch of a sequence still works well after the sequence finishes.
GET /v1/profiles/{profileId}/upload-links
Every link issued for one client, newest first, dead ones included. SendReminders on the client. A
plain array.
| Field | Type | Means |
|---|---|---|
id |
uuid | The link. This is the linkId for revoke |
profileId |
uuid | The client |
issuedAt |
date-time | When it was created |
expiresAt |
date-time | When it stops working. Never null |
revokedAt |
date-time, null | When somebody killed it |
isRevoked |
boolean | Whether it was killed |
isExpired |
boolean | Whether the expiry has passed |
isUsable |
boolean | Neither revoked nor expired |
useCount |
integer | How many uploads came through it |
lastUsedAt |
date-time, null | The last one. Null means it was never used |
The token is not here. The link list shows metadata and only metadata. The token exists in the email that was sent and nowhere else readable; Syllevo stores a hash. There is no way to re-send an existing link, and no way to recover one a client deleted. Send another reminder instead.
Read isUsable rather than comparing expiresAt to your own clock. It is the same rule the public
door applies.
useCount and lastUsedAt count uploads, not page views. Opening the link and not sending anything
leaves both alone, on purpose: an accountant reads these numbers to decide whether a client is
engaging at all, and counting a page load as an answer would corrupt that.
POST /v1/profiles/{profileId}/upload-links/{linkId}/revoke
Kills one link. SendReminders on the client.
{
"linkId": "77777777-7777-4777-8777-777777777777",
"revokedAt": "2026-09-11T10:00:00Z"
}
Immediate. The next request on that token gets 404. Use it when a client says the link reached the
wrong person.
Revoking one link does not touch the others. A client who has been reminded four times holds four live links, and each one has to be revoked on its own.
The public door
GET and POST /v1/uploads/{token} take no credential. No bearer token, no API key, no
X-Organization-Id. The token in the URL is the credential, and it names exactly one client company
and exactly one action.
Every dead token gets the same answer. Malformed, unknown, revoked, and expired all produce the
same 404 with the same body:
{
"status": 404,
"code": "not_found",
"messageKey": "web.errors.not_found",
"detail": null
}
There is no code that says "expired" and none that says "revoked". A caller who could tell them apart could confirm which links ever existed.
Rate limited to 30 requests a minute per client address, under the upload-link policy, with the
global unauthenticated ceiling of 200 a minute on top of it. See
Conventions.
GET /v1/uploads/{token}
What the upload page shows before anybody uploads.
| Field | Type | Means |
|---|---|---|
profileName |
string | The client company the link belongs to |
locale |
string | The language to render the page in |
expiresAt |
date-time | When the link stops working |
That is the whole response, and the omissions are deliberate. There is no organization, no ids, no document counts, and no list of what is missing. Which invoices an accountant is waiting for is that accountant's working knowledge of their client's affairs, and it never appears on an unauthenticated surface.
The three fields that are here are already in the hands of whoever holds the token: the reminder that carried the link was addressed to that company, named it, and was written in that language.
This call records no use.
POST /v1/uploads/{token}
Takes the files. multipart/form-data, up to 10 files per request.
Field names do not matter. Every file part in the form is read, whatever it is called.
That is also why the OpenAPI document describes no body for this route, where it spells out the
parts for POST /v1/documents and the avatar uploads. There is no part name to publish. A client
generated from the contract will need this one call written by hand: post a multipart body, put
the files in it, call them anything.
POST /v1/uploads/exampletokennotreal00000000000000 HTTP/1.1
Host: api.syllevo.com
Content-Type: multipart/form-data; boundary=----example
Answers 200:
{
"accepted": 2,
"rejected": 1,
"files": [
{
"fileName": "orange-08.pdf",
"accepted": true,
"duplicate": false,
"errorCode": null,
"messageKey": null
},
{
"fileName": "omv-08.pdf",
"accepted": true,
"duplicate": true,
"errorCode": null,
"messageKey": null
},
{
"fileName": "photo.heic",
"accepted": false,
"duplicate": false,
"errorCode": "file_type_not_accepted",
"messageKey": "validation.invalidValue"
}
]
}
200 with per-file rows, not 201 and not a partial-content status. One status code cannot say
which of six files went through, so the body does. A single bad file never fails the batch.
| Field | Type | Means |
|---|---|---|
accepted |
integer | How many went through |
rejected |
integer | How many did not |
files |
array | One row per file, in the order they were sent |
Each row:
| Field | Type | Means |
|---|---|---|
fileName |
string | The name as it was sent |
accepted |
boolean | Whether Syllevo kept it |
duplicate |
boolean | True when Syllevo already had these exact bytes |
errorCode |
string or null | Why it was refused. Null when it was not |
messageKey |
string or null | The translation key for the sentence to show the client |
duplicate: true comes with accepted: true. The client sent something Syllevo already had, and
that is a success, not an error. A business owner who forwards the same invoice twice should see it
land twice, not be told off. Show it as received.
Per-file refusal codes:
| Code | Means |
|---|---|
file_empty |
Zero bytes |
file_too_large |
Over 25 MB |
file_type_not_accepted |
Not a type Syllevo collects |
file_type_mismatch |
The declared type and the actual bytes disagree |
file_name_invalid |
The name is unusable |
too_many_files |
Past the ten-file ceiling |
not_stored |
Storage refused it. Nothing was silently dropped |
Files past the ten-file ceiling are reported, not dropped. They come back with too_many_files
so the page can tell the client exactly which ones still need sending.
Whole-request failures, which are HTTP statuses rather than rows:
| Status | Means |
|---|---|
400 |
The form carried no files at all |
404 |
The token did not resolve |
413 |
The request body is over 26 MB |
Size and type limits are exactly the authenticated upload's. A public door held to different limits than the private one is a door where a file is accepted here and refused later. See Documents.
Several small invoices fit comfortably inside one request. A client with more than that sends a second batch, because the link is single-purpose, not single-use: it keeps working for its whole window, however many times it is used.