# FirmeAPI.ro — API Monitorul Oficial — Partea I — acte normative: ghid de implementare pentru agenți AI

> Acest document e scris pentru agenți AI de programare (Claude Code, Cursor, Copilot, Codex, ChatGPT etc.).
> Conține tot ce trebuie pentru o integrare completă, fără întrebări suplimentare către utilizator.
> Versiunea pentru oameni: https://www.firmeapi.ro/info-api/monitorul-oficial-partea-i

Legi, decrete, ordonanțe, hotărâri, ordine ale miniștrilor și deciziile Curții Constituționale, câte un document pe act. Lista întoarce metadatele; textul integral (HTML) se obține după numărul documentului (nro). Sursa: Monitorul Oficial al României.

## 0. Ce trebuie să livrezi

Integrează în aplicația utilizatorului API-ul Monitorul Oficial — Partea I — acte normative FirmeAPI.ro, cap-coadă:

1. **Configurare** — cheia API citită din variabila de mediu `FIRMEAPI_KEY`; adaug-o în `.env.example` (sau echivalent) cu valoare goală. Nu scrie cheia în cod și nu o trimite niciodată în browser.
2. **Client API** — un modul/serviciu în backend cu câte o metodă pentru fiecare endpoint (`moParteaI`, `moParteaICauta`), cu tipurile răspunsurilor, tratarea erorilor (secțiunea 4) și reîncercări doar unde e permis.
3. **Cache** — rezultatele păstrate pe cheia cererii (de exemplu 24 de ore pentru datele unei firme). Fiecare cerere reușită consumă credite.
4. **Logica de domeniu** — regulile din secțiunea 5, într-un singur loc, nu împrăștiate prin interfață.
5. **Interfață** (dacă aplicația are UI) — afișarea datelor și a mesajelor de eroare; câmpurile `null` apar ca „nedeclarat” sau „—”, nu ca 0.
6. **Teste** — cu răspunsuri simulate din acest ghid (mock HTTP), care acoperă cazurile din secțiunea 8.

Respectă convențiile proiectului existent (limbaj, framework, structură de foldere, client HTTP deja folosit).

## 1. Autentificare și cereri

- Bază URL: `https://www.firmeapi.ro/api/v1`
- Header de autentificare: `X-API-KEY: <cheie>` (alternativ `Authorization: Bearer <cheie>`).
- Cheia se creează din contul FirmeAPI, pagina „Chei API”.
- Trimite `Accept: application/json`. Răspunsurile sunt JSON UTF-8; datele calendaristice au formatul `YYYY-MM-DD`.
- Timeout recomandat: 30 s.

## 2. Endpoint-urile

### 2.1 Textul integral al unui document — `GET /monitorul-oficial/partea-i/{nro}`

Cost: **2 credite fix** per cerere reușită (2xx). Erorile nu se taxează.

Documentul complet, cu conținutul HTML.

| Parametru | Unde | Tip | Obligatoriu | Descriere |
|---|---|---|---|---|
| `nro` | cale | string | da | Numărul documentului (nro, din listă). Ex.: `109764`. |

Exemplu: `GET https://www.firmeapi.ro/api/v1/monitorul-oficial/partea-i/109764`

Răspuns 200 (răspuns real, scurtat: listele la câteva elemente, fără nume de persoane fizice):

```json
{
    "success": true,
    "nro": "0000109764",
    "sursa": "Monitorul Oficial al României",
    "rezultat": {
        "nro": "0000109764",
        "company": "DECIZIA Nr. 45",
        "publicatieNr": "271",
        "data": "2016-04-11",
        "title": "Decizia nr. 45 45 din 16 februarie 2016",
        "cui": null,
        "regCom": null,
        "content": "<p>DECIZIA Nr. 45</p><p>referitoare la excepţia de neconstituţionalitate a dispoziţiilor art. 346 alin. (1)</p><p>din Codul de procedură penală</p><p>Augustin Zegrean</p><p>— preşedinte</p><p>Valer Dorneanu</p><p>— ju…"
    }
}
```

