Skip to content
API pagese-Factura and SPV

API documentation

e-Factura and SPV

Romania's e-Factura is mandatory for business-to-business invoicing, so ANAF is a first-class invoice source rather than an afterthought. These routes connect a firm's qualified certificate to ANAF once, enroll client companies against the grant that produces, and read the firm-wide SPV message stream.

This is the one area of the API that is entirely Romania-specific. If you are integrating outside Romania, skip this page.

The module is a deployment switch

e-Factura is switched on for the whole deployment, not per organization, and the production API at api.syllevo.com has it on. Where it is off, every route on this page answers:

{
  "status": 404,
  "code": "not_found",
  "messageKey": "web.errors.not_found",
  "detail": null
}

Not 403, and not an e-Factura-specific code. That is deliberate: a door that is not there tells a scanner nothing about a door that is. If you get 404 from every route here and 200 from the rest of the API, the module is off rather than your credential being wrong.

Everything below describes the behavior when it is on, which is the case in production.

Routes

Method Path Needs
POST /v1/efactura/connect Admin
POST /v1/efactura/callback Admin
GET /v1/efactura/connections Admin
DELETE /v1/efactura/connections/{id} Admin
GET /v1/efactura/connections/{id}/companies ManageSources
POST /v1/efactura/enrollments ManageSources
PUT /v1/efactura/enrollments/{accountId}/scope ManageSources
GET /v1/efactura/profiles/{profileId}/enrollments View
GET /v1/spv/messages View
POST /v1/spv/messages/{id}/read View
POST /v1/spv/messages/{id}/collect ManageSources
POST /v1/spv/messages/read View
GET /v1/spv/messages/unread-count View
GET /v1/profiles/{profileId}/efactura/transmission-consent View
PUT /v1/profiles/{profileId}/efactura/transmission-consent ManageSettings, see below

How the pieces fit

There are two layers, and mixing them up is the usual confusion.

  1. A connection is one OAuth grant, obtained once by a person holding the firm's qualified certificate. It belongs to the organization, not to a client, and it is what lets Syllevo talk to ANAF at all. Admin owns it.
  2. An enrollment ties one client company's fiscal code, its CUI, to one connection. It is what makes that client's invoices start arriving. ManageSources on the client owns it.

One connection can carry many enrollments. A firm connects once and enrolls each client it acts for.

Which companies a connection can act for is never configured. ANAF names them on its own listing answers, and Syllevo reads that inventory as it polls, which is what /v1/efactura/connections/{id}/companies hands back.

Each enrollment also carries a collection scope, three values that decide what it files rather than what it fetches. everything files every received invoice as it arrives. messagesOnly files none of them, leaving every received invoice for the messages inbox. listedSuppliers files only the ones whose issuer is on the client's e-Factura-flagged supplier list and holds the rest - see Suppliers for what puts a supplier on that list. ANAF's message listing never says who issued an invoice - it gives a type, a date, and an id - so there is no way to decide from the listing alone. Under listedSuppliers the invoice is downloaded anyway, the issuer is read out of the XML, and only then is it filed or held.

Whatever a scope leaves uncollected, held or never downloaded, still shows up on the SPV message stream, and POST /v1/spv/messages/{id}/collect files any one of them on request. What changing scope does to a backlog already sitting there is under PUT /v1/efactura/enrollments/{accountId}/scope below.

POST /v1/efactura/connect

Starts a connect. Admin. Answers with the URL to send a browser to.

Field Type Means
url string Open this. ANAF asks for the qualified certificate
browserNoteKey string Translation key for a note about browser requirements

browserNoteKey is a key, not a sentence. ANAF's certificate step has browser requirements worth warning about, and the wording lives in the Syllevo web app.

The state on that URL is a short random id, about 25 characters. What it stands for, the firm and the person who started the connect, is kept on our side for ten minutes and spent on the first callback that presents it. A callback after that, or a second one with the same state, answers 409 efactura-state-invalid. ANAF documents its flow with state empty, and its login gateway ended the certificate session on a "Logged out successfully" page when the state was long, so it stays short. A host whose cache cannot hold the entry answers 409 efactura-not-configured here, before anyone reaches for a USB token, rather than at the callback.

