Autonify
Български · English

Autonify Public API v1

Български: API документация

For the developer wiring something up to Autonify. Base URL https://app.autonify.bg/api/v1, server-to-server only.

Authorization: Bearer atn_live_01k…_9f3…
Content-Type: application/json
Idempotency-Key: <your own unique string>     # required on every POST and PATCH

No CORS is configured, and none will be. An Origin header on these routes means the key is sitting in a browser, which is a leaked credential, not a missing feature. It is recorded so it can be noticed.

There is a second direction: events we push to you, so an integration does not have to poll for the four things worth knowing about. That is §9.


1. Keys

Create one at Settings → Developer API, for the company you have open. The key is shown once; if you lose it, rotate rather than open a ticket.

API access is one of the capabilities a Growth or Enterprise customer picks, not something every plan carries. If the account has not picked it, the keys still mint and whoami still answers — every other call returns 403 api_not_in_plan until they do. Sandbox keys keep working regardless (§6), so you can build against it first.

atn_live_<key id>_<secret>      # a real company's books
atn_test_<key id>_<secret>      # the sandbox company

A key carries one company for its whole life. There is no company parameter anywhere in this API — not in a path, not in a query, not in a body — so a request for a different EIK cannot be expressed. That is not a check we perform; it is a request you cannot make.

Two consequences worth knowing before you design around it:

  • One EIK per key. Integrating eight companies means eight keys, and your configuration needs somewhere to hold eight credentials.
  • Access is re-evaluated on every request against the same delegation and staff-assignment rules the browser uses. If the customer withdraws your accountant's delegation, the key stops on the next call.

Rotation mints a new key and leaves the old one working for seven days, so you can deploy without an outage. Revocation is immediate.

Keys expire

Every key carries a hard expiry date, set at mint:

Environment Lifetime
atn_live_… 365 days
atn_test_… 90 days — a sandbox key is for building, not for running

Past it every call answers 401 token_expired. Nothing about that is gradual, and no grace window applies to it — this is a different clock from the developer terms above.

Two things keep it from surprising you. whoami returns expires_at on every call, so a health check can watch its own credential; and we notify the person who minted the key, and the account's owners, at 30, 7 and 1 days before the date. Rotate rather than wait for the refusal: rotation is the same seven-day overlap as always, so the switch costs you no downtime, while an expiry costs you all of it.

Terms changes

The developer terms carry a version. It is stamped on every key at mint and on the account that accepted it. When the terms change materially the version moves, and a 75-day grace window opens for every account still standing on the old one.

Nothing changes for you during the window. What does change is that every response you get — a 200, a 422, a 429, anything — carries:

X-Autonify-Terms: reaccept-by=2026-10-10

Log that header. It is the only place the news reaches your side, because the person who has to accept is the customer, and they are usually not you.

Past that date every endpoint answers 403 terms_reacceptance_required, whoami included. The message names the date and the page.

Nothing is revoked, rotated or re-issued by any of this. The customer opens Settings → Developer API and presses one button; your very next request is answered normally, with the same credential you are already holding. There is nothing for you to deploy, and no secret changes hands.

Sandbox keys follow the same policy. One rule is easier to build against than two, and it is the same customer accepting either way.

Permissions

Ticked per key at creation. Read-only by default.

purchases:read  purchases:write
sales:read      sales:write
orders:write
bank:read
masterdata:read masterdata:write
filings:read    filings:write
stock:read      stock:write
books:read
work:read       work:write

A key minted without any writes ticked carries purchases:read, sales:read, bank:read, masterdata:read, filings:read and stock:read.

work:read / work:write log work and nothing else. They reach /billable-entries (§5, „Work to invoice") and no other route: a time tracker's key can push the hours a client is billed for, and cannot issue, send, cancel or even read an invoice. Those three routes accept sales:read / sales:write as well, so an existing sales key keeps working unchanged.

books:read is not in that set, and that is deliberate. Every other read here is a document the customer's counterparty already holds a copy of — an invoice they sent, a purchase they received, a bank line they can see. The books are the whole ledger, so it is ticked on purpose or not at all.

filings:write records an outcome; it does not file anything. It writes down what a human did through the НАП channel and the входящ номер they were handed. There is no ability on this API that submits to НАП, because there is no endpoint that does — see §7.

There is no admin, billing, payroll, company or user permission, because there is no such endpoint. The API surface is deliberately smaller than the app's.

Your key's permissions are also intersected with the plan at request time. A sales:write key on a plan that only includes order push gets 403 ability_not_in_plan and starts working again, untouched, on upgrade.


2. Limits

Units, not requests. A flat request counter would charge the same for one row and a hundred.

Call Units
GET /whoami, every /filings read 0
An idempotent replay 0 — served even past the ceiling
One record 1
A list page (≤ 100) 5
Any write 10
Any file upload 25

A rendered or built artefact — an invoice PDF, its UBL XML, a counterparty statement, a stored original — is priced as a list, not as a single record: it costs a render pass or an XML build. The file weight is different in kind; it is the price of us reading a document for you, not of handing back one the customer already owns.

Allowance is per company per day, plus an account-wide daily ceiling so a practice with many clients cannot multiply a per-company cap. Minting more keys on the same company buys no extra quota. Burst is 120 requests/minute per key.

Every response carries:

X-Autonify-Units-Limit: 5000
X-Autonify-Units-Remaining: 4310
X-Autonify-Units-Reset: 2026-07-27T23:59:59+03:00     # midnight, Europe/Sofia

At the ceiling you get 429 quota_exceeded and a Retry-After. Never a partial or degraded response — a half-answer from an accounting API is worse than a refusal, because your code will persist it.

A request that is refused before it does any work — a 404, a validation failure — is refunded. The ceiling is not a punishment for a typo.

Two deliberate exceptions:

  • POST /purchases and POST /orders have a 10% grace band. A refused supplier invoice or customer order is a document that legally exists and the customer loses it, not you. Inside the band the call succeeds with X-Autonify-Quota-Warning: grace.
  • Every filings read is exempt entirely — the list, the file listing and the files themselves. A billing limit never blocks a compliance filing, and your quota is a billing limit. POST /filings/{id}/transition is a write and is priced as one: recording an outcome is bookkeeping, not a compliance read.

Free is a price, never a permission. A zero-unit route still requires the ability it declares. A key minted with orders:write alone cannot list a company's filings, and the 403 it gets costs nothing.


3. Idempotency

Idempotency-Key is required on every POST and every PATCH. Not because a duplicate row is untidy, but because a duplicate invoice lands in a legally gap-free series, becomes visible to the NRA, and cannot be quietly deleted.

  • Same key, same body → the first response, replayed, at 0 units, even past your quota. The replay is marked:

    X-Autonify-Idempotent-Replay: true
    X-Autonify-Units-Charged: 0
    
  • Same key, different body → 422 idempotency_key_reused. That is a bug in your code — two documents sharing one key — and answering with the first document's id would let the second one vanish.

  • Keys are honoured for 24 hours. A 5xx is not remembered, so a genuine retry after a server error genuinely retries.

What is remembered is only what the bytes deserve. A 2xx, a 400 and a 422 are verdicts on your request, and the same bytes must always get the same answer. Everything else — 402, 403, 409, 429, 5xx — is a fact about the world at that instant: a plan that has lapsed, an allowance that is spent, a period that is locked. Those are released, so the retry the documentation tells you to make genuinely re-runs. Nothing was created either way; that is what makes releasing them safe.

If two of your workers race the same key, the loser gets 409 idempotency_in_progress rather than a second document.


4. Shapes

Money is always an object, never a float:

{ "amount": "1234.56", "currency": "EUR" }

A document carries figures in its own currency and in EUR, because the books are in EUR and the supplier's invoice may not be. A float would also lose cents on the way through JavaScript, which is what most integrations are written in.

A quantity is not money. Stock levels and line quantities are plain decimal strings — "20.000" — because three decimals of a kilogram is not an amount of anything.

Dates are YYYY-MM-DD. Timestamps are RFC 3339. Every business-day boundary is Europe/Sofia, including the quota reset.

Success:

{ "data": { … }, "meta": { "next_cursor": "01k…", "limit": 50 } }

Failure:

{ "error": { "code": "duplicate_document", "message": "…", "request_id": "01k…" } }

Some refusals carry a details object beside those three — the id you already have, the transition you asked for, the seconds until a cooldown lifts. §8 says which.

One exception, and it is the framework's. A body that fails field validation is answered by Laravel itself, in its own shape, with the messages in the installation's language:

{ "message": "Полето дата на издаване е задължително. (and 3 more errors)",
  "errors": {
    "issued_on": ["Полето дата на издаване е задължително."],
    "lines":     ["Полето редове е задължително."]
  } }

Two endpoints validate by hand and therefore answer 422 validation_failed in the ordinary envelope with the field errors in details: POST /orders and PATCH /counterparties/{id}. Everything else that validates a body answers the framework shape. Handle both; the status is 422 either way, and neither one comes right on a retry. Branch on the status, not on the body.

Lists are cursor-paginated. Pass ?cursor= from meta.next_cursor, up to ?limit=100, and ?updated_since= for incremental sync. A cursor rather than a page number because these lists change while you read them: a document captured between page 2 and page 3 shifts every later row, and an offset would silently skip one.

Binary answers

Five endpoints hand back a file rather than JSON: the invoice PDF and its UBL XML, a purchase's stored original, a counterparty statement, and one of a filing's artefacts.

Content-Type: application/pdf
Content-Disposition: attachment; filename="invoice-0000000042.pdf"
  • Raw bytes. No {"data":…} wrapper and no base64 — the artefact these routes exist to hand over is a PDF a buyer prints or an XML their ERP parses, and wrapping it would mean every integrator writes the same decode step before they can use it.
  • Refusals are still JSON. A 403, a 404 or a 409 on these routes is the ordinary {"error":{…}} envelope, so your error handling does not fork on the endpoint. Only the 200 is bytes.
  • The quota headers are still there, and the call is still metered and logged: the meter reads the status code and never the body.
  • Content-Disposition is always attachment, with an ASCII filename. There is no ?inline=1 — nothing here is being rendered in a page — and a Cyrillic filename would need RFC 5987 encoding to survive every client, so names are transliterated or keyed by id.

409 or 422 — the doctrine

The distinction is not severity. It is whose fact it is.

  • 422 is a fact about your bytes. The field is missing, the VAT rate is not one ЗДДС has, the payload does not match the contract. Re-sending the identical body could never succeed, so the answer is memoised against your Idempotency-Key.
  • 409 is a fact about the world. The document is already posted, the period is locked, the proforma has been converted, the transition is not legal from the status the filing is in, the buyer has no email address yet. The same bytes succeed once the fact changes, so the answer is released and your retry genuinely retries.

If you are ever in doubt reading one: a 409 is worth retrying after you or the customer has changed something. A 422 is worth fixing in your code.


5. Endpoints

Method Path Permission Units
GET /whoami any 0
GET /purchases purchases:read 5
GET /purchases/{id} purchases:read 1
POST /purchases (JSON) purchases:write 10
POST /purchases (file) purchases:write 25
GET /purchases/uploads/{id} purchases:read 1
POST /purchases/{id}/approve purchases:write 10
PATCH /purchases/{id}/fields purchases:write 10
POST /purchases/{id}/annul purchases:write 10
POST /purchases/{id}/protocol purchases:write 10
GET /purchases/{id}/file purchases:read 5
GET /sales/invoices sales:read 5
GET /sales/invoices/{id} sales:read 1
GET /sales/next-number sales:read 1
POST /sales/invoices sales:write 10
GET /sales/invoices/{id}/pdf sales:read 5
GET /sales/invoices/{id}/ubl sales:read 5
POST /sales/invoices/{id}/send sales:write 10
POST /sales/invoices/{id}/storno sales:write 10
POST /sales/invoices/{id}/annul sales:write 10
POST /sales/proformas/{id}/convert sales:write 10
GET /billable-entries work:read or sales:read 5
POST /billable-entries work:write or sales:write 10
DELETE /billable-entries/{id} work:write or sales:write 10
POST /orders orders:write 10
GET /counterparties masterdata:read 5
POST /counterparties masterdata:write 10
PATCH /counterparties/{id} masterdata:write 10
GET /counterparties/{id}/statement masterdata:read 5
GET /items masterdata:read 5
POST /items masterdata:write 10
GET /chart-of-accounts masterdata:read 5
GET /bank-accounts bank:read 5
GET /bank-transactions bank:read 5
GET /warehouses stock:read 5
GET /stock stock:read 5
GET /stock/movements stock:read 5
POST /stock/transfers stock:write 10
POST /stock/adjustments stock:write 10
POST /stock/counts stock:write 10
POST /stock/conversions stock:write 10
GET /books/trial-balance books:read 5
GET /books/journal books:read 5
GET /filings filings:read 0
GET /filings/{id}/files filings:read 0
GET /filings/{id}/files/{fileId} filings:read 0
POST /filings/{id}/transition filings:write 10

An id that belongs to another company answers 404 not_found, never 403. A 403 would confirm the id exists somewhere, which is an existence oracle over every EIK on the platform. That holds on every route below without being repeated on each one.

Start here

curl https://app.autonify.bg/api/v1/whoami \
  -H "Authorization: Bearer $AUTONIFY_KEY"

whoami costs nothing and works even when the plan does not include the API, so your health check reports the real reason instead of dying. It is the one endpoint to build your monitoring on.

{ "data": {
  "company": { "id": "01k…", "eik": "131063188", "name": "Тест ЕООД",
               "vat_status": "registered", "vat_number": "BG131063188" },
  "environment": "live",
  "plan": { "api": "full" },
  "abilities": ["purchases:read", "sales:read", "sales:write", "…"],
  "token": { "name": "ERP", "masked": "atn_live_01k9m2p1…4T7Q",
             "expires_at": "2027-07-27T09:14:02+00:00" },
  "quota": { "units_used_today": 690, "units_daily": 5000,
             "units_remaining": 4310, "resets_at": "2026-07-27T23:59:59+03:00" }
} }

abilities is the effective set — what the key holds, narrowed by the plan. A token whose sales:write is currently unusable says so here rather than only at the moment it fails.

Capture a supplier invoice

curl -X POST https://app.autonify.bg/api/v1/purchases \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: erp-doc-88213" \
  -H "Content-Type: application/json" \
  -d '{
    "supplier": { "name": "Доставчик ЕООД", "eik": "131063188" },
    "document_number": "0000012345",
    "document_date": "2026-07-14",
    "payment_due_on": "2026-08-13",
    "currency": "EUR",
    "total_net": "100.00",
    "total_vat": "20.00",
    "lines": [
      { "description": "Хартия A4", "product_code": "PAP-A4",
        "quantity": 10, "unit_price": "10.00", "vat_rate": 20 }
    ]
  }'

