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:
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=…):
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.
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.
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.
| Permisiune | Ce permite |
|---|---|
customers.read | Citește clienții. |
customers.write | Creează clienți. |
catalog.read | Rezervat pentru catalog (fără endpoint în v1). |
invoices.read | Citește documentele și starea e-Factura. |
invoices.write | Creează ciorne. |
invoices.issue | Emite documente (alocă numărul). |
efactura.send | Transmite în e-Factura facturile B2B. Are nevoie și de invoices.issue. |
payments.read | Citeș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", nu121. 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 cuIdempotent-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ă:
{
"error": {
"code": "validation",
"message": "Date invalide.",
"fieldErrors": { "lines.0.unitPrice": "Sumă invalidă" }
}
}| 401 | unauthenticated | Cheie lipsă, greșită, expirată sau revocată. |
| 403 | forbidden | Cheia sau rolul nu permite operația. |
| 404 | not_found | Nu există (și pentru id-urile altor companii). |
| 409 | conflict | Starea nu permite operația (ex. B2C spre e-Factura). |
| 422 | validation | Date invalide; detalii în fieldErrors. |
| 429 | rate_limited | Prea multe cereri; reîncearcă după Retry-After. |
Endpoint-uri
Toate căile sunt relative la https://app.qivo.ro/api/v1.
/meoricareCompania și permisiunile cheii.
/customerscustomers.readListă de clienți. Filtre: q (nume, CUI sau e-mail), kind=COMPANY|INDIVIDUAL, archived=true, page.
/customerscustomers.writeCreează un client, cu aceleași validări ca în aplicație (cifra de control a CUI-ului).
/customers/{id}customers.readUn client.
/invoicesinvoices.readListă de documente. Filtre: type, status, customerId, from, to, page (50 pe pagină).
/invoicesinvoices.writeCreează o ciornă (factură sau proformă). Numărul se alocă doar la emitere.
/invoices/{id}invoices.readLinii, totaluri, plăți, rest de plată și ultima transmitere e-Factura.
/invoices/{id}/issueinvoices.issueEmite documentul, cu toate verificările fiscale din aplicație. Opțional: { "sendToEfactura": true }.
/invoices/{id}/efacturainvoices.readTransmiterile e-Factura ale documentului, cele mai noi primele.
/invoices/{id}/efacturaefactura.sendPune în coada e-Factura o factură B2B emisă. Idempotent; răspunde 202.
/invoices/{id}/paymentspayments.readÎncasările unei facturi.
/invoices/{id}/paymentspayments.writeÎnregistrează o încasare: amount, paidAt, method (BANK_TRANSFER, CASH, CARD, OTHER).
/paymentspayments.readToate î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șiefactura.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
efacturaErrorcu regulile încălcate. Corectezi datele și reîncerci cuPOST /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_finalizedsau cuGET /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șipayment.voided: încasare înregistrată sau anulată.efactura.submission_finalized: rezultatul transmiterii (ACCEPTED, REJECTED, INVALID_XML sau UPLOAD_REJECTED).webhook.test: din butonul „Trimite un test”.
{
"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.
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
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 — 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.
<?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 (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.