| Câmp | Tip | Descriere |
|---|---|---|
| `success` | boolean | true când cererea a reușit. |
| `nro` | string | Valoarea cerută, normalizată. |
| `sursa` | string | Sursa datelor. |
| `rezultat` | object | Obiectul găsit. |
| `rezultat.nro` | string | Numărul documentului (cheia pentru textul integral). |
| `rezultat.company` | string | Emitentul sau subiectul publicației. |
| `rezultat.title` | string | Titlul. |
| `rezultat.publicatieNr` | string | Numărul Monitorului Oficial. |
| `rezultat.data` | date | Data publicării. |
| `rezultat.cui` | string\|null | CUI-ul, când e menționat (doar partea a VII-a). |
| `rezultat.regCom` | string\|null | Numărul de registru, când e menționat. |
| `rezultat.ocr` | boolean\|null | Documentul a fost recunoscut optic (texte vechi). |
| `rezultat.content` | string | Doar pe documentul integral: conținutul HTML. |

Reguli:

- Întoarce un singur obiect, în câmpul rezultat.

### 2.2 Caută în partea I — `GET /monitorul-oficial/partea-i/lista`

Cost: **1 credit / rezultat** per cerere reușită (2xx). Erorile nu se taxează.

Căutare full-text în titlu și conținut, cu filtre pe emitent și perioadă. Fără conținut în listă.

| Parametru | Unde | Tip | Obligatoriu | Descriere |
|---|---|---|---|---|
| `q` | query | string | nu | Text liber. Ex.: `lege`. |
| `emitent` | query | string | nu | Emitentul / subiectul publicației. |
| `numar_mo` | query | string | nu | Numărul Monitorului Oficial. |
| `data_de` | query | date (AAAA-LL-ZZ) | nu | De la data publicării (AAAA-LL-ZZ). |
| `data_pana` | query | date (AAAA-LL-ZZ) | nu | Până la data publicării (AAAA-LL-ZZ). |
| `pagina` | query | integer | nu | Numărul paginii, de la 1. Ex.: `1`. |
| `per_pagina` | query | integer | nu | Rezultate pe pagină, maxim 20. Ex.: `20`. |
| `ordine` | query | string | nu | Ordinea rezultatelor (vezi valorile permise pe endpoint). Valori: `relevanta`, `recente`. Ex.: `relevanta`. |

Exemplu: `GET https://www.firmeapi.ro/api/v1/monitorul-oficial/partea-i/lista?q=lege`

Răspuns 200 (răspuns real, scurtat: listele la câteva elemente, fără nume de persoane fizice):

```json
{
    "success": true,
    "sursa": "Monitorul Oficial al României",
    "pagina": 1,
    "per_pagina": 2,
    "total": 52274,
    "total_pagini": 5000,
    "rezultate": [
        {
            "nro": "0000109840",
            "company": "LEGE",
            "publicatieNr": "282",
            "data": "2016-04-14",
            "title": "Lege",
            "cui": null,
            "regCom": null
        },
        {
            "nro": "0000109848",
            "company": "LEGE",
            "publicatieNr": "283",
            "data": "2016-04-14",
            "title": "Lege",
            "cui": null,
            "regCom": null
        }
    ],
    "cost": {
        "credite_consumate": 2,
        "mod": "per_rezultat",
        "tip": "general"
    }
}
```

| Câmp | Tip | Descriere |
|---|---|---|
| `success` | boolean | true când cererea a reușit. |
| `sursa` | string | Sursa datelor. |
| `pagina · per_pagina` | integer | Pagina curentă și rezultatele pe pagină. |
| `total · total_pagini` | integer | Totalul rezultatelor care se potrivesc filtrelor și numărul de pagini pe care le poți parcurge. |
| `rezultate` | array | Rezultatele paginii. |
| `rezultate[].nro` | string | Numărul documentului (cheia pentru textul integral). |
| `rezultate[].company` | string | Emitentul sau subiectul publicației. |
| `rezultate[].title` | string | Titlul. |
| `rezultate[].publicatieNr` | string | Numărul Monitorului Oficial. |
| `rezultate[].data` | date | Data publicării. |
| `rezultate[].cui` | string\|null | CUI-ul, când e menționat (doar partea a VII-a). |
| `rezultate[].regCom` | string\|null | Numărul de registru, când e menționat. |
| `rezultate[].ocr` | boolean\|null | Documentul a fost recunoscut optic (texte vechi). |
| `rezultate[].content` | string | Doar pe documentul integral: conținutul HTML. |
| `cost.credite_consumate · mod · tip` | integer · string | Creditele consumate de cerere, modul de taxare (fix sau per_rezultat) și tipul creditelor (general sau premium). |

Reguli:

