# Webhook-uri

Platforma anunță serverul tău când se emite o factură, un aviz, o ofertă sau vine o comandă de catering: cereri POST semnate după Standard Webhooks, cu reîncercări și jurnal al livrărilor.

## Cum funcționează

Adaugi o adresă `https` a serverului tău și alegi evenimentele care te interesează. Când se întâmplă unul dintre ele, platforma trimite la adresa ta o cerere `POST` cu un corp JSON scurt, semnată cu un secret pe care îl primești la adăugare. Serverul tău verifică semnătura, răspunde `2xx` și, dacă are nevoie de detalii, citește documentul prin API.

Formatul urmează specificația [Standard Webhooks](https://www.standardwebhooks.com): antetele `webhook-id`, `webhook-timestamp`, `webhook-signature` și semnătură HMAC-SHA256. Bibliotecile oficiale Standard Webhooks pot verifica semnătura direct.

Evenimentul se scrie în aceeași tranzacție cu documentul: dacă documentul nu se salvează, nu pleacă nici evenimentul. Trimiterea o face o sarcină din fundal, la câteva secunde după emitere, niciodată în cererea omului care a emis documentul.

## Evenimentele

| Eveniment | Când se trimite | `data` |
| --- | --- | --- |
| `factura.emisa` | s-a emis o factură (din documente sau dintr-un bon) | `id`, `serie`, `numar` |
| `aviz.emis` | s-a emis un aviz de însoțire | `id`, `serie`, `numar` |
| `oferta.creata` | s-a creat o ofertă | `id`, `an`, `numar` |
| `comanda_catering.primita` | a venit o comandă de catering | `id` |
| `test.ping` | doar la „Trimite test” | `mesaj` |

`data` conține doar identificatori, nu date personale sau sume. Ca să afli restul, citești documentul prin API.

```json title="Corpul unei livrări (JSON compact, pe un singur rând)"
{"type":"factura.emisa","timestamp":"2026-11-12T09:30:00.000Z","data":{"id":"3f0c2a8e-6b1d-4c3a-9e2f-1a2b3c4d5e6f","serie":"A","numar":1042}}
```

```http title="Antetele unei livrări"
POST /webhook HTTP/1.1
Content-Type: application/json
webhook-id: msg_2f1c0d3e4a5b4c2b9b1a8f14e45fea8e
webhook-timestamp: 1794475800
webhook-signature: v1,xvrLd4URs9sqyiPEOJPIQbrLYrXbWMUwTlIJXh4lqBU=
```

## Adaugă o destinație

Destinațiile se gestionează doar de un **Administrator al firmei**, din sesiune. Pe o firmă unde livrarea webhook-urilor nu e pornită, rutele răspund `409` cu `{"eroare": "webhookuri_oprite_pe_firma"}`.

### Trimite adresa și evenimentele

```bash title="Adăugare"
curl -X POST https://app.ilustromania.com/nucleu/v1/webhookuri \
  -H "Cookie: __Host-sesiune=$JETON_SESIUNE" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://exemplu.ro/webhook", "evenimente": ["factura.emisa", "aviz.emis"], "descriere": "Contabilitate"}'
```

| Câmp | Tip | Obligatoriu | Detalii |
| --- | --- | --- | --- |
| `url` | `string` | da | cel mult 2048 de caractere; doar `https`, pe portul 443, fără utilizator și parolă în adresă, fără adrese locale sau private |
| `evenimente` | `string[]` | da | unul sau mai multe din `factura.emisa`, `aviz.emis`, `oferta.creata`, `comanda_catering.primita` |
| `descriere` | `string` | nu | cel mult 200 de caractere |

O adresă refuzată dă `400` cu `{"eroare": "url_interzis"}`; un corp greșit, `400` cu `{"eroare": "corp_invalid"}`.

### Salvează secretul

Răspunsul `201` are forma `{"destinatie": {…}, "secret": "whsec_…"}`. Secretul **se vede doar acum**; platforma îl ține criptat în Seif și nu îl mai arată. Pune-l într-o variabilă de mediu a serverului care primește.

### Trimite un test

```bash title="Test"
curl -X POST https://app.ilustromania.com/nucleu/v1/webhookuri/$ID_DESTINATIE/test \
  -H "Cookie: __Host-sesiune=$JETON_SESIUNE"
```

Răspunsul este `202`; în câteva secunde serverul tău primește un `test.ping`. Rezultatul apare în jurnalul livrărilor.

## Verifică semnătura

Semnătura este HMAC-SHA256 peste textul `<webhook-id>.<webhook-timestamp>.<corp>`, cu cheia dată de partea base64 de după `whsec_`, codată în base64 și prefixată cu `v1,`. Ca să o verifici:

1. Citește corpul **exact cum a venit**, ca text sau octeți, înainte de orice parsare JSON. Un corp parsat și scris din nou nu mai are aceeași semnătură.
2. Refuză cererea dacă `webhook-timestamp` e la mai mult de 5 minute de ora ta: așa nu poate fi retrimisă mai târziu de altcineva.
3. Calculează semnătura și compar-o, în timp constant, cu fiecare valoare `v1,…` din `webhook-signature`. Valorile sunt despărțite prin spațiu; ajunge să se potrivească una.

```javascript title="Node.js (Express)"
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const SECRET = process.env.WEBHOOK_SECRET; // whsec_…
const TOLERANTA_S = 5 * 60;

function verifica(secret, antete, corp) {
  const id = antete["webhook-id"];
  const ts = Number(antete["webhook-timestamp"]);
  const semnaturi = String(antete["webhook-signature"] ?? "");
  if (!id || !Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANTA_S) return false;
  const cheie = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const asteptata = createHmac("sha256", cheie).update(`${id}.${ts}.${corp}`).digest();
  return semnaturi.split(" ").some((parte) => {
    const [versiune, valoare] = parte.split(",", 2);
    if (versiune !== "v1" || !valoare) return false;
    const primita = Buffer.from(valoare, "base64");
    return primita.length === asteptata.length && timingSafeEqual(primita, asteptata);
  });
}

const app = express();
// corpul brut: semnătura se calculează pe textul exact primit
app.post("/webhook", express.raw({ type: "application/json" }), (cerere, raspuns) => {
  const corp = cerere.body.toString("utf8");
  if (!verifica(SECRET, cerere.headers, corp)) return raspuns.sendStatus(400);
  const eveniment = JSON.parse(corp);
  // ... pune evenimentul într-o coadă și răspunde repede
  raspuns.sendStatus(204);
});
app.listen(3000);
```

```python title="Python (Flask)"
import base64
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["WEBHOOK_SECRET"]  # whsec_…
TOLERANTA_S = 5 * 60

def verifica(secret: str, antete, corp: bytes) -> bool:
    id_ = antete.get("webhook-id", "")
    ts = antete.get("webhook-timestamp", "")
    semnaturi = antete.get("webhook-signature", "")
    if not id_ or not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANTA_S:
        return False
    cheie = base64.b64decode(secret.removeprefix("whsec_"))
    asteptata = hmac.new(cheie, f"{id_}.{ts}.".encode() + corp, hashlib.sha256).digest()
    for parte in semnaturi.split(" "):
        versiune, _, valoare = parte.partition(",")
        if versiune == "v1" and valoare:
            try:
                primita = base64.b64decode(valoare)
            except ValueError:
                continue
            if hmac.compare_digest(primita, asteptata):
                return True
    return False

app = Flask(__name__)

@app.post("/webhook")
def webhook():
    corp = request.get_data()  # octeții exacți, înainte de JSON
    if not verifica(SECRET, request.headers, corp):
        abort(400)
    eveniment = request.get_json()
    # ... pune evenimentul într-o coadă și răspunde repede
    return "", 204
```

**Un exemplu calculat, ca să-ți probezi codul**
Cu secretul de probă format din `whsec_` urmat de `Y2hlaWUtZGUtcHJvYmEtcGVudHJ1LWRvY3VtZW50YXRpZSEh` (nu e al nimănui; partea de după prefix este base64 pentru textul `cheie-de-proba-pentru-documentatie!!`), `webhook-id` = `msg_2f1c0d3e4a5b4c2b9b1a8f14e45fea8e`, `webhook-timestamp` = `1794475800` și corpul de mai sus, exact pe un rând, semnătura corectă este:

```text
v1,xvrLd4URs9sqyiPEOJPIQbrLYrXbWMUwTlIJXh4lqBU=
```

Momentul `1794475800` este fix (12.11.2026, 09:30 UTC), deci funcțiile de mai sus îl refuză din cauza ferestrei de 5 minute; pentru probă, scoate temporar verificarea timpului. Un singur spațiu în plus în corp schimbă semnătura.

> [!TIP]
> Răspunde repede, cu `2xx`, și fă munca grea după aceea. Platforma așteaptă cel mult 15 secunde; peste acest timp livrarea se consideră eșuată și se reîncearcă.

## Livrări, reîncercări și dubluri

| Răspunsul serverului tău | Ce face platforma |
| --- | --- |
| `2xx` | livrat |
| `410 Gone` | oprește destinația; nu mai trimite nimic |
| `3xx` (redirecționările nu se urmează), alt cod, eroare de rețea, peste 15 s | eșec: reîncearcă după pauza următoare |

Pauzele dintre încercări sunt **5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h, 24 h**, fiecare cu o abatere mică (până la +10%), deci cel mult 10 încercări în aproximativ trei zile și jumătate. Un antet `Retry-After` (secunde sau dată HTTP) poate lungi pauza, până la 24 de ore, dar nu o scurtează. După a zecea încercare eșuată, destinația se oprește, iar Administratorii firmei primesc o notificare.

- **Aceeași livrare are mereu același `webhook-id`** și același corp, la fiecare încercare și la „Retrimite”. Doar `webhook-timestamp` și semnătura se refac.
- **Pot apărea dubluri:** dacă procesul se oprește în timpul unei trimiteri, livrarea se reia. Păstrează `webhook-id`-urile primite și ignoră-le pe cele deja tratate.
- **Ordinea nu e garantată.** Folosește `timestamp` din corp sau citește documentul prin API, nu ordinea sosirii.
- Corpul răspunsului tău nu se citește: contează doar codul și `Retry-After`.

## Rotirea secretului

`POST /v1/webhookuri/{id}/roteste-secret` întoarce `{"destinatie": {…}, "secret": "whsec_…"}` cu un secret nou. Timp de **24 de ore** după rotire, fiecare livrare poartă două semnături în `webhook-signature`, despărțite prin spațiu: cu secretul nou și cu cel vechi. Ai deci o zi să pui secretul nou pe server; codul de verificare de mai sus le acceptă pe amândouă fără schimbări.

## Toate rutele

| Rută | Ce face |
| --- | --- |
| `GET /v1/webhookuri` | destinațiile firmei, fără secrete |
| `POST /v1/webhookuri` | adaugă o destinație; `201` cu secretul, o singură dată |
| `POST /v1/webhookuri/{id}/opreste` | oprește destinația; livrările în așteptare se anulează |
| `POST /v1/webhookuri/{id}/roteste-secret` | secret nou; cel vechi mai semnează 24 de ore |
| `POST /v1/webhookuri/{id}/test` | `202`: un `test.ping` doar spre această destinație |
| `GET /v1/webhookuri/{id}/livrari` | jurnalul livrărilor destinației (cele mai noi 100) |
| `POST /v1/webhookuri/livrari/{id}/retrimite` | `202`: aceeași livrare, de la încercarea 1 |

Coduri comune: `401` fără sesiune, `403` fără rolul `admin`, `404` pentru o destinație a altei firme sau inexistentă, `409` cu `{"eroare": "destinatie_oprita"}` pe o destinație oprită, `503` cu `{"eroare": "seif_neconfigurat"}` când Seiful nu poate păstra secretul. O destinație oprită nu se mai pornește: adaugi una nouă.

**Câmpurile unei destinații și ale unei livrări**
**Destinația:** `id`, `url`, `descriere`, `evenimente`, `activ`, `dezactivat_la` și `motiv` (de exemplu `oprita_de_admin`, `destinatia_a_raspuns_410`, `livrari_esuate`), `rotire_pana` (până când mai semnează secretul vechi), `creata_la`.

**Livrarea:** `id`, `destinatie_id`, `eveniment_id` (valoarea din `webhook-id`), `tip`, `incercare` (1–10), `stare` (`de_trimis`, `livrat`, `esuat`, `anulat`), `manual` (test sau retrimitere), `planificata_la`, `trimisa_la`, `cod` (codul HTTP primit), `durata_ms`, `eroare` (de exemplu `termen_depasit`, `dns_esuat`, `adresa_interna`, `conexiune_esuata`), `urmatoarea_la`. Momentele sunt ISO 8601, în UTC.

## Securitatea trimiterii

Platforma trimite doar la adrese `https` pe portul 443. Numele serverului se rezolvă înainte de trimitere, iar cererea pleacă doar dacă **toate** adresele găsite sunt publice; conexiunea merge apoi exact la adresa verificată, fără o a doua căutare DNS. Nu se urmează redirecționări. Secretul de semnare stă criptat în Seif și se folosește doar la semnare.
