Документация за разработчици

Интеграцията е разделена на две независими части: издаване на JWT в твоя backend и извикване на агента от браузъра. Секретният ключ остава само на сървъра — никога в frontend код.

OpenAPI 3.0 + SDK генерация

Машинно-четима спецификация на всички 16 операции на агента. Генерирайте client SDK за вашия език (C#, TypeScript, Java, Python, …) с openapi-generator или NSwag.

npx @openapitools/openapi-generator-cli generate \
    -i https://napi.bg/api/v1/openapi.json \
    -g csharp \
    -o ./napi-agent-sdk
SERVER-SIDE — backend-ът ти издава JWT

Тези endpoint-и се викат от твоя backend (C# / Python / Node / PHP / Go). Секретният ключ napi_test_… или napi_live_… остава тук — никога не достига до браузъра. Вземи си ключ от Профил → Секретен ключ за НАПИ агент.

POST /api/v1/tokens/issue

Обменете секретен ключ за кратък JWT, обвързан с конкретна операция и ЕИК. Issuer-ът проверява разрешените ЕИК-ове, минималната версия на агента и при тестов ключ слага test: true в JWT-а.

JWT живее 5 секунди. Издавай нов за всяка операция — не кеширай. Кратък TTL е защитата срещу replay, ако токенът изтече по пътя (browser ↔ агент).
POST /api/v1/tokens/issue HTTP/1.1
Host: napi.bg
Authorization: Bearer napi_test_XXXXXXXXXXXXXXXXX
Content-Type: application/json

{
  "typeId": "vat",
  "eik":    "123456789"
}

→ 200 OK
{
  "token":      "eyJhbGciOiJSUzI1NiIs...",
  "jti":        "a3f6b9...",
  "expiresAt":  "2026-04-27T12:34:56Z",
  "issuer":     "napi.bg"
}

→ 401   невалиден / revoked / expired секретен ключ
→ 402   изчерпан лимит на ЗЛ за този ключ (по подразбиране 3 ЗЛ
        за реален ключ без подписан договор; admin override
        чрез ApplicationUser.MaxEiksOverride; неограничен след
        signed contract; тестовите ключове не са лимитирани)
→ 400   липсва eik в заявката

Стойностите за typeId съвпадат с операциите в openapi.json (напр. vat, dec1_6, debt_payments, taxpayers). Изброени са и в горния dropdown „Live example".

Server-side примери

Минимални snippet-и за обмяна на секретен ключ срещу JWT. Вкарай ги в твоя backend и излагай JWT-а на frontend-а чрез вътрешен endpoint (напр. POST /napi/token).

SECRET="napi_test_XXXXXXXXXXXXXXXXX"

curl --silent --show-error --fail \
  -X POST https://napi.bg/api/v1/tokens/issue \
  -H "Authorization: Bearer $SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "typeId": "vat", "eik": "123456789" }'
// Node 18+ (built-in fetch). Хвани го зад твоя endpoint
// и НИКОГА не давай SECRET на frontend-а.
const SECRET = process.env.NAPI_SECRET;   // "napi_test_…"

export async function issueNapiJwt(typeId, eik) {
  const r = await fetch("https://napi.bg/api/v1/tokens/issue", {
    method:  "POST",
    headers: {
      "Authorization": `Bearer ${SECRET}`,
      "Content-Type":  "application/json"
    },
    body: JSON.stringify({ typeId, eik })
  });
  if (!r.ok) throw new Error(`napi.bg ${r.status}: ${await r.text()}`);
  return r.json();   // { token, jti, expiresAt, issuer }
}
// ASP.NET — регистрирай HttpClient в DI с BaseAddress.
public sealed class NapiTokenClient(HttpClient http, IConfiguration cfg)
{
    private readonly string _secret = cfg["Napi:Secret"]!;  // "napi_test_…"

    public async Task<JsonDocument> IssueAsync(string typeId, string eik)
    {
        using var req = new HttpRequestMessage(HttpMethod.Post,
            "https://napi.bg/api/v1/tokens/issue");
        req.Headers.Authorization = new("Bearer", _secret);
        req.Content = JsonContent.Create(new { typeId, eik });
        using var res = await http.SendAsync(req);
        res.EnsureSuccessStatusCode();
        return JsonDocument.Parse(await res.Content.ReadAsStringAsync());
    }
}
import os, requests

SECRET = os.environ["NAPI_SECRET"]   # "napi_test_…"

def issue_napi_jwt(type_id: str, eik: str) -> dict:
    r = requests.post(
        "https://napi.bg/api/v1/tokens/issue",
        headers={
            "Authorization": f"Bearer {SECRET}",
            "Content-Type":  "application/json",
        },
        json={"typeId": type_id, "eik": eik},
        timeout=10,
    )
    r.raise_for_status()
    return r.json()   # {"token": "...", "jti": "...", "expiresAt": "...", "issuer": "napi.bg"}
<?php
// PHP 8 + curl. $SECRET идва от .env / config — не от потребителя.
$SECRET = getenv('NAPI_SECRET');   // "napi_test_…"

function issue_napi_jwt(string $typeId, string $eik): array {
    global $SECRET;
    $ch = curl_init('https://napi.bg/api/v1/tokens/issue');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $SECRET",
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS     => json_encode(['typeId' => $typeId, 'eik' => $eik]),
    ]);
    $body   = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($status >= 300) throw new RuntimeException("napi.bg $status: $body");
    return json_decode($body, true);   // ['token' => …, 'jti' => …, 'expiresAt' => …, 'issuer' => …]
}
Live example (автоматично от openapi.json)

