FacturareIntră în aplicație
API pentru firma taWebhookuri · pagina 8 din 9

Webhookuri și evenimente

Îți spunem noi când se întâmplă ceva, la adresa ta, cu semnătură.

Actualizat la

Un webhook nou

În aplicație, la API și webhookuri, adaugi adresa serverului tău (doar https://, publică), alegi evenimentele și firmele. Primești un secret whsec_…, cu care verifici că cererile vin de la noi. Butonul de test trimite pe loc un eveniment webhook.test și îți arată răspunsul serverului tău.

Dialogul Webhook nou din Chappa Facturare: adresa serverului, firmele și evenimentele alese, de la validarea ANAF la facturile primite
Un webhook nou: adresa ta, firmele și evenimentele pe care le vrei.

Webhookurile sunt ale contului tău, ca și cheile: auzi doar firmele în care ești și doar cât timp ești în ele. Un cont are cel mult 20 de webhookuri.

Evenimentele

typeCe s-a întâmplatPermisiune pentru GET /events
document.issuedUn document a fost emis (factură, proformă sau stornare), din API, din aplicație sau dintr-o factură recurentă.documents:read
document.deletedUn document a fost șters.documents:read
document.importedO factură trimisă din alt program prin SPV a fost preluată (doar cele apărute în ultimele 3 zile).documents:read
document.paidO factură a fost încasată integral.payments:read
efactura.submittedANAF a primit XML-ul (are index de încărcare) și îl procesează.documents:read
efactura.acceptedANAF a validat factura.documents:read
efactura.rejectedANAF a respins factura; erorile sunt în efactura.errors.documents:read
payment.createdO încasare a fost înregistrată.payments:read
payment.deletedO încasare a fost ștearsă (sau chitanța ei anulată).payments:read
receipt.issuedO chitanță a fost emisă.payments:read
receipt.cancelledO chitanță a fost anulată.payments:read
received_invoice.createdO factură nouă de la un furnizor a apărut în SPV.received:read
company.verifiedFirma a devenit verificată (cineva a dovedit drepturi SPV pe codul ei).documents:read
company.mergedO copie a firmei a fost unită cu firma verificată; data.object are from și into.documents:read
{
  "id": "evt_10422",
  "object": "event",
  "type": "efactura.accepted",
  "company_id": 7,
  "created_at": "2026-10-09T14:09:40+03:00",
  "data": {
    "object": {
      "id": 812, "object": "document", "type": "invoice", "status": "issued", "number": "FCT 42",
      "external_ref": "comanda-1042",
      "efactura": { "applies": true, "status": "accepted", "upload_index": "5012345678", "errors": null, "scheduled_at": null, "anaf_id": null },
      "…": "…"
    }
  }
}

Documentele vin în data.object ca în liste: fără linii și încasări. Pentru ele, citești documentul după id.

Cererea pe care o primești

Un POST cu evenimentul în corp (JSON) și trei antete, după specificația Standard Webhooks:

AntetCe este
webhook-idId-ul evenimentului (evt_…), același la fiecare reîncercare.
webhook-timestampMomentul trimiterii, în secunde Unix.
webhook-signaturev1, urmat de semnătura HMAC-SHA256, în base64. După o schimbare de secret vin două semnături, separate prin spațiu, 24 de ore.

Răspunde cu orice cod 2xx în cel mult 15 secunde. Fă munca grea după răspuns, dintr-o coadă: un răspuns lent sau un cod de eroare înseamnă reîncercare.

Verificarea semnăturii

Semnătura este HMAC-SHA256 peste {webhook-id}.{webhook-timestamp}.{corpul exact primit}, cu cheia dată de partea de după whsec_, decodată din base64. Verifică și momentul (cel mult 5 minute diferență), ca o cerere veche să nu poată fi repetată. Folosește corpul exact cum a venit, înainte de orice parsare JSON.

Node.js

import crypto from 'node:crypto';

export function verify(secret, headers, body) {
  const id = headers['webhook-id'], ts = headers['webhook-timestamp'], sigs = headers['webhook-signature'];
  if (!id || !ts || !sigs || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${body}`).digest();
  return sigs.split(' ').some((s) => {
    const [v, b64] = s.split(',');
    const got = Buffer.from(b64 || '', 'base64');
    return v === 'v1' && got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}

// Express: corpul brut, nu cel parsat
app.post('/webhooks/chappa', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(process.env.CHAPPA_WHSEC, req.headers, req.body)) return res.sendStatus(400);
  res.sendStatus(200);
  queue.push(JSON.parse(req.body));
});

Python

import base64, hashlib, hmac, time

def verify(secret: str, headers, body: bytes) -> bool:
    msg_id, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
    if not (msg_id and ts and sigs) or abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    expected = base64.b64encode(mac).decode()
    return any(hmac.compare_digest(s[3:], expected) for s in sigs.split(" ") if s.startswith("v1,"))

PHP

function verify(string $secret, array $h, string $body): bool {
    $id = $h['webhook-id'] ?? ''; $ts = $h['webhook-timestamp'] ?? ''; $sigs = $h['webhook-signature'] ?? '';
    if ($id === '' || $ts === '' || abs(time() - (int) $ts) > 300) return false;
    $key = base64_decode(substr($secret, strlen('whsec_')));
    $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$body", $key, true));
    foreach (explode(' ', $sigs) as $s) {
        if (str_starts_with($s, 'v1,') && hash_equals($expected, substr($s, 3))) return true;
    }
    return false;
}

$ok = verify(getenv('CHAPPA_WHSEC'), array_change_key_case(getallheaders()), file_get_contents('php://input'));

Bibliotecile Standard Webhooks (standardwebhooks pentru JavaScript, Python, PHP, Go, Java, C#, Ruby, Rust) fac aceeași verificare, dacă preferi una gata făcută.

Reîncercările

Dacă serverul tău nu răspunde cu 2xx în 15 secunde, reîncercăm după 1 minut, 5 minute, 30 de minute, 2 ore, 5 ore, 10 ore și 14 ore: opt încercări, în aproximativ 32 de ore. În aplicație vezi fiecare livrare, cu codul primit și răspunsul, și o poți retrimite.

Livrările unui webhook în Chappa Facturare: evenimentele trimise, codul HTTP primit, încercările și următoarea reîncercare
Livrările unui webhook: ce a plecat, ce a răspuns serverul tău, când reîncercăm.

O adresă care nu răspunde cu succes 5 zile la rând este oprită și primești un email; o repornești din aplicație.

Duplicate și ordine

  • Același eveniment poate veni de două ori (de exemplu dacă răspunsul tău s-a pierdut). Ține minte webhook-id și sari peste cele deja tratate.
  • Evenimentele pot sosi în altă ordine decât s-au întâmplat, mai ales după reîncercări. Când contează starea de acum, citește documentul prin API.

Fără webhook

GET /api/v1/events dă aceleași evenimente, din ultimele 30 de zile, pentru firmele cheii. Îl folosești în locul unui webhook, dacă serverul tău nu poate primi cereri, sau ca să recuperezi ce ai pierdut cât a fost oprit: ții minte ultimul id tratat și ceri mai departe cu starting_after.

Referința

Lista evenimentelor

GET /api/v1/events

Evenimentele ultimelor 30 de zile din firmele cheii, cele mai noi primele; fiecare tip doar cu permisiunea de citire a resursei lui. Aceleași obiecte ca la webhookuri: le poți citi periodic în loc de webhook sau ca să recuperezi ce ai pierdut.

ParametruTipCe este
company_idintegerAle unei firme.
typestringDe un tip.
limitintegerCâte pe pagină, 1–100. Implicit 25.
starting_afterstringnext_cursor din pagina anterioară.

Răspuns 200: o List de Event.

Un eveniment

GET /api/v1/events/{event_id}

Evenimentul, după id (evt_…).

ParametruTipCe este
event_id în adresăstringId-ul evenimentului (evt_…).

Răspuns 200: Event.

{
  "id": "evt_10422",
  "object": "event",
  "type": "efactura.accepted",
  "company_id": 7,
  "created_at": "2026-10-09T14:09:40+03:00",
  "data": {
    "object": {
      "id": 812, "object": "document", "type": "invoice", "status": "issued", "number": "FCT 42",
      "external_ref": "comanda-1042",
      "efactura": { "applies": true, "status": "accepted", "upload_index": "5012345678", "errors": null, "scheduled_at": null, "anaf_id": null },
      "…": "…"
    }
  }
}

Obiectul Event

Ce s-a întâmplat într-o firmă. Același obiect vine la webhookuri și din GET /events.

CâmpTipCe este
idstringevt_…, unic; la webhookuri și în antetul webhook-id.
objecteventMereu event.
typestringTipul (lista mai jos).
company_idintegerFirma.
created_atdate-timeCând s-a întâmplat.
dataobjectobject: documentul (ca în liste), încasarea, chitanța, factura primită sau firma, după tip.