Required: supplier.name, document_number, document_date, currency, total_net, total_vat. Optional: supplier.eik, supplier.vat_number, payment_due_on, kind (invoice | credit_note | debit_note | protocol, default invoice), expense_account_code, external_ref, and up to 500 lines.

expense_account_code — one document, more than one account. Send it at the top level to say where the whole cost goes, and on a LINE to charge that line somewhere else: a landlord's invoice carrying rent and re-billed electricity is one paper document with two accounts, not two documents. The codes are the company's own chart, so an account that is not on it — or one its owner has retired — is refused rather than quietly replaced. Omit both and nothing changes: the document is classified the way it always was. A line that names no account belongs to the document's own.

The document's total_net and total_vat stay authoritative whatever the lines say — they are what the supplier printed, what the дневник покупки reports and what 401 is credited with. Lines only describe how the cost is DIVIDED, so they are never cross-footed against the totals and a line list that does not add up produces a different split rather than a refusal. What the lines leave unexplained is charged to the document's own account.

One shape is worth knowing about, and it is not refused: a document whose own account is a stock account (302–308) and whose lines leave a remainder. That remainder is debited to the stock account with no line to receive it into the stock records, so the ledger and the sub-ledger part by exactly it and those goods cost out at zero when sold. The document is captured and posted normally — a supplier's invoice is a fact your books have to record — and a notice naming the amount is raised in the app when it is posted. Send the remainder as its own line, or put the document on an account that is not stock, if you want it gone. Line-level rounding never triggers it: a cent per line is tolerated, because Σ round(x) ≠ round(Σ x).

payment_due_on is the due date the supplier's document STATES, and it is optional because a due date is not a чл. 114 ЗДДС requisite — plenty of invoices print none. Send it only when the paper carries one: it is what lets the payables aging report lateness rather than only age, and what dates the bill in the cash-flow forecast instead of the assumed 30-day term. Omit it and the document simply has none; never send a term you computed yourself.

A line takes description, quantity, unit_price, optionally vat_rate, net, vat — both derived from quantity × price when absent — expense_account_code (see above), and product_code. That last one is how goods reach the stock sub-ledger: a line with a code that matches an article receives stock when the document is posted. Without it the purchase is an expense and nothing else.

The response is your own document record — supplier, number, date, totals, VAT credit right, expense account — after Autonify's classification has run.

{ "data": {
  "id": "01k…",
  "kind": "invoice",
  "source": "api",
  "supplier": { "name": "Доставчик ЕООД", "eik": "131063188", "vat_number": null },
  "document_number": "0000012345",
  "document_date": "2026-07-14",
  "payment_due_on": "2026-08-13",
  "currency": "EUR",
  "totals": {
    "net":   { "amount": "100.00", "currency": "EUR" },
    "vat":   { "amount": "20.00",  "currency": "EUR" },
    "gross": { "amount": "120.00", "currency": "EUR" },
    "eur_net":   { "amount": "100.00", "currency": "EUR" },
    "eur_vat":   { "amount": "20.00",  "currency": "EUR" },
    "eur_gross": { "amount": "120.00", "currency": "EUR" }
  },
  "vat_credit_right": "full",
  "expense_account_code": "601",
  "status": "posted",
  "needs_review": false,
  "review_reasons": [],
  "created_at": "2026-07-14T10:02:11+03:00",
  "updated_at": "2026-07-14T10:02:11+03:00",
  "lines": [ { "description": "Хартия A4", "product_code": "PAP-A4",
               "quantity": "10.000000",
               "unit_price": { "amount": "10.00", "currency": "EUR" },
               "vat_rate": "20.00",
               "net": { "amount": "100.00", "currency": "EUR" },
               "vat": { "amount": "20.00",  "currency": "EUR" } } ]
} }