Избери операция от dropdown-а. Ако нищо не се зарежда: /api/v1/openapi.json трябва да е достъпен.

HTTP headers

Всички операции минават през POST /api/v1/operations/<typeId>. Authorization: Bearer <JWT> е задължителен за операции, които изискват ЕИК; останалите headers са опционални.

Header Назначение
X-Napi-Sender-Id Идентификатор на подател (sender) от настройките на агента. Когато липсва и са конфигурирани повече от един подател, агентът показва picker. С единствен подател се ползва автоматично.
X-Napi-File-Path Абсолютен път до файл/папка за операциите, които приемат local input (напр. dec1_6, vat_submit). Обикновено идва от резултата на /api/pick-file или /api/pick-folder. Като алтернатива за browser-driven flow-и виж _inlineFile / _inlineDir в секцията „Inline payloads" по-долу.
X-Napi-Batch-Id Опционален GUID за multi-step бутони. Първата операция със стойност, която агентът не е виждал (или TTL-ът от 10 минути е изтекъл), показва диалог за потвърждение на подателя; всички следващи заявки със същия GUID в рамките на TTL-а се изпълняват мълчаливо. Подава се на всяка заявка от batch-а — генерирай го веднъж в момента на click (crypto.randomUUID()) и преизползвай за цялата последователност. Различен или липсващ GUID винаги пита наново.
Inline payloads (без локален файл)

Browser-side flow-ите често нямат удобен начин да запишат файл на диска преди да го подадат — нито искат да го правят. Като алтернатива на X-Napi-File-Path агентът приема съдържанието на файла директно в JSON body-то под един от двата ключа. Базата се декодира в RAII-guard-нат temp път и се изтрива автоматично след handler-а, така че submission кодът не вижда stale файлове и disk-ът остава ограничен.

_inlineFile — единичен файл

За операции с един входен файл (HFR, Д1/Д6, ETZ 62/123, ОДИТ, Дек7.3). Записва се в %TEMP%/napi_<random>_<filename>.

{
  "eik": "131063188",
  "month": 5,
  "year": 2026,
  "fund": "0",
  "_inlineFile": {
    "filename": "dec1_6.txt",
    "contentBase64": "<base64 на TXT файла>"
  }
}
_inlineDir — множество файлове в обща папка

За VAT (изисква deklar.txt + pokypki.txt + prodajbi.txt в една папка) и други multi-file submission-и. Файловете се записват в обща %TEMP%/napi_<random>/ папка, която се подава на operation-а вместо single file path.

