Invoices
An invoice is what Syllevo read out of a document: who billed the client, for what, and for how much. A document is the file that arrived. An invoice is the meaning taken from it. The two are separate rows and this page is about the second. For the files, see Documents.
Syllevo does not issue invoices. There is no route that creates, numbers, or changes one, and there will not be one. There is one route that takes in a sales invoice the business already issued in its own software, so the accountant has sales next to purchases. See Import a sales invoice. When a business opts in, Syllevo can also upload that same XML file to SPV, exactly as the invoicing software produced it. See Send a sales invoice to SPV.
Every invoice has a direction. 0 Incoming is a purchase: a supplier billed the client, and this
is what Syllevo collects and chases. 1 Outgoing is a sale the client issued and imported. Sales
never count towards completeness, reminders, missing-invoice predictions, or suppliers, and the two
anomaly detectors skip them. The one flag a sale can carry is ConflictingCopy (see
Anomalies), raised when e-Factura's copy of it doesn't match the one imported. When
they don't match, e-Factura's copy is the one the sale is read from. A sale imported as a PDF alone
isn't flagged when e-Factura's XML of it agrees on currency and amounts: the XML just completes it.
Invoice routes are flat rather than nested under a profile, because an invoice is addressable on
its own. A completeness row, a search hit, and a link in a reminder all carry an invoice id and no
profile id. The permission still lives on the profile: the API loads the invoice inside the
caller's organization first, then asks whether the caller holds View on the client it belongs to.
Routes
| Method | Path | Needs |
|---|---|---|
| GET | /v1/invoices |
View |
| GET | /v1/invoices/facets |
View |
| GET | /v1/invoices/number-gaps |
View |
| GET | /v1/invoices/{id} |
View |
| POST | /v1/profiles/{profileId}/sales-invoices |
ManageDocuments |
| POST | /v1/sales-invoices/{id}/deliveries |
TransmitInvoices |
| GET | /v1/sales-invoices/{id}/deliveries |
View |
| POST | /v1/sales-invoices/{id}/submission |
TransmitInvoices |
| GET | /v1/sales-invoices/{id}/submission |
View |
| GET | /v1/anomalies |
View |
| GET | /v1/anomalies/count |
View |
| POST | /v1/anomalies/{id}/dismiss |
View |
The invoice shape
| Field | Type | Means |
|---|---|---|
id |
uuid | The invoice |
profileId |
uuid | The client company it belongs to |
direction |
InvoiceDirection |
0 Incoming (a purchase), 1 Outgoing (a sale) |
supplierId |
uuid or null | The supplier. Always set on a purchase, always null on a sale. See Suppliers |
supplierName |
string or null | The supplier's name at read time, null if it was not known |
number |
string | The invoice number as printed |
numberSeries |
string or null | The series part of the number, uppercased: FCT for FCT 0042. See number series |
numberSequence |
decimal or null | The running number inside the series: 42 for FCT 0042. Null when the number ends in no digits |
issueDate |
date | The date on the invoice. This decides which month it is in |
dueDate |
date or null | Null when the invoice does not state one |
currencyCode |
string | Three letters, RON, EUR, and so on |
netAmount |
decimal | Before VAT |
vatAmount |
decimal | The VAT |
totalAmount |
decimal | What is owed. Negative on a credit note, and so are the net and the VAT |
documentTypeCode |
string or null | 380 invoice, 381 credit note, from the XML. Null when there is no XML to read |
isCreditNote |
boolean | True for a 381, or when totalAmount is negative. See credit notes |
correctedInvoiceNumber |
string or null | On a credit note, the number of the invoice it corrects, as printed |
correctedInvoiceIssueDate |
date or null | The issue date of that invoice, when the credit note states one |
correctedInvoiceId |
uuid or null | That invoice, when exactly one on file matches. Only on GET /v1/invoices/{id} |
documentLinks |
array | The stored files that evidence it, most trusted first |
source |
DataProviderType or null |
How the most trusted file behind it arrived. Null when every file is in the trash |
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 |
submission |
object or null | On a sale sent to SPV through Syllevo, its latest submission in short. Null otherwise. See below |
deliveries |
array | Reserved. Always empty for now. Emailed copies are listed by GET /v1/sales-invoices/{id}/deliveries |
submission is the latest SPV submission, in short:
{
"channel": "efactura",
"id": "0199a1b2-0000-7000-8000-000000000001",
"state": 3,
"uploadIndex": "5001234567",
"lastErrorCode": null,
"updatedAt": "2026-09-23T08:15:00Z"
}
state is the submission state and lastErrorCode is one of the
submission reasons. deliveries is reserved and always empty: emailed
copies are listed by GET /v1/sales-invoices/{id}/deliveries.
Each entry in documentLinks:
| Field | Type | Means |
|---|---|---|
documentId |
uuid | Download it from Documents |
reason |
InvoiceDocumentLinkReason |
Why this file is attached to this invoice |
trustRank |
InvoiceSourceTrust |
How much the reading from it can be trusted |
reason:
| Value | Name | Means |
|---|---|---|
0 |
OriginalSource | This is the file the invoice was read out of |
1 |
ContentHashMatch | Byte for byte the same file, arriving a second time |
2 |
SemanticMatch | A different file carrying the same invoice, a PDF beside an XML |
3 |
ManualLink | Somebody attached it by hand |
4 |
SupportingDocument | Attached as evidence, a receipt or a delivery note |
5 |
SubmissionAnswer | ANAF's answer to a sale Syllevo sent to SPV |
6 |
TaxAuthorityVersion | e-Factura's copy of a sale, used over a different one on file |
A SubmissionAnswer file is proof of what ANAF did with the sale, not a copy of the invoice. It
never sets source, so "Came in through" stays how the sale arrived. It is never the file a sale
is emailed, matched, or re-imported by, and it is not in the sale's file list in an export, where
it travels as a document of its own.
trustRank:
| Value | Name | Means |
|---|---|---|
0 |
Extracted | Read out of a PDF or an image |
1 |
StructuredXml | Read out of structured XML |
2 |
EFactura | Came from ANAF e-Factura. The highest trust there is |
An invoice with several links is normal, and it is not a duplicate. One invoice often arrives twice,
once as an e-Factura XML and once as the supplier's own PDF. Syllevo keeps both files and reads the
one it trusts most. Take the amounts from the invoice, and pick the file at the top of
documentLinks when you need something to show a human.
Credit notes
A credit note (storno) reverses all or part of an earlier invoice. Its amounts are negative here:
netAmount, vatAmount, and totalAmount. Add up a client's invoices and the credit notes
subtract on their own.
An e-Factura credit note prints positive amounts and says what it is with its type code, 381.
Syllevo reads the code and stores the amounts with the minus sign. A credit note that already
printed negative amounts is stored as printed, never flipped back to positive.
Use isCreditNote to tell them apart rather than testing documentTypeCode. It is also true for a
credit note that only came as a PDF with a printed minus, where there is no code to read.
When the credit note names the invoice it corrects, correctedInvoiceNumber and
correctedInvoiceIssueDate say which. GET /v1/invoices/{id} also looks for that invoice in the
same client company, the same direction, and for a purchase the same supplier. When exactly one
matches, its id is in correctedInvoiceId. When none or several match, it is null.
Invoices filed before 2026-10-12 get their type code from their XML over the following days. Until
then documentTypeCode is null on them.
GET /v1/invoices
One page of invoices, newest issue date first unless you ask for another order.
| Parameter | Type | Default | Means |
|---|---|---|---|
profileId |
uuid | none | Narrow to one client company |
direction |
incoming or outgoing |
incoming |
Purchases or sales. Never both in one page |
from |
date | none | Earliest issue date, inclusive |
to |
date | none | Latest issue date, inclusive |
series |
string | none | Only numbers in this series, case ignored. Up to 64 characters |
withoutSeries |
boolean | false | true keeps only numbers with no series. Can't be sent with series |
sortBy |
InvoiceSortField |
0 |
0 issue date, 1 number, 2 buyer, 3 total |
sortDirection |
SortDirection |
1 |
0 ascending, 1 descending, for whichever field sortBy names |
page |
integer | 1 | |
pageSize |
integer | 25 | Clamped to 200 |
from and to bound the issue date, not the arrival date, and both ends are inclusive. Asking for
from=2026-09-01&to=2026-09-30 gives you September, including the 30th. The issue date is what
decides which month a document belongs to, which is the same rule an accountant uses.
An invoice can arrive in October and still be a September invoice. If you are reconciling a month that is already closed, poll this route again rather than assuming what you pulled on the 1st is final.
Number series
Most invoicing software numbers sales as a series plus a running number, like FCT 0042 or
SYL-2026-118. Syllevo reads both parts off number when the invoice is stored, the same way for
every invoice, and returns them as numberSeries and numberSequence:
- Spaces at either end go, and letters are uppercased.
- A year at the very end goes, when a separator (
/,-, or., spaces allowed around it) stands between it and a digit before it.FCT 12/2026reads asFCT 12. A year stuck to the series, likeSYL2026, stays, because nothing says it's a year. - The last run of digits is
numberSequence, read as a number, so leading zeros don't count. - What stands before it, with trailing spaces and
-,/,.,_, and#removed, isnumberSeries.
number |
numberSeries |
numberSequence |
|---|---|---|
FCT 0042 |
FCT |
42 |
syl-2026-118 |
SYL-2026 |
118 |
FCT 12/2026 |
FCT |
12 |
000981 |
null | 981 |
PROFORMA |
null | null |
A number with no digits at the end has no series and no sequence. A run of more than 28 digits,
leading zeros aside, has no numberSequence, and its series still reads as usual.
series matches numberSeries exactly after spaces at either end are removed and case is ignored,
so series=fct finds FCT 0042. Send withoutSeries=true for the numbers that have no series at
all. Sending both answers 400.
Order
Every order ends on the invoice id, so a page boundary never moves between two calls.
sortBy |
Order |
|---|---|
0 issue date |
Issue date |
1 number |
Series, numbers with no series last, then the client, then the year of issue, then numberSequence as a number, so FCT 9 comes before FCT 10. Ties fall back to the number |
2 buyer |
The buyer's name, case ignored, sales with no buyer name last. Then newest issue date first |
3 total |
totalAmount. Then newest issue date first |
sortDirection turns the named field around. In the number order it turns the series, the year,
the sequence, and the final fallback on the number. The client, the tie breakers on the other orders,
and the place of empty values (always last) stay where they are. The
year of issue is in the number order because many businesses start their sequence again at 1 every
January: without it, FCT 3 from this year would sit between FCT 2 and FCT 4 from last year.
GET /v1/invoices?profileId=22222222-2222-4222-8222-222222222222&from=2026-09-01&to=2026-09-30 HTTP/1.1
Host: api.syllevo.com
X-Api-Key: syl_live_examplekeynotreal000000000000
{
"items": [
{
"id": "33333333-3333-4333-8333-333333333333",
"profileId": "22222222-2222-4222-8222-222222222222",
"direction": 0,
"supplierId": "44444444-4444-4444-8444-444444444444",
"supplierName": "Example Utilities SA",
"number": "EU-2026-000120",
"numberSeries": "EU-2026",
"numberSequence": 120,
"issueDate": "2026-09-04",
"dueDate": "2026-09-24",
"currencyCode": "RON",
"netAmount": 1000.0,
"vatAmount": 190.0,
"totalAmount": 1190.0,
"documentLinks": [
{ "documentId": "55555555-5555-4555-8555-555555555555", "reason": 0, "trustRank": 2 }
],
"source": 1,
"counterpartyName": null,
"counterpartyTaxId": null,
"counterpartyCountryCode": null,
"submission": null,
"deliveries": []
}
],
"page": 1,
"pageSize": 25,
"totalCount": 1,
"totalPages": 1
}
GET /v1/invoices/facets
The counts beside the list: how many invoices fall in each issue month, and how many carry each
number series. Takes the list's filters, direction, profileId, from, to, series, and
withoutSeries, with the same rules and the same 400 for series with withoutSeries. No
paging, no order.
Each count leaves out its own filter, so a picker built on it still offers the other choices once one is picked:
periodsignoresfromandto. One entry per month that has invoices, oldest first.periodisYYYY-MM.seriesignoresseriesandwithoutSeries. One entry per client and series, because two clients can both number sales asFCT. Ordered by series, withnull(numbers that have no series) last, then by client.
Months and series with no invoices aren't listed.
GET /v1/invoices/facets?direction=outgoing&profileId=22222222-2222-4222-8222-222222222222&from=2026-09-01&to=2026-09-30 HTTP/1.1
Host: api.syllevo.com
X-Api-Key: syl_live_examplekeynotreal000000000000
{
"periods": [
{ "period": "2026-08", "count": 31 },
{ "period": "2026-09", "count": 27 }
],
"series": [
{ "profileId": "22222222-2222-4222-8222-222222222222", "series": "FCT", "count": 25 },
{ "profileId": "22222222-2222-4222-8222-222222222222", "series": null, "count": 2 }
]
}
GET /v1/invoices/number-gaps
Where a client's sales numbering skips. If Syllevo holds FCT 0041 and FCT 0044, the client
issued FCT 0042 and FCT 0043 and they aren't in Syllevo yet. Sales only, so there's no
direction: a supplier's numbering skips for reasons that have nothing to do with your client.
Takes profileId, from, to, series, and withoutSeries, with the list's rules and the same
400 for series with withoutSeries. A client the caller can't see has no gaps, never a 403.
How a gap is found:
- Numbers are compared per client, series, and year of issue, because most series restart in
January.
FCT 0041in December andFCT 0001in January is not a gap. - The sequence is the number series split: the last run of digits, after a trailing year is dropped.
- A number on several invoices counts once, so a duplicate is never a gap. The invoice issued first stands for it.
- Nothing is reported below the first number on file or above the last. Where a series starts and how far it has got aren't things the numbers on file can tell.
from and to pick which gaps are listed, not which sales are read. A gap is listed when the sale
on either side of it was issued inside the range, and those sales are found over the whole year. So
FCT 0010 on 30 September and FCT 0013 on 2 October shows under September and under October.
series and withoutSeries narrow which series are looked at.
At most 200 gaps come back, ordered by series with null (numbers that have no series) last, then
by client, year, and the number before the gap. totalGaps and totalMissing count every gap,
including the ones past the 200.
| Field | Type | Means |
|---|---|---|
totalGaps |
integer | Every gap the filters reach |
totalMissing |
number | Every missing number across those gaps |
gaps[].profileId |
uuid | The client |
gaps[].series |
string, null | Upper-cased, like numberSeries. null for numbers that have no series |
gaps[].year |
integer | The year of issue both sales belong to |
gaps[].missingCount |
number | How many numbers are missing between the two sales. Always 1 or more |
gaps[].missingNumbers |
string[] | The first missing numbers, at most 5, written like the sale before the gap |
gaps[].previous |
object | The sale just below the gap: invoiceId, number, and issueDate |
gaps[].next |
object | The sale just above it, same fields |
missingNumbers keeps the client's own spelling, case, and zero padding, so you can search the
invoicing software for it as printed. A number that can't be written that way without reading as
another series is left out, so the list can be shorter than missingCount even for a short gap.
GET /v1/invoices/number-gaps?profileId=22222222-2222-4222-8222-222222222222&from=2026-09-01&to=2026-09-30 HTTP/1.1
Host: api.syllevo.com
X-Api-Key: syl_live_examplekeynotreal000000000000
{
"totalGaps": 1,
"totalMissing": 2,
"gaps": [
{
"profileId": "22222222-2222-4222-8222-222222222222",
"series": "FCT",
"year": 2026,
"missingCount": 2,
"missingNumbers": ["FCT 0042", "FCT 0043"],
"previous": {
"invoiceId": "55555555-5555-4555-8555-555555555555",
"number": "FCT 0041",
"issueDate": "2026-09-12"
},
"next": {
"invoiceId": "66666666-6666-4666-8666-666666666666",
"number": "FCT 0044",
"issueDate": "2026-09-19"
}
}
]
}
GET /v1/invoices/{id}
One invoice, with documentLinks ordered most trusted first. View on the client it belongs to.
This is the only read that fills in correctedInvoiceId. Lists always answer it null.
An invoice the caller cannot see answers 404.
POST /v1/profiles/{profileId}/sales-invoices
Imports one sales invoice the business already issued, for example from its invoicing software.
Syllevo does not issue it, number it, or change it. It files it, and sends it to SPV only when you
ask with submit=true.
Needs ManageDocuments on the profile and an Idempotency-Key header (see
Conventions). Send multipart/form-data:
| Part | Type | Means |
|---|---|---|
xml |
file | The invoice as UBL 2.1 or CIUS-RO XML |
pdf |
file | The printable copy |
number |
string | The invoice number, up to 64 characters |
issueDate |
date | YYYY-MM-DD |
dueDate |
date | YYYY-MM-DD, not before the issue date |
currency |
string | Three letters |
netAmount |
decimal | Before VAT, written 1234.50 |
vatAmount |
decimal | The VAT |
totalAmount |
decimal | What the customer owes |
customerName |
string | Who was billed |
customerTaxId |
string | Their tax id |
customerCountry |
string | Two letters |
emailTo |
string | Optional. Email the PDF to these buyers |
submit |
boolean | Optional. true sends the XML to SPV |
Send xml, pdf, or both. Each file is capped at 25 MB, and its first bytes must match the part
it was sent as.
- With XML, every fact is read from the XML. The other fields are optional. Anything you send
anyway must agree with the XML, or the answer is
400 sales_invoice_metadata_mismatchwith each field that disagrees indetails, next to what the XML says. The XML wins where you send nothing. For a credit note (381), send the amounts as the XML prints them or with the minus sign. Both agree, and Syllevo stores them negative either way. Pick one for the whole request: a printed net beside a negative total is a mismatch. - With only a PDF, Syllevo reads nothing out of it, so
number,issueDate,currency,netAmount,vatAmount, andtotalAmountare required.
The number and issue date identify the sale inside one profile. That decides the answer:
| Status | When |
|---|---|
201 |
A new sales invoice was filed. Location points at GET /v1/invoices/{id} |
200 |
The same files were already imported under this number and date. duplicate: true |
409 |
invoice_number_exists: a different invoice already has this number and date |
409 |
sales_invoice_file_in_use: one of the files is already a purchase or another sale |
400 |
Validation, sales_invoice_xml_unreadable, or sales_invoice_metadata_mismatch |
Numbers match ignoring case and spaces at either end: F-001, f-001, and F-001 are one
number. That holds even when two imports arrive at the same moment.
A replay under the same Idempotency-Key answers with the first response, Location header
included.
400 sales_invoice_xml_unreadable means one of two things, and nothing is stored in either case:
- The XML isn't an invoice we can read, or it has no number, issue date, currency, or total.
- The XML reads fine, but states a value no invoice can hold. For example, a buyer country of
ROUinstead ofRO, or a buyer name longer than 256 characters.errorsnames each such field, keyed like the form parts (customerCountry,customerName,customerTaxId,number,currency,netAmount,vatAmount,totalAmount), anddetails.fieldnames the first. Fix the XML in your invoicing software and send it again.
An issued invoice is never overwritten. If 409 invoice_number_exists comes back, details
carries existingInvoiceId. Look at that invoice before doing anything else: either you are
sending the same sale twice with different files, or two sales carry the same number.
That includes a sale Syllevo already collected from the business's SPV sent list. That copy is the
one ANAF holds, so a different file under its number and issue date gets the same 409, and
nothing is stored. Your invoicing software and ANAF disagree about that invoice, and the fix
belongs in your software. The other order is covered under
Anomalies: import first, and a different SPV copy that arrives later becomes the
sale's main copy.
One case is finished instead of refused. An import that failed halfway before September 2026 could
leave a sale with no files at all, and every retry then got 409. If you send the same files again
and the sale has the same currency and total, Syllevo attaches them to that sale and answers 201.
The audit trail records it as invoice.import_repaired.
With XML, the customer is read from the XML, from UBL and CII alike. The name, tax id, and country are whatever the buyer block prints.
A customerTaxId you send agrees with the XML's when the digits match, ignoring spaces,
punctuation, and case. A two-letter prefix counts only when it can disagree: RO12345678 and
DE12345678 differ, and 12345678 agrees with RO12345678 unless the buyer's country is known and
isn't RO.
A sale that is in the trash
Trashing a sale moves its files to the trash. The invoice record stays, so its number and date are still taken for the 60 days before the files are deleted for good.
| You import again | Answer |
|---|---|
| The same files | 200, duplicate: true, restored: true, the existing invoice. The files come back out of the trash, as if restored |
| Different files, same number | 409 invoice_number_exists, as for any other sale with that number |
| Anything, after the files purge | 201. The purge removes a sale that has no files left, which frees its number |
Sending the same files again counts as asking for them back. Each file that comes out of the trash
this way is recorded in the audit trail as document.restored with reason: "rearrival". To bring
a trashed sale back without sending it again, restore its document with
POST /v1/documents/{id}/restore. Trashed sales are listed by
GET /v1/documents?trashed=true&direction=1.
A sale that is on its way to SPV, or that ANAF accepted, can't go to the trash. DELETE on any of
its files, ANAF's answer archive included, answers 409 sales_invoice_in_spv. Once a submission
ends Rejected, Unreadable, or Failed, the sale can be trashed, and the answer archive goes
with it. The 60-day purge never deletes the XML or the answer archive of a submission that is
still in flight or was accepted.
POST /v1/profiles/22222222-2222-4222-8222-222222222222/sales-invoices HTTP/1.1
Host: api.syllevo.com
X-Api-Key: syl_live_examplekeynotreal000000000000
Idempotency-Key: postify-sale-2026-000481
Content-Type: multipart/form-data; boundary=----b
------b
Content-Disposition: form-data; name="xml"; filename="F-2026-000481.xml"
Content-Type: application/xml
<?xml version="1.0" encoding="UTF-8"?><Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">...</Invoice>
------b
Content-Disposition: form-data; name="pdf"; filename="F-2026-000481.pdf"
Content-Type: application/pdf
%PDF-1.7 ...
------b--
{
"invoice": {
"id": "66666666-6666-4666-8666-666666666666",
"profileId": "22222222-2222-4222-8222-222222222222",
"direction": 1,
"supplierId": null,
"supplierName": null,
"number": "F-2026-000481",
"issueDate": "2026-09-18",
"dueDate": "2026-10-18",
"currencyCode": "RON",
"netAmount": 500.0,
"vatAmount": 95.0,
"totalAmount": 595.0,
"documentLinks": [
{ "documentId": "77777777-7777-4777-8777-777777777777", "reason": 0, "trustRank": 1 },
{ "documentId": "99999999-9999-4999-8999-999999999999", "reason": 4, "trustRank": 0 }
],
"counterpartyName": "Example Customer SRL",
"counterpartyTaxId": "RO12345678",
"counterpartyCountryCode": "RO",
"submission": null,
"deliveries": []
},
"duplicate": false,
"restored": false,
"deliveries": []
}
| Field | Type | Means |
|---|---|---|
invoice |
object | The sales invoice, in the invoice shape |
duplicate |
boolean | true when the same files were already imported under this number and date |
restored |
boolean | true when this repeat brought at least one of the invoice's files out of the trash |
deliveries |
array | The emails asked for with emailTo. Empty when you didn't send it |
submission |
object or null | The SPV submission asked for with submit=true, new or existing. Null otherwise |
submissionError |
object or null | Why nothing was queued for SPV, in the error shape. Null otherwise |
restored is true only on a repeat (duplicate: true) that found one or more of the invoice's
files in the trash and brought them back. A plain repeat with nothing in the trash, and a new
import, answer false. Retrying with the same Idempotency-Key returns the first answer unchanged.
Emailing it on import
Leave emailTo out and nothing is emailed. Send it, repeated or as one comma-separated value,
with 1 to 10 addresses, and Syllevo emails the PDF to each of them once the invoice is filed.
This works exactly like emailing a sales invoice with
resend false, and needs TransmitInvoices on the profile as well as ManageDocuments.
- Both permissions are checked before anything is filed, so a caller who may import but not
email gets
403and nothing is imported. emailTowithout apdfpart answers422 sales_invoice_pdf_missing, also before filing.- The answer carries a top-level
deliveriesarray next toinvoiceandduplicate, one entry per address, in the delivery shape. Repeating an import that was already filed returns the existing deliveries and emails nobody a second time.
Sending it to SPV on import
Leave submit out and nothing is sent. Send submit=true and, once the invoice is filed, Syllevo
queues its XML for SPV exactly like POST /v1/sales-invoices/{id}/submission.
submit=truewithout anxmlpart answers422 spv_xml_missingbefore anything is filed.- Every other refusal leaves the import standing. The answer is still
201or200, withsubmission: nulland the reason insubmissionError, for example{ "status": 403, "code": "spv_consent_missing", "messageKey": "web.errors.spv_consent_missing" }. Fix the cause and callPOST /v1/sales-invoices/{id}/submission. - A repeat of an import that was already sent returns the existing submission in
submissionand sends nothing twice.
The files are stored as documents like any other and can be downloaded from Documents by id. They do not show up in the documents list, the review queue, or the trash, which are about purchases.
Email a sales invoice to its buyer
Syllevo emails a sales invoice only when you ask, on the call in front of it. Your invoicing software probably emails your buyers already, so Syllevo never does it on its own: no default, no setting, and no automatic send after an import. Each address named on a call gets one email with the invoice's PDF attached.
POST /v1/sales-invoices/{id}/deliveries
Needs TransmitInvoices on the profile the sale belongs to and an Idempotency-Key header (see
Conventions). TransmitInvoices is off for members by default,
because sending an invoice to a buyer speaks for the business. SendReminders does not cover it:
that permission is about mail to the firm's own client.
{ "to": ["billing@example-customer.ro"], "resend": false }
| Field | Type | Means |
|---|---|---|
to |
string[] | 1 to 10 addresses. Case and spaces are ignored, and repeats count once |
resend |
boolean | Optional, default false. true emails addresses that already got one |
What each address gets:
| The address already has | resend: false |
resend: true |
|---|---|---|
| Nothing, or only failed deliveries | A new delivery | A new delivery |
A delivery still Queued |
That delivery, no second email | That delivery, no second email |
A delivery that was Sent |
That delivery, no second email | A new delivery, resendOfId set to it |
The answer is 202 Accepted: a background worker sends the email, usually within seconds. The
body is { "deliveries": [...] }, one entry per address in the order you sent them.
| Status | When |
|---|---|
202 |
Every address has a delivery, new or existing |
400 |
Validation: no addresses, more than 10, or one is not an email address |
403 |
The caller can see the invoice but lacks TransmitInvoices |
404 |
No sales invoice with this id that the caller can see. A purchase answers 404 too |
422 |
sales_invoice_pdf_missing: the sale was imported without a PDF, so there is nothing to send |
GET /v1/sales-invoices/{id}/deliveries
Every delivery the sale has, oldest first, as { "deliveries": [...] }. Needs View.
The delivery shape
| Field | Type | Means |
|---|---|---|
id |
uuid | The delivery |
invoiceId |
uuid | The sale |
to |
string | The address, lowercased |
state |
integer | 0 Queued, 1 Sent, 2 Bounced (reserved), 3 Failed |
attempts |
integer | How many times the worker tried to send it |
lastError |
string or null | Why the last attempt failed. See below |
resendOfId |
uuid or null | The sent delivery this one repeats, when it is a resend |
requestedByUserId |
uuid or null | Who asked, when it was a person |
requestedByApiKeyId |
uuid or null | Which API key asked, when it was an integration |
createdAt |
datetime | When it was asked for |
lastAttemptAt |
datetime or null | When the worker last tried |
sentAt |
datetime or null | When our mail relay accepted it |
lastError codes:
| Code | Means | Final |
|---|---|---|
recipient_rejected |
The receiving mail server refused the address | Yes |
pdf_missing |
The PDF was deleted after the request | Yes |
invoice_missing |
The sale was deleted after the request | Yes |
queue_unavailable |
Syllevo could not queue the email. The call itself failed with a 5xx |
Yes |
send_failed |
Our mail relay failed. The worker retries up to five times in all | Only after the fifth try |
stalled |
Still queued an hour after its last try. The next request for the address replaces it | Yes |
While the worker is still retrying, lastError is send_failed and state is still 0 Queued.
If the fifth try fails too, state becomes 3 Failed. A failed delivery never blocks a new one:
ask again and Syllevo queues a fresh one. A delivery stuck in 0 Queued for more than an hour
with no attempt doesn't block one either: the next request marks it 3 Failed with stalled
and queues a fresh one. If the worker picks up that delivery at the same moment, the request
answers with it instead, so the buyer never gets the email from two deliveries at once.
The email
The sender name is the business's company name, and replies go to the business's primary contact. The address itself is on Syllevo's sending domain, as it is for reminders, so the mail passes SPF and DKIM checks. The subject and text are short, in the business's language, and the PDF is attached under the file name it was imported with.
What Syllevo does not do yet
- Bounces are not tracked.
Sentmeans our mail relay accepted the email, not that it reached the inbox. A mailbox that bounces it later still showsSent. TheBouncedstate is reserved for when Syllevo reads bounce reports. - A rare double send. If the worker stops after the relay accepted the email but before it
saved
Sent, the retry sends it again. The buyer gets two copies, never none. - No custom text. The subject and message are fixed. Anything the buyer needs to know belongs on the invoice itself.
Send a sales invoice to SPV
Syllevo sends only the XML the business's invoicing software made, unchanged, and only when you ask. It never writes an invoice, never fixes one, and never sends one on its own. This is for businesses whose invoicing software doesn't upload to SPV itself.
Two switches have to be on first, and both are off by default:
- The firm. Syllevo turns sales invoice transmission on per organization. There is no setting
for it in the app yet. Until it is on, every request answers
403 spv_transmission_disabled. - The business. The person who connected the business to e-Factura turns on "Allow Syllevo to
send sales invoices to SPV" (see e-Factura and SPV).
Uploads go out under that person's ANAF certificate, so nobody else can turn it on. Until it is
on, requests answer
403 spv_consent_missing.
POST /v1/sales-invoices/{id}/submission
Needs TransmitInvoices on the profile and an Idempotency-Key header. No body.
Before anything is queued, Syllevo checks, in this order:
| Check | Refused with |
|---|---|
| Nothing is in flight or accepted for this invoice | 409 spv_submission_exists, with that submission |
| Transmission is on for the firm | 403 spv_transmission_disabled |
| The business opted in | 403 spv_consent_missing |
| The invoice isn't already in SPV (not on the business's sent list) | 409 spv_already_in_spv |
| The invoice has an XML file, still in storage | 422 spv_xml_missing |
| The XML is a UBL Invoice or CreditNote in the CIUS-RO format | 422 spv_xml_invalid, reason in details.reason |
| The seller's CUI in the XML is the business's CUI | 422 spv_seller_mismatch |
| The business is connected to e-Factura for that CUI | 422 spv_not_enrolled |
| ANAF lists the connected certificate's rights for that CUI as active | 422 spv_cui_not_authorized |
details.reason on spv_xml_invalid is not_xml, not_ubl, not_cius_ro, or unreadable.
The answer is 202 Accepted with the new submission, in the submission shape.
A background worker runs the same checks again, asks ANAF's validator, and uploads. It never
uploads a file the validator hasn't passed: if the validator can't be reached, the submission waits
and tries again later.
The 409 spv_submission_exists body is the usual error shape plus the existing submission:
{
"status": 409,
"code": "spv_submission_exists",
"messageKey": "web.errors.spv_submission_exists",
"submission": { "id": "0199a1b2-0000-7000-8000-000000000001", "state": 3 }
}
At most one submission per invoice is in flight at a time, and at most one is ever accepted.
After a final state other than Accepted, you can ask again, and a new submission starts.
GET /v1/sales-invoices/{id}/submission
Needs View. Answers { "latest": {...} or null, "history": [...] }, newest first. latest is
null when the invoice was never sent through Syllevo.
The submission shape
| Field | Type | Means |
|---|---|---|
id |
uuid | The submission |
invoiceId |
uuid | The sale |
profileId |
uuid | The business |
channel |
string | efactura |
state |
integer | See submission states |
cui |
string | The seller's CUI it was sent under, digits only |
uploadIndex |
string or null | ANAF's index_incarcare, once ANAF gave one |
downloadId |
string or null | The id of ANAF's answer, once there is one |
attempts |
integer | How many uploads were started |
lastErrorCode |
string or null | Why it stopped or is waiting. See below |
lastErrorMessage |
string or null | ANAF's own words, often in Romanian, when ANAF gave any |
xmlDocumentId |
uuid | The XML file that was sent |
responseArchiveDocumentId |
uuid or null | ANAF's answer archive, stored as a document on the invoice |
requestedByUserId |
uuid or null | Who asked, when it was a person |
requestedByApiKeyId |
uuid or null | Which API key asked, when it was an integration |
requestedByName |
string or null | The person's name (or email), or the API key's name. See below |
createdAt |
datetime | When it was asked for |
updatedAt |
datetime | Its last change |
uploadedAt |
datetime or null | When ANAF gave it an upload index |
completedAt |
datetime or null | When it reached a final state |
uncertainSince |
datetime or null | When Syllevo stopped knowing whether the upload reached ANAF |
Syllevo fills in requestedByName, so a member who can't list the team or the API keys still sees
who sent the sale. When requestedByApiKeyId is set it holds the key's name alone, for the app to
label as an API key in the reader's language. It is null when the person has left the firm.
Submission states
| Value | State | Means | Final |
|---|---|---|---|
0 |
Queued |
Waiting for the worker, or for a retry after ANAF was unreachable | No |
1 |
Validating |
The checks and ANAF's validator are running | No |
2 |
Sending |
The upload is going out | No |
3 |
Uploaded |
ANAF gave it an index and is processing it. Syllevo checks back | No |
4 |
Accepted |
ANAF accepted it | Yes |
5 |
Rejected |
ANAF processed it and rejected it. lastErrorMessage says why |
Yes |
6 |
Unreadable |
ANAF refused the file or couldn't read it | Yes |
7 |
Uncertain |
The upload may or may not have reached ANAF. See below | No |
8 |
Failed |
Nothing reached ANAF. lastErrorCode says why |
Yes |
Uncertain is the safety net. If the connection drops mid-upload, Syllevo can't know whether ANAF got the file, and sending it again could file it twice. So it never re-uploads an Uncertain submission on its own. It watches the business's SPV sent list instead, and when the exact file it uploaded shows up there (the same hash) or an entry carries that upload's index, the submission moves on with the index and answer found there. A sent-list entry with the same number and date but a different file never settles it. If nothing has shown up after 3 days, the firm's Owners get one email asking them to check SPV by hand.
Syllevo retries only when the error proves the request never reached ANAF: it couldn't resolve or
connect to ANAF's host, the TLS handshake failed, ANAF's gateway refused the token (401 or
403), or ANAF said the rate limit was hit. Those go back to Queued with a growing wait (2, 5,
15, 30, then 60 minutes), for up to 6 tries. A validator that can't be reached goes back to
Queued the same way, with anaf_unreachable. Nothing was uploaded, so that wait has no try
limit, only the 3-day one below.
A submission still Queued 3 days after the request stops as Failed with send_failed.
If a submission looked stuck and was marked Uncertain while its upload was in fact still going,
ANAF's answer wins when it arrives: an upload index moves it to Uploaded, and a refusal ends it.
The audit trail marks those as settled by a race.
While a submission is Uploaded, Syllevo asks ANAF for its status after 1 minute, then 2, 4, 8,
16, and 32 minutes, then hourly. When it is final, Syllevo downloads ANAF's answer archive once and
stores it on the invoice as a document.
The deadline alert. Romanian law gives an invoice 5 working days from its issue date to reach
SPV. If a sale sent through Syllevo isn't Accepted after 3 working days (Romanian public holidays
excluded), the firm's Owners get one email about it.
Why a submission stopped
lastErrorCode is one of the spv_* codes in the checks table above (the worker runs the checks
again right before sending), or one of these:
| Code | Means |
|---|---|
validation_failed |
ANAF's validator found errors. lastErrorMessage lists them. Failed |
upload_refused |
ANAF refused the file without giving it an index. Unreadable |
anaf_unreachable |
ANAF or its validator couldn't be reached. Still Queued, retrying |
send_failed |
ANAF stayed unreachable through every try. Failed |
outcome_unknown |
The upload may have reached ANAF. Uncertain |
rejected |
ANAF rejected it. lastErrorMessage says why. Rejected |
not_ingested |
ANAF gave it an index, then couldn't process it. Unreadable |
status_unavailable |
The last status check got no answer. Still Uploaded, checking again |
xml_changed |
The invoice's XML file changed after the request. Failed |
invoice_gone |
The invoice was deleted before it was sent. Failed |
interrupted |
The worker stopped before the upload went out. Back to Queued |
token_unavailable |
The e-Factura connection had no usable token. Back to Queued |
Anomalies
Syllevo runs two conservative detectors over collected invoices and raises a flag when one fires. A flag is a suggestion to look, not a refusal and not a correction. Nothing is held back because of one.
| Value | Kind | The claim |
|---|---|---|
0 |
AmountOutlier | This total is far above what this supplier usually bills |
1 |
PossibleDuplicate | This may be the same invoice already on file |
2 |
ConflictingCopy | Another source sent a copy of this invoice with different content |
Both detectors are tuned to stay quiet. If you are building a review queue, an open flag is worth a human's attention.
ConflictingCopy is not a detector's guess. Today it's raised on a sales invoice imported through
the API when e-Factura's sent list carries the same number and issue date with a different file
(e-Factura and SPV). e-Factura's copy wins: it's the
version ANAF holds, so it's the one the accountant books. It's linked to the sale with trustRank
2 and reason 6 (TaxAuthorityVersion), which puts it at the top of documentLinks, and the
sale's number, dates, amounts, and buyer are read again from it. The imported file stays linked
beside it, and counterpartDocumentId is that imported file, so the two can be compared. The audit
trail entry lists which fields changed. supplierName is null on this kind, because a sale has no
supplier.
A sale imported as a PDF alone is the common case, and it isn't a conflict when the two agree. If
the sale has no XML file and e-Factura's XML carries the same currency, net, VAT, and total, the XML
is linked with trustRank 2 and reason 2 (SemanticMatch), becomes the main copy, fills in
anything the PDF import left blank or wrote differently (the buyer, the due date), and no flag is
raised. The audit trail records it as invoice.spv_sent_linked with resolution
completed_pdf_import, naming both files and the fields that changed. If the currency or any
amount differs, it's a conflict like any other. Two different XML files are always a conflict,
even when they carry the same amounts.
One exception: if the sale's main copy is already one ANAF holds (it came from the sent list, or
Syllevo sent it to SPV and the sent list returned the same bytes), a second, different e-Factura
copy doesn't replace it. That copy is stored as its own document, linked to no invoice, and
counterpartDocumentId names it instead.
GET /v1/anomalies
One page of flags, newest first. Open flags only unless you ask for the settled ones.
| Parameter | Type | Default | Means |
|---|---|---|---|
profileId |
uuid | none | Narrow to one client company |
kind |
InvoiceAnomalyKind |
none | 0 amount outlier, 1 possible duplicate, 2 conflicting copy |
includeDismissed |
boolean | false | Add the flags somebody already settled |
page |
integer | 1 | |
pageSize |
integer | 25 | Clamped to 200 |
| Field | Type | Means |
|---|---|---|
id |
uuid | The flag |
kind |
InvoiceAnomalyKind |
Which detector raised it |
invoiceId |
uuid | The invoice it is about |
invoiceNumber |
string | Its number, so you do not have to fetch it |
profileId |
uuid | The client company |
supplierName |
string or null | The supplier |
issueDate |
date | The invoice's issue date |
totalAmount |
decimal | The invoice's total |
currencyCode |
string | Its currency |
medianTotalAmount |
decimal or null | The usual total from this supplier. Amount outliers only |
amountRatio |
decimal or null | How many times the usual total this one is. Outliers only |
counterpartInvoiceId |
uuid or null | The invoice it may duplicate. Duplicates only |
counterpartDocumentId |
uuid or null | The copy that isn't used. Conflicting copies only |
createdAt |
date-time | When the flag was raised |
dismissedAt |
date-time, null | Null while the flag is open |
dismissedById |
uuid or null | Who settled it |
{
"items": [
{
"id": "88888888-8888-4888-8888-888888888888",
"kind": 0,
"invoiceId": "33333333-3333-4333-8333-333333333333",
"invoiceNumber": "EU-2026-000120",
"profileId": "22222222-2222-4222-8222-222222222222",
"supplierName": "Example Utilities SA",
"issueDate": "2026-09-04",
"totalAmount": 1190.0,
"currencyCode": "RON",
"medianTotalAmount": 200.0,
"amountRatio": 5.95,
"counterpartInvoiceId": null,
"counterpartDocumentId": null,
"createdAt": "2026-09-05T04:10:00Z",
"dismissedAt": null,
"dismissedById": null
}
],
"page": 1,
"pageSize": 25,
"totalCount": 1,
"totalPages": 1
}
GET /v1/anomalies/count
How many flags still need a look, in total and per client, over the clients this caller can see. This is the badge number.
| Field | Type | Means |
|---|---|---|
total |
integer | Open flags across every visible client |
profiles |
array | { "profileId": uuid, "openCount": integer } |
Clients with no open flags are not in the array.
POST /v1/anomalies/{id}/dismiss
Marks one flag handled. View on the client it belongs to.
This is final. The flag never comes back, and detection never raises the same question about the same invoice again. There is no undo route.
Answers 200 with the flag, now carrying dismissedAt and dismissedById.