source says where the FIELDS came from, not where the request came from: api for a document you described, api_upload for one our reader filled in. An integrator reconciling a mismatch needs to know which of the two they are looking at.

Duplicate protection is built in: a content hash over supplier, number, date and amount means re-sending the same invoice returns 409 duplicate_document with the existing id in details.existing_id, not a second document.

Filter the list with ?kind=, ?needs_review=, ?from=, ?to=, plus the usual ?cursor=, ?limit= and ?updated_since=.

Send a file instead

Same endpoint, multipart/form-data, one file field. PDF, JPG or PNG, up to 10 MB.

curl -X POST https://app.autonify.bg/api/v1/purchases \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: scan-2026-07-27-0041" \
  -F "file=@invoice.pdf"
{ "data": {
  "id": "01k9…",
  "object": "purchase_upload",
  "status": "extracting",
  "original_name": "invoice.pdf",
  "document": null
} }

You get 202 and a Location header, not a document — because there isn't one yet. Poll the receipt:

curl https://app.autonify.bg/api/v1/purchases/uploads/01k9… \
  -H "Authorization: Bearer $AUTONIFY_KEY"
status Meaning document
extracting Queued or being read. null
captured Read and captured. the document
needs_review Not enough was legible to capture. A human finishes it in the app. null
duplicate You already have this one. the document it duplicates
failed The file could not be read at all. null

Or subscribe to purchase.upload.finished (§9) and stop polling: it fires on every one of those outcomes, failed included.

Three things about this endpoint are deliberate and will not change:

  • The read is asynchronous, at the account's normal queue priority. There is no ?wait=true and there will not be one: poll the receipt. A busy hour can mean minutes, so do not block a checkout on it.
  • A file costs 25 units, not 10. Budget uploads separately from writes.
  • You never receive the reader's output. Not the raw fields, not the text, not confidences or bounding boxes. File in, your own record out. If a read is wrong, correct the document — in the app or through PATCH /purchases/{id}/fields below — and the correction is remembered for that supplier, so the next invoice from them comes back better.

The upload also needs the OCR entitlement, which is a separate feature from the API. Without it you get 403 ocr_not_in_plan — and capturing the same document as JSON still works, because a billing gate never blocks entering a document, only reading one for you.

A plan whose monthly reading allowance is spent is not a refusal: the file is accepted and receipted as always, and comes back needs_review for a human to finish. Losing a supplier's invoice over a monthly count would be the one shape this endpoint must not have.

Idempotency on an upload hashes the file's bytes, not just the body, so the same key with a different scan is refused (422 idempotency_key_reused) rather than silently answered with the first document. Retrying the identical file under the same key replays, as it should.

Correct, approve, annul a supplier document

curl -X PATCH https://app.autonify.bg/api/v1/purchases/01k…/fields \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: erp-fix-88213-1" \
  -H "Content-Type: application/json" \
  -d '{
    "supplier": { "name": "Доставчик ЕООД", "eik": "131063188" },
    "document_number": "0000001234",
    "document_date": "2026-06-11",
    "payment_due_on": "2026-07-11",
    "total_net": "120.00",
    "total_vat": "24.00"
  }'

The five header fields above payment_due_on are required — this is the API half of the review screen, and it submits the header as a whole rather than a field at a time. payment_due_on is the exception: send it to set or change the stated due date, send it as null to say the document states none, and leave it out entirely to keep whatever is stored. Omitting it is not the same as sending null — a client written before the field existed must not silently erase a падеж somebody read off the page. It answers the document with meta.corrections_recorded beside it, and it runs the same four consequences the browser's review screen runs: the recorder's snapshot, the withdrawn registry suggestion, the supplier's master record, the learning pass. An API correction that skipped any of them would slowly make the learning corpus mean "what people fixed in a browser".

  • 409 document_posted — the figures are already in the ledger. The remedy is a storno, which exists and leaves a trail.
  • 409 document_annulled — an annulled document is immutable.

POST /purchases/{id}/approve posts a captured document to the ledger and answers the document with status: "posted". It is idempotent in the service: approving twice posts once.

POST /purchases/{id}/annul takes a required reason (≤ 500 chars) and writes a mirrored entry — never an edit. A document that never reached the ledger cannot be annulled: 409 annul_refused. (Binning an unposted document is a decision a person makes while looking at it, and is deliberately not on this API.)

POST /purchases/{id}/protocol issues the чл. 117 protocol for a reverse-charge acquisition and answers 201 with the protocol as a purchase document (kind: "protocol", number ПРОТ-…). It is idempotent — a second call returns the protocol that already exists — and refused with 409 protocol_requires_reverse_charge when the document is not a reverse-charge acquisition. Whether it is one is a classification decision the app makes, not one the caller may assert.

GET /purchases/{id}/file streams the stored original — the scan or PDF the supplier sent. Bytes, per §4. It is the customer's own document coming back out, which is the one thing the extraction embargo was never about. A document captured as JSON has no file: 404 no_file.

Issue an invoice

curl -X POST https://app.autonify.bg/api/v1/sales/invoices \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: shop-order-5512" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "invoice",
    "issued_on": "2026-07-27",
    "buyer": { "name": "Клиент ЕООД", "eik": "831919992" },
    "lines": [
      { "description": "Абонамент", "quantity": 1, "unit_price": "50.00", "vat_rate": 20 }
    ]
  }'

kind is invoice, proforma or credit_note. buyer takes either a counterparty_id or a name, plus optional eik, vat_number, address. Optional on the document: taxable_event_on (defaults to issued_on — an advance or a continuous supply sends its own), payment_method, corrects_document_id, external_ref. Up to 200 lines, each description + quantity + unit_price + vat_rate, plus the optional unit (a measure label of up to 20 characters, e.g. бр. or kg — printed on the invoice) and the optional item_id and restocked below. Line quantities must be above zero: a negative line is not how a supply comes down — that is what a кредитно известие is for, it takes the next number in the same series, and it is the only instrument that can also put the goods back.

vat_rate must be 20, 9 or 0. Those are the rates ЗДДС has. Anything else is refused at the door, because a 10% line would produce an invoice charging 10%, a ledger crediting it, and a ДДС return declaring the sale zero-rated — undeclared output tax, which is the direction НАП penalises.

A line that names an article moves goods. lines[].item_id is the id of an income Артикул from GET /items, and it is the same optional link the browser's composer sends: an invoice or a дебитно известие carrying one relieves the stock sub-ledger and posts COGS through the same engine an order does. Without it the line is a service, a free-text line, or goods you are choosing not to track — the document posts revenue and VAT and nothing leaves a shelf.

The id is resolved within the token's own company, and an id that is not is 422 on that field rather than a silent skip. It is 26 characters anybody can generate, and unscoped it would let a key relieve another tenant's shelf and put their goods into this company's 611.

lines[].restocked says the goods came back, and only a кредитно известие may say it. чл. 115 is about VALUE — a discount agreed later, a cancelled supply, a quality allowance, and sometimes an actual return — and nothing on the document tells those apart, so the flag is per line and opt-in, and silence never moves stock. A restocking line returns the units at the original issue's own cost, to the place they left from, capped at what that issue actually took, which is why corrects_document_id is what makes it possible at all.

On any other kind the flag is a 422, not an ignored field. It used to be dropped silently while that same line's item_id moved the article the OPPOSITE way, so an integrator reading 201 for "restocked": true and watching stock go down had been told the wrong thing twice.

corrects_document_id must be an invoice of this company's own. Validated as a bare string it would let a token park a кредитно известие against another tenant's invoice — landing in these books pointing across the fence and blocking the victim's own annulment for ever. "Not yours" and "does not exist" answer identically, so the check leaks nothing.

{ "data": {
  "id": "01k…",
  "kind": "invoice",
  "status": "issued",
  "annulled_on": null,
  "corrects_document_id": null,
  "series": "1",
  "number": 42,
  "issued_on": "2026-07-27",
  "taxable_event_on": "2026-07-27",
  "buyer": { "name": "Клиент ЕООД", "eik": "831919992" },
  "currency": "EUR",
  "totals": {
    "net":   { "amount": "50.00", "currency": "EUR" },
    "vat":   { "amount": "10.00", "currency": "EUR" },
    "gross": { "amount": "60.00", "currency": "EUR" }
  },
  "vat_breakdown": { "20": { "base": "50.00", "vat": "10.00" } },
  "origin": "api",
  "created_at": "2026-07-27T12:00:00+03:00",
  "updated_at": "2026-07-27T12:00:00+03:00",
  "lines": [ … ]
} }

vat_breakdown is keyed by rate and carries plain decimal strings — it is the document's own VAT summary, in the shape the ДДС return reads, and a zero-rated bucket carries the legal basis beside them.

Numbering goes through the same counter the browser uses, so an API-issued invoice takes the next number in the same gap-free series as one typed by hand. There is no separate API series — that would be the fastest way to produce the gap Art. 113 forbids.

GET /sales/next-number is a preview. The number is allocated at issue, so two callers asking at once see the same answer and only one of them gets it. Never render it on a document you have not issued.

A refusal about the company's state — a chart with no account 702, a locked period, a posting that does not balance — is 409 invoice_refused, not 422. Nothing was created, so your retry re-runs work that never happened.

Filter the list with ?kind=, ?from=, ?to=.

The life of an invoice after it is issued

All six of these re-enter the same service the browser button calls. A сторно issued through the API takes the next number in the same чл. 113 series, posts the same reversal, and is refused by the same guards as one clicked in the app — because it is one.