Connecting a second certificate while one is already connected is allowed. The sources dialog lets the accountant pick which one to enroll a client against.

POST /v1/efactura/callback

Finishes the connect: trades the authorization code for a token pair and stores the grant. Admin.

Field Type Required Means
code string yes The authorization code ANAF sent back
state string yes The state value from the redirect

Answers 200 with the connection. Refusals are all 409:

Code Means
efactura-state-invalid The state is unknown, expired, already spent, or names somebody else
efactura-exchange-failed ANAF could not be reached or answered unusably
efactura-opaque-token ANAF returned a token the exchange could not read
efactura-invalid-grant ANAF refused the grant
efactura-not-configured The module is on but this deployment has no ANAF OAuth application

All of them mean the same thing for a client: start again at /connect. The code is single use.

GET /v1/efactura/connections

Every grant this organization holds, disconnected and expired ones included. Admin. Answers a plain array.

Field Type Means
id uuid The connection
status EFacturaConnectionStatus 0 connected, 1 refresh failed, 2 expired, 3 disconnected
accessTokenExpiresAt date-time When the access token dies
refreshTokenExpiresAt date-time When the whole grant dies and somebody must reconnect
lastRefreshedAt date-time, null Last successful refresh
certificateIssuer string or null Who issued the qualified certificate
certificateSerialLast4 string or null The last four of its serial, to tell two apart
createdAt date-time When it was connected

Watch refreshTokenExpiresAt. ANAF grants do not renew themselves forever. When it passes, a person with the certificate has to sit down at a browser again, and every enrolled client stops receiving until they do. Alert on it well ahead.

Only the last four digits of the certificate serial are stored. The certificate itself never reaches Syllevo.

DELETE /v1/efactura/connections/{id}

Hands the grant back to ANAF and marks it disconnected here. Admin. Answers 200 with the connection, now at status 3.

Documents already collected through it stay.

GET /v1/efactura/connections/{id}/companies

The client picker behind the enroll dialog: which companies this certificate can act for, so nobody types a fiscal code from memory. ManageSources on the client named by profileId.

Parameter Type Required Means
profileId uuid yes The client the caller is about to enroll

profileId is required because the permission is checked on a client, not on the organization. Omitting it is 400 validation_failed. A caller without ManageSources on that client gets 403. A connection that belongs to another organization, or a profileId that is not one of this organization's clients, is 404, the same answer as the module being off.

Field Type Means
status EFacturaCompanyDirectoryStatus 0 unknown, 1 known
companies array The inventory, empty when status is 0
checkedAt date-time, null When the inventory was last established
Company field Type Means
cui string The fiscal code, normalized, no RO prefix
legalName string or null The name ANAF's registry holds for it, when looked up
enrolledProfileIds uuid array Clients already enrolled on that code, empty when none

status 1, known, means the list is ANAF's and can be shown as a picker. status 0, unknown, means Syllevo has nothing to show yet and the dialog should ask for a fiscal code by hand. It is not an error, and it is not permanent: the first invoice any client behind this grant receives fills the inventory in.

Where the list comes from

ANAF publishes no route that lists the fiscal codes a certificate has rights over. What it does publish, on every message listing that carries at least one message, is a field naming all of them. So the inventory is discovered rather than configured. The poll pass records it for free as it runs, and this route probes for one when nothing has recorded it yet.

The probe asks about at most three fiscal codes, in this order: the codes already enrolled on this connection, then the fiscal code on the client in profileId, then codes from the caller's other clients. It stops at the first answer that names anything. The probed endpoint allows 1,500 calls a day per fiscal code, which is why the count is capped and why both answers are cached.

Caching

An inventory younger than 24 hours is answered from the stored row without contacting ANAF. A connection that was checked within the last hour and still holds nothing is answered 0 unknown without contacting ANAF either. Enrolling a client, or the poll pass picking up an invoice, refreshes the list on its own.

ANAF being slow or refusing is never a 5xx here. It is status 0 and a picker that falls back to a text box.

POST /v1/efactura/enrollments

