For developers

The Factuza API

The same engine the web and the app use: SHA-256 chained hash, numbering by series, PDF with verification QR code and filing with the AEAT.

Status: preview for integrators. The API works and is tested, and the pricing and the split of responsibilities are on the Motor API page. Sign-up does not have a button yet: it happens by talking. Write to hola@factuza.com and we will give you test-environment access the same day.

This is not a list of intentions: every example on this page is executed on every run of the test suite (case CU-K04). If something stopped working, the test goes red before you find out.

1. Who this is for

For software that already runs a business —a garage, a clinic, a training centre, a vertical ERP— and needs its invoices to comply with VeriFactu without rewriting the tax side. Your system is still the one in charge; Factuza supplies the numbering, the chained hash, the PDF and the filing.

What it is not: a signing gateway or a simple PDF generator. Every invoice you issue through here is born with its invoicing record, enters the hash chain and is filed with the tax office exactly as if you had issued it from the website.

An issued invoice is never deleted. It enters the hash chain and stays there. What exists is voiding and correcting, which are two distinct operations and both leave a trail. Try it in the test environment before issuing for real.

2. Authentication: the API key

The whole API accepts two credentials: a person's token (what the web and the app use) or an API key, which belongs to a machine. For integrating, yours is the second one.

A key is created from the client area, under API keys, with your administrator user, and travels in a header:

X-Api-Key: fzk_x8Kq2vN...

When you create it you choose what it can do, and the difference matters:

If your integration does not onboard clients, use the first: a key that can do less does less damage the day it leaks. And it cannot be upgraded on the fly — you deliberately create another one.

Three rules worth knowing beforehand

The key inherits your account's permissions: its issuers, its plan and its licence. It cannot issue in the name of a tax number that is not yours, and if your licence expires it stops issuing just as the website does — but it can still query and download what has already been issued, because your books are yours.

3. Your first invoice, step by step

Five calls. All with the same header and none with a user token. The example uses the test environment, where nothing counts and nothing is filed with the AEAT.

01Who am I?

First of all, which issuers your key can work with. This is where the tax number and the name on the invoice come from: do not type them in, read them.

curl https://func-factuza-test-obt1.azurewebsites.net/api/emisores \
  -H "X-Api-Key: $FACTUZA_CLAVE"

{ "emisores": [ { "emisorId": 2, "nif": "B87654323",
                 "nombreRazon": "Pruebas Automaticas SL" } ],
  "plan": "PYME", "tope": 1, "usados": 1, "disponibles": 0 }

02Your series

The invoice number is set by the server, inside the same transaction that issues it. You send the serieId, not the number: typing it in from outside leaves sequential numbering in the hands of nobody making a mistake, and a gap in the numbering has to be explained to the tax office.

curl https://func-factuza-test-obt1.azurewebsites.net/api/series \
  -H "X-Api-Key: $FACTUZA_CLAVE"

{ "series": [ { "serieId": 1, "codigo": "PRU", "activa": true,
                "siguienteNumero": "PRU-2026/0457" } ], "total": 1 }

03Issue

The minimum body for an ordinary invoice (F1) with a single net amount and a single rate. For several lines or special VAT treatments, use POST /facturas/detallada.

curl -X POST https://func-factuza-test-obt1.azurewebsites.net/api/facturas \
  -H "X-Api-Key: $FACTUZA_CLAVE" \
  -H "Content-Type: application/json" \
  -d '{
    "nifEmisor": "B87654323",
    "nombreEmisor": "Pruebas Automaticas SL",
    "numSerieFactura": "",          // lo pone la serie
    "serieId": 1,
    "fechaExpedicion": "2026-08-23",
    "tipoFactura": "F1",
    "descripcion": "Primera factura por API",
    "nifDestinatario": "B12345674",
    "nombreDestinatario": "Cliente de pruebas SL",
    "baseImponible": 100,
    "tipoImpositivo": 21
  }'

It answers 201 —a resource has been created, it is not a 200— with the number the series assigned and the hash:

{ "facturaId": 457,
  "numSerieFactura": "PRU-2026/0457",
  "huella": "9F2A…64 caracteres en hexadecimal" }

Without a hash it is not VeriFactu. It is 64 characters: the SHA-256 of the record, chained to the previous invoice's.

