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: 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.
{"type":"factura.emisa","timestamp":"2026-11-12T09:30:00.000Z","data":{"id":"3f0c2a8e-6b1d-4c3a-9e2f-1a2b3c4d5e6f","serie":"A","numar":1042}}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
Adăugarebashcurl -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 urlstringda cel mult 2048 de caractere; doar https, pe portul 443, fără utilizator și parolă în adresă, fără adrese locale sau privateevenimentestring[]da unul sau mai multe din factura.emisa,aviz.emis,oferta.creata,comanda_catering.primitadescrierestringnu cel mult 200 de caractere O adresă refuzată dă
400cu{"eroare": "url_interzis"}; un corp greșit,400cu{"eroare": "corp_invalid"}.Salvează secretul
Răspunsul
201are 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
Testbashcurl -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 untest.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:
- 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ă.
- Refuză cererea dacă
webhook-timestampe la mai mult de 5 minute de ora ta: așa nu poate fi retrimisă mai târziu de altcineva. - Calculează semnătura și compar-o, în timp constant, cu fiecare valoare
v1,…dinwebhook-signature. Valorile sunt despărțite prin spațiu; ajunge să se potrivească una.
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);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 "", 204Un 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:
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.
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”. Doarwebhook-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
timestampdin 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.