Enrolls one client company's fiscal code against a connection. ManageSources on the client.

Field Type Required Means
profileId uuid yes The client company
connectionId uuid yes The grant to enroll against
cui string yes The client's Romanian fiscal code
tag string no A label for the source this creates
scope integer no What to file: 0 everything, 1 messages only, 2 listed suppliers. Default 0

Answers 201 with the enrollment. This creates a source on the client, so it counts against the e-Factura source cap of one per client. See Sources.

When the client has no tax id yet, enrolling sets it to the enrolled CUI, digits only, and records profile.updated in the audit trail. A tax id that's already there is never changed, even when it differs from the CUI.

Refusals are 409:

Code Means
efactura-cui-invalid Not a valid Romanian CUI
efactura-already-enrolled That CUI is already enrolled on this client
efactura-connection-not-connected The grant is disconnected, expired, or last failed to refresh

PUT /v1/efactura/enrollments/{accountId}/scope

Changes what one enrollment files. ManageSources on the client. accountId is the accountId from the enrollment, the source row this enrollment created.

Field Type Required Means
scope integer yes 0 everything, 1 messages only, 2 listed suppliers

Answers 200 with the enrollment, scope included. A scope that is already the one asked for is a 200 that writes nothing and audits nothing. An enrollment belonging to another firm is 404, the same answer as an id that does not exist.

Widening to 0, everything, queues every invoice this client held under the narrower scope. They are filed from the archives already stored, so nothing is asked of ANAF and the 60-day retention window does not apply - a backlog held since June still files today. Expect them to appear over the next minutes rather than instantly.

That only holds for invoices that were downloaded and held, which means scope 2. Coming back from 1, messages only, has no backlog to file: nothing was downloaded under it. Those invoices are still on the SPV message stream and can be collected one at a time from there, while ANAF still holds them - 60 days from publication, and then they are gone.

Setting 0 on an enrollment that is already 0 is allowed and does the release again. Nothing is written and nothing is audited, but the held invoices are queued a second time, which is the way to retry after a release that did not go out.

Narrowing only changes what happens next. Nothing already filed is unfiled, and no document is deleted.

GET /v1/efactura/profiles/{profileId}/enrollments

Every enrollment on one client, and where each cursor stands. View. Answers a plain array.

Field Type Means
accountId uuid The source this enrollment created
profileId uuid The client company
connectionId uuid The grant behind it
cui string The fiscal code
legalName string or null The name ANAF holds for that CUI
rightsStatus EFacturaRightsStatus 0 unknown, 1 active, 2 missing
rightsCheckedAt date-time, null When rights were last checked
eFacturaEnrolled boolean or null Whether ANAF says this CUI is in the e-Factura register
lastSuccessfulPollAt date-time, null Last time messages were fetched
nextPollAt date-time When the next fetch is due
windowEnd date-time The far edge of the message window currently readable
scope integer 0 everything, 1 messages only, 2 listed suppliers
sentWindowEnd date-time, null The far edge of the sent-list window. Null before the first sent pass
sentLastSuccessfulPollAt date-time, null Last time the sent list answered
sentConsecutiveFailures integer Sent listings that failed in a row since the last success. 0 is healthy

sentConsecutiveFailures climbing while lastSuccessfulPollAt stays fresh means the received list works and the sent list doesn't. Sales stop arriving from SPV, and an Uncertain submission can't be settled, until it answers again.

rightsStatus 2, missing, is the one to act on. It means the certificate holder does not have e-Factura rights for that CUI at ANAF, so nothing will ever arrive no matter how long you wait. The fix is at ANAF, not in Syllevo.

windowEnd matters because ANAF keeps a message downloadable for 60 days. Past that the document is gone from ANAF's side and no retry brings it back, which surfaces as efactura-download-expired. A client enrolled today does not get its back catalog.

The SPV message stream

Every message ANAF published for any of the firm's clients, in one stream, so nobody logs into ANAF once per client to check. Received invoices, rejections, and buyer messages all appear here.

GET /v1/spv/messages

One page, newest ANAF publication first. View.