04The PDF

With its verification QR code, ready to send to the client.

curl https://func-factuza-test-obt1.azurewebsites.net/api/facturas/457/pdf \
  -H "X-Api-Key: $FACTUZA_CLAVE" -o factura.pdf

05How your usage is going

This endpoint can be called by a machine, unlike the key-management ones: refusing to let you see where you stand and then billing you for the excess would be setting a trap.

curl https://func-factuza-test-obt1.azurewebsites.net/api/claves-api/consumo \
  -H "X-Api-Key: $FACTUZA_CLAVE"

{ "desde": "2026-08-01…", "hasta": "2026-09-01…",
  "porEmisor": [ { "nif": "B87654323", "registros": 12,
                   "incluidos": 3000, "restantes": 2988,
                   "exceso": 0, "eurosDeExceso": 0 } ],
  "totalRegistros": 12, "peticionesPorMinuto": 120 }

4. The four flows that matter

Issuing

POST /facturas for the simple case, POST /facturas/detallada when there are several lines, discounts, or VAT treatments other than the standard one (exempt, reverse charge, out of scope by place of supply, disbursement). With no recipient you get a simplified invoice (F2), which does not entitle the recipient to deduct the VAT.

Voiding and correcting — they are not the same

An invoice that has already been paid cannot be voided. That is a rule of the engine, not of the interface.

Expenses

POST /gastos registers a deductible expense, which stays pending until somebody approves it with POST /gastos/{id}/validar. That pause is deliberate: a misread expense that lets itself into the books goes unnoticed until the quarter comes round.

If you have the photograph or the PDF but not the data, POST /ocr/analizar returns a draft with what it read and a list of what it does not trust itself on. It never creates the expense: it proposes it.

Legal export

GET /exportar/registros?desde=&hasta= returns the invoicing records in the standardised format of article 10 of Royal Decree 1007/2023: one XML per record, the hash chain as CSV and a manifest so they can be recalculated and checked that nobody has touched anything. It is what you have to be able to hand over in an audit.

GET /exportar/gestoria is something else entirely: the convenient pack for the accountant, with PDFs and CSV.

Finding out what the AEAT says (webhook)

When you issue, the 201 arrives in milliseconds, but what it is telling you is “received and queued”. The AEAT answers afterwards, and that answer —accepted, accepted with errors, or rejected with a code— is the one that decides whether the invoice stands. You can query it with GET /facturas/{id}, but polling one by one is expensive and in practice nobody does it: rejections get discovered weeks later, when you have already issued a hundred more with the same mistake.

So tell us where to notify you. From Client area → API keys → Webhook, or through the API itself:

PUT /webhook
{ "url": "https://api.tu-erp.es/factuza/avisos" }

→ 200 { "secreto": "whsec_…", "cabecera": "X-Factuza-Firma" }

The secret is shown only once. Keep it: it is what you use to check the notification came from us. If you lose it, you save the URL again and another one is generated (changing the URL always renews the secret, so that the one signing towards your previous server stops being valid).

Each notification arrives as a POST with this body:

{ "evento": "registro.remitido",
  "registroId": 48211,
  "numSerieFactura": "FA2026-0117",
  "nifEmisor": "B12345674",
  "resultado": "RECHAZADO",
  "codigoError": "1103",
  "descripcionError": "El NIF del destinatario no está identificado",
  "csv": null,
  "ocurridoUtc": "2026-08-25T09:14:03Z" }

resultado is ACEPTADO, ACEPTADO_ERRORES or RECHAZADO. We only notify final states: if the AEAT is unavailable and a retry is needed, that is our business and we do not bother you with it.

How to check the signature

The X-Factuza-Firma header looks like this: t=1756112043,v1=<hex>. The v1 is the HMAC-SHA256 of the string “<t>.<body exactly as it arrived>” using your secret.

firmado = t + "." + cuerpoCrudo
esperado = hmac_sha256(secreto, firmado).hex()
valido   = comparacionEnTiempoConstante(esperado, v1) and (ahora - t) < 300

Check the timestamp too. The t mark is inside what is signed for exactly that reason: without checking it, anyone who intercepts a notification can replay it to you months later and the signature will still add up. Reject anything that arrives more than five minutes old.

