Documents
A document is a file that arrived for a client company, whatever route it came by: a forwarded email, an e-Factura message, a supplier portal, an upload link, or somebody dropping a scan in by hand. The reading taken from it is an invoice, which lives on Invoices.
Syllevo never loses a document. The original is written to immutable storage before any row exists, and it is kept byte for byte as it arrived. Nothing on this page rewrites a file.
Document routes are flat rather than nested under a profile, because a document is addressable on its own. The permission still lives on the client company: the API loads the document inside the caller's organization first, then asks whether the caller holds the right permission on the client it turns out to belong to.
Routes
| Method | Path | Needs |
|---|---|---|
| GET | /v1/documents |
View |
| GET | /v1/documents/facets |
View |
| GET | /v1/documents/{id} |
View |
| GET | /v1/documents/{id}/download |
View |
| POST | /v1/documents |
ManageDocuments |
| DELETE | /v1/documents/{id} |
ManageDocuments |
| POST | /v1/documents/{id}/restore |
ManageDocuments |
| GET | /v1/review |
View |
| GET | /v1/review/count |
View |
| GET | /v1/review/{documentId} |
View |
| POST | /v1/review/{documentId}/decision |
ManageDocuments |
The document shape
| Field | Type | Means |
|---|---|---|
id |
uuid | The document |
profileId |
uuid | The client company it belongs to |
status |
DocumentStatus |
See below |
originalFileName |
string or null | The name it arrived with |
contentType |
string | The verified type, not the declared one |
contentHash |
string | Hash of the bytes. Equal hashes mean the same file |
sizeBytes |
integer | Size of the stored original |
receivedAt |
date-time | The first time these bytes arrived |
lastArrivalAt |
date-time | The most recent time they arrived. Sorting default |
createdAt |
date-time | When the row was written |
lastUpdatedAt |
date-time, null | When it last changed |
sourceProviderType |
DataProviderType |
Which kind of source it came from |
direction |
InvoiceDirection |
0 a purchase the client received, 1 a sale it issued |
sourceAccountTag |
string or null | The label on that source, when it has one |
lastErrorCode |
string or null | Why processing last failed. See Error codes |
invoice |
object or null | A summary of the reading, null while there is none |
deletedAt |
date-time, null | Null unless the document is in the trash |
deletedByUserId |
uuid or null | Who trashed it |
deletedByName |
string or null | Their name |
purgeAt |
date-time, null | When a trashed document is deleted for good |
status:
| Value | Name | Means |
|---|---|---|
0 |
Received | Stored. Nothing has been read out of it yet |
1 |
Processing | Being read now |
2 |
NeedsReview | Read, but a person has to settle it. See the review queue below |
3 |
Processed | Read and filed. The invoice is on invoice |
4 |
Failed | Reading failed. lastErrorCode says why |
sourceProviderType:
| Value | Name | Means |
|---|---|---|
0 |
EmailInbox | A forwarded or watched mailbox |
1 |
EFacturaAnaf | ANAF e-Factura |
2 |
SupplierPortal | A supplier connector |
3 |
CloudStorage | A connected drive |
4 |
ManualUpload | Uploaded by hand or through a link |
5 |
Api | Sent through the public API |
invoice, when there is one:
| Field | Type | Means |
|---|---|---|
invoiceId |
uuid or null | The invoice. Fetch it from Invoices |
number |
string or null | The invoice number |
issueDate |
date or null | The date on the invoice |
dueDate |
date or null | When it is payable |
totalAmount |
decimal or null | What is owed |
currencyCode |
string or null | Three letters |
isCreditNote |
boolean | A credit note: type code 381, or a negative total |
direction |
InvoiceDirection |
0 a purchase, 1 a sale |
supplierId |
uuid or null | The supplier. Null on a sale |
supplierName |
string or null | Its name. Null on a sale |
supplierDirectorySlug |
string or null | Set when the supplier matched a directory entry |
counterpartyName |
string or null | On a sale, the customer's name. Null on a purchase |
counterpartyTaxId |
string or null | On a sale, the customer's tax id as printed |
counterpartyCountryCode |
string or null | On a sale, the customer's country, two letters |
confirmed |
boolean | Whether a person has settled the reading |
A sale's document used to answer invoice: null, because the summary looked for a supplier and a
sale has none. It now answers the sale, with the customer in the counterparty fields. A reading
that has not been settled yet is always a purchase: sales are filed on import and never wait for
review.
Use isCreditNote to mark a credit note rather than testing for a negative totalAmount. A 381
with a zero total is still a credit note. On a reading that has not been settled yet, it comes
from the type code and total the reading found.
GET /v1/documents
One page of documents, newest arrival first by default.
| Parameter | Type | Default | Means |
|---|---|---|---|
profileId |
uuid | none | Narrow to one client company |
status |
DocumentStatus |
none | Narrow to one status |
period |
string | none | YYYY-MM. The month the document arrived in |
supplierId |
uuid | none | Keep only documents evidencing that supplier |
source |
DocumentSourceKind |
none | 0 e-Factura, 1 everything else |
from |
date | none | Earliest arrival day, inclusive |
to |
date | none | Latest arrival day, inclusive |
sortBy |
DocumentSortField |
7 |
See below |
sortDirection |
SortDirection |
1 descending |
0 ascending, 1 descending |
trashed |
boolean | false | Show the trash instead of the working set |
direction |
InvoiceDirection |
0 purchases |
Which trash to show. Only with trashed=true |
page |
integer | 1 | |
pageSize |
integer | 25 | Clamped to 200 |
sortBy:
| Value | Sorts by |
|---|---|
0 |
Received at |
1 |
Issue date on the invoice |
2 |
File name |
3 |
Supplier |
4 |
Status |
5 |
Client company |
6 |
Source |
7 |
Last arrival. The default |
Two date rules are worth stating, because they are the opposite of each other and both are right:
- Documents filter on arrival.
period,from, andtohere are about when the file came in, because "what came in for October" is what month-end asks of a collected original. - Invoices filter on issue date. See Invoices.
Sending period and a from/to range together gives you the overlap of the two, not the union.
Filtering never widens what you can see. Without profileId you get every client this credential
can see. With one, that same set is narrowed to one client, so naming a client you cannot see gives
an empty page rather than a 403. Asking what is waiting is not a forbidden question just because
the answer is nothing.
Sorting orders the whole filtered set before paging, so changing the sort moves rows between pages rather than reordering the page you already have.
GET /v1/documents/facets
How many documents sit behind each choice on the filters. Takes exactly the same parameters as
GET /v1/documents.
| Field | Type | Means |
|---|---|---|
statuses |
array | { "status": DocumentStatus, "count": integer } |
periods |
array | { "period": "YYYY-MM", "count": integer } |
sources |
array | { "source": DocumentSourceKind, "count": integer } |
trashed |
integer | How many are in the trash. direction=1 counts sales |
earliestPeriod |
string | YYYY-MM of the oldest document, for a month picker |
GET /v1/documents/{id}
One document, plus provenance: every arrival it has had, newest first. View.
A document that arrives twice is one row with two provenance entries, not two rows. This is how you answer "where did this come from, and has it come before".
Each provenance entry:
| Field | Type | Means |
|---|---|---|
receivedAt |
date-time | When this arrival happened |
providerType |
DataProviderType |
How it arrived |
accountId |
uuid or null | The source it came through |
accountTag |
string or null | That source's label |
originalFileName |
string or null | The name on this arrival |
senderAddress |
string or null | Who sent it, for email arrivals |
sourceMessageId |
string or null | The message id, for email arrivals |
externalDocumentId |
string or null | The id at the source, for portals and ANAF |
externalTransactionId |
string or null | The transaction id at the source |
spfVerdict |
EmailAuthenticationResult |
SPF result on the message |
dkimVerdict |
EmailAuthenticationResult |
DKIM result |
dmarcVerdict |
EmailAuthenticationResult |
DMARC result |
The three verdicts use EmailAuthenticationResult: 0 unknown, 1 pass, 2 fail, 3 soft fail,
4 neutral, 5 none, 6 temporary error, 7 permanent error.
GET /v1/documents/{id}/download
A short-lived link to the stored original. View.
| Field | Type | Means |
|---|---|---|
documentId |
uuid | The document |
url |
uri | Fetch the bytes from here. No credential needed |
contentType |
string | The verified type |
fileName |
string or null | A sensible name to save it as |
expiresAt |
date-time | After this the link is dead |
Call this each time you need the file. Do not store the URL: it expires, and a stored one is a credential you did not mean to keep.
If the row exists but the stored file behind it does not, this answers 409 with
document_original_missing. That is not retryable. Re-signing points at the same absent key.
POST /v1/documents
Drops a document in by hand: the invoice a client handed over on paper, or one that arrived outside
every connected source. ManageDocuments.
multipart/form-data with two parts:
| Part | Means |
|---|---|
profileId |
The client company it is for |
file |
The file itself |
Limits:
| Limit | Value |
|---|---|
| File | 25 MB |
| Whole request | 26 MB |
| File name | 260 characters |
| Declared type | 128 characters |
The declared type is checked against the file's own leading bytes before anything is stored. A PDF
named .jpg is filed as a PDF. Something claiming to be a PDF and not being one is refused.
POST /v1/documents HTTP/1.1
Host: api.syllevo.com
X-Api-Key: syl_live_examplekeynotreal000000000000
Content-Type: multipart/form-data; boundary=----example
------example
Content-Disposition: form-data; name="profileId"
22222222-2222-4222-8222-222222222222
------example
Content-Disposition: form-data; name="file"; filename="invoice.pdf"
Content-Type: application/pdf
<bytes>
------example--
{
"duplicate": false,
"document": {
"id": "55555555-5555-4555-8555-555555555555",
"profileId": "22222222-2222-4222-8222-222222222222",
"status": 0,
"originalFileName": "invoice.pdf",
"contentType": "application/pdf",
"contentHash": "9f2c0000000000000000000000000000000000000000000000000000000000ab",
"sizeBytes": 84213,
"receivedAt": "2026-09-11T08:00:00Z",
"lastArrivalAt": "2026-09-11T08:00:00Z",
"createdAt": "2026-09-11T08:00:00Z",
"lastUpdatedAt": null,
"sourceProviderType": 4,
"sourceAccountTag": null,
"lastErrorCode": null,
"invoice": null,
"deletedAt": null,
"deletedByUserId": null,
"deletedByName": null,
"purgeAt": null,
"provenance": []
}
}
201 means the document is new. 200 means these exact bytes were already on file for this
client. In the second case the upload recorded another arrival on the existing document and
created nothing, and duplicate is true. That is a success, not an error. Uploading the same file
twice is safe, so a client that is not sure whether a send went through can just send it again.
DELETE and POST /v1/documents/{id}/restore
DELETE moves a document to the trash. ManageDocuments. Answers 204 with no body.
Nothing is destroyed at that moment. The document gets deletedAt, deletedByUserId,
deletedByName, and a purgeAt about 60 days out, and it disappears from the default list. Pass
trashed=true on GET /v1/documents to find it.
POST /v1/documents/{id}/restore brings it back out, clearing those fields. ManageDocuments.
Answers 200 with the document.
Sales in the trash
Purchases and sales have separate trash views. GET /v1/documents?trashed=true shows purchases,
the same as it always has. Add direction=1 to see trashed sales instead, and send the same
parameter to GET /v1/documents/facets to get the sales count in trashed. The other facet
counts stay about purchases.
direction=1 without trashed=true is a 400: the working list is the purchases inbox, and it
refuses a question about sales rather than answering it with purchases. Imported sales are listed
by GET /v1/invoices?direction=outgoing.
DELETE and restore work on a sale's document the same way. Trashing or restoring a sale never
changes a completeness count and never touches the usage meter: completeness asks what the client
received, and a sale was never counted in it.
The invoice record stays while its files sit in the trash. What happens when the same sale is imported again is in Invoices.
A sale that is on its way to SPV, or that ANAF accepted, stays out of the trash: DELETE on any of
its files answers 409 sales_invoice_in_spv. A sale ANAF rejected, or that couldn't be sent, can be
trashed as usual, and ANAF's answer archive goes with it.
After purgeAt the original is deleted for good, and there is no route that undoes that.
The review queue
Some documents cannot be filed without a person. The reading might be incomplete, the supplier
unidentifiable, or the invoice might look like one already on file. Those get status 2,
NeedsReview, and land in this queue.
GET /v1/review
One page of the queue, newest arrival first. View.
| Parameter | Type | Default | Means |
|---|---|---|---|
profileId |
uuid | none | Narrow to one client company |
page |
integer | 1 | |
pageSize |
integer | 25 | Clamped to 200 |
| Field | Type | Means |
|---|---|---|
documentId |
uuid | The document waiting |
profileId |
uuid | The client company |
profileName |
string | Its name, so the queue reads without a second call |
originalFileName |
string or null | The file name |
receivedAt |
date-time | First arrival |
lastArrivalAt |
date-time | Most recent arrival |
hasCandidate |
boolean | Whether the pipeline has a proposed reading |
preFillSuggested |
boolean | Whether that proposal is good enough to pre-fill a form |
GET /v1/review/count
The badge: how much is waiting, in total and per client, over the clients this caller can see.
| Field | Type | Means |
|---|---|---|
total |
integer | Documents waiting across every visible client |
profiles |
array | { "profileId": uuid, "pendingCount": integer } |
GET /v1/review/{documentId}
Everything one review screen needs: the document's facts, and the values the pipeline proposes.
View.
| Field | Type | Means |
|---|---|---|
documentId, profileId, profileName |
Which document and whose | |
status |
DocumentStatus |
Should be 2 while it is waiting |
originalFileName, contentType, contentHash, sizeBytes |
The file | |
receivedAt, lastArrivalAt |
date-time | Arrivals |
hasCandidate |
boolean | Whether there is a proposed reading |
preFillSuggested |
boolean | Whether to pre-fill the form with it |
values |
object or null | The proposed reading, as a JSON object |
fieldScores |
object or null | Per-field confidence, as a JSON object |
values and fieldScores are open JSON objects rather than a fixed schema, because the fields a
reading proposes depend on what kind of document arrived. Read them defensively.
Use GET /v1/documents/{id}/download to put the original in front of the person deciding.
POST /v1/review/{documentId}/decision
Records a person's answer about one document and applies it. ManageDocuments.
| Field | Type | Required | Means |
|---|---|---|---|
outcome |
ReviewOutcome |
yes | 0 confirmed, 1 corrected, 2 rejected |
correctedFields |
object | only for 1 |
The corrected values, as a JSON object |
notes |
string | no | Free text, kept on the record |
correctedFields is a JSON object, not a string, and it is a full snapshot of the corrected
values rather than a diff. Send every field, including the ones you did not change.
POST /v1/review/55555555-5555-4555-8555-555555555555/decision HTTP/1.1
Host: api.syllevo.com
X-Api-Key: syl_live_examplekeynotreal000000000000
Content-Type: application/json
{
"outcome": 1,
"correctedFields": {
"number": "EU-2026-000120",
"issueDate": "2026-09-04",
"totalAmount": 1190.0,
"currencyCode": "RON",
"supplierName": "Example Utilities SA",
"supplierTaxIdentifier": "RO00000000"
},
"notes": "Number was misread as EU-2026-00012O."
}
{
"decisionId": "99999999-9999-4999-8999-999999999999",
"documentId": "55555555-5555-4555-8555-555555555555",
"profileId": "22222222-2222-4222-8222-222222222222",
"outcome": 1,
"status": 3,
"decidedAt": "2026-09-11T08:05:00Z"
}
Statuses to expect:
| Status | Code | Means |
|---|---|---|
400 |
validation_failed |
correctedFields missing on a correction, or sent on the other two |
404 |
not_found |
No such document for this caller |
409 |
conflict |
The document is no longer waiting. A colleague settled it |
409 |
review_possible_duplicate |
Confirming would file a second copy of an invoice on file |
409 |
review_incomplete_reading |
The reading is missing fields a confirmation needs |
409 |
review_unidentifiable_supplier |
The supplier could not be identified. Correct it and resend |
The plain 409 is the ordinary two-people-one-queue case rather than an error. Refresh the queue,
do not retry.
Decisions are append-only. A reviewer who changes their mind decides again, and both answers stay in the record. That is the point of the record.