Parameter Type Means
profileId uuid Narrow to one client
type string One ANAF message type, ANAF's own exact value
unreadOnly boolean Only what nobody at the firm has read
page integer Default 1
pageSize integer Default 25, clamped to 200
Field Type Means
id uuid The message in Syllevo
profileId uuid The client it belongs to
cui string The fiscal code ANAF published it for
anafMessageId string ANAF's own id
anafCreatedAt date-time When ANAF published it
type string ANAF's message type, passed through unchanged
details string or null ANAF's own detail text
requestId string or null ANAF's request id, when the message carries one
readAt date-time, null Null while unread
readById uuid or null Who read it
createdAt date-time When Syllevo recorded it
collection string Where the invoice behind it stands - see below
documentId uuid or null The document it became, on collected
heldReason string or null Why it is held, on held

type and details are ANAF's values, passed through as they arrived rather than translated into something tidier. They are in Romanian and their vocabulary is ANAF's.

collection, documentId, and heldReason are not stored on the message. They are read off the download ledger on the way out, one query per distinct fiscal code on the page.

collection Means
notApplicable Not a received invoice, so there is nothing to collect
notCollected A received invoice the enrollment's scope never picked up
collecting Asked for, and the Worker has it
held Downloaded and kept, nobody has asked for it. heldReason says why
collected Filed. documentId points at the document
failed Given up on, for a reason no retry fixes

Only FACTURA PRIMITA can be anything but notApplicable. A rejection, a buyer message, or an invoice the client itself sent has no document behind it.

A held invoice somebody has collected reads collecting, not held, and carries no heldReason, until the Worker files it. Collecting stamps the request on the row; it does not move the row's status, which stays held until the invoice is out of the archive and filed. Reading it back as held would snap the screen to Held the moment the person pressed the button.

heldReason is notListed - the issuer matched no supplier expected through e-Factura - or issuerUnreadable, meaning the invoice XML carried no readable issuer fiscal code.

A message here is a notification. The invoice itself, once fetched, is a document. See Documents.

POST /v1/spv/messages/{id}/read

Marks one message read for the whole firm, not for the caller. View on the client it belongs to. Answers 200 with the message.

Read state is shared on purpose. Two people at the same firm should not each have to work through the same stream.

POST /v1/spv/messages/{id}/collect

Files one received invoice the enrollment's scope left uncollected or held. ManageSources on the client it belongs to - this spends an ANAF download and creates a document, which is a source decision rather than a reading one.

No body. The answer is the same shape either way:

{ "status": "collecting", "documentId": null }
Status Means
200 "status": "collected" with documentId. It was already filed, nothing was queued
202 "status": "collecting". The request is on the ledger row and the Worker has it
404 No such message for this firm, or the module is off
403 The caller does not hold ManageSources on that client
409 One of the four refusals below
Code Means
efactura-not-an-invoice The message is not a FACTURA PRIMITA. There is nothing to file
efactura-collect-failed The ledger already gave up on it. ANAF's 60 days ran out, or the right is missing
efactura-not-enrolled The client company has no e-Factura enrollment to fetch under
efactura-messaging-unavailable The request is recorded but the broker would not take the work item

Pressing it again is safe, but it is not always free.

  • Already filed. 200 with the document id. Nothing is stamped, nothing is queued, nothing is asked of ANAF.
  • Held. Free. The invoice is filed from the archive already in storage, so nothing is asked of ANAF and the 60-day window does not apply.
  • Still on its way. The request is re-stamped and the download is queued again, and that costs another ANAF attempt. The consumer treats only filed and given-up-on rows as done, so a row still waiting for its bytes is fetched again on every delivery.

That last one is why collection is on the message: a screen showing collecting should stop offering the button rather than let somebody press it a second time. ANAF allows ten downloads of a message per day and then locks it until tomorrow, and the row gives up on its own after six attempts.

An invoice that was never downloaded - scope 1, messages only - is fetched now, which means ANAF has to still be holding it.

efactura-messaging-unavailable is the one worth explaining to the person. The request is rolled back before the refusal is answered: the message goes back to reading held or notCollected, which means the button comes back on the next refetch and pressing it once the broker is up starts the collect fresh. Nothing is lost in the meantime - a held invoice's ZIP is already in storage and stays there, and a message that was never downloaded is still at ANAF for the rest of its 60 days.