GET /sales/invoices/{id}/pdf — the invoice as a PDF, bytes. Rendered on demand if the stored copy is missing, so a document issued before the renderer ran is still fetchable.

GET /sales/invoices/{id}/ubl — the EN 16931 / UBL 2.1 XML, the artefact a buyer's ERP ingests. Generated from the row on every call rather than stored: the document is immutable once issued (чл. 116), so the XML is a pure function of it. A proforma has no e-invoice form — 404 ubl_not_available — and a row whose lines no longer reproduce its total is 409 ubl_refused.

POST /sales/invoices/{id}/send — email it to the buyer. Answers 202:

{ "data": { "id": "01k…", "status": "queued", "to": "kupuvach@example.bg" } }

Queued, not delivered — delivery is the mail provider's verdict and arrives later; nothing here can honestly promise it. The ceilings are the browser's ceilings, key for key: one budget per document and one per company per day, shared with the resend button a customer may be watching. Two meters on one mailbox is no meter at all.

A document is delivered to its buyer once. If the app has already emailed it — every issued document with a buyer address is delivered automatically unless the company has switched that off — this answers 200 and sends nothing:

{ "data": { "id": "01k…", "status": "already_delivered",
            "to": "kupuvach@example.bg", "delivered_at": "2026-08-30T09:12:44+00:00" } }

Not an error, and it spends no send allowance: the state you asked for already holds. To put a second copy in the buyer's hands anyway, send {"resend": true} — the API's spelling of the Изпрати повторно button — and it answers 202 as above. Without it, calling this endpoint straight after issuing a document cannot mail one numbered чл. 114 document twice from two message ids.

  • 409 no_buyer_email — the document has no buyer address yet.
  • 429 send_cooldown — emailed a moment ago. details.retry_after is seconds.
  • 429 send_daily_limit — this company has spent today's manual send allowance. details.retry_after is seconds.
  • 403 send_not_in_sandbox — sandbox documents are never emailed (§6). A refusal rather than a silent no-op: an endpoint that answers 202 and sends nothing is worse to build against than one that says no.

POST /sales/invoices/{id}/storno — a кредитно известие under ЗДДС чл. 115. reason is required, because чл. 115, ал. 4 makes основанието part of the document. Optional quantities credits a subset, keyed by the original invoice's line index; absent, the whole invoice is credited. Answers 201 with the credit note, whose corrects_document_id names the invoice.

POST /sales/invoices/{id}/annul — this document should never have been issued. A different legal act from a сторно, hence a different route: a кредитно известие declares a reversal, an annulment declares nothing at all. The number stays spent either way (ППЗДДС чл. 78). reason required; answers 200 with status: "annulled". annulled_on is not accepted, here as in the browser — the date decides which VAT period the reversal lands in, and letting the caller choose it is letting the caller move output tax between periods.

Both answer 409 period_locked against a closed period, and 409 storno_refused / 409 annul_refused when the service refuses — an invoice already credited cannot then be annulled.

POST /sales/proformas/{id}/convert — проформа → фактура. The real invoice takes the next number in the чл. 114 sequence, never the proforma's; that counter is one the law does not read. One conversion per proforma: the second is 409 convert_refused, with a unique constraint behind it. Answers 201 with the new invoice.

Work to invoice — billable entries

A standing invoice set to bill work done (on /recurring-invoices) puts on each period's invoice the services logged for its client in that period, one invoice line per entry. These three routes are how a time tracker, a helpdesk or an ERP hands that work over, so nobody retypes it.

curl -X POST https://app.autonify.bg/api/v1/billable-entries \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: tracker-7731" \
  -H "Content-Type: application/json" \
  -d '{ "counterparty_id": "01k…", "performed_on": "2026-07-03",
        "description": "Консултация по договора", "quantity": "2.5",
        "unit": "ч.", "unit_price": "80.00", "vat_rate": 20,
        "external_ref": "tracker-7731" }'
{ "data": {
  "id": "01k…", "counterparty_id": "01k…", "performed_on": "2026-07-03",
  "description": "Консултация по договора", "quantity": "2.5000", "unit": "ч.",
  "unit_price": "80.0000", "vat_rate": 20,
  "net": { "amount": "200.00", "currency": "EUR" },
  "item_id": null, "account_code": "703", "status": "unbilled",
  "sales_document_id": null, "billed_at": null, "recurring_invoice_id": null,
  "source": "api", "external_ref": "tracker-7731", "…": "…"
} }

POST /billable-entries — required: counterparty_id (one of the company's counterparties), performed_on (the day the work was done, never later than today in Sofia), description, quantity (> 0), unit, unit_price (net of VAT, EUR, up to four decimals) and vat_rate (a rate this seller may charge — 20, 9 or 0, and 0 only for a company not registered under the VAT Act). Optional: item_id (one of the company's income items), account_code (702 | 703 | 709; default the item's, else 703) and external_ref (up to 100 characters).

  • description is at most 240 characters, because the entry is printed as the invoice line 03.07.2026 — Консултация по договора and an invoice line is at most 255 on every door that issues one.
  • 201 when it created the entry. The same external_ref again answers 200 with the entry it already made — send the tool's own id for the work, and re-syncing a week can never put the same hour on an invoice twice. That is beside the Idempotency-Key (§3), not instead of it: the key covers a retried request for a day, the reference covers the same work sent twice.
  • 403 recurring_not_in_plan — the plan does not include recurring invoices. Reading and deleting entries keep working.
  • unit_price is a decimal string with the four places an invoice line keeps, like a line's own; net is an amount, so it is money (§4).

What happens to an entry next. On day 1–5 of the month after its period, the client's work-based rule issues that period's invoice: one line per unbilled entry dated inside the period, the period's last day as the данъчно събитие (ЗДДС чл. 25, ал. 4), and the entry turns billed — with sales_document_id and billed_at — in the same transaction that issued the document. An entry dated in a period whose invoice has already gone out is never pulled into a later one: that would declare a supply in the wrong period. It stays unbilled, and the customer is told and offered a separate invoice for it. So send work when it is done, dated the day it was done.

GET /billable-entries — ?counterparty_id=, ?status=unbilled|billed, and ?from= / ?to= on performed_on, both inclusive. Cursor-paginated (§4).

DELETE /billable-entries/{id} — takes back an entry no document carries: 200 {"data": {"id": "01k…", "deleted": true}}. A billed entry is the опис of an issued invoice, which чл. 116 forbids editing, so it answers 409 entry_billed with details.sales_document_id; the correction is a credit note (POST /sales/invoices/{id}/storno).

The permission is work:write / work:read — the pair a time tracker's key should be minted with, which reaches these three routes and nothing else — or sales:write / sales:read: an entry exists to become an invoice line, and a key that may issue the invoice may say what goes on it.

Push a shop order

curl -X POST https://app.autonify.bg/api/v1/orders \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: shop-5512" \
  -H "Content-Type: application/json" \
  -d '{
    "store_id": "01k…",
    "id": "SHOP-5512", "number": "5512", "status": "completed",
    "currency": "EUR",
    "created_at": "2026-06-10T09:00:00+03:00",
    "paid_at": "2026-06-10T09:01:00+03:00",
    "payment_method": "card",
    "total_gross": "120.00", "total_vat": "20.00",
    "lines": [
      { "name": "Чаша", "sku": "CUP-1", "quantity": 2, "unit_price": "50.00",
        "vat_rate": 20, "net": "100.00", "vat": "20.00", "gross": "120.00" }
    ]
  }'

The same ingestion the signed store webhooks reach, judged by the same wire contract the WooCommerce plugin, the OpenCart plugin and the Windows till connector are judged by. A second normaliser for API callers would mean two ideas of what an order is, drifting apart until a merchant's shop and their ERP booked the same sale two different ways.

store_id names the store and is resolved within the token's company — a store belonging to someone else is simply not found. Required beside it: id, status, currency, created_at, payment_method, total_gross, total_vat, lines. Optional: number, paid_at, delivered_at, business_date (strict Y-m-d), total_discount, buyer. Totals may not be negative — the direction of money lives in the event, and a negative total here is always an adapter that read a сторно as a sale.

The document must foot against itself. Σ lines.vat has to equal total_vat, and Σ lines.gross has to equal total_gross, both within one stotinka. This is not a formality: the ledger credits output VAT from the SUM OF THE LINES and derives revenue as total_gross − that sum, while the чл. 114 document takes its own totals from the HEADER and its per-rate breakdown from the lines. A body whose halves disagree does not fail anywhere downstream — it books, and the difference lands silently in the merchant's VAT return. Note that total_discount is stated, not subtracted: the lines are already net of any order-level discount, exactly as the WooCommerce and Shopify rails send them, so it never enters the sum. 422 validation_failed names both figures and the difference.

Synchronous, where the webhook is queued: nothing is waiting on a checkout here, and you want the order id back. Answers 201 with the order, its totals in the shop's currency and in EUR, and delivered_at — the field that says whether the sale is in the books yet. Ingestion is idempotent on (store, order id): a re-send updates the paid/delivered transitions and never duplicates, so 201 does not mean "new".

  • 404 not_found — no such store on this company.
  • 409 store_not_push — Shopify and eMAG stores are pulled on a cursor, and accepting a push would let an integration inject orders the real connector then contradicts on its next tick.
  • 402 plan_required — no active plan or trial. details.retryable is true: hold the order and re-send it once one is live. A pause, not a rejection.
  • 422 validation_failed — the body does not match the contract, with the field errors in details. The one refusal here that can never come right on a retry.
  • 409 period_locked / 409 order_refused — the ledger said no.

