Pentru dezvoltatori

API-ul Qivo

Emiți facturi din aplicația sau magazinul tău: creezi clientul și ciorna, emiți factura și, pentru firmele din România, o trimiți automat în RO e-Factura. Primești rezultatul prin webhook-uri semnate.

Cuprins

Introducere

  • URL de bază: https://app.qivo.ro/api/v1
  • Descrierea completă, pentru generatoare de cod și Postman: OpenAPI 3.1 (openapi.json).
  • O cheie de acces lucrează pe o singură companie. Pentru mai multe companii, creezi câte o cheie în fiecare.
  • Mesajele de eroare sunt în limba română.

Pornire rapidă

1. În Qivo, deschide Setări → Integrări și creează o cheie. Bifează doar permisiunile de care ai nevoie. Cheia se afișează o singură dată.

2. Verifică cheia:

Terminal
curl https://app.qivo.ro/api/v1/me \
  -H "Authorization: Bearer $QIVO_API_KEY"

3. Creează clientul (sau caută-l după CUI cu GET /customers?q=…):

Terminal
curl -X POST https://app.qivo.ro/api/v1/customers \
  -H "Authorization: Bearer $QIVO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: client-ro12345678" \
  -d '{
    "kind": "COMPANY",
    "name": "Client Exemplu SRL",
    "cui": "RO12345678",
    "vatPayer": true,
    "country": "RO",
    "addressLine1": "Str. Exemplu 1",
    "city": "Cluj-Napoca",
    "county": "Cluj",
    "email": "contabilitate@client.ro"
  }'

4. Creează ciorna. Fără issueDate, factura primește data zilei în care o emiți; numărul se alocă abia la emitere.

Terminal
curl -X POST https://app.qivo.ro/api/v1/invoices \
  -H "Authorization: Bearer $QIVO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: comanda-1042-ciorna" \
  -d '{
    "customerId": "<id client>",
    "dueDate": "2026-12-15",
    "lines": [
      {
        "name": "Abonament lunar",
        "unit": "lună",
        "quantity": "1",
        "unitPrice": "100.00",
        "vatCategoryCode": "S",
        "vatRate": "21"
      }
    ]
  }'

5. Emite factura. Cu sendToEfactura, o factură către o firmă din România pleacă și în SPV.

Terminal
curl -X POST https://app.qivo.ro/api/v1/invoices/<id>/issue \
  -H "Authorization: Bearer $QIVO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: comanda-1042-emitere" \
  -d '{"sendToEfactura": true}'

Autentificare

Trimite cheia în antetul Authorization: Bearer qv_…. Qivo păstrează doar o amprentă a cheii, nu cheia. Ține cheia doar pe server, niciodată în codul din browser sau în aplicații mobile.

  • Cheia acționează în numele membrului care a creat-o. Permisiunile efective sunt cele ale cheii, limitate la rolul actual al acelui membru.
  • Dacă membrul pleacă din companie sau pierde drepturile, cheia nu mai funcționează (401) sau primește mai puține drepturi (403).
  • Cheile pot avea dată de expirare și pot fi revocate oricând. Tot ce face o cheie apare în jurnalul de activitate.
PermisiuneCe permite
customers.readCitește clienții.
customers.writeCreează clienți.
catalog.readRezervat pentru catalog (fără endpoint în v1).
invoices.readCitește documentele și starea e-Factura.
invoices.writeCreează ciorne.
invoices.issueEmite documente (alocă numărul).
efactura.sendTransmite în e-Factura facturile B2B. Are nevoie și de invoices.issue.
payments.readCitește încasările.
payments.writeÎnregistrează încasări.

Cheile nu primesc niciodată acces la setări, membri, jurnal, conexiunea ANAF sau integrări și nu văd CNP-ul complet al persoanelor fizice (doar ultimele 4 cifre).