A row this call had to create is removed outright rather than left behind, because a ledger row nothing is carrying would read as collecting forever and hide the button for good.

POST /v1/spv/messages/read

Marks a set read in one act. View.

Field Type Required Means
profileId uuid no One client. Omit it for every client the caller can see
{ "markedCount": 42 }

GET /v1/spv/messages/unread-count

The badge, over the clients the caller can see.

Field Type Means
total integer Unread across every visible client
profiles array { "profileId": uuid, "unreadCount": integer }

Sales invoices from the sent list

Businesses that bill with SmartBill, FGO, or similar already upload their sales invoices to SPV. ANAF keeps those on a second list, the sent list (filtru=T, type FACTURA TRIMISA), beside the received list Syllevo has always polled (filtru=P, FACTURA PRIMITA). Reading it gives the accountant both sides of the VAT return without the business setting up an API integration.

Every poll reads the sent list right after the received list, for each enrolled client, on a cursor of its own (sent_window_end on the enrollment). The window rules are the same: thirty minutes of overlap, ten minutes behind now, never more than 59 days back, and the cursor only moves once ANAF has returned the last page. The first sent pass for a client starts 59 days back, the way a new enrollment does.

  • The sent list is only asked for once the received list has answered. A client without rights, a throttled grant, or an ANAF outage costs one call per pass, not two.
  • A sent listing that fails leaves the sent cursor where it was and is retried next pass. It never backs off the received side or counts as a poll failure.
  • Clients set to Messages only aren't asked for their sent list, since that scope files no invoices. Only listed suppliers narrows purchases by issuer and doesn't apply to sales, so those clients get their sales like everyone else.
  • Sent invoices never appear on /v1/spv/messages. That stream is what the client received from ANAF; its own sales aren't news to it.