This lane carries the grace band (§2), for the same reason POST /purchases does.

Наличности — stock

This whole section needs a plan that carries the stock module. Every route below answers 403 inventory_not_in_plan without it — a different refusal from api_not_in_plan: the plan has the API and does not have this module, so a bigger API allowance changes nothing and the customer upgrades the plan instead. Sandbox keys are not exempt, because the door would still be shut on the company the key is really for. What keeps working on every plan is the BOOKKEEPING: a purchase with lines[].product_code still receives into the sub-ledger and a delivered order still relieves it and posts COGS. Only the stock endpoints are priced.

GET /warehouses lists the places stock can be: a shop with a till and an online channel are both warehouses here, and kind says which is which. Filter with ?kind=.

GET /stock answers per article, with the breakdown per place:

{ "data": [ {
  "id": "01k…",
  "sku": "CUP-1",
  "name": "Чаша",
  "quantity_on_hand": "20.000",
  "unit_cost": { "amount": "5.00", "currency": "EUR" },
  "locations": [ { "warehouse_id": "01k…", "quantity": "8.000" } ],
  "updated_at": "2026-06-05T12:00:00+03:00"
} ], "meta": { "next_cursor": null, "limit": 50 } }

quantity_on_hand is always the company total, even when ?warehouse_id= narrows the breakdown below it: the two answer different questions and you need both to reconcile a count. unit_cost is the НСС 2 weighted average per article in the books' currency — not per location, because a per-location average would make carrying a box between two shelves post a gain or a loss. ?sku= narrows to one article; a warehouse id that is not this company's is 404 not_found.

A negative location balance is information, not an error, and it is reported as it stands. A shop that sold goods the books still show in the warehouse has moved them without recording it, and «магазин −3 / склад +10» is exactly what that looks like. The company total and account 304 both stay right, and the next count settles it.

GET /stock/movements?sku= is the стокова карта: every movement of one article, oldest first, with the running balance beside it. It is how the figure above is proved rather than trusted — the balance on the article's newest movement is its quantity_on_hand, because the balance is summed over the whole history in business-day order before anything is filtered or paged. meta.quantity_on_hand carries that figure on every page, so you can assert the column without reading to the end of it.

curl "https://app.autonify.bg/api/v1/stock/movements?sku=CUP-1&from=2026-06-01&to=2026-06-30" \
  -H "Authorization: Bearer $AUTONIFY_KEY"
{ "data": [ {
  "id": "01k…",
  "moved_at": "2026-06-05T12:00:00+03:00",
  "warehouse_id": "01k…",
  "direction": "in",
  "quantity": "20.000",
  "unit_cost": { "amount": "5.00", "currency": "EUR" },
  "value": { "amount": "100.00", "currency": "EUR" },
  "balance": "20.000",
  "movement_type": null,
  "source_type": "purchase_document",
  "source_id": "01k…"
} ],
  "meta": { "next_cursor": null, "limit": 50, "sku": "CUP-1", "name": "Чаша",
            "quantity_on_hand": "20.000",
            "from": "2026-06-01", "to": "2026-06-30" } }

sku is required — a running balance belongs to one article. An SKU these books have never held is an empty page, not a 404, exactly as ?sku= on GET /stock: an article that does not exist here has no history rather than a missing one.

?from= and ?to= are inclusive business days, and they narrow what is shown, never what is counted. A balance re-based on the visible rows would read as "the article held this much" while meaning "this much moved since the 1st" — the same number with a different meaning, on the one document an accountant reconciles an article with.

value is what the movement did to account 304: on an issue, the weighted average in force at the time, not a revaluation at today's. Do not re-derive it by multiplying — unit_cost is rounded to the cent like every other money figure here, while the sub-ledger carries four decimals.

movement_type is the SAF-T nomenclature entry the row was stamped with, and null where nothing stamped one: a purchase receipt and an ordinary order issue both leave it empty, and the стоков файл derives the entry from the source and direction when it is built. What you get here is what the row carries, never a guess at what the file will say.

Two of those codes mean something different from every other row. On a 130 «Увеличение от преоценка» or 140 «Намаление от преоценка» row an НСС 2 обезценка moved VALUE and no units: quantity is "0.000", unit_cost carries the whole amount of the write-down or its reversal, value restates it, and balance is unchanged. Reported rather than hidden, because that row is the only trace the article carries of having been written down.

Paging is keyset on (business day, id), not on the bare id every other listing here uses. A back-dated document — a June write-off entered in July — carries a later id than movements that happened after it, and paging by id alone would hand back a balance column that jumps. Pass ?cursor= from meta.next_cursor as usual; a cursor that is not a movement of that article is 404 not_found rather than a silent restart from page one, because restarting a sync in silence is how the same movements get imported twice.

?updated_since= is not accepted here, unlike every other listing — it is ignored rather than honoured, so do not sync on it. A movement row is never edited: history only ever grows, and the cursor is where you left off.

There is no set-quantity call, and there will not be one. The obvious API to ask for is PUT /stock/{sku} {quantity}. It cannot exist here: the quantity on hand is a sub-ledger. It cross-foots against account 304, every costing path reads it, and its value is the sum of the movements that produced it. A call that assigns to it breaks that sum silently — the books say one thing, the count says another, and nothing on either side records which write disagreed.

So stock moves the way it moves in the app: a document.

curl -X POST https://app.autonify.bg/api/v1/stock/transfers \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: wms-move-4471" \
  -H "Content-Type: application/json" \
  -d '{
    "from_warehouse_id": "01k…",
    "to_warehouse_id": "01k…",
    "moved_on": "2026-06-05",
    "lines": [ { "sku": "CUP-1", "quantity": "8" } ]
  }'

Both warehouses must be this company's and must differ; up to 200 lines, each quantity greater than zero. Two lines of one article on one document are summed by the service, not written twice. Answers 201:

{ "data": { "document_number": "ПРХ-000004",
            "from_warehouse_id": "01k…", "to_warehouse_id": "01k…",
            "moved_on": "2026-06-05",
            "lines": [ { "sku": "CUP-1", "quantity": "8" } ] } }

A transfer changes where the goods are and nothing else: same quantity, same average cost, no journal entry. A correction is the transfer back, which is the operation that actually happened.

  • 409 period_locked — the period is closed.
  • 409 transfer_refused — a fact about the stock at this instant: not enough at the source, an unknown SKU, a unit that will not convert. details.reason names which.

Корекция / брак

POST /stock/adjustments writes signed deltas off, each with a reason.

curl -X POST https://app.autonify.bg/api/v1/stock/adjustments \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: wms-writeoff-8891" \
  -H "Content-Type: application/json" \
  -d '{
    "adjusted_on": "2026-06-20",
    "warehouse_id": "01k…",
    "notes": "Счупени при разтоварване",
    "lines": [ { "sku": "CUP-1", "quantity_delta": "-3", "reason": "scrap" } ]
  }'

adjusted_on and lines are required; warehouse_id falls back to the company's default place — a single-warehouse merchant is never asked a question with one answer — and notes is free text. Up to 200 lines, each with a non-zero quantity_delta of at most three decimals, which is the sub-ledger's own precision.

The reason is required, and the taxonomy is closed:

reason What it says happened SAF-T
scrap брак — damaged, spoiled, destroyed 160
shortage липса — missing, and nobody can say where it went 120
surplus излишък — more on the shelf than the books hold 110
own_use лично ползване — taken out of the business by its owner 180
donation дарение — given away 150
za_smetka_na_mol за сметка на МОЛ — a липса somebody is answerable for 120

The sign decides the ledger legs and cannot carry what the reason carries: the SAF-T entry (a брак filed as «други движения» is a false statement to НАП) and the ЗДДС чл. 79 consequence, which turns on the cause and never on the direction. A free-text reason would be neither mappable nor searchable, so an unknown one is a 422 no retry fixes. surplus is the only reason that may carry a positive delta, and any other one fighting its own sign is refused.

Answers 201 with the document: its number (КОР-…), its net effect on 304 as money, and per line the book quantity, the delta, the reason and the movement_type a SAF-T reader will see — returned because an integrator reconciling a filed стоков файл has no other way to learn it.

One field is deliberately not on this wire: the продажна цена a начет may be raised at (ЗЗД чл. 82 / КТ чл. 203) is a browser-only field, so a za_smetka_na_mol line sent here is charged at what the goods carried and nothing is credited to 709.

  • 409 adjustment_refused — a fact about the stock at this instant: deeper than that place holds, an unknown SKU, a reason that fights its sign. details.reason names which.
  • 409 period_locked — refused, never clamped. The caller chose the date, and moving the document into a month they did not name would file a ДДС период that disagrees with the one already sent.

Инвентаризация

POST /stock/counts states what is on one place's shelves on one day.

curl -X POST https://app.autonify.bg/api/v1/stock/counts \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: wms-count-2026-06-20" \
  -H "Content-Type: application/json" \
  -d '{
    "counted_on": "2026-06-20",
    "warehouse_id": "01k…",
    "lines": [ { "sku": "CUP-1", "quantity_on_hand": "17" } ]
  }'