- Maximum 20 rezultate pe pagină. Paginile acoperă toate rezultatele care se potrivesc filtrelor; dacă o căutare e prea largă pentru a fi paginată până la capăt, răspunsul spune până unde poți merge.
- Costul se calculează pe numărul de rezultate întoarse; o căutare fără rezultate nu costă nimic.

## 3. Testare

Endpoint-urile acestui API nu au sandbox. Pentru testele automate, folosește răspunsurile din secțiunea 2 ca fixture (mock HTTP pe clientul tău). Pentru o verificare reală, fă o singură cerere cu cheia utilizatorului: erorile și răspunsurile 404 nu consumă credite.

## 4. Erori

Corpul unei erori: `{ "success": false, "error": "...", "code": "...", "message": "..." }` (câmpul `message` poate lipsi). Decide după statusul HTTP și `code`, nu după text. Nicio eroare nu consumă credite.

| HTTP | code | Când apare | Ce face integrarea | Reîncearcă |
|---|---|---|---|---|
| 401 | AUTH_MISSING_KEY, AUTH_INVALID_KEY, AUTH_KEY_EXPIRED | Cheia lipsește, e greșită sau a expirat. | Eroare de configurare: loghează și verifică FIRMEAPI_KEY. | nu |
| 403 | CREDITS_EXHAUSTED | Rezultatele paginii costă mai mult decât creditele disponibile. | Răspunsul spune costul cererii și soldul: cere mai puține rezultate (per_pagina mai mic) sau cumpără credite. | nu |
| 403 | ACCESS_DENIED_IP, ACCESS_DENIED_SCOPE | Cheia e limitată la alte IP-uri sau endpoint-uri. | Eroare de configurare a cheii, din contul FirmeAPI. | nu |
| 403 | MONTHLY_LIMIT_REACHED | Planul gratuit și-a atins limita lunară de cereri. | Mesaj către utilizator cu linkul din upgrade_url. | nu |
| 404 | — | Nu există date pentru cheia cerută. | Afișează „fără date”. Păstrează în cache scurt (de exemplu o oră). | nu |
| 422 | VALIDATION_FAILED | Un filtru invalid sau lipsește un parametru obligatoriu. | Arată eroarea din errors, pe câmp. Validează înainte de cerere. | nu |
| 429 | LIMIT_EXCEEDED_SECOND | Prea multe cereri într-o secundă. | Așteaptă retry_after secunde și reia cererea. | da |
| 429 | LIMIT_EXCEEDED_DAILY, LIMIT_EXCEEDED_MONTHLY | Limita zilnică sau lunară a planului. | Oprește cererile până la retry_after și anunță utilizatorul. | nu |
| 500 | — | Eroare neașteptată pe server. | Reîncearcă de cel mult 3 ori, la 1, 2 și 4 secunde. | da |

## 5. Reguli de domeniu și de integrare

1. Un câmp `null` înseamnă că sursa nu are informația: nu îl transforma în 0 sau în șir gol.
2. Apelurile se fac din backend; cheia API nu ajunge niciodată în browser sau în aplicația mobilă.
3. Păstrează rezultatele în cache pe cheia cererii; nu repeta aceeași cerere la fiecare afișare.

## 6. Header-e de răspuns utile

| Header | Conținut |
|---|---|
| `X-Credit-Cost` | Creditele consumate de cerere. |
| `X-Credit-Type` | Tipul creditelor folosite de endpoint. |
| `X-Credit-Balance-General` | Soldul de credite generale. |
| `X-Credit-Balance-{TIP}` | Soldul pentru tipul cererii, când nu e general (de exemplu X-Credit-Balance-ACTIONARI). |
| `X-RateLimit-Limit-Second · -Day · -Month` | Limitele planului. |
| `X-RateLimit-Remaining-Second · -Day · -Month` | Cât a mai rămas din fiecare limită. |

## 7. Implementare de referință

TypeScript / Node 18+ (tradu în limbajul proiectului dacă e altul):

