Skip to content
API pagesUpload links

API documentation

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

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.

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.