This is the call that looks like the set-quantity API and is the opposite of one. Nothing is assigned. The payload states what is on the shelf, and the DIFFERENCE against that place's books becomes a correction with a reason (shortage or surplus), a value at the weighted average, a journal entry and a SAF-T code — so the sub-ledger still equals the sum of its movements. A line that agrees moves nothing and still comes back on the опис, with a quantity_delta of "0.000" and no reason: classifying it anyway would put a липса on every article a merchant ever counted correctly. Up to 500 lines.

Zero is a legitimate answer — "the shelf is empty" is exactly what a count of a sold-out article says. A SKU the payload omits is unanswered, not zero. The sub-ledger is per company and a count is per place, so silence means "not counted here"; zeroing on silence would wipe every other location's stock the moment one shop counted, and it is what lets you send a partial sheet honestly.

  • 409 count_refused, details.reason ui.items.count_stale — a snapshot older than the last count of that place, whether that count came from this endpoint, the browser or a connected till. It is the one refusal here that reads like a bug and is not: every delta is derived against the CURRENT books, so replaying an old snapshot is not a no-op but a second, opposite correction. The message names the day that place was last counted.
  • 409 count_refused also covers the ordinary stock facts, and 409 period_locked the filed month.

Answers 201 with the same document shape the adjustment returns, numbered ИНВ-…, carrying every line you sent — the ones that moved and the ones that agreed — with counted_quantity beside book_quantity.

Преработка

POST /stock/conversions consumes articles and produces others, splitting one carrying amount between them.

curl -X POST https://app.autonify.bg/api/v1/stock/conversions \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: wms-kit-4471" \
  -H "Content-Type: application/json" \
  -d '{
    "direction": "disassemble",
    "warehouse_id": "01k…",
    "converted_on": "2026-06-20",
    "allocation_method": "sales_value",
    "inputs":  [ { "sku": "KIT-1",  "quantity": "2" } ],
    "outputs": [ { "sku": "PART-A", "quantity": "2", "sale_value": "30.00" },
                 { "sku": "PART-B", "quantity": "2", "sale_value": "10.00" } ]
  }'

Required: direction (disassemble | assemble), warehouse_id, converted_on, and at least one inputs[] and one outputs[] — up to 200 lines on each side — every line with a sku and a quantity above zero.