Convenții

  • JSON la intrare și la ieșire. Câmpurile necunoscute sunt refuzate (422), ca să afli imediat de o greșeală de scriere.
  • Sumele și cantitățile sunt text: "121.00", nu 121. Prețurile și cantitățile pot avea până la 4 zecimale. Numerele JSON sunt refuzate pentru sume, ca să nu apară erori de rotunjire.
  • Datele sunt AAAA-LL-ZZ; momentele de timp sunt ISO 8601, în UTC.
  • Listele sunt paginate: ?page=1, răspuns { data, page, pageSize, total }.
  • Limită: 120 de cereri pe minut pentru fiecare cheie. Antetele X-RateLimit-Remaining și, la 429, Retry-After îți spun cât să aștepți.
  • Idempotență: trimite Idempotency-Key (8–200 caractere) la orice POST, de exemplu numărul comenzii. Dacă repeți cererea (timeout, retry), primești același răspuns, marcat cu Idempotent-Replayed: true, fără o factură dublă. Răspunsul se păstrează 24 de ore. Aceeași cheie cu altă cerere primește 409.

Erorile au aceeași formă:

Răspuns de eroare
{
  "error": {
    "code": "validation",
    "message": "Date invalide.",
    "fieldErrors": { "lines.0.unitPrice": "Sumă invalidă" }
  }
}
401unauthenticatedCheie lipsă, greșită, expirată sau revocată.
403forbiddenCheia sau rolul nu permite operația.
404not_foundNu există (și pentru id-urile altor companii).
409conflictStarea nu permite operația (ex. B2C spre e-Factura).
422validationDate invalide; detalii în fieldErrors.
429rate_limitedPrea multe cereri; reîncearcă după Retry-After.

Endpoint-uri

Toate căile sunt relative la https://app.qivo.ro/api/v1.

GET/meoricare

Compania și permisiunile cheii.

GET/customerscustomers.read

Listă de clienți. Filtre: q (nume, CUI sau e-mail), kind=COMPANY|INDIVIDUAL, archived=true, page.

POST/customerscustomers.write

Creează un client, cu aceleași validări ca în aplicație (cifra de control a CUI-ului).

GET/customers/{id}customers.read

Un client.

GET/invoicesinvoices.read

Listă de documente. Filtre: type, status, customerId, from, to, page (50 pe pagină).

POST/invoicesinvoices.write

Creează o ciornă (factură sau proformă). Numărul se alocă doar la emitere.

GET/invoices/{id}invoices.read

Linii, totaluri, plăți, rest de plată și ultima transmitere e-Factura.

POST/invoices/{id}/issueinvoices.issue

Emite documentul, cu toate verificările fiscale din aplicație. Opțional: { "sendToEfactura": true }.

GET/invoices/{id}/efacturainvoices.read

Transmiterile e-Factura ale documentului, cele mai noi primele.

POST/invoices/{id}/efacturaefactura.send

Pune în coada e-Factura o factură B2B emisă. Idempotent; răspunde 202.

GET/invoices/{id}/paymentspayments.read

Încasările unei facturi.

POST/invoices/{id}/paymentspayments.write

Înregistrează o încasare: amount, paidAt, method (BANK_TRANSFER, CASH, CARD, OTHER).

GET/paymentspayments.read

Toate încasările. Filtre: from, to, includeVoided=true, page.

RO e-Factura (B2B)

Prin API se transmit în e-Factura doar facturile către firme din România (cumpărător cu CUI). Facturile către persoane fizice se transmit din aplicație.

  • Ai nevoie de permisiunile invoices.issue și efactura.send, iar compania trebuie să fie conectată la ANAF (Setări → Conexiune ANAF).
  • Cu {"sendToEfactura": true} la emitere, Qivo verifică întâi permisiunea, regula B2B și conexiunea ANAF. Dacă una nu e îndeplinită, factura nu se emite (409).
  • După emitere, XML-ul se verifică cu regulile oficiale CIUS-RO. Dacă nu trece, factura rămâne emisă, iar răspunsul conține efacturaError cu regulile încălcate. Corectezi datele și reîncerci cu POST /invoices/{id}/efactura.
  • Încărcarea în SPV și urmărirea răspunsului se fac în aceeași coadă ca în aplicație. O încărcare cu rezultat incert nu se repetă automat; o rezolvă o persoană din aplicație.
  • Rezultatul îl primești prin webhook-ul efactura.submission_finalized sau cu GET /invoices/{id}/efactura.
  • Fiecare linie trebuie să aibă o unitate de măsură recunoscută, de exemplu buc, oră, zi, lună, kg, l, m, mp, serviciu, set, pachet.

