Sources
A source is a route documents arrive by: a mailbox invoices are forwarded to, an ANAF e-Factura connection, a supplier portal login, a connected drive. The API calls a source an account.
Connecting a source is meant to be a one-time act of under two minutes, after which nobody opens Syllevo again for that client. These routes are how an integration does that act.
For what arrives, see Documents. For the ANAF connection specifically, see e-Factura and SPV.
Routes
| Method | Path | Needs |
|---|---|---|
| GET | /v1/profiles/{profileId}/accounts |
View |
| GET | /v1/profiles/{profileId}/accounts/{id} |
View |
| POST | /v1/profiles/{profileId}/accounts |
ManageSources |
| PUT | /v1/profiles/{profileId}/accounts/{id} |
ManageSources |
| DELETE | /v1/profiles/{profileId}/accounts/{id} |
ManageSources |
| POST | /v1/profiles/{profileId}/accounts/{id}/sync |
ManageSources |
| POST | /v1/profiles/{profileId}/accounts/sync |
ManageSources |
| GET | /v1/accounts |
View, across the portfolio |
| GET | /v1/connectors |
Any credential |
| POST | /v1/connectors/{slug}/connect |
ManageSources |
| POST | /v1/connectors/{slug}/callback |
ManageSources |
| POST | /v1/connectors/{slug}/credentials |
ManageSources |
| GET | /v1/connectors/accounts |
View |
| DELETE | /v1/connectors/accounts/{accountId} |
ManageSources |
| GET | /v1/ingestion/unfiled |
View |
| POST | /v1/ingestion/unfiled/{id}/resolve |
ManageSources |
The account routes are nested under the client company on purpose. Permissions are held on the client, so the client id belongs in the URL where the router can require it, and an account id from somewhere else cannot be addressed at all: the API proves the account belongs to the client in the path before it answers anything.
The account shape
| Field | Type | Means |
|---|---|---|
id |
uuid | The source |
profileId |
uuid | The client company |
providerType |
DataProviderType |
What kind of source it is |
tag |
string or null | Free-text label, for example office inbox |
syncStatus |
AccountSyncStatus |
How it is doing |
lastSuccessfulSyncAt |
date-time, null | Last time it worked |
lastErrorCode |
string or null | Why it last failed. See Error codes |
ingestionAddress |
string or null | The address suppliers send to. Mail sources only |
providerType:
| Value | Name | Means |
|---|---|---|
0 |
EmailInbox | A mailbox invoices are sent to |
1 |
EFacturaAnaf | ANAF e-Factura |
2 |
SupplierPortal | A supplier connector |
3 |
CloudStorage | A connected drive |
4 |
ManualUpload | Uploads by hand and upload links |
syncStatus:
| Value | Name | Means |
|---|---|---|
0 |
NeverSynced | Connected, nothing has run yet |
1 |
Healthy | Working |
2 |
Degraded | Working, but something went wrong recently |
3 |
Failing | Not working. lastErrorCode says why |
4 |
Disconnected | The credential is gone. It needs reconnecting |
There is no config on the response, and there never will be. Credentials are encrypted before
they reach the database and do not come back out, not to the web app, not to an API key, not to the
person who typed them.
ingestionAddress is the one thing about a source that does come back, and the contrast is the
point. A credential is something only its holder may see. An inbound address is a mailbox printed on
screen precisely so it can be handed to strangers, and hiding it would make the product's central
instruction, "send your invoices here", unanswerable.
GET /v1/profiles/{profileId}/accounts
Every source on this client. View. Answers a plain array.
| Parameter | Type | Means |
|---|---|---|
tag |
string | Optional. Narrow to sources with that label |
View is the gate rather than ManageSources, because seeing that a client has an inbox connected
is part of seeing the client. Changing one is the other question, and every change here asks
ManageSources.
GET /v1/accounts
The same rows, across every client the caller can see, paged. A firm with three hundred clients would otherwise need three hundred calls to draw one grid.
| Parameter | Type | Means |
|---|---|---|
profileId |
uuid | Narrow to one client |
tag |
string | Narrow to one label. ?tag= is no filter |
page |
integer | Default 1 |
pageSize |
integer | Default 25, clamped to 200 |
Both filters narrow within what the caller can already see. Naming a client the caller cannot see is
an empty page, not a 403, so a foreign client id and an id nobody ever created give the same
answer. An all-zero GUID is refused with 400.
This route is not a second door onto one client's sources. That door is
GET /v1/profiles/{profileId}/accounts, and it stays the only place a single client is authorized
by name.
POST /v1/profiles/{profileId}/accounts
Connects a source. ManageSources.
| Field | Type | Required | Means |
|---|---|---|---|
providerType |
DataProviderType |
yes | What kind of source |
tag |
string | no | A label |
config |
object | no | Connection settings and credentials |
config is shaped by whichever provider owns the providerType: an IMAP host and password, a
portal login, and so on. It is encrypted before it reaches storage and has no counterpart on the
response.
Answers 201 with the account. For a mail source, ingestionAddress on that response is the
address to give the client's suppliers.
Each organization has a cap on how many sources of each kind it can hold. Passing it answers 409
with source_cap_reached. The e-Factura and manual-upload caps are one per client, and that one is
a correctness constraint rather than a fair-use limit.
PUT /v1/profiles/{profileId}/accounts/{id}
Retags a source, and rotates its credentials when a config is sent. ManageSources.
| Field | Type | Means |
|---|---|---|
tag |
string | Sent as null to clear it. This is a PUT, what arrives is what you get |
config |
object | Omit to keep the stored credentials. Send it to replace them wholesale |
The asymmetry between the two is deliberate. Credentials are never returned, so a client cannot read
the current value, change one field, and send the whole thing back the way it can with a tag. If
config were required here, every rename would mean re-typing a password.
DELETE /v1/profiles/{profileId}/accounts/{id}
Disconnects a source. ManageSources. Answers 204.
Nothing already collected through it is touched. The documents stay, the invoices stay, and the exports still work. Disconnecting stops future arrivals and nothing else.
POST /v1/profiles/{profileId}/accounts/{id}/sync
Checks this source now instead of waiting for its schedule. ManageSources.
Answers 202, not 200, because the check runs on a background worker and 202 is the honest
answer.
| Field | Type | Means |
|---|---|---|
accountId |
uuid | The source that will be checked |
queuedAt |
date-time | When the request was accepted. The cooldown runs from here |
This is a receipt, not a result. Whether anything was collected shows up where it always does: on the documents list, and on the account's own sync fields.
Refusals:
| Status | Code | Means |
|---|---|---|
404 |
account-not-found |
No such source on that client |
409 |
account-sync-not-pullable |
This source receives rather than fetches. An inbox has nothing to pull |
409 |
account-sync-too-soon |
Checked too recently. details.availableAt says when it reopens |
The cooldown is 15 minutes. Read details.availableAt rather than assuming it.
POST /v1/profiles/{profileId}/accounts/sync
Checks every source on this client that can be checked. ManageSources. Answers 200 with counts
rather than 202 with a list.
| Field | Type | Means |
|---|---|---|
queued |
integer | Sources whose check was accepted |
skippedTooSoon |
integer | Sources checked too recently to ask again yet |
nothingToPull |
integer | Inboxes, manual upload, and disconnected sources waiting on a reconnect |
The three add up to every source on the client. A mixed answer is normal and is not an error: the button is "check everything", and refusing the whole request over one member of it would be refusing the only thing that was asked.
{ "queued": 3, "skippedTooSoon": 1, "nothingToPull": 2 }
Supplier connectors
A connector logs into a supplier's own portal and fetches invoices from it. Two shapes exist, and the catalog says which one a given connector uses.
Each connector is switched on per deployment. The catalog lists only the connectors switched on
for this deployment, so read it rather than assuming a slug exists. A connector switched on before its
credentials were filled in answers connector-not-configured at connect time. In production, new
connectors are switched on one at a time as their credentials are set up. Nothing on your side
changes that. To ask for one, write to sales@syllevo.com.
GET /v1/connectors
Every connector this host ships, and the inputs each one asks the client for. Answers a plain array.
| Field | Type | Means |
|---|---|---|
slug |
string | Use this in the three routes below |
credentialMode |
ConnectorCredentialMode |
0 redirect approval, 1 pasted secret |
fields |
array | What to ask for, in a pasted-secret connector |
Each field:
| Field | Type | Means |
|---|---|---|
name |
string | The key to send in fields on the credentials route |
labelKey |
string | Translation key for the label. Not a label |
secret |
boolean | Whether to mask it on screen and keep it out of logs |
The redirect flow, credentialMode 0
POST /v1/connectors/{slug}/connectwith{ "profileId": ..., "directorySlug": ... }.directorySlugis optional and ties the connection to a directory-backed supplier. Answers{ "url": "..." }.- Send the person to that URL. The supplier asks them to approve.
- The supplier redirects back to your application with a query string. Post it whole:
POST /v1/connectors/{slug}/callbackwith{ "state": "...", "query": { ... } }, wherequeryis the callback's query parameters as a JSON object. - Answers
200with the connected account.
The state value is single use and expires. A callback posted twice, or late, answers 409
connector-state-invalid, and the fix is to start again at step 1.
The pasted-secret flow, credentialMode 1
POST /v1/connectors/{slug}/credentials:
| Field | Type | Required | Means |
|---|---|---|---|
profileId |
uuid | yes | The client company |
fields |
object | yes | Keyed by the name values from the catalog |
directorySlug |
string | no | Tie it to a directory-backed supplier |
The connector proves the credentials work before anything is stored. Credentials that do not work
answer 409 connector-credential-invalid and nothing is saved.
Using the wrong flow for a connector answers 409 connector-mode-mismatch.
GET /v1/connectors/accounts
Every supplier account connected for one client. View. Answers a plain array.
| Parameter | Type | Means |
|---|---|---|
profileId |
uuid | The client company |
| Field | Type | Means |
|---|---|---|
accountId |
uuid | The connection |
profileId |
uuid | The client company |
connectorSlug |
string | Which connector |
directorySlug |
string or null | The supplier behind it |
externalAccountId |
string | The account id at the supplier |
externalAccountName |
string | Its name at the supplier |
externalFiscalIdentifier |
string or null | The fiscal code at the supplier |
credentialState |
SupplierConnectorCredentialState |
0 valid, 1 expired, 2 revoked, 3 refused |
credentialExpiresAt |
date-time, null | When it will need renewing |
lastSuccessfulPollAt |
date-time, null | Last time it fetched something |
nextPollAt |
date-time | When it will try again |
lastErrorCode |
string or null | Why the last poll failed |
lastErrorCode here describes the last background poll, not your request. See the connector codes
in Error codes.
DELETE /v1/connectors/accounts/{accountId}
Tells the supplier Syllevo is done and removes the source. ManageSources. Answers 204.
Documents already collected through it stay.
Unfiled mail
Mail that arrived through a source and could not be filed: a portal invitation, a verification code, a "confirm this address" nobody at the firm ever saw. This is the screen that answers why a source has gone quiet.
GET /v1/ingestion/unfiled
One page, newest arrival first. View.
| Parameter | Type | Means |
|---|---|---|
profileId |
uuid | Narrow to one client |
accountId |
uuid | Narrow to one source |
unresolvedOnly |
boolean | Only what nobody has dealt with yet |
page |
integer | Default 1 |
pageSize |
integer | Default 25, clamped to 200 |
| Field | Type | Means |
|---|---|---|
id |
uuid | The item |
profileId |
uuid | The client company |
accountId |
uuid | The source it came through |
receivedAt |
date-time | When the mail arrived |
createdAt |
date-time | When the row was written |
senderAddress |
string or null | Who sent it |
senderDisplayName |
string or null | Their display name |
subject |
string or null | The subject line |
links |
array | Links found in the message. See below |
linkCount |
integer | How many were found |
linksTruncated |
boolean | True when links holds fewer than linkCount |
forwardedAt |
date-time, null | When it was forwarded on |
forwardedTo |
string or null | Where it was forwarded |
resolvedAt |
date-time, null | Null while it still needs attention |
resolvedById |
uuid or null | Who closed it |
fieldsPurgedAt |
date-time, null | When the sender, subject, and links were dropped |
Each link:
| Field | Type | Means |
|---|---|---|
url |
string | The link as it appeared |
host |
string | Its host |
clickable |
boolean | Whether it is safe to offer as a link |
decision |
UnfiledLinkDecision |
Why, see below |
fetchFailed |
boolean | Whether Syllevo tried to follow it and could not |
decision:
| Value | Name | Means |
|---|---|---|
0 |
Clickable | The sender and the host both check out |
1 |
SenderUnauthenticated | The message failed its own authentication |
2 |
SenderNotOnAllowlist | The sender is not a supplier Syllevo recognizes |
3 |
HostNotPermittedForSender | The link points somewhere that sender should not send to |
4 |
Unreadable | The link could not be parsed |
Do not make a link clickable when clickable is false. These messages arrive from outside and
are exactly where a phishing link would be. The field is the answer, not a suggestion.
fieldsPurgedAt is set when the sender, subject, and links have been dropped on retention. The row
stays so the count is honest, but those fields are then null.
POST /v1/ingestion/unfiled/{id}/resolve
Closes one item: the invitation was accepted, the code was used, or the mail was noise.
ManageSources on the client the source belongs to. Answers 200 with the item, now carrying
resolvedAt and resolvedById.