Optional on the document: allocation_method, residual_method, item_composition_id (a рецепта of this company's), input_account_code, output_account_code, scrap_account_code, notes, and up to 20 costs[] — разходи за преработка absorbed per СС 2 т. 6.1, each with a basis (actual | normal_capacity), an account_code, an amount and, for the second basis, a normal_capacity. Per output line: sale_value, fixed_share, manual_value, is_by_product, nrv. Per input line: abnormal_loss — the part of what was consumed that was lost beyond нормалните граници (СС 2 т. 7.2 „а"), which never reaches the surviving articles.

allocation_method is the basis the input value is split by — sales_value (the default), quantity, fixed_share, by_product or manual. On a разкомплектоване it is a judgement, not an arithmetic detail. residual_method says how the remainder is split once the by-products are deducted, and takes the same four proportional bases — never by_product itself, because "take the by-products out again" is not an instruction for what is left.

manual is refused when the figures do not meet the total, and that refusal is the reason this endpoint could not simply be "post the numbers you already have". Ten and ten typed against a carrying amount of a hundred books ninety and ten: the document's total comes out right, every per-article cost is wrong, and every future COGS of both articles is poisoned by it. 409 conversion_refused, with details.reason ui.items.conv_manual_off_total.

Answers 201 with the протокол — its number (РАЗК-… for a разкомплектоване, КОМП-… for a комплектоване), the carrying amount, the by-product value, the absorbed and unabsorbed cost, the abnormal loss, and one line per article on each side carrying a role of input or output, its quantity, its unit_cost, its allocated value, and is_by_product / abnormal_loss_quantity where they apply. Σ of the outputs' value equals total_value to the cent, and so does Σ of the inputs less the abnormal loss plus the absorbed cost: the СС 2 cross-foot, on the wire rather than only in the протокол. value is the exact figure and unit_cost is derived from it, so foot the document on value.

  • 409 conversion_refused — anything the service or the ledger refuses: not enough of an input at that place, an unknown SKU (an output has to be an article the books already know), an abnormal loss bigger than its own line, a cost line whose account_code is not an active class-6 account of this company's own chart, a normal_capacity basis with no positive capacity or on a document producing more than one article — there is no single rate to absorb against — and a chart that will not take the entry. details.reason is present when the refusal has a name.
  • 409 period_locked — the month is filed.

Unlike a transfer, a conversion posts — and a plain one posts nothing, because what leaves the inputs is exactly what lands on the outputs. The entry appears when the document carries something that is not a reclassification: absorbed разходи за преработка arriving in stock, and абнормен брак leaving it for the scrap account, each booked where СС 2 puts it. A correction is the inverse document, which the app writes; there is no conversion-reversal endpoint here.

Goods come in through POST /purchases with lines[].product_code, and out through a sale. Neither needs a stock endpoint.

The books

Read-only, and only ever read-only. There is no endpoint here that posts an entry and there must not be: the ledger has one write seam, the balance invariant is a database constraint, and a posted entry is only ever changed by another one. An API that could post would be a second door into all three.

GET /books/trial-balance?from=&to= — the оборотна ведомост. Both dates are required and to may not precede from.

{ "data": [ {
  "code": "702", "name": "Приходи от продажби на продукция",
  "name_en": "Revenue from sales of products", "class": 7, "type": "revenue",
  "opening": { "amount": "0.00",    "currency": "EUR" },
  "debit":   { "amount": "0.00",    "currency": "EUR" },
  "credit":  { "amount": "100.00",  "currency": "EUR" },
  "closing": { "amount": "-100.00", "currency": "EUR" }
} ],
  "meta": { "from": "2026-06-01", "to": "2026-06-30", "count": 12,
            "totals": { "opening": {…}, "debit": {…}, "credit": {…}, "closing": {…} } } }

Six figures per account, because that is what a Bulgarian ведомост is: начално салдо, обороти за периода, крайно салдо. A file carrying only the middle pair cannot be tied back to the previous period's closing balances, and an account that carries a balance but saw no movement in the window would vanish from it entirely.

Both saldo figures are signed, debit-positive: "-1234.56" on a payable means 1234.56 credit. The browser splits them into Дт and Кт columns because that is how the ведомост is read and filed on paper; a JSON reader wants one number with a sign.

Every figure is money, in EUR — the books' functional currency — including the negative ones.

GET /books/journal?from=&to= — the journal behind it, cursor-paged.

{ "data": [ {
  "id": "01k…",
  "entry_id": "01k…", "entry_number": "2026-000123",
  "entry_date": "2026-06-10", "entry_status": "posted",
  "description": "Фактура 0000000042",
  "account_code": "411", "account_name": "Клиенти", "account_name_en": "Trade receivables",
  "debit":  { "amount": "120.00", "currency": "EUR" },
  "credit": { "amount": "0.00",   "currency": "EUR" },
  "original": null
} ],
  "meta": { "next_cursor": "01k…", "limit": 50, "from": "2026-06-01", "to": "2026-06-30" } }

One row is one line, not one entry. A caller reconciling against their own ledger matches on account code and amount, and nesting lines inside entries would make a page boundary fall in the middle of a double entry. entry_id and entry_number are on every row, so regrouping is a one-liner for anyone who wants entries back.

Posted and reversed entries only — a draft is not in the books. original carries the original-currency trail on an FX posting and is null when the line was always in EUR. updated_since is accepted for symmetry, but a posted line is immutable by database trigger: it will only ever surface a reversal's own new lines, never an edit to an old one, because there are none.

Master data

GET /counterparties (?search=) and POST /counterparties. The create is matched on EIK first, so importing the same customer list twice does not double every counterparty in the books:

{ "data": { "id": "01k…", "name": "Клиент ЕООД", "created": false } }

201 when it created one, 200 with created: false when it updated the match. Required: name. Optional: eik, vat_number, address, country (2 letters), email, iban, kind (client | supplier, default client).

PATCH /counterparties/{id} corrects one, and it means PATCH: what you do not send keeps the value it has. The conditional integrity rules are applied to the merged row rather than to your request alone — a company still needs an ЕИК and an individual still needs an identifier, whether or not this particular request mentioned kind. Accepts role (client | supplier), kind (company | individual), country, name, name_latin, eik, personal_identifier, vat_number, legal_form, mol_name, address, email, phone, and answers the full counterparty.

kind means two different things across those two routes, and it is worth naming before it costs you an afternoon. On the create it is client | supplier — what the counterparty is to you; it lands on the is_client / is_supplier flags you read back, and the row itself is stored as a company. On the PATCH it is company | individual — what the counterparty is — while the relationship travels as role. A row created through this API PATCHes cleanly without either field; send kind on the PATCH only when you mean to reclassify the legal shape. One caveat: address is optional on the create but required by the merged-row rules — a row created without one must supply it on its first PATCH, and the 422 names it.

  • A role is only ever added, exactly as in the browser: a supplier who starts buying is both, and a document already posted against the old role must not be orphaned by a script narrowing it.
  • 422 validation_failed, with the field errors in details.
  • No delete and no merge, deliberately. Both destroy a row that documents point at, and a merge in particular picks a survivor — not a thing a script can be trusted to get right at three in the morning.
  • registry_verified is always false through this API and is not writable. It is a claim about the Commercial Register, not a field (§7).

GET /counterparties/{id}/statement?from=&to= — извлечение, the year-end изравняване document: what was invoiced, what was paid, what is left. Both dates required. ?format=csv|pdf (default csv), ?lang=bg|en (default the key holder's own language — a statement often goes to the counterparty, who may not read Bulgarian).

Bytes, per §4. The CSV carries a UTF-8 BOM and CRLF line endings, without which Excel renders the Cyrillic as mojibake. There is no JSON form: what the service builds is a document's layout — sections, a running balance, a footer that has to add up — and publishing that as an API contract would freeze a rendering decision as an integration promise.

GET /items (?kind=income|expense) and POST /items — the catalogue an invoice line can be prefilled from. sku on the wire is the item's code; kind is its direction.

GET /chart-of-accounts — the company's own сметкоплан, both names, not paginated (a Bulgarian model chart is a few hundred rows and bounded by construction). ?active=true|false narrows it; meta.count is the total.

{ "data": [ { "id": "01k…", "code": "702", "name": "Приходи от продажби",
              "name_en": "Sales revenue", "class": 7, "type": "revenue",
              "is_active": true } ], "meta": { "count": 214 } }

Read only, and it will stay that way. A chart is editable in the app because a human doing it is looking at the consequences; a script adding or renaming an account is one exception away from a company that can no longer issue an invoice, because the posting rules name account codes.

GET /bank-accounts returns the accounts with the IBAN masked — the first four characters and the last four, everything between them starred. You are reconciling payments, not initiating them. GET /bank-transactions (?from=, ?to=) returns booked lines with amount, counterparty name and IBAN, reference (the end-to-end reference) and description.

Filings

GET /filings (?kind=, ?period=) lists what exists, with status, due_on, generated_at, submitted_at and nra_incoming_number.

GET /filings/{id}/files lists the artefacts of one:

{ "data": [ { "id": "1", "name": "DEKLAR.TXT", "size": 6 } ],
  "meta": { "filing_id": "01k…", "count": 1 } }

id is the file's position in the filing's artefact list, and it is a position on purpose: a submitted filing's artefacts are immutable by database constraint, so nothing may reorder them and the position cannot slide. size is null when the row points at a file no longer on disk — reported rather than hidden, because a caller reconciling an archive needs to know the difference between an empty file and a missing one.

The listing hides Autonify's own evidence record, declaration.json: НАП never asks for it, and listing it would put a fourth file in front of somebody about to upload three. So the ids you see may not start at 0 and may not run consecutively — index the list, do not count it. The download deliberately does not apply that filter, because 404ing a stored artefact would be a lie told to whoever kept the link.

GET /filings/{id}/files/{fileId} streams one, bytes per §4.

Both are free, and reachable on a plan whose API has lapsed: a customer at their ceiling must still be able to fetch the .txt files they are about to upload to НАП. They still require filings:read — free is a price, not a permission, and DEKLAR.TXT travels with POKUPKI.TXT and PRODAGBI.TXT beside it, which together are the company's whole purchase and sales дневник for the period.

POST /filings/{id}/transition records what happened at НАП.

curl -X POST https://app.autonify.bg/api/v1/filings/01k…/transition \
  -H "Authorization: Bearer $AUTONIFY_KEY" \
  -H "Idempotency-Key: erp-filing-2026-06-1" \
  -H "Content-Type: application/json" \
  -d '{ "to": "submitted", "nra_incoming_number": "1234567890" }'

to is submitted, accepted or rejected. nra_incoming_number is required when to is submitted. It runs the same status machine as the browser, tells the rest of the company what was recorded, and answers the filing.

It does not submit anything to НАП, it cannot, and no ability on this API can. The portal is mTLS with the КЕП signing every handshake, so the last mile is not automatable, and pretending otherwise in an API would be the single most dangerous lie this product could tell. filings:write is a bookkeeping permission.

409 invalid_transition when the move is not legal from the status the filing is in, with details.from and details.to. The same body is legal again once the filing reaches a status it can move from, which is why it is a 409.

409 thin_cap_stale when an annual tax return (gdd) is moved to submitted while its Art. 43 CITA carry-forward must be recomputed: an earlier year was closed again, the return was prepared before the carried interest reached it, or the year's last close did not finish. details.key names the reason. Close the year again, then repeat the same request. The same code answers a return for a year given as the last one filed outside Autonify: change that answer on the Filings page first. Moving a submitted return to accepted is never refused for this reason.


6. Sandbox

Create a key with environment sandbox.

Who may hold one. Anyone with a confirmed email address. Sign up, open Developers, create a key. No invitation, no EIK, no card.

What it reads. A sandbox key you mint this way points at a sample company we create for you: a handful of invented invoices and a counterparty, on the same endpoints returning the same shapes as production. Nothing you do there touches a real document, and nothing real is visible from it.

That is what makes free minting safe. It used to be by invitation, because a sandbox key read the customer's actual books on a separate meter - so anyone who could mint one for free could read data for free. Moving the data solved the problem the gate was standing in for, so the gate is gone.

If your own plan carries the API, your sandbox keys point at your own company instead. Testing against your own data is the reason you wanted a sandbox, and a fixture company would be a downgrade.

Live keys are unchanged. They still need a plan that carries the API, or an explicit grant from a customer who has one - being trusted to build is not being trusted to run - and withdrawing that grant revokes the live keys already issued, immediately.

Its own budget. Sandbox traffic never touches the customer's plan: it has separate counters and a flat ceiling of its own, the same for everybody.

2 000 units/day per company        30 000 units/month
6 000 units/day across your account
2 live sandbox keys                60 requests/minute

Sized for BUILDING — a working day of writing and re-running an integration — and deliberately not for running anything. No nightly batch, no bulk import, no pointing production at test "for now". There is no grace band in the sandbox: the band exists because a refused real supplier invoice is a real document a customer loses, and a test one is not.

  • atn_test_* is refused on live paths, and atn_live_* on sandbox paths, by the key's own shape.
  • Sandbox writes are genuinely real: numbering, duplicate detection and the VAT breakdown behave exactly as they will in production, which is the only thing a sandbox is for.
  • Sandbox documents are never filed with the NRA, never emailed, never counted in the customer's usage or plan limits. POST /sales/invoices/{id}/send answers 403 send_not_in_sandbox rather than pretending.

What the sandbox is not: an isolated staging environment. It is a separated company on the same deployment, sharing this code and this database. In practice that means what you test is exactly what you ship against, and it also means sandbox traffic is subject to the same rate limits and the same maintenance windows as everything else.


7. What this API will not do, and why

The endpoint list is deliberate. Do not plan around these arriving — they are absent by decision, not by backlog:

  • The reader's raw output. You may send a file with a purchase; what comes back is your own persisted document, never the extraction, confidences or bounding boxes, and never synchronously.
  • Registry enrichment. Autonify fills a company's details from the Commercial Register when a person types an EIK in the app. There is no endpoint for it, and none is planned — it is not a bulk lookup service.
  • Computing a return for you. No VAT return, no SAF-T, no ГДД, no ГФО. You can read what was filed and fetch the files it was filed with (§5); you cannot ask Autonify to compute one on demand.
  • Filing anything. Nothing on this API talks to НАП. filings:write records what a human did at the portal, and the portal is mTLS with the КЕП signing every handshake — the last mile is not automatable by anyone.
  • Signing with a КЕП, and moving money. No endpoint signs a document with a customer's certificate, and none initiates a payment. Both exist in the app, behind a person, and that is where they stay.
  • Payroll, billing, users, other companies. No endpoint, no permission.

What changed, and it is a widening rather than an erosion. This section used to list "the ledger, the trial balance" among the absences. It should not have: the point being made was about generation, and it read as a promise never to show a customer their own posted books. Refusing that was the one gap that made Autonify unusable as a system of record — an accountant could not pull a trial balance into their own working papers, and a customer's data was exportable only through a browser download. books:read closes it, read-only, off by default, on the customer's own ledger and nobody else's. Nothing above moved to make room for it.

Terms, in one sentence — the long version is in the Terms of Service: you may integrate your own or your client's software with Autonify; you may not resell, sublicense, or expose the API — or anything derived from it — as a service to third parties. The version you accepted is stamped on the key when it is created.


8. Error codes

Code Status What to do
unauthenticated 401 Send the key as a Bearer token.
invalid_token 401 Wrong key, or wrong environment for this path.
token_expired / token_revoked 401 Rotate in Settings → Developer API.
token_orphaned 403 The person who minted the key has left the account. Mint a new one.
company_unavailable 403 The company was deleted, or a delegation was withdrawn.
account_suspended 403 The customer's account is suspended.
api_not_in_plan 403 The plan does not include the API. whoami still answers.
ability_missing 403 The key was not created with that permission.
ability_not_in_plan 403 The key has it; the plan does not. Retry after upgrade — no re-key needed.
subscription_read_only 403 Billing lapsed. All reads keep working.
terms_reacceptance_required 403 The terms changed and the grace window closed. The customer accepts on Settings → Developer API; your existing key answers again on the next request. See §1.
ocr_not_in_plan 403 The plan does not include document reading. JSON capture still works.
inventory_not_in_plan 403 The plan does not include the stock module (§ Наличности). Purchases and orders still keep the sub-ledger and COGS.
recurring_not_in_plan 403 The plan does not include recurring invoices, so work cannot be logged for them. Reading and deleting billable entries still work.
send_not_in_sandbox 403 Sandbox documents are never emailed.
idempotency_key_required 400 Send one on every POST and PATCH.
idempotency_key_reused 422 Same key, different body. Fix your key generation.
idempotency_in_progress 409 The first attempt is still running. Retry shortly.
idempotency_key_invalid 400 The key is longer than 255 characters.
idempotency_key_expired 409 The key's window lapsed and could not be reclaimed mid-request. Retry, or send a new key.
validation_failed 422 The body does not match the contract. details has the field errors.
not_found 404 No such record on this company. Never 403 — see §5.
no_file 404 That purchase was captured without a file.
ubl_not_available 404 That document kind has no e-invoice form.
ubl_refused 409 The document's lines no longer reproduce its total.
duplicate_document 409 Already captured. details.existing_id is the one you have.
document_posted 409 Its figures are in the ledger. Issue a storno instead.
document_annulled 409 An annulled document is immutable.
annul_refused 409 Annulment needs a posted document, or the document is already credited.
storno_refused 409 The service refused the credit note. The message says why.
convert_refused 409 Not a proforma, or already converted.
entry_billed 409 The billable entry is on an issued invoice. details.sales_document_id names it; correct it with a credit note.
invoice_refused 409 The company's state refused the posting. Fix it and retry.
protocol_requires_reverse_charge 409 Not a reverse-charge acquisition.
period_locked 409 The accounting period is closed.
invalid_transition 409 Not legal from this status. details.from / details.to.
thin_cap_stale 409 An annual tax return whose Art. 43 carry-forward must be recomputed. Close the year again and retry. details.key.
no_buyer_email 409 Add an address to the document and retry.
store_not_push 409 That store is synchronised by pulling.
order_refused 409 The ledger refused the order.
transfer_refused 409 A fact about the stock. details.reason names it.
adjustment_refused 409 A fact about the stock, or a reason fighting its sign. details.reason names it.
count_refused 409 Most often a snapshot older than that place's last count. details.reason names it.
conversion_refused 409 The service or the ledger refused the преработка — including a manual split that does not meet the total. details.reason names it where the refusal has a name.
plan_required 402 No active plan or trial. Hold the document; details.retryable is true.
quota_exceeded 429 Wait for X-Autonify-Units-Reset.
send_cooldown / send_daily_limit 429 details.retry_after is seconds.
api_disabled 503 The API is off on this installation.

9. Webhooks — the other direction

Four things are worth knowing about the moment they happen, and polling for them costs you units and costs us a query. So you can be told instead.

Manage endpoints on /developers. Up to five per account. The signing secret is shown once, at creation; if you lose it, delete the endpoint and create another.

HTTPS on port 443, and public hosts only. The body carries a customer's document ids and the header carries an HMAC — over plain http both are readable by anything on the path, and a signature an eavesdropper can copy is not a signature. The address is checked when you save it and again at every delivery, because a hostname that resolved publicly in March can resolve to 10.0.0.1 in April.

An endpoint is created either pinned to one company — which is what a practice creating it while on a client's books gets — or for every company of your own account. Not every company you can reach: a delegation is permission to do the work, not a standing subscription to a client's events.

Events

Event Fires when resource.type Follow up with
sales.invoice.issued A чл. 114 document reached its number sales_invoice GET /v1/sales/invoices/{id}
purchase.upload.finished A file you sent has a verdict purchase_upload GET /v1/purchases/uploads/{id}
purchase.approved A supplier document was approved and posted purchase GET /v1/purchases/{id}
filing.generated A filing's artefacts were written filing GET /v1/filings/{id}/files

sales.invoice.issued covers invoices, credit notes and debit notes. A проформа is not a tax document and an order document (чл. 52о) carries no чл. 114 number, so neither is announced — an integrator acting on this event is acting on something that exists in the VAT дневник.

purchase.upload.finished fires for every outcome, failed included. The answer you are waiting for is as often "it could not be read" as "here is your document", and a push that only fired on success would leave you polling for ever on the case where polling costs the most. The resource is the upload, not a purchase document — for a review or a failure there is no document.

There is a fifth event on the wire, ping, sent only by the „Тестова доставка" button. It is not subscribable: nobody subscribes to a ping, and an endpoint that could would be one whose real events nobody ever checked. Its resource.type is ping and its resource.id is the endpoint's own id, so no receiver can mistake it for a document to go and fetch.

The delivery

POST https://hooks.example.bg/autonify
Content-Type: application/json
User-Agent: Autonify-Webhooks/1
X-Autonify-Event: sales.invoice.issued
X-Autonify-Delivery: 01K3H2N7Q9YV6M4W8ZB5R1TCXE
X-Autonify-Signature: t=1785312000,v1=6f1c…a93b
{
  "id": "01K3H2N7Q9YV6M4W8ZB5R1TCXE",
  "event": "sales.invoice.issued",
  "occurred_at": "2026-07-27T12:00:00+03:00",
  "company_id": "01K2…",
  "resource": { "type": "sales_invoice", "id": "01K3…" }
}

That is the whole body, and the thinness is the design. No amounts, no document fields, no counterparty. The push is free and the follow-up GET is metered, so a fat payload would be the metering quietly undone — and it would make every webhook a copy of the books travelling to a third party's server on our initiative rather than on their request. Take the pointer and fetch what you need.

id is the delivery id, not the resource's, and it is what you deduplicate on. It is in the body and in X-Autonify-Delivery, so you can drop a repeat before you parse anything.

Verifying the signature

X-Autonify-Signature: t=<unix seconds>,v1=<hex hmac-sha256>

The HMAC is computed with your endpoint's secret over the string "{t}.{raw body}".

  • Verify over the raw bytes. A receiver that parses the JSON, re-encodes it and hashes that will get a different answer for the same message: key order, unicode escaping and float formatting all differ between languages. Take the body exactly as it arrived.
  • The timestamp is inside the signed string, not merely beside it, so it cannot be rewritten in flight. Enforce your own replay window — five minutes is the right one — by comparing t to your clock. We only timestamp; the window is yours.
  • v1= is a version. A future scheme will travel in the same header beside it, and your existing code will go on reading v1.
  • Compare in constant time. hash_equals, crypto.timingSafeEqual, or your language's equivalent.
<?php
$secret = getenv('AUTONIFY_WEBHOOK_SECRET');          // whsec_…
$raw    = file_get_contents('php://input');           // RAW, never json_encode(json_decode(…))
$header = $_SERVER['HTTP_X_AUTONIFY_SIGNATURE'] ?? '';

parse_str(str_replace(',', '&', $header), $parts);    // t=…&v1=…
$t  = (int) ($parts['t'] ?? 0);
$v1 = (string) ($parts['v1'] ?? '');

// Your replay window, not ours.
if ($t === 0 || abs(time() - $t) > 300) {
    http_response_code(400);
    exit;
}

if (! hash_equals(hash_hmac('sha256', $t.'.'.$raw, $secret), $v1)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);

// Deduplicate on the DELIVERY id: a retry carries the original body and a
// fresh signature, so the id is the only thing that is stable.
if (alreadySeen($event['id'])) {
    http_response_code(200);
    exit;
}

enqueueForProcessing($event);                          // then fetch the resource
http_response_code(202);
// Node — the body must be the RAW buffer:
//   app.post('/autonify', express.raw({ type: 'application/json' }), handler)
const crypto = require('crypto');

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    String(header).split(',').map((p) => p.split('=')),
  );

  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;   // your replay window

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest();

  const given = Buffer.from(String(parts.v1 || ''), 'hex');

  return given.length === expected.length && crypto.timingSafeEqual(expected, given);
}