```ts
const BASE = "https://www.firmeapi.ro/api/v1";

export class FirmeApiError extends Error {
  constructor(public status: number, public code: string | null, message: string, public body: unknown) {
    super(message);
  }
}

type Query = Record<string, string | number | boolean | undefined>;

async function call<T>(path: string, query?: Query): Promise<T> {
  const url = new URL(BASE + path);
  for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined) url.searchParams.set(k, String(v));

  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { "X-API-KEY": process.env.FIRMEAPI_KEY ?? "", Accept: "application/json" }, signal: AbortSignal.timeout(30_000) });
    const body: any = await res.json().catch(() => null);
    if (res.ok) return body as T;

    const code = body?.code ?? null;
    const retriable = res.status >= 500 || code === "LIMIT_EXCEEDED_SECOND";
    if (retriable && attempt < 3) {
      const wait = code === "LIMIT_EXCEEDED_SECOND" ? (body?.retry_after ?? 1) * 1000 : 1000 * 2 ** attempt;
      await new Promise((r) => setTimeout(r, wait));
      continue;
    }
    throw new FirmeApiError(res.status, code, body?.message ?? body?.error ?? res.statusText, body);
  }
}

/** 404 = fără date (netaxat): întoarce null în loc de excepție. */
async function orNull<T>(p: Promise<T>): Promise<T | null> {
  try {
    return await p;
  } catch (e) {
    if (e instanceof FirmeApiError && e.status === 404) return null;
    throw e;
  }
}

// Câte o funcție pentru fiecare endpoint (tipează răspunsurile după câmpurile din secțiunea 2).
export const moParteaI = (nro: string) => orNull(call<any>(`/monitorul-oficial/partea-i/${encodeURIComponent(nro)}`));
export const moParteaICauta = (query: Query = {}) => call<any>(`/monitorul-oficial/partea-i/lista`, query);
```

Echivalent PHP (Laravel):

```php
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

// config/services.php: 'firmeapi' => ['key' => env('FIRMEAPI_KEY')]
$res = Http::withHeaders(['X-API-KEY' => config('services.firmeapi.key')])->acceptJson()
    ->timeout(30)
    ->retry(3, fn (int $attempt) => 1000 * 2 ** ($attempt - 1),
        fn ($e) => $e instanceof RequestException && $e->response->serverError(), throw: false)
    ->get('https://www.firmeapi.ro/api/v1/monitorul-oficial/partea-i/109764');

if ($res->status() === 404) return null;   // fără date, netaxat
$res->throw();                              // restul erorilor: vezi secțiunea 4
$data = $res->json();
```

Echivalent Python:

```python
import os, time, requests

BASE = "https://www.firmeapi.ro/api/v1"

def call(path: str, params: dict | None = None):
    for attempt in range(4):
        r = requests.get(BASE + path, params=params, timeout=30,
                         headers={"X-API-KEY": os.environ["FIRMEAPI_KEY"], "Accept": "application/json"})
        if r.status_code == 404:
            return None                      # fără date, netaxat
        retriable = r.status_code >= 500 or (r.status_code == 429 and r.json().get("code") == "LIMIT_EXCEEDED_SECOND")
        if retriable and attempt < 3:
            time.sleep(r.json().get("retry_after", 1) if r.status_code == 429 else 2 ** attempt)
            continue
        r.raise_for_status()
        return r.json()
```

## 8. Teste de acceptanță

1. `moParteaI` cu cererea `/monitorul-oficial/partea-i/109764` întoarce 200 și se parsează în câmpurile din secțiunea 2 (fixture).
2. `moParteaICauta` cu cererea `/monitorul-oficial/partea-i/lista?q=lege` întoarce 200 și se parsează în câmpurile din secțiunea 2 (fixture).
3. Un răspuns 404 devine „fără date” (null sau listă goală), fără excepție.
4. Un 503 sau 500 e reîncercat de cel mult 3 ori; un 401 sau 403 nu e reîncercat.
5. Un 429 `LIMIT_EXCEEDED_SECOND` așteaptă `retry_after` secunde și reia cererea.
6. A doua cerere identică vine din cache, fără cerere HTTP.

## 9. Alternativa fără cod: MCP

Dacă utilizatorul vrea ca un asistent AI să interogheze datele direct (nu o integrare în aplicație), folosește serverul MCP: `https://www.firmeapi.ro/api/mcp` cu header-ul `Authorization: Bearer <cheie>`. Tool-uri: `mo_partea_i`, `mo_partea_i_cauta`, cu aceleași costuri. Ghid pe fiecare client: https://www.firmeapi.ro/mcp

## Legături

- Pagina API-ului: https://www.firmeapi.ro/info-api/monitorul-oficial-partea-i
- Acest ghid (Markdown): https://www.firmeapi.ro/info-api/monitorul-oficial-partea-i.md
- Specificația OpenAPI: https://www.firmeapi.ro/api/mcp/openapi.json
- Documentația generală: https://www.firmeapi.ro/docs
- Rezumat pentru LLM-uri: https://www.firmeapi.ro/llms.txt