Webhook-uri

În Setări → Integrări adaugi până la 10 adrese HTTPS. Qivo trimite un POST cu JSON pentru fiecare eveniment:

  • invoice.issued: factură sau notă de credit emisă (proformele nu).
  • payment.recorded și payment.voided: încasare înregistrată sau anulată.
  • efactura.submission_finalized: rezultatul transmiterii (ACCEPTED, REJECTED, INVALID_XML sau UPLOAD_REJECTED).
  • webhook.test: din butonul „Trimite un test”.
Corpul cererii
{
  "id": "…",
  "type": "invoice.issued",
  "createdAt": "2026-11-13T10:15:00.000Z",
  "organizationId": "…",
  "data": {
    "invoiceId": "…",
    "documentType": "INVOICE",
    "fullNumber": "QV-0042",
    "issueDate": "2026-11-13",
    "currency": "RON",
    "total": "121.00",
    "customerId": "…"
  }
}

Corpul conține doar identificatori și câmpuri publice; detaliile le citești prin API. Antete: Qivo-Event-Id, Qivo-Event-Type, Qivo-Delivery-Id, Qivo-Signature.

Verifică semnătura pe corpul brut, exact cum a sosit: Qivo-Signature: t=<secunde>,v1=<HMAC-SHA256 hex>, calculat din "<t>.<corp>" cu secretul endpoint-ului. Refuză semnăturile mai vechi de 5 minute.

Node.js / TypeScript
import crypto from "node:crypto";

/** header = valoarea Qivo-Signature, rawBody = corpul exact, ca text */
export function verifyQivoSignature(header: string, rawBody: string, secret: string) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!parts.v1 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
PHP
<?php
function qivo_verify_signature(string $header, string $raw_body, string $secret): bool {
  if (!preg_match('/t=(\d+),v1=([a-f0-9]+)/', $header, $m)) return false;
  if (abs(time() - (int) $m[1]) > 300) return false;
  $expected = hash_hmac('sha256', $m[1] . '.' . $raw_body, $secret);
  return hash_equals($expected, $m[2]);
}

$raw = file_get_contents('php://input');
$ok = qivo_verify_signature($_SERVER['HTTP_QIVO_SIGNATURE'] ?? '', $raw, QIVO_WEBHOOK_SECRET);
http_response_code($ok ? 200 : 401);
  • Orice răspuns 2xx în 10 secunde înseamnă primit. Altfel Qivo reîncearcă după 1 min, 5 min, 30 min, 2 h, 6 h, 12 h și 24 h.
  • Un eveniment poate sosi de mai multe ori sau în altă ordine: elimină duplicatele după id.

Ghid: Next.js / Node

Un client mic pentru server și o funcție care facturează o comandă: caută clientul după CUI, îl creează dacă lipsește, creează ciorna și o emite. Cheile de idempotență derivate din id-ul comenzii fac reîncercările sigure.

lib/qivo.ts
// lib/qivo.ts — doar pe server (cheia nu ajunge niciodată în browser)
import "server-only";

const BASE = process.env.QIVO_API_URL ?? "https://app.qivo.ro/api/v1";

