From zero to structured JSON
in 10 minutes
Connect your ERP to the Parse extraction API and turn any invoice — PDF, scan or photo — into structured, validated JSON. Optional add-on for Spain: generate Facturae 3.2.2 XML with XAdES-BES signature, ready for FACe, with no signing libraries or certificates to manage manually.
PDF, scan or photo
OCR + fields
Ready to use in your ERP
From the core flow above
Facturae XML
XAdES-BES ready
Public administration
Go to your dashboard → API Keys → Create key. Copy the fct_... key — it's only shown once. Then verify the connection:
# Check API status (no auth) curl https://api.facturax.app/v1/status # Check your quota curl https://api.facturax.app/quota \ -H "X-API-Key: fct_YOUR_API_KEY"
import requests API_KEY = "fct_YOUR_API_KEY" BASE_URL = "https://api.facturax.app" HEADERS = { "X-API-Key": API_KEY, } # Check status r = requests.get(f"{BASE_URL}/v1/status") print(r.json()) # {"status":"ok","version":"1.1.0",...} # Check available quota r = requests.get(f"{BASE_URL}/quota", headers=HEADERS) data = r.json() # plan_remaining = monthly plan (resets) · api_credits = API credits (no expiry) print(f"Plan: {data['plan_remaining']}/{data['plan_quota']} | API credits: {data['api_credits']}")
<?php $apiKey = 'fct_YOUR_API_KEY'; $baseUrl = 'https://api.facturax.app'; // Check quota $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); // Monthly plan and API credits are separate echo "Plan: {$response['plan_remaining']}/{$response['plan_quota']}\n"; echo "API credits: {$response['api_credits']} (no expiry)\n";
const API_KEY = 'fct_YOUR_API_KEY'; const BASE_URL = 'https://api.facturax.app'; const HEADERS = { 'X-API-Key': API_KEY }; // Check quota const r = await fetch(`${BASE_URL}/quota`, { headers: HEADERS }); const data = await r.json(); // Monthly plan and API credits are separate fields console.log(`Plan: ${data.plan_remaining}/${data.plan_quota} | API credits: ${data.api_credits}`);
X-Sandbox: true header to any request to test without consuming quota. The result includes mock data marked with [SANDBOX].
Send the file as multipart/form-data. Include X-External-Ref with your own invoice ID to map results without an extra table.
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()), # safe retries "X-External-Ref": erp_invoice_id, # your ERP ID }, files={"file": (pdf_path, f, "application/pdf")}, ) r.raise_for_status() data = r.json() # Useful response headers remaining = r.headers.get("X-Quota-Remaining") replayed = r.headers.get("X-Idempotency-Replayed") # "true" if this was a retry return data # Usage invoice = extract_invoice("invoice.pdf", "ERP-INV-2025-0312") print(f"Vendor: {invoice['vendor_name']} ({invoice['vendor_vat']})") print(f"Total: {invoice['total']} {invoice['currency']}") print(f"Inv. no: {invoice['invoice_number']}") print(f"Account: {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('invoice.pdf', 'ERP-INV-2025-0312'); echo "Vendor: {$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('invoice.pdf', 'ERP-INV-2025-0312'); console.log(`Vendor: ${invoice.vendor_name} | Total: ${invoice.total} EUR`);
curl -X POST https://api.facturax.app/extract \ -H "X-API-Key: fct_YOUR_API_KEY" \ -H "X-Idempotency-Key: $(uuidgen)" \ -H "X-External-Ref: ERP-INV-2025-0312" \ -F "file=@invoice.pdf"
{
"_log_id": 42, // internal ID — save it for /invoices/{id}/facturae
"external_ref": "ERP-INV-2025-0312", // your ID, returned back
"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" // (Utilities),
"confidence": "alta" // (high)
},
"confidence_score": 0.97,
"warnings": []
}
Two options: from the log_id of a previous extraction, or directly from JSON if your ERP already has the data.
# 1. Extract the invoice and save the log_id invoice = extract_invoice("invoice.pdf", "ERP-INV-2025-0312") log_id = invoice["_log_id"] # 2. Generate and download the signed XML r = requests.post( f"{BASE_URL}/invoices/{log_id}/facturae", headers=HEADERS, ) r.raise_for_status() # 3. Save the .xml to disk with open(f"invoice_{log_id}.xml", "wb") as f: f.write(r.content) print(f"Signed XML saved: invoice_{log_id}.xml")
# If your ERP already has the data — skip OCR entirely payload = { "invoice_data": { "vendor_name": "My Company Ltd.", "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" # (Web development services), "quantity": 10, "unit_price": 100.00, "total": 1000.00, "tax_rate": 21, } ], }, "external_ref": "ERP-INV-2025-001", "sign": True, # Option A — the user's own certificate (saved in the dashboard) # Option B — certificate_id for ERPs with multiple clients: # "certificate_id": "cert_a1b2c3d4..." # from 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("invoice_2025-001.xml", "wb") as f: f.write(r.content) print("Facturae 3.2.2 XML with XAdES-BES saved")
$payload = [ 'invoice_data' => [ 'vendor_name' => 'My Company Ltd.', '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' // (Services), '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('invoice_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: 'My Company Ltd.', 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('invoice.xml', Buffer.from(await r.arrayBuffer()));
The API allows 60 requests/minute per API key. If you process invoices in batches (month-end, overnight processing), add a 1-second delay between calls to stay within the limit:
for invoice in invoices: result = convert(invoice) time.sleep(1) # max 60/min, no 429If you get a
429, wait for the value of the Retry-After header before retrying. For sustained volumes above 60 invoices/min, contact us — Business plans have custom limits.
Register an endpoint on your ERP and subscribe to the events you need. Always verify the HMAC signature to reject forged requests.
r = requests.post(f"{BASE_URL}/webhooks", headers=HEADERS, json={ "url": "https://my-erp.com/facturax/webhook", "events": ["invoice.signed", "quota.warning"], "secret": "my_hmac_verification_secret", }) print(r.json()) # {"id": 1, "url": "...", "events": [...]}
import hmac, hashlib from fastapi import Request, HTTPException WEBHOOK_SECRET = "my_hmac_verification_secret" async def facturax_webhook(request: Request): body = await request.body() signature = request.headers.get("X-Factura-Signature", "") # ── Verify HMAC-SHA256 signature ───────────────────────── expected = "sha256=" + hmac.new( WEBHOOK_SECRET.encode(), body, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(signature, expected): raise HTTPException(401, "Invalid signature") # ── Process event ───────────────────────────────────────── event = (await request.json()) e_type = event["event"] data = event["data"] if e_type == "invoice.signed": # The signed XML is already in custody — download if you need it log_id = data["log_id"] external_ref = data["external_ref"] # your ERP ID expires_at = data["expires_at"] mark_invoice_signed(external_ref, log_id, expires_at) elif e_type == "quota.warning": # Alert the team before running out of credits send_alert(f"FacturaX: {data['remaining']} invoices remaining ({data['pct_remaining']}%)") # 'remaining' is in the webhook payload (different from GET /quota) return {"ok": True}
$secret = 'my_hmac_verification_secret'; $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('Invalid signature'); } $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 quota: {$data['pct_used']}% used"); } http_response_code(200); echo 'ok';
import { createHmac, timingSafeEqual } from 'crypto'; const WEBHOOK_SECRET = 'my_hmac_verification_secret'; 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('Invalid signature'); 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 quota: ${data.pct_used}% used`); res.json({ ok: true }); });
All errors return error_code + detail. Make decisions based on error_code, never on the detail text.
| error_code | HTTP | Retry | Strategy |
|---|---|---|---|
| QUOTA_EXCEEDED | 429 | No | Wait for the monthly reset or buy credits |
| EXTRACTION_FAILED | 500 | Yes × 3 | Backoff: 2s, 4s, 8s. Same idempotency key |
| SERVICE_ERROR | 500 | Yes × 3 | Backoff: 5s, 15s, 30s. Check /status |
| RATE_LIMIT_EXCEEDED | 429 | Wait | The Retry-After header tells you how many seconds |
| FILE_TYPE_INVALID | 400 | No | Fix the format before retrying |
| CERT_INVALID_PASSWORD | 400 | No | Check the certificate and password |
| AUTH_INVALID_KEY | 401 | No | Check the API key in the 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
- Every response includes
X-Quota-Remaining— check it after each call - The
quota.warningwebhook fires at 80% and 95% — subscribe in step 4 GET /quotareturns real-time quota without consuming credits- Credit packs (Pack S 15 · Pack M 40) never expire — use them as a buffer
def check_quota_before_batch(n_invoices: int) -> bool: """Check there's enough quota before running a batch.""" r = requests.get(f"{BASE_URL}/quota", headers=HEADERS) data = r.json() # Sum monthly plan + API credits (no expiry) total = data.get("plan_remaining", 0) + data.get("api_credits", 0) if total < n_invoices: raise RuntimeError( f"Not enough quota: {total} available, " f"need {n_invoices}. Plan reset: {data.get('quota_reset_at', '—')}" ) return True # Before processing a batch of invoices check_quota_before_batch(len(pending_invoices))
If your ERP already generates the Facturae XML correctly but doesn't have XAdES-BES signing built in, you don't need to change anything about how you generate invoices. Just add a call to /sign, which takes the XML and returns the signed .xml ready for FACe.
POST /certificates (once per client) → certificate_idYour ERP generates XML → POST /sign + X-Certificate-ID → .xml → FACe1 credit per invoice. The end user never needs to know FacturaX exists.
import requests # Step 1 (once per client): upload the .p12 and save the certificate_id with open("client.p12", "rb") as cert: r = requests.post( "https://api.facturax.app/certificates", headers={"X-API-Key": "fct_your_api_key"}, files={"file": cert}, data={"password": "p12_password", "name": "Cliente ABC S.L."}, ) certificate_id = r.json()["certificate_id"] # save it in your DB # Step 2 (per invoice): the XML is generated by your ERP as usual with open("unsigned_invoice.xml", "rb") as f: r = requests.post( "https://api.facturax.app/sign", headers={ "X-API-Key": "fct_your_api_key", "X-Certificate-ID": certificate_id, # sign with the client's certificate "X-External-Ref": "F2024-001", }, files={"file": ("invoice.xml", f, "application/xml")}, ) r.raise_for_status() data = r.json() # Save the signed .xml with open(data["filename"], "w", encoding="utf-8") as out: out.write(data["xml"]) print(f"✓ {data['filename']} — ready to upload to FACe")
// The XML is generated by your ERP as usual $xml_path = 'unsigned_invoice.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_your_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']} — ready for FACe";
const fs = require('fs'); const FormData = require('form-data'); const form = new FormData(); form.append('file', fs.createReadStream('unsigned_invoice.xml')); const res = await fetch('https://api.facturax.app/sign', { method: 'POST', headers: { 'X-API-Key': 'fct_your_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} — ready for FACe`);
curl -X POST https://api.facturax.app/sign \ -H "X-API-Key: fct_your_api_key" \ -H "X-External-Ref: F2024-001" \ -F "file=@unsigned_invoice.xml"
For month-end batches, add time.sleep(1) between calls to respect the 60 req/min limit. The invoice.signed webhook includes the external_ref to correlate with your ERP.