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 /purchasesandPOST /ordershave 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 withX-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}/transitionis 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-Dispositionis alwaysattachment, 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=trueand 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}/fieldsbelow — 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_afteris seconds.429 send_daily_limit— this company has spent today's manual send allowance.details.retry_afteris 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).
descriptionis at most 240 characters, because the entry is printed as the invoice line03.07.2026 — Консултация по договораand an invoice line is at most 255 on every door that issues one.201when it created the entry. The sameexternal_refagain answers200with 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 theIdempotency-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_priceis a decimal string with the four places an invoice line keeps, like a line's own;netis 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.retryableis 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 indetails. 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.reasonnames 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.reasonnames 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.reasonui.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_refusedalso covers the ordinary stock facts, and409 period_lockedthe 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 whoseaccount_codeis not an active class-6 account of this company's own chart, anormal_capacitybasis 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.reasonis 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 indetails.- 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_verifiedis alwaysfalsethrough 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, andatn_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}/sendanswers403 send_not_in_sandboxrather 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:writerecords 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
tto 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 readingv1.- 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.