export async function qivo<T>(
  method: "GET" | "POST",
  path: string,
  body?: unknown,
  idempotencyKey?: string,
): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.QIVO_API_KEY}`,
      "Content-Type": "application/json",
      ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
    cache: "no-store",
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data?.error?.message ?? `Qivo HTTP ${res.status}`);
  return data as T;
}

type Line = {
  name: string;
  quantity: string; // sume și cantități ca text, niciodată number
  unitPrice: string; // fără TVA
  unit?: string; // buc, oră, lună, serviciu…
  vatCategoryCode: "S";
  vatRate: string; // cota care se aplică produsului tău
};

type Buyer = {
  name: string;
  cui?: string; // prezent doar pentru firme
  addressLine1: string;
  city: string;
  county: string;
  email?: string;
};

/** Emite factura unei comenzi; o trimite în SPV dacă cumpărătorul e firmă (B2B). */
export async function invoiceOrder(orderId: string, buyer: Buyer, lines: Line[]) {
  const isCompany = Boolean(buyer.cui);
  let customerId: string | undefined;
  if (isCompany) {
    const found = await qivo<{ data: { id: string }[] }>(
      "GET",
      `/customers?kind=COMPANY&q=${encodeURIComponent(buyer.cui!)}`,
    );
    customerId = found.data[0]?.id;
  }
  if (!customerId) {
    const created = await qivo<{ id: string }>(
      "POST",
      "/customers",
      { kind: isCompany ? "COMPANY" : "INDIVIDUAL", country: "RO", ...buyer },
      `order-${orderId}-customer`,
    );
    customerId = created.id;
  }
  const draft = await qivo<{ id: string }>(
    "POST",
    "/invoices",
    { customerId, lines },
    `order-${orderId}-draft`,
  );
  return qivo<{ id: string; number: string; efactura?: { state: string } }>(
    "POST",
    `/invoices/${draft.id}/issue`,
    { sendToEfactura: isCompany },
    `order-${orderId}-issue`,
  );
}

Pune QIVO_API_KEY în variabilele de mediu ale serverului. Apelează invoiceOrder dintr-un Route Handler, o Server Action sau un job, după ce plata e confirmată.

Ghid: WooCommerce / WordPress

Un plugin minimal care emite factura când comanda devine „Finalizată”. Pentru firme, adaugă la checkout un câmp pentru CUI (aici salvat ca _billing_cui); comenzile cu CUI se trimit și în SPV.

wp-content/plugins/qivo-facturi/qivo-facturi.php
<?php
/**
 * Plugin Name: Qivo — facturi pentru comenzi WooCommerce (exemplu)
 * În wp-config.php: define('QIVO_API_KEY', 'qv_…');
 */

const QIVO_API = 'https://app.qivo.ro/api/v1';

function qivo_request(string $method, string $path, ?array $body = null, ?string $idem = null) {
  $headers = ['Authorization' => 'Bearer ' . QIVO_API_KEY, 'Content-Type' => 'application/json'];
  if ($idem) $headers['Idempotency-Key'] = $idem;
  $res = wp_remote_request(QIVO_API . $path, [
    'method'  => $method,
    'headers' => $headers,
    'body'    => $body === null ? null : wp_json_encode($body),
    'timeout' => 20,
  ]);
  if (is_wp_error($res)) throw new Exception($res->get_error_message());
  $data = json_decode(wp_remote_retrieve_body($res), true);
  $code = wp_remote_retrieve_response_code($res);
  if ($code >= 400) throw new Exception($data['error']['message'] ?? "Qivo HTTP $code");
  return $data;
}

add_action('woocommerce_order_status_completed', function ($order_id) {
  $order = wc_get_order($order_id);
  if (!$order || $order->get_meta('_qivo_invoice_id')) return; // deja facturată

  try {
    // CUI-ul vine dintr-un câmp de checkout adăugat de tine (ex. „CUI firmă”).
    $cui = trim((string) $order->get_meta('_billing_cui'));
    $is_company = $cui !== '';

    $customer_id = null;
    if ($is_company) {
      $found = qivo_request('GET', '/customers?kind=COMPANY&q=' . rawurlencode($cui));
      $customer_id = $found['data'][0]['id'] ?? null;
    }
    if (!$customer_id) {
      $customer = qivo_request('POST', '/customers', [
        'kind'         => $is_company ? 'COMPANY' : 'INDIVIDUAL',
        'name'         => $is_company ? $order->get_billing_company()
                                      : $order->get_formatted_billing_full_name(),
        'cui'          => $is_company ? $cui : null,
        'country'      => $order->get_billing_country() ?: 'RO',
        'addressLine1' => $order->get_billing_address_1(),
        'city'         => $order->get_billing_city(),
        'county'       => $order->get_billing_state(),
        'email'        => $order->get_billing_email(),
      ], "wc-$order_id-customer");
      $customer_id = $customer['id'];
    }

    $lines = [];
    foreach ($order->get_items() as $item) {
      $lines[] = [
        'name'            => $item->get_name(),
        'unit'            => 'buc',
        'quantity'        => (string) $item->get_quantity(),
        'unitPrice'       => wc_format_decimal($order->get_item_total($item, false, false), 4),
        'vatCategoryCode' => 'S',
        'vatRate'         => '21', // cota care se aplică produselor tale
      ];
    }

    $draft = qivo_request('POST', '/invoices',
      ['customerId' => $customer_id, 'lines' => $lines], "wc-$order_id-draft");
    $issued = qivo_request('POST', '/invoices/' . $draft['id'] . '/issue',
      ['sendToEfactura' => $is_company], "wc-$order_id-issue");

    $order->update_meta_data('_qivo_invoice_id', $issued['id']);
    $order->save();
    $order->add_order_note('Factura Qivo ' . $issued['number'] . ' a fost emisă.');
  } catch (Exception $e) {
    $order->add_order_note('Qivo: factura nu a fost emisă — ' . $e->getMessage());
  }
});

Exemplul rulează sincron, la schimbarea stării. La volum mare, mută apelul într-o acțiune programată (Action Scheduler) ca să nu încetinești administrarea comenzilor.

Ghid: Shopify

Shopify trimite webhook-ul orders/paid către un serviciu al tău (de exemplu o aplicație Next.js), care verifică semnătura Shopify și cheamă Qivo cu funcția invoiceOrder din ghidul de mai sus.

app/api/shopify/orders-paid/route.ts
// app/api/shopify/orders-paid/route.ts (Next.js) — webhook Shopify „orders/paid”
import crypto from "node:crypto";
import Decimal from "decimal.js";
import { invoiceOrder } from "@/lib/qivo";

export async function POST(req: Request) {
  const raw = await req.text();
  const digest = crypto
    .createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET!)
    .update(raw, "utf8")
    .digest("base64");
  const received = req.headers.get("x-shopify-hmac-sha256") ?? "";
  if (
    received.length !== digest.length ||
    !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(digest))
  ) {
    return new Response("invalid signature", { status: 401 });
  }

  const order = JSON.parse(raw);
  // CUI-ul: de exemplu un atribut de comandă „CUI” completat la checkout.
  const cui = order.note_attributes?.find((a: { name: string }) => a.name === "CUI")?.value;
  const vat = "21"; // cota care se aplică produselor tale
  const lines = order.line_items.map((li: { title: string; quantity: number; price: string }) => ({
    name: li.title,
    unit: "buc",
    quantity: String(li.quantity),
    // Prețurile Shopify includ de obicei TVA: Qivo primește prețul fără TVA.
    unitPrice: order.taxes_included
      ? new Decimal(li.price).div(new Decimal(vat).div(100).plus(1)).toDecimalPlaces(4).toString()
      : li.price,
    vatCategoryCode: "S" as const,
    vatRate: vat,
  }));
  const b = order.billing_address ?? {};
  await invoiceOrder(
    String(order.id),
    {
      name: cui ? b.company : b.name,
      cui,
      addressLine1: b.address1,
      city: b.city,
      county: b.province,
      email: order.email,
    },
    lines,
  );
  return new Response(null, { status: 200 });
}
  • Shopify așteaptă răspunsul în câteva secunde. La volum mare, pune comanda într-o coadă și răspunde imediat.
  • Prețurile cu TVA inclus se transformă în prețuri fără TVA cu o bibliotecă zecimală, nu cu numere JavaScript. Verifică totalurile pe câteva comenzi de test.

Ce nu face încă API-ul

  • Note de credit (stornări), anulări și descărcarea PDF: se fac din aplicație.
  • Trimiterea în e-Factura a facturilor către persoane fizice: din aplicație.
  • Nu există încă pluginuri gata de instalat pentru Shopify sau WooCommerce.