{
  "eik": "131063188",
  "month": 5,
  "year": 2026,
  "_inlineDir": {
    "files": [
      { "filename": "deklar.txt",   "contentBase64": "..." },
      { "filename": "pokypki.txt",  "contentBase64": "..." },
      { "filename": "prodajbi.txt", "contentBase64": "..." }
    ]
  }
}
Правила и precedence
  • Когато X-Napi-File-Path header-ът присъства, той има предимство — inline ключовете се игнорират.
  • В една заявка използвай или _inlineFile, или _inlineDir. Когато и двете са налични, _inlineFile се обработва пръв.
  • Filename-ите се санитизират — path separator-и (/ \ : * ? " < > |) се махат за защита от path traversal.
  • Temp файловете/папките се изтриват автоматично при излизане от handler-а — без cleanup отговорност за клиента.
Множествени операции (batch)

Един user-action понякога стартира няколко операции наведнъж — напр. „покажи всички ДДС файлове за 2026 г. и свали PDF-ите" (1× vat_list + N× vat_file), или „подай Дек1/6 + свали входящ номер" (dec1_6_submitdec1_6_receipt). По подразбиране агентът показва диалог за избор на подател на всяка такава заявка — досадно, когато всички идват от един и същи click. Batch-ът решава точно това.

Как работи
  1. В момента на клика frontend-ът генерира GUID: const batchId = crypto.randomUUID();
  2. Същият GUID отива в X-Napi-Batch-Id header-а на всяка заявка от поредицата.
  3. На първата заявка агентът показва picker-а и запомня избора срещу този GUID за 10 минути.
  4. Всички следващи заявки със същия GUID минават мълчаливо с този подател — без диалози.
  5. Различен GUID или липсващ header → агентът пита наново.
Сценарии
  • Справка + сваляне — списък файлове (1 заявка) + сваляне на всеки (N заявки). Едно потвърждение на подателя за целия батч.
  • Подаване + квитанция — submit-операция, после извличане на входящ номер / PDF потвърждение.
  • Периодичен синк — твоят backend стартира background задача, която върви през N ЕИК-а с taxpayers_list; всички споделят един batch ID така че потребителят се пита веднъж.
JS пример (browser-side)
// Един клик стартира 1 списък + N сваляния. Всички споделят
// един и същ batchId, така че агентът пита за подател само
// при първата заявка.
async function downloadAllVatFiles(year) {
  const batchId = crypto.randomUUID();   // веднъж на click

  // 1) Списък
  const list = await callAgent("vat_list",
    { typeId: "vat_list", eik: "123456789" },
    { batchId, params: { year } });

  // 2) Парче по парче — същия batchId
  for (const f of list.files) {
    const pdf = await callAgent("vat_file",
      { typeId: "vat_file", eik: "123456789" },
      { batchId, params: { fileId: f.id } });
    saveBlob(pdf);
  }
}

async function callAgent(typeId, jwtReq, { batchId, params }) {
  // "/napi/token" е ТВОЙ endpoint на твоя backend, който вътрешно
  // вика napi.bg/api/v1/tokens/issue със секретния ключ. Това НЕ е
  // endpoint на napi.bg — секретният ключ не трябва да напуска твоя
  // сървър. Виж секцията „Server-side примери" по-горе.
  const { token } = await fetch("/napi/token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body:    JSON.stringify(jwtReq),
  }).then(r => r.json());

  const headers = {
    "Authorization": `Bearer ${token}`,
    "Content-Type":  "application/json",
    "X-Napi-Batch-Id": batchId,   // ← същият GUID за цялата серия
  };

  const r = await fetch(`http://127.0.0.1:5123/api/v1/operations/${typeId}`, {
    method: "POST", headers, body: JSON.stringify(params)
  });
  if (!r.ok) throw new Error(`agent ${r.status}: ${await r.text()}`);
  return r.json();
}
JWT-ът остава per-операция. Batch-ът дедупликира само picker диалога; всяка заявка си иска свой кратък JWT (typeId + eik) — JWT-ите НЕ се преизползват между операции.
Помощни endpoint-и на агента (file / folder picker)

Браузърите по сигурност не дават absolute path през <input type="file">. Агентът предоставя два помощни POST endpoint-а, които отварят native OS диалог и връщат пълния път — удобно когато операцията очаква път до файл/папка (напр. dec1FilePath в dec1_6). Тези endpoint-и са извън typed /api/v1/operations/* повърхността и не изискват JWT.

POST http://127.0.0.1:5123/api/pick-file
{ "startDir": "C:/Declarations",
  "filter":   "TXT файлове (*.txt);;Всички файлове (*)" }

→ 200 { "path": "C:/Declarations/dec1_2026_03.txt" }
POST http://127.0.0.1:5123/api/pick-folder
{ "startDir": "C:/Declarations" }

→ 200 { "path": "C:/Declarations" }

При отказан диалог се връща {"path": ""} с HTTP 200. Диалогът блокира HTTP handler-а докато потребителят затвори прозореца — заявката има timeout 600 секунди.

Пример: избор на файл и подаване към dec1_6

Типичен сценарий — потребителят натиска бутон „Подай Дек1/6", браузърът извиква /api/pick-file за да получи пълен път, после го подава като X-Napi-File-Path header към операцията. Същият pattern важи и за /api/pick-folder при операции, които приемат директория.

async function submitDec16(jwt) {
  // 1) Отвори native OS file dialog
  const pickRes = await fetch("http://127.0.0.1:5123/api/pick-file", {
    method:  "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      startDir: "C:/Declarations",
      filter:   "Дек1/6 TXT (*.txt);;Всички файлове (*)"
    })
  });
  const { path } = await pickRes.json();
  if (!path) return;   // потребителят отказа диалога

  // 2) Извикай операцията със selected path в headera
  const opRes = await fetch("http://127.0.0.1:5123/api/v1/operations/dec1_6", {
    method:  "POST",
    headers: {
      "Authorization":    `Bearer ${jwt}`,
      "Content-Type":     "application/json",
      "X-Napi-File-Path": path,        // ← пълен път от picker-а
    },
    body: JSON.stringify({ eik: "123456789", year: 2026, month: 3 })
  });
  if (!opRes.ok) throw new Error(`agent ${opRes.status}: ${await opRes.text()}`);
  return opRes.json();   // submission result
}
Пример: избор на папка (вход за обработка)
async function pickInputFolder() {
  const r = await fetch("http://127.0.0.1:5123/api/pick-folder", {
    method:  "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ startDir: "C:/Declarations" })
  });
  const { path } = await r.json();
  return path || null;   // null когато потребителят отказа
}
Защо chain-ваме две извиквания? Защото браузърите умишлено не разкриват пълни пътища от <input type="file"> (sandbox/sigurnost). Агентът работи локално и има пълен достъп до файловата система — затова го молим да отвори native диалог и да ни върне резултата. Без този обход операциите, които приемат X-Napi-File-Path, остават неизползваеми от браузър.
An unhandled error has occurred. Reload 🗙