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.
On this page
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:
- Issue only (the usual choice). Issues, voids and queries for taxpayers that are already registered.
- Issue and register taxpayers. It can also onboard new clients with
POST /emisores, which is what you need if your program registers them on its own.
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
- You see the key once. When you create it we show it to you in full and it never appears again: all that stays in our database is its SHA-256 hash. If you lose it, you revoke it and create another — there is no “recover”, and that is deliberate.
- A machine does not manage machines. A key cannot create or revoke keys. If one leaked, whoever holds it cannot manufacture successors or erase the trail.
- Revoking is immediate. The next request using that key gets a
401. There is no cache and no grace window.
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
POST /facturas/{id}/anularleaves the invoice voided, not deleted: it generates its own cancellation record, which is also chained and filed.POST /facturas/{id}/rectificarcreates a new invoice pointing at the original, and the original still exists. This is what to do when the amount or the details were wrong.
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
| Limit | How much | What 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
| Code | What it means |
|---|---|
400 | The body is not valid. The message says which field and why, in Spanish. |
401 | No valid credential: the header is missing, or the key does not exist or has been revoked. |
403 | Your account cannot do that: insufficient role, licence not current, or an issuer that is not yours. |
404 | It 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. |
409 | Conflict: it already exists, or its state does not allow the operation (voiding an invoice that has been paid). |
429 | Too 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.
7. Endpoint catalogue
The most used ones. They all hang off https://func-factuza-prod-obt1.azurewebsites.net/api in production and off …-test-… in testing.
| Verb | Endpoint | What it does |
|---|---|---|
| get | /emisores | Which issuers your key works with |
| get | /series | Your numbering series and the next number |
| post | /facturas | Issue (single net amount and rate) |
| post | /facturas/detallada | Issue with line items and VAT treatments |
| get | /facturas | List, with filters |
| get | /facturas/{id}/pdf | The PDF with its QR code |
| post | /facturas/{id}/anular | Void (does not delete) |
| post | /facturas/{id}/rectificar | Correct (creates another) |
| post | /facturas/{id}/email | Send it by email |
| get | /gastos | Expenses for the period |
| post | /gastos | Register an expense (stays pending) |
| post | /gastos/{id}/validar | Approve it |
| post | /ocr/analizar | Read a photograph or a PDF (proposes, does not create) |
| get | /maestras/destinatarios | Your clients |
| get | /resumen | Invoiced, quarter and outstanding |
| get | /modelos/{modelo} | Draft of Modelo 303, 130, 390… |
| get | /exportar/registros | Article 10 legal export |
| get | /claves-api/consumo | Your usage this month |
| get | /webhook | Where we notify you and how the last deliveries went |
| put | /webhook | Set or change the URL (returns the secret once) |
| delete | /webhook | Stop 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
- We do not remove or rename fields from a response without 90 days' notice. Adding new fields can happen at any time: your client should ignore the ones it does not recognise.
- We do not change the meaning of an existing field. If something has to mean something else, it will be a new field.
- Status codes are part of the contract. If an operation answers
201today, it will go on answering201. - Breaking changes are announced by email to accounts with a live key, not only on a page you have to go and look at.