Retries, and what a dead endpoint costs

Success is a 2xx and nothing else. A 3xx is not an acceptance and is never followed: replaying a body signed for one endpoint at whatever a redirect names would hand a customer's event, correctly signed, to a host they never registered.

A refused attempt is retried on a fixed ladder — 1 minute, 10 minutes, 1 hour, 6 hours, across five attempts — after which the delivery is dead and nobody will try it again. Each attempt is signed fresh, with a new t, and carries the original body. A retry six hours later is therefore a legitimate delivery with a current timestamp; it is not a replay, and your replay window will accept it. Deduplicate on the delivery id.

An attempt that takes longer than 8 seconds is abandoned. Answer first, process afterwards.

Ten consecutive dead deliveries switch the endpoint off, and the account's owners are told — an integration that has silently stopped is worse than one that has visibly stopped. A dead delivery is already five refused attempts over roughly eight hours, so ten of them is not a bad afternoon; it is an address that has answered nothing for days. Any success anywhere resets the counter to zero, including a success on a different event: the question the counter asks is whether the address is alive at all, and one 200 is a complete answer to it. Switch it back on from /developers when the far end is fixed.

The plan still decides. An account whose API has lapsed keeps its endpoint rows — re-subscribing must not be a re-integration — and hears nothing meanwhile. Free pushes to a plan that does not include the API would be the metering handed back with the other hand.

Delivery records are kept for thirty days, which is long enough to answer „why did I not get it last week?". The fact a delivery describes lives on the document itself, in the books, for as long as the law says.

Autonify is accounting and compliance software.

Privacy Terms