Search and company lookup
Two routes that answer questions rather than return resources. One searches everything the caller can see. The other asks ANAF who a Romanian fiscal code belongs to.
Routes
| Method | Path | Needs |
|---|---|---|
| GET | /v1/search |
Any credential |
| GET | /v1/registry/company |
Any credential, no organization |
GET /v1/search
One query, seven or eight categories, up to three matches in each. Type "scaffolding rental" and find the invoice, whichever client it belongs to; type half a client name, a colleague, or a supplier and find those too.
| Parameter | Type | Means |
|---|---|---|
q |
string | What was typed. 2 to 200 characters |
profileId |
uuid | Optional. Narrows documents, sources, SPV messages, and the firm's own suppliers to one client |
Rate limited to 120 requests a minute per organization under the search policy. It is one
endpoint fanning out server side rather than one per category, because seven requests per keystroke
would refuse five colleagues at once. See Conventions.
The response
| Field | Type | Means |
|---|---|---|
query |
string | The query as it was understood, trimmed |
profileId |
uuid or null | The narrowing that was applied |
documents |
category | Matches in invoices and documents |
profiles |
category | Client companies by name or tax id |
suppliers |
category | The firm's suppliers, then the shared directory |
sources |
category | Connected sources |
spvMessages |
category | ANAF messages |
organizations |
category | Organizations the caller belongs to |
users |
category or null | Colleagues. Null for anyone who is not an Admin |
Every category has the same envelope:
| Field | Type | Means |
|---|---|---|
items |
array | Up to limit matches |
limit |
integer | Always 3 |
totalCount |
integer | How many matched, capped at 1000 |
totalPages |
integer | totalCount divided by limit |
timedOut |
boolean | True when this category ran out of time and answered nothing |
timedOut: true means unknown, not empty. Each category runs against its own deadline, 2.5
seconds for documents and 1 second for each of the name lookups, so one slow query never holds the
response. Show "still looking" or offer a retry for a category that timed out. Showing "no results"
there tells an accountant an invoice is gone when it is not, which is the one thing this product may
never do.
totalCount stops counting out loud at 1000. A query matching more says exactly 1000. Nothing is
hidden and the returned rows are unaffected; a search matching a thousand documents needs different
words.
What matches
| Category | Matches on |
|---|---|
documents |
Invoice number, supplier, tax id, amounts, line items, senders, file names, text |
profiles |
Client name and tax identifier |
suppliers |
Supplier name, fiscal code, contact address, sender addresses, and directory slug |
sources |
Source tag and provider type |
spvMessages |
ANAF message id and type |
organizations |
Organization name |
users |
Colleague display name |
Words are matched together and diacritics are forgiven, so typing schela finds schelă.
The documents category is ranked, not just filtered. An invoice's number, supplier, tax id, and
totals weigh the most, then its line items, then how it arrived (sender addresses, file names,
account tags), then the text captured from the file. So typing a supplier name puts the invoices
from that supplier above a document that merely mentions it in a paragraph.
One row per invoice, not per file. An invoice that arrived as an e-Factura XML and again as the
supplier's own PDF is one result, and documentId is the most trusted copy that is still there. A
document nothing has been filed from yet is its own row, so something still in extraction or review
turns up rather than going quiet until somebody files it.
A document in the trash never matches, even though its captured text is kept for the sixty-day retention window in case it comes back. If the trashed file was the most trusted copy of an invoice, the row opens the best copy that is left. A purge removes the entry keyed on the purged document, and the entry of any invoice the purge leaves with no document at all; deleting an invoice removes its entry too. An invoice another document still evidences keeps its entry, and that entry holds what the purged document said until the next refresh rewrites it.
Line items became searchable on 2026-09-19. Lines are recorded when an invoice is filed, so line items exist only for invoices filed on or after that date. An invoice filed before it is searchable by its header, by how it arrived, and by its text, never by line – a document that has already been processed is not read again. The backfill that walks the older invoices, 500 subjects an hour, fills in their header and their text, not their lines. Two limits: at most 200 lines are kept per invoice, and at most 50,000 characters of line text go into the index. An invoice filed from a source that never sent lines - a scan, a PDF with nothing machine-readable in it - has none, and matches on its header and its text.
The text half only reaches text Syllevo could read: structured e-Factura XML and PDFs that carry a text layer. A scan or a phone photo has no captured text, so it cannot match on its contents. It still matches through its supplier, its invoice number, its amounts, or how it arrived - all of which come from the filed invoice rather than from the file.
Result shapes
documents items:
| Field | Type | Means |
|---|---|---|
documentId |
uuid | The document to open |
profileId |
uuid | Which client it belongs to |
fileName |
string or null | What it arrived as |
snippet |
string | The matched text with context around it |
matchedIn |
integer | Which part of the row matched |
matchedLine |
object or null | The matched invoice line, when one matched |
receivedAt |
date-time | When Syllevo first got it |
lastArrivalAt |
date-time | When it last arrived again |
invoiceId |
uuid or null | The invoice it evidences, when it has one |
invoiceNumber |
string or null | That invoice's number |
invoiceIssueDate |
date, null | Its issue date |
invoiceTotalAmount |
number or null | Its total |
invoiceCurrencyCode |
string or null | Its currency |
supplierName |
string or null | Who it is from |
The invoice fields are null as a group when nothing has been filed from the document yet.
snippet is the excerpt to show: about 90 characters on each side of the first matched term, or the
first 180 characters when no term could be located.
matchedIn says which part of the row the query was found in, so a result can be labeled rather
than just listed. It travels as an integer:
| Value | Means |
|---|---|
0 |
The text captured from the file |
1 |
The header: number, supplier, tax id, currency, amounts |
2 |
An invoice line |
3 |
How it arrived: sender addresses, file names, source tags |
Some matches are found by the index and cannot be found again in the plain text - a tax id stored
without its separators, an amount written with the other decimal separator, a sender address split
into its parts. Those rows report the part the excerpt was taken from rather than a fifth "not sure"
value, so matchedIn is always one of the four above. The excerpt then comes from the first of
1, 3, 0, 2 that has anything in it, in that order, because an unlocatable match almost
always came from the header or the arrival context.
matchedLine is set only when a specific line was located, so it can be null even when
matchedIn is 2. That happens on the fallback above: the row is labeled as a line because the
excerpt came from the line text, but no single line matched the query, and naming one anyway would
tell an accountant "this is the line you were looking for" about a line nobody asked for. Draw the
snippet when matchedLine is null.
When it is set:
| Field | Type | Means |
|---|---|---|
description |
string | What was bought, as the invoice printed it |
quantity |
number or null | How much of it |
unit |
string or null | The unit as printed - buc, H87, KGM |
unitPrice |
number or null | Price per unit |
netAmount |
number or null | The line amount before VAT |
lineTotal |
number or null | The line total including tax |
currencyCode |
string or null | The invoice's currency, for formatting |
Draw netAmount as the total of the quantity x unitPrice arithmetic, because that is the amount
the multiplication equals. Syllevo's own search palette does, and falls back to lineTotal when the
document printed no net amount. An e-Factura line carries a net amount and usually no gross one, so a
client that reads lineTotal alone shows no arithmetic for most Romanian invoices.
Amounts travel raw and are formatted per locale by the client, the same rule as everywhere else on
this API. snippet still carries the line's description, so a client that draws only the excerpt
loses nothing.
suppliers items carry displayName, countryCode, and exactly one id:
| Shape | Ids | Also |
|---|---|---|
| The firm's own | supplierId and profileId |
taxIdentifier |
| Shared directory | supplierDirectoryEntryId and slug |
- |
The firm's own rows come first and the directory fills what is left of the three, because somebody
typing a supplier name while looking at a client usually means that client's supplier, not a public
entry they might one day connect. totalCount is both halves added together.
Supplier email and sender addresses are matched and never returned. Somebody who knows a supplier only as "the one that mails from billing@" has to be able to find it, and a search that printed the address back would confirm it to anybody who can type two characters.
The remaining categories are small and name-shaped: profiles carry profileId, name,
taxIdentifier, countryCode, and avatar links; sources carry accountId, profileId,
providerType, and tag; spvMessages carry messageId, profileId, anafMessageId, type, and
anafCreatedAt; organizations carry organizationId, name, and avatar links; users carry
membershipId, displayName, role, and avatar links.
Edge cases that are not errors
- A query under 2 characters answers
200with every category empty. The box fires while somebody is still typing, and an error for each keystroke would be noise. - A caller with no grants gets empty categories, the same way the invoice list gets an empty page.
usersis absent for anyone belowAdmin, rather than the whole request being refused. A non-admin still gets their documents and their clients.profileIdcan only ever narrow. Naming a client the caller cannot see removes every row rather than adding one.
GET /v1/registry/company
Looks one Romanian fiscal code up in ANAF's public registers, as of today. This is the route an onboarding form calls when somebody types a CUI.
| Parameter | Type | Required | Means |
|---|---|---|---|
cui |
string | yes | The fiscal code. Up to 32 characters, RO prefix and spaces tolerated |
This parameter was spelled Cui before 2026-09-11. It is cui now, in the contract and in any
client generated from it. Query string keys are matched without regard to case, so a caller still sending
Cui= keeps working. See Changelog.
This route takes no X-Organization-Id. It is reached while an accountant is adding their first
client company, often before they have an organization at all. See
Authentication.
| Field | Type | Means |
|---|---|---|
found |
boolean | Whether ANAF knows this code |
cui |
string | The code, as it was asked |
legalName |
string or null | The registered name |
address |
string or null | The registered address |
tradeRegisterNumber |
string or null | The trade register number |
vatRegistered |
boolean | Whether it is registered for VAT |
eFacturaEnrolled |
boolean | Whether it is in the e-Factura register |
eFacturaEnrolledSince |
date, null | When it joined |
{
"found": true,
"cui": "RO00000000",
"legalName": "Example Contabil SRL",
"address": "Str. Exemplu 1, Bucuresti",
"tradeRegisterNumber": "J00/0000/2020",
"vatRegistered": true,
"eFacturaEnrolled": true,
"eFacturaEnrolledSince": "2024-07-01"
}
Rate limited to 20 requests a minute under the registry policy. That is not protecting ANAF,
which has its own one-request-a-second limit held process-wide inside Syllevo. It is protecting the
queue in front of that limiter from one caller looping over a client list while everybody else's form
waits behind them. Look codes up as people type them, not in a batch.
Two answers that must never be confused
| What happened | Answer |
|---|---|
| ANAF does not know this code | 200 with found: false |
| ANAF could not be reached or understood | 503 provider_unreachable |
This route never answers 404. A code nobody recognizes is a 200 the form can render, and a
404 would read as "that route is gone".
Treat the 503 as "ask again", not as "this company does not exist". ANAF is down for hours at a
time, and its public host sits behind a filter that sometimes answers 200 with an HTML rejection
page. Telling an accountant their client is not in e-Factura when Syllevo simply could not ask would
have it stop expecting invoices that are on their way.
A missing or over-long cui answers 400 with a field error on cui.