How a sent invoice is filed:

  • It is stored as outgoing: the document and the invoice both carry direction: outgoing, the buyer goes into the counterparty fields, and there is no supplier. The provider stays e-Factura, and the ANAF archive with the Ministry of Finance signature is kept exactly as it is for received invoices.
  • It is not a collected document. It doesn't count toward usage, isn't queued for extraction, never creates a supplier, and never moves completeness, reminders, or the review queue.
  • The seller has to be the enrolled company. If the XML names a different seller, the invoice isn't filed as a sale and the message ends with efactura-sent-seller-mismatch. The usual cause is self-billing, where the client uploads an invoice on its supplier's behalf, so for the client it's really a purchase. The archive is kept.
  • Already imported through the API with the same XML: nothing new is created. The SPV arrival is added to the document already on file.
  • Imported as a PDF only, and the SPV XML agrees with it: the XML completes the sale. The sale has no XML file, and the SPV XML carries the same currency, net, VAT, and total (the number and issue date already match, since that's how the sale was found). The XML is linked at e-Factura trust and becomes the main copy, it fills in anything the PDF import left blank or wrote differently (the buyer, the due date), and the PDF stays linked beside it. No flag. The audit trail records invoice.spv_sent_linked with resolution completed_pdf_import, naming both files and the fields that changed. Getting the same SPV copy again changes nothing.
  • Already imported with a different file under the same number and issue date: another XML, or a PDF whose currency or amounts differ. The SPV copy wins. It's the version ANAF holds, so it's the one the accountant books. It's linked to the invoice at e-Factura trust and becomes its main copy, and the invoice's number, dates, amounts, and buyer are read again from it. The imported file stays linked beside it. The clash is written to the audit trail (invoice.spv_sent_conflict, naming both files, both hashes, and the fields that changed), and the invoice gets a ConflictingCopy anomaly flag naming the imported file in counterpartDocumentId (Invoices). The flag shows up wherever flags already do, with no email. One flag per invoice; once dismissed, it stays dismissed. Getting the same SPV copy again changes nothing.
  • A second, different SPV copy of a sale whose main copy ANAF already holds (it came from the sent list, or Syllevo sent it and the sent list returned the same bytes): the main copy stays. The new copy is stored as its own outgoing document, linked to no invoice, and the flag names it.
  • Imported after the SPV copy was filed, with a different file: the import is refused with 409 invoice_number_exists and nothing is stored. The business's invoicing software disagrees with ANAF about that invoice, and that's where it gets fixed.
  • The same bytes already filed as a purchase: left alone, and the message ends with efactura-sent-held-as-purchase.
  • An XML with no readable number, issue date, currency, or amounts: the message ends with efactura-sent-xml-unreadable, and the archive is kept.

Polling the same window again creates nothing new. The message ledger is unique on ANAF's message id, and a redelivered download finds its document by content hash.

An Uncertain submission is settled here. When Syllevo can't tell whether one of its own uploads reached ANAF, it never uploads again. It settles the submission from the sent list in only two cases: the entry's XML has the same hash as the file Syllevo uploaded, or the entry carries the upload index ANAF gave that upload. Then the submission moves on with the entry's upload index and answer id. Nothing else settles it: not the invoice the entry is filed onto, and not a matching number or issue date. An entry with the same number but different bytes leaves the submission Uncertain. See Invoices.

Sending sales invoices to SPV

Syllevo can upload a sales invoice the business already issued, for businesses whose invoicing software doesn't do it. It sends the XML file that software made, unchanged. It never writes, numbers, or fixes an invoice. The routes and states are in Invoices; this section is the switch that allows it.

View. Answers:

{
  "profileId": "11111111-1111-4111-8111-111111111111",
  "enabled": false,
  "organizationEnabled": true,
  "canEnable": true,
  "lockedReason": null,
  "holderName": null,
  "enabledById": null,
  "enabledAt": null,
  "disabledById": null,
  "disabledAt": null,
  "enabledByName": null,
  "disabledByName": null
}
Field Means
enabled The business allows Syllevo to send its sales invoices to SPV
organizationEnabled Sales invoice transmission is on for the firm. When false, nothing is sent either way
canEnable The caller may turn it on: the person, signed in, who connected this business to e-Factura
lockedReason Why canEnable is false, see below. null when it's true
holderName Who connected the business to e-Factura, set only when lockedReason is notHolder
enabledByName Who last turned it on: their name, or their email when we have none
disabledByName Who last turned it off, the same way

All three names are filled in by Syllevo, so a member who can't list the team still sees who did it. They are null when no one did, or when that person has left the firm.

lockedReason names the first thing that stands in the way, checked in this order:

Value Means
noPermission The caller can't manage the business's settings, or isn't a signed in person (an API key)
noTaxId The business has no tax id, or it isn't a valid Romanian CUI
notEnrolled The business isn't enrolled in e-Factura under that CUI
notConnected The e-Factura connection behind the enrollment is disconnected, expired, or failing
notHolder Someone else connected the business to e-Factura. holderName says who

noPermission comes first because a caller without it can't fix any of the others: adding the tax id, enrolling, and reconnecting all take the same rights.

The organization role doesn't matter. A Member who connected the business to e-Factura and has ManageSettings on it can turn it on; an Owner who didn't connect it can't.

Body { "enabled": true } or { "enabled": false }. Answers the same shape as the GET.

  • Turning it on takes the person who connected the business to e-Factura, whatever their role, signed in (not an API key), with ManageSettings on the profile. Uploads go out under that person's ANAF certificate, so it's their call alone. Anyone else gets 403 spv_consent_not_connection_holder. A business with no readable tax id, no enrollment under it, or no working e-Factura connection gets 422 spv_not_enrolled.
  • Turning it off takes ManageSettings on the profile, signed in. Anyone who may manage the business can stop it.
  • Both are recorded in the audit trail as profile.spv_transmission_consent_changed. Setting the value it already has changes nothing and records nothing.

Nothing already sent is withdrawn when it is turned off. A submission already queued is checked again right before the upload, and fails with spv_consent_missing if the switch is off by then.

What e-Factura does not cover

e-Factura is domestic business-to-business only. An invoice from a supplier in another EU country never appears here, and it still has to reach the accountant some other way: a forwarded email, a supplier connector, or an upload. That gap is the reason the rest of the sources on Sources exist.