De cero a XML firmado
en 10 minutos
Conecta tu ERP a la API de FacturaX.app y genera facturas electrónicas Facturae 3.2.2 con firma XAdES-BES listas para FACe. Sin instalar librerías de firma, sin gestionar certificados manualmente.
factura PDF o JSON
OCR + campos
Facturae XML
XAdES-BES listo
Administración
Ve a tu dashboard → API Keys → Crear key. Copia la clave fct_... — solo se muestra una vez. Luego verifica la conexión:
# Verificar estado de la API (sin auth) curl https://api.facturax.app/v1/status # Verificar tu cuota curl https://api.facturax.app/quota \ -H "X-API-Key: fct_TU_API_KEY"
import requests API_KEY = "fct_TU_API_KEY" BASE_URL = "https://api.facturax.app" HEADERS = { "X-API-Key": API_KEY, } # Verificar estado r = requests.get(f"{BASE_URL}/v1/status") print(r.json()) # {"status":"ok","version":"1.1.0",...} # Verificar cuota disponible r = requests.get(f"{BASE_URL}/quota", headers=HEADERS) data = r.json() # plan_remaining = plan mensual (se resetea) · api_credits = créditos API (sin caducidad) print(f"Plan: {data['plan_remaining']}/{data['plan_quota']} | Créditos API: {data['api_credits']}")
<?php $apiKey = 'fct_TU_API_KEY'; $baseUrl = 'https://api.facturax.app'; // Verificar cuota $ch = curl_init("$baseUrl/quota"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ["X-API-Key: $apiKey"], ]); $response = json_decode(curl_exec($ch), true); curl_close($ch); // Plan mensual y créditos API separados echo "Plan: {$response['plan_remaining']}/{$response['plan_quota']}\n"; echo "Créditos API: {$response['api_credits']} (sin caducidad)\n";
const API_KEY = 'fct_TU_API_KEY'; const BASE_URL = 'https://api.facturax.app'; const HEADERS = { 'X-API-Key': API_KEY }; // Verificar cuota const r = await fetch(`${BASE_URL}/quota`, { headers: HEADERS }); const data = await r.json(); // Plan mensual y créditos API son campos separados console.log(`Plan: ${data.plan_remaining}/${data.plan_quota} | Créditos API: ${data.api_credits}`);
X-Sandbox: true a cualquier petición para probar sin consumir cuota. El resultado incluye datos ficticios con la marca [SANDBOX].
Envía el archivo como multipart/form-data. Incluye X-External-Ref con tu propio ID de factura para mapear sin tabla auxiliar.
import requests, uuid def extract_invoice(pdf_path: str, erp_invoice_id: str) -> dict: with open(pdf_path, "rb") as f: r = requests.post( f"{BASE_URL}/extract", headers={ "X-API-Key": API_KEY, "X-Idempotency-Key": str(uuid.uuid4()), # reintento seguro "X-External-Ref": erp_invoice_id, # tu ID del ERP }, files={"file": (pdf_path, f, "application/pdf")}, ) r.raise_for_status() data = r.json() # Cabeceras útiles de la respuesta remaining = r.headers.get("X-Quota-Remaining") replayed = r.headers.get("X-Idempotency-Replayed") # "true" si era reintento return data # Uso invoice = extract_invoice("factura.pdf", "ERP-INV-2025-0312") print(f"Emisor: {invoice['vendor_name']} ({invoice['vendor_vat']})") print(f"Total: {invoice['total']} {invoice['currency']}") print(f"Nº fac: {invoice['invoice_number']}") print(f"Cuenta: {invoice['accounting']['account_code']} — {invoice['accounting']['account_label']}")
function extractInvoice($pdfPath, $erpInvoiceId): array { $ch = curl_init("https://api.facturax.app/extract"); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "X-API-Key: {$GLOBALS['apiKey']}", "X-Idempotency-Key: " . bin2hex(random_bytes(16)), "X-External-Ref: $erpInvoiceId", ], CURLOPT_POSTFIELDS => [ 'file' => new CURLFile($pdfPath, 'application/pdf'), ], ]); $response = json_decode(curl_exec($ch), true); curl_close($ch); return $response; } $invoice = extractInvoice('factura.pdf', 'ERP-INV-2025-0312'); echo "Emisor: {$invoice['vendor_name']}\n"; echo "Total: {$invoice['total']} EUR\n";
import { readFileSync } from 'fs'; import { randomUUID } from 'crypto'; async function extractInvoice(pdfPath, erpInvoiceId) { const form = new FormData(); form.append('file', new Blob([readFileSync(pdfPath)], { type: 'application/pdf' }), pdfPath); const r = await fetch(`${BASE_URL}/extract`, { method: 'POST', headers: { 'X-API-Key': API_KEY, 'X-Idempotency-Key': randomUUID(), 'X-External-Ref': erpInvoiceId, }, body: form, }); if (!r.ok) { const err = await r.json(); throw new Error(`${err.error_code}: ${err.detail}`); } return r.json(); } const invoice = await extractInvoice('factura.pdf', 'ERP-INV-2025-0312'); console.log(`Emisor: ${invoice.vendor_name} | Total: ${invoice.total} EUR`);
curl -X POST https://api.facturax.app/extract \ -H "X-API-Key: fct_TU_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "X-External-Ref: ERP-INV-2025-0312" \ -F "file=@factura.pdf"
{
"_log_id": 42, // ID interno — guárdalo para /invoices/{id}/facturae
"external_ref": "ERP-INV-2025-0312", // tu ID devuelto
"vendor_name": "Iberdrola S.A.U.",
"vendor_vat": "A95758389",
"invoice_number": "FAC-2024-0312",
"invoice_date": "2024-03-12",
"total": 187.43,
"subtotal": 154.90,
"tax": 32.53,
"accounting": {
"account_code": "628",
"account_label": "Suministros",
"confidence": "alta"
},
"confidence_score": 0.97,
"warnings": []
}
Dos opciones: desde el log_id de una extracción previa, o directo desde JSON si tu ERP ya tiene los datos.
# 1. Extraer la factura y guardar el log_id invoice = extract_invoice("factura.pdf", "ERP-INV-2025-0312") log_id = invoice["_log_id"] # 2. Generar y descargar el XML firmado r = requests.post( f"{BASE_URL}/invoices/{log_id}/facturae", headers=HEADERS, ) r.raise_for_status() # 3. Guardar el .xml en disco with open(f"factura_{log_id}.xml", "wb") as f: f.write(r.content) print(f"XML firmado guardado: factura_{log_id}.xml")
# Si tu ERP ya tiene los datos — salta el OCR completamente payload = { "invoice_data": { "vendor_name": "Mi Empresa S.L.", "vendor_vat": "B12345678", "vendor_address": "Calle Mayor 1", "vendor_city": "Madrid", "vendor_postal_code": "28001", "vendor_province": "Madrid", "vendor_country": "ESP", "buyer_name": "Ayuntamiento de Madrid", "buyer_vat": "P2807900B", "invoice_number": "2025-001", "invoice_date": "2025-05-01", "due_date": "2025-06-01", "currency": "EUR", "subtotal": 1000.00, "tax": 210.00, "total": 1210.00, "line_items": [ { "description": "Servicios de desarrollo web", "quantity": 10, "unit_price": 100.00, "total": 1000.00, "tax_rate": 21, } ], }, "external_ref": "ERP-INV-2025-001", "sign": True, # Opción A — certificado personal del usuario (guardado en el dashboard) # Opción B — certificate_id para ERPs con múltiples clientes: # "certificate_id": "cert_a1b2c3d4..." # de POST /certificates } r = requests.post( f"{BASE_URL}/convert-to-facturae/download", headers={**HEADERS, "X-Idempotency-Key": str(uuid.uuid4())}, json=payload, ) r.raise_for_status() with open("factura_2025-001.xml", "wb") as f: f.write(r.content) print("XML Facturae 3.2.2 con XAdES-BES guardado")
$payload = [ 'invoice_data' => [ 'vendor_name' => 'Mi Empresa S.L.', 'vendor_vat' => 'B12345678', 'invoice_number' => '2025-001', 'invoice_date' => '2025-05-01', 'total' => 1210.00, 'subtotal' => 1000.00, 'tax' => 210.00, 'line_items' => [[ 'description' => 'Servicios', 'quantity' => 1, 'unit_price' => 1000.00, 'total' => 1000.00, 'tax_rate' => 21, ]], ], 'external_ref' => 'ERP-INV-2025-001', 'sign' => true, ]; $ch = curl_init('https://api.facturax.app/convert-to-facturae/download'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "X-API-Key: {$apiKey}", 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode($payload), ]); $xml = curl_exec($ch); file_put_contents('factura_2025-001.xml', $xml); curl_close($ch);
import { writeFileSync } from 'fs'; const r = await fetch(`${BASE_URL}/convert-to-facturae/download`, { method: 'POST', headers: { ...HEADERS, 'Content-Type': 'application/json' }, body: JSON.stringify({ invoice_data: { vendor_name: 'Mi Empresa S.L.', vendor_vat: 'B12345678', invoice_number: '2025-001', invoice_date: '2025-05-01', total: 1210, subtotal: 1000, tax: 210, line_items: [{ description: 'Servicios', quantity: 1, unit_price: 1000, total: 1000, tax_rate: 21 }], }, external_ref: 'ERP-INV-2025-001', sign: true, }), }); writeFileSync('factura.xml', Buffer.from(await r.arrayBuffer()));
La API permite 60 peticiones/minuto por API key. Si procesas facturas en batch (fin de mes, procesamiento nocturno), añade un delay de 1 segundo entre llamadas para no superar el límite:
for factura in facturas: resultado = convert(factura) time.sleep(1) # máx 60/min, sin 429Si recibes un
429, espera el valor del header Retry-After antes de reintentar. Para volúmenes superiores a 60 facturas/min de forma sostenida, contacta con nosotros — los planes Business tienen límites personalizados.
Registra un endpoint en tu ERP y suscríbete a los eventos que necesitas. Verifica siempre la firma HMAC para descartar peticiones falsas.
r = requests.post(f"{BASE_URL}/webhooks", headers=HEADERS, json={ "url": "https://mi-erp.com/facturax/webhook", "events": ["invoice.signed", "quota.warning"], "secret": "mi_secret_para_verificar_hmac", }) print(r.json()) # {"id": 1, "url": "...", "events": [...]}
import hmac, hashlib from fastapi import Request, HTTPException WEBHOOK_SECRET = "mi_secret_para_verificar_hmac" async def facturax_webhook(request: Request): body = await request.body() signature = request.headers.get("X-Factura-Signature", "") # ── Verificar firma HMAC-SHA256 ────────────────────────── expected = "sha256=" + hmac.new( WEBHOOK_SECRET.encode(), body, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(signature, expected): raise HTTPException(401, "Firma inválida") # ── Procesar evento ────────────────────────────────────── event = (await request.json()) e_type = event["event"] data = event["data"] if e_type == "invoice.signed": # El XML firmado ya está en custodia — descargar si lo necesitas log_id = data["log_id"] external_ref = data["external_ref"] # tu ID del ERP expires_at = data["expires_at"] mark_invoice_signed(external_ref, log_id, expires_at) elif e_type == "quota.warning": # Avisar al equipo antes de quedarse sin créditos send_alert(f"FacturaX: {data['remaining']} facturas restantes ({data['pct_remaining']}%)") # 'remaining' en el payload del webhook (distinto de GET /quota) return {"ok": True}
$secret = 'mi_secret_para_verificar_hmac'; $body = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_FACTURA_SIGNATURE'] ?? ''; $expected = 'sha256=' . hash_hmac('sha256', $body, $secret); if (!hash_equals($expected, $signature)) { http_response_code(401); exit('Firma inválida'); } $event = json_decode($body, true); $type = $event['event']; $data = $event['data']; if ($type === 'invoice.signed') { markInvoiceSigned($data['external_ref'], $data['log_id']); } elseif ($type === 'quota.warning') { sendAlert("FacturaX cuota: {$data['pct_used']}% usada"); } http_response_code(200); echo 'ok';
import { createHmac, timingSafeEqual } from 'crypto'; const WEBHOOK_SECRET = 'mi_secret_para_verificar_hmac'; app.post('/facturax/webhook', express.raw({ type: '*/*' }), (req, res) => { const sig = req.headers['x-factura-signature'] ?? ''; const expected = 'sha256=' + createHmac('sha256', WEBHOOK_SECRET) .update(req.body).digest('hex'); if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return res.status(401).send('Firma inválida'); const { event, data } = JSON.parse(req.body); if (event === 'invoice.signed') markInvoiceSigned(data.external_ref, data.log_id); else if (event === 'quota.warning') sendAlert(`FacturaX cuota: ${data.pct_used}% usada`); res.json({ ok: true }); });
Todos los errores devuelven error_code + detail. Toma decisiones sobre error_code, nunca sobre el texto de detail.
| error_code | HTTP | Reintento | Estrategia |
|---|---|---|---|
| QUOTA_EXCEEDED | 429 | No | Esperar reset mensual o comprar créditos |
| EXTRACTION_FAILED | 500 | Sí × 3 | Backoff: 2s, 4s, 8s. Misma idempotency key |
| SERVICE_ERROR | 500 | Sí × 3 | Backoff: 5s, 15s, 30s. Consultar /status |
| RATE_LIMIT_EXCEEDED | 429 | Esperar | Cabecera Retry-After indica los segundos |
| FILE_TYPE_INVALID | 400 | No | Corregir formato antes de reintentar |
| CERT_INVALID_PASSWORD | 400 | No | Verificar certificado y contraseña |
| AUTH_INVALID_KEY | 401 | No | Revisar API key en el dashboard |
import time, requests RETRYABLE = {"EXTRACTION_FAILED", "SERVICE_ERROR", "XML_BUILD_FAILED"} def call_with_retry(method, url, max_retries=3, **kwargs): idempotency_key = kwargs.get("headers", {}).get("X-Idempotency-Key") for attempt in range(max_retries): r = requests.request(method, url, **kwargs) if r.status_code == 429: wait = int(r.headers.get("Retry-After", 60)) time.sleep(wait) continue if r.status_code >= 500: error_code = r.json().get("error_code", "") if error_code in RETRYABLE and attempt < max_retries - 1: time.sleep(2 ** (attempt + 1)) # 2s, 4s, 8s continue return r return r
- Cada respuesta incluye
X-Quota-Remaining— compruébalo tras cada llamada - El webhook
quota.warningdispara al 80% y al 95% — suscríbete en el paso 4 GET /quotadevuelve cuota en tiempo real sin consumir créditos- Los packs de créditos (Pack S 15 · Pack M 40) no caducan — úsalos como buffer
def check_quota_before_batch(n_invoices: int) -> bool: """Comprueba si hay cuota suficiente antes de un lote.""" r = requests.get(f"{BASE_URL}/quota", headers=HEADERS) data = r.json() # Sumar plan mensual + créditos API (sin caducidad) total = data.get("plan_remaining", 0) + data.get("api_credits", 0) if total < n_invoices: raise RuntimeError( f"Cuota insuficiente: {total} disponibles, " f"necesitas {n_invoices}. Reset plan: {data.get('quota_reset_at', '—')}" ) return True # Antes de procesar un lote de facturas check_quota_before_batch(len(facturas_pendientes))
Si tu ERP ya genera el XML Facturae correctamente pero no tiene firma XAdES-BES integrada, no necesitas cambiar nada de cómo generas facturas. Solo añade una llamada a /sign que recibe el XML y devuelve el .xml firmado listo para FACe.
POST /certificates (1 vez/cliente) → certificate_idTu ERP genera XML → POST /sign + X-Certificate-ID → .xml → FACe1 crédito por factura. El usuario final no sabe que existe FacturaX.
import requests # Paso 1 (una vez por cliente): subir el .p12 y guardar el certificate_id with open("cliente.p12", "rb") as cert: r = requests.post( "https://api.facturax.app/certificates", headers={"X-API-Key": "fct_tu_api_key"}, files={"file": cert}, data={"password": "contraseña_p12", "name": "Cliente ABC S.L."}, ) certificate_id = r.json()["certificate_id"] # guardar en tu BD # Paso 2 (en cada factura): el XML lo genera tu ERP como siempre with open("factura_sin_firmar.xml", "rb") as f: r = requests.post( "https://api.facturax.app/sign", headers={ "X-API-Key": "fct_tu_api_key", "X-Certificate-ID": certificate_id, # firma con el cert del cliente "X-External-Ref": "F2024-001", }, files={"file": ("factura.xml", f, "application/xml")}, ) r.raise_for_status() data = r.json() # Guardar el .xml firmado with open(data["filename"], "w", encoding="utf-8") as out: out.write(data["xml"]) print(f"✓ {data['filename']} — listo para subir a FACe")
// El XML lo genera tu ERP como siempre $xml_path = 'factura_sin_firmar.xml'; $ch = curl_init('https://api.facturax.app/sign'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => [ 'file' => new CURLFile($xml_path, 'application/xml'), ], CURLOPT_HTTPHEADER => [ 'X-API-Key: fct_tu_api_key', 'X-External-Ref: F2024-001', ], ]); $data = json_decode(curl_exec($ch), true); curl_close($ch); file_put_contents($data['filename'], $data['xml']); echo "✓ {$data['filename']} — listo para FACe";
const fs = require('fs'); const FormData = require('form-data'); const form = new FormData(); form.append('file', fs.createReadStream('factura_sin_firmar.xml')); const res = await fetch('https://api.facturax.app/sign', { method: 'POST', headers: { 'X-API-Key': 'fct_tu_api_key', 'X-External-Ref': 'F2024-001', ...form.getHeaders(), }, body: form, }); const data = await res.json(); fs.writeFileSync(data.filename, data.xml, 'utf8'); console.log(`✓ ${data.filename} — listo para FACe`);
curl -X POST https://api.facturax.app/sign \ -H "X-API-Key: fct_tu_api_key" \ -H "X-External-Ref: F2024-001" \ -F "file=@factura_sin_firmar.xml"
Para batch de fin de mes añade time.sleep(1) entre llamadas para respetar el límite de 60 req/min. El webhook invoice.signed incluye el external_ref para correlacionar con tu ERP.