And use the raw body, before parsing the JSON: if you re-serialise it, the key order or the whitespace may change and the signature will stop matching.

Retries and duplicates

If your server does not answer with a 2xx within ten seconds, we retry at 1, 5, 15, 60, 180, 360 and 720 minutes, and give up on the eighth attempt —about twenty hours. In the client area you will see every delivery and why it failed.

Every retry carries the same X-Factuza-Entrega header, so if you received the notification but went down before answering, you will recognise it by that number and will not process it twice. Answer 200 first and do the work afterwards: whatever you take inside the request counts against those ten seconds.

Two things we reject, worth knowing in advance: the URL has to be https:// and point at a public server —a notification carries the taxpayer's tax number inside it, and we check the address right before calling— and we do not follow redirects: if you answer with a 302 we count it as a failure. Point the webhook at the final URL.

5. Quotas and limits

LimitHow muchWhat happens if you exceed it
Records per issuer per month 3,000 included Nothing is blocked. The excess is billed at €2 per 1,000.
Requests per minute 120 429 with a Retry-After header.

The asymmetry is deliberate and worth understanding: a monthly cap that cut off issuing would leave a taxpayer unable to comply with the law over a commercial matter of ours. Issuing an invoice is not a whim, it has a deadline. So the monthly cap warns you and gets billed.

The per-minute limit does cut off, and for a different reason: a runaway loop in one integration is nobody's legal obligation — it is a fault which, unchecked, takes down everyone else's service. The 429 also tells you that you have a bug.

6. Errors

CodeWhat it means
400The body is not valid. The message says which field and why, in Spanish.
401No valid credential: the header is missing, or the key does not exist or has been revoked.
403Your account cannot do that: insufficient role, licence not current, or an issuer that is not yours.
404It does not exist — or it is not yours. Asking for another account's resource answers exactly like asking for one that does not exist, deliberately.
409Conflict: it already exists, or its state does not allow the operation (voiding an invoice that has been paid).
429Too many requests per minute. Wait for whatever Retry-After says.

Errors come with a JSON body containing error and, where it helps, a detalle. They are written to be understood without knowing our internals: “You cannot issue invoices in the name of X. Your account issues as Y” says what is wrong and how to fix it.

The most used ones. They all hang off https://func-factuza-prod-obt1.azurewebsites.net/api in production and off …-test-… in testing.

VerbEndpointWhat it does
get/emisoresWhich issuers your key works with
get/seriesYour numbering series and the next number
post/facturasIssue (single net amount and rate)
post/facturas/detalladaIssue with line items and VAT treatments
get/facturasList, with filters
get/facturas/{id}/pdfThe PDF with its QR code
post/facturas/{id}/anularVoid (does not delete)
post/facturas/{id}/rectificarCorrect (creates another)
post/facturas/{id}/emailSend it by email
get/gastosExpenses for the period
post/gastosRegister an expense (stays pending)
post/gastos/{id}/validarApprove it
post/ocr/analizarRead a photograph or a PDF (proposes, does not create)
get/maestras/destinatariosYour clients
get/resumenInvoiced, quarter and outstanding
get/modelos/{modelo}Draft of Modelo 303, 130, 390…
get/exportar/registrosArticle 10 legal export
get/claves-api/consumoYour usage this month
get/webhookWhere we notify you and how the last deliveries went
put/webhookSet or change the URL (returns the secret once)
delete/webhookStop notifying

There are more than sixty endpoints in total —quotes, recurring invoices, receipts, series, users. If you cannot find one, write to us and we will tell you whether it exists.

8. Download the specification

The full catalogue —62 endpoints, 82 operations— in OpenAPI 3.0, to load into Postman, Insomnia or whichever client generator you use:

Download factuza-api.yaml version 1.0.0 · 25 Aug 2026

It is generated from the code, not written by hand. The endpoints come from the engine itself and the descriptions from the comment sitting next to each one, and there is a test that turns the suite red if we publish an API with endpoints that are not in the file. That is your guarantee that what you download is what is actually served.

We mention it because there used to be a hand-written file here describing 19 endpoints when the engine already served 62, with names that did not exist. It was withdrawn.

Two things the specification does not carry yet, said before you miss them: the schemas for the request and response bodies —which are on this page, with examples that run on every pass of the test suite— and the Postman collection.

9. Versioning commitment