Documentación para desarrolladores

Integra VeriFactu y Facturae en una tarde.

Una API REST clara, con ejemplos en cinco lenguajes. Autenticación por API key, emisión asíncrona y webhooks. Prueba gratis en el sandbox.

x-api-key: vsk_…Consigue tu API key

Quick Start

Una API key autentica tu cuenta (el integrador). La cuenta agrupa N emisores (los obligados por los que facturas). Cada emisor tiene su NIF, certificado y domicilio.

1. Crea tu cuenta

Regístrate gratis en el dashboard. Obtienes 10 llamadas API de prueba en live, y una clave de test contra la AEAT de preproducción sin tope.

2. Obtén tu API Key

Cópiala en el dashboard. Va en el header x-api-key de todas las peticiones.

3. Da de alta un emisor y su certificado

Crea el obligado con POST /api/v1/emisores y sube su certificado P12 con POST /api/v1/emisores/:id/certificate. El NIF del certificado debe coincidir con el del emisor.

4. Crea tu primera factura

POST /api/v1/invoices (indica emisorNif si tu cuenta tiene varios emisores). Copia un ejemplo de abajo y prueba en segundos.

5. Entrega el PDF a tu cliente

Descarga el PDF imprimible con GET /api/v1/invoices/:id/pdf: lleva el QR de cotejo, la mención VeriFactu y, si subes el logo del emisor (POST /api/v1/emisores/:id/logo), tu marca bajo el nombre de la empresa.

Ejemplo: Crear tu primera factura

Copia este ejemplo, reemplaza TU_API_KEY y ejecuta. La factura se firmará y encolará automáticamente.

curl -X POST https://factuneo.es/api/v1/invoices \
  -H "Content-Type: application/json" \
  -H "x-api-key: vsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -d '{
    "numeroSerie": "FAC-2026-001",
    "fechaExpedicion": "2026-06-08",
    "baseImponible": 100.00,
    "cuotaIva": 21.00,
    "importeTotal": 121.00,
    "tipoFactura": "F1",
    "nombreRazonEmisor": "Empresa Test SL",
    "descripcionOperacion": "Venta de productos",
    "destinatarios": [
      { "nombreRazon": "Cliente Ejemplo SL", "nif": "B87654321" }
    ],
    "desglose": [{
      "claveRegimen": "01",
      "calificacionOperacion": "S1",
      "tipoImpositivo": 21,
      "baseImponible": 100.00,
      "cuotaRepercutida": 21.00
    }]
  }'

Usar el panel

El panel es la ficha de tu cuenta (una por integrador, modelo 1:1). Lo usas una vez para configurarte; el alta de tus clientes y la emisión van siempre por API desde tu aplicación. No entras al panel cada vez que un cliente te contrata.

1. API key

Regístrate y, en tu cuenta, genera la API key (se muestra una sola vez — guárdala). Va en el header x-api-key de todas las peticiones.

2. Certificado representante (solo si usas modo Representado)

Sube una sola vez tu certificado de colaborador social / apoderado en la tarjeta Certificado representante. Firma por todos tus clientes en modo Representado. Si solo usas modo Propio, no necesitas subir nada aquí.

Para retirarlo, el botón Eliminar de esa misma tarjeta. Pide confirmación y te dice cuántos emisores se quedan sin poder emitir antes de hacerlo. Equivale a DELETE /api/v1/account/certificate.

Con el certificado subido aparece también el domicilio del representante: Facturae lo exige cuando la factura la firma el representante como tercero (emisores en modo Representado). Equivale a PATCH /api/v1/account.

2 bis. FACe (solo si facturas a Administraciones Públicas)

La tarjeta FACe está plegada si no la usas. Ahí subes el certificado con el que diste de alta tu plataforma en el portal de integradores de FACe y ves si FACe lo reconoce en pruebas y en producción (con el botón Volver a comprobar tras dar de alta la plataforma). Equivale a POST /api/v1/account/face-certificate. Ver FACe (Administraciones Públicas).

3. Webhook (opcional)

URL para recibir los eventos de tus facturas y certificados sin tener que consultar. Si lo dejas vacío, consultas con GET /api/v1/invoices/:id.

4. Suscripción

Tu plan y facturación.

5. Borrar cuenta

Si el servicio no te encaja, te vas sin escribirnos: botón Borrar mi cuenta. Elimina de forma permanente tu cuenta, tu API key y tu usuario, previa confirmación. No se puede deshacer.

Exige que la cuenta esté vacía antes: primero eliminas tus emisores y tu certificado representante con sus propios borrados —que ya explican qué se lleva cada uno— y después la cuenta. No arrastramos material de firma ni obligados en cascada desde un solo clic.

Si has emitido facturas, la cuenta no se puede borrar: son registros fiscales con obligación de conservación.

6. Logs y observabilidad

En Logs y observabilidad (/dashboard/logs) ves qué hace tu integración sin tener que escribirnos: peticiones a la API, errores, latencia, IPs, entregas de webhook con reenvío de cualquier evento, facturas por estado, certificados por caducar, consumo por NIF, tu auditoría legal (cada factura enviada/aceptada/ rechazada, cambios de certificados y emisores, login, claves regeneradas, suscripción) y tus alertas (certificados por caducar, facturas rechazadas o con error — filtradas a tus propios NIFs, no las de toda la plataforma). Es una vista del panel (sesión de navegador) — no hay endpoint por API key para consultarlo desde tu propio backend; es para ti, no para tu software.

Importante: el estado de una entrega de webhook (PENDIENTE / ENTREGADO / FALLIDO, de esta sección) y el estado de la factura que que viaja dentro del webhook (el nombre del evento y data.status, ver Webhooks) son dos cosas distintas que comparten nombre por casualidad. Una entrega FALLIDO significa que no logramos avisarte del cambio — no dice nada sobre si la factura en sí fue aceptada o rechazada por la AEAT.

Por servicio: VeriFactu, Facturae y FACe

Cada petición lleva su servicio: VeriFactu (alta, anulación, consulta, PDF…), Facturae (/facturae), FACe (/face y el certificado de plataforma) o General (cuenta, emisores, certificados). Las pestañas Peticiones y Errores tienen un filtro Servicio. Si usas FACe verás además un bloque FACe en el resumen, una pestaña FACe y el estado en FACe de cada factura en la pestaña Facturas. Si no lo usas, no aparece nada de FACe.

Selector de entorno

Arriba de todo, un selector live / test. Abre en live por defecto a propósito: así un fallo probando en el sandbox nunca se confunde con una incidencia real. Cada pestaña se filtra por el entorno elegido — cambiarlo recarga los datos y vuelve a la página 1 de cualquier listado.

Resumen

Cuatro bloques (cinco si usas FACe), todos del entorno seleccionado:

BloqueCampoQué es
APILlamadas (7 días)Total de peticiones a /api/v1/ de los últimos 7 días
Errores (7 días)De esas, cuántas devolvieron statusCode >= 400
Latencia mediaMedia de durationMs de los últimos 7 días, en ms
IPs con más tráficoTop 10 IPs de origen por nº de llamadas
ClientesEmisores activosEmisores no dados de baja en este entorno
Certificados por caducarLos que vencen en menos de 30 días (tuyo de representante + los de tus clientes en modo Propio). Solo se calcula en live — el sandbox usa certificados de prueba, no importa que caduquen
VeriFactu · AEATTotalRegistros de alta creados en este entorno
Por estadoDesglose ACEPTADO / PENDIENTE / ENVIADO / ACEPTADA_CON_ERRORES / RECHAZADO / ERROR — mismo significado que en la tabla de estados del webhook
FACe (si lo usas)PresentadasFacturas presentadas en FACe en este entorno, con cuántas están pagadas, en trámite, rechazadas y con anulación solicitada
Webhooks% entregadosENTREGADO ÷ (ENTREGADO + FALLIDO + PENDIENTE) del entorno. — si no hay ninguna entrega todavía
FallidosEntregas que agotaron sus reintentos automáticos

Pestaña Peticiones

Una fila por llamada a la API: fecha, método, ruta (patrón, p. ej. /api/v1/invoices/:id — nunca la URL con el id real), código de estado, latencia e IP de origen.

Nunca se guarda el cuerpo de la petición ni las cabeceras — ahí viajan NIFs de terceros, datos fiscales y contraseñas de certificado. Solo se registran esos seis metadatos.

Retención: el detalle se conserva 30 días y se purga automáticamente después. El agregado diario (llamadas y errores por día, usado en el resumen y en Consumo por NIF) es perpetuo, no se purga nunca.

Pestaña Errores

Igual que Peticiones, filtrado a statusCode >= 400. Útil para ver de un vistazo si algo se está rompiendo sin tener que buscarlo entre las llamadas correctas. Si aparece un errorCode (p. ej. EMISOR_QUOTA_EXCEEDED, INVOICE_NOT_FOUND), es el mismo código del cuerpo de la respuesta — consulta la tabla de errores para saber qué significa cada uno.

Pestaña FACe (si lo usas)

Una fila por factura presentada: serie, emisor, organismo (unidad tramitadora DIR3; las tres unidades al pasar el ratón), número de registro de FACe, estado (registrada, registrada en RCF, en trámite, pagada, rechazada, anulada) con el comentario del organismo si lo hay, cuándo se consultó por última vez y el estado de la anulación. El estado se consulta solo en FACe cada hora mientras pueda cambiar. Se puede filtrar por estado.

Pestaña Webhooks

Una fila por entrega (cada intento de POST a tu URL), con su evento, código de respuesta HTTP, nº de intentos y el error si falló.

EstadoSignificado
PENDIENTEEncolada o reintentándose. Puede que veas alguno así y en el siguiente refresco ya haya pasado a ENTREGADO
ENTREGADOTu servidor respondió 2xx
FALLIDOSe agotaron los reintentos automáticos (hasta 5, con espera creciente) sin recibir 2xx

Reenviar a mano: el botón Reenviar está en todas las filas, no solo en las fallidas. Manda de nuevo el mismo payload que se generó la primera vez (no se recalcula nada de la factura) a la misma URL.

Reenviar una entrega ya ENTREGADO es un caso legítimo y frecuente: tu servidor contestó 2xx pero el mensaje se perdió después (una cola que se vació, un despliegue a medias, un bug que ya arreglaste). El reenvío conserva el mismo id de evento que el original, así que si deduplicas por ese id —y deberías— no lo contabilizas dos veces.

Cada reenvío crea una fila nueva, marcada como reenvío; la entrega original se queda como estaba. El historial no se pisa: si algo se entregó, esa prueba no desaparece porque un reenvío posterior falle.

Si tu integración ya reintenta por su cuenta consultando GET /api/v1/invoices/:id, esta pestaña es solo para diagnóstico — no hace falta que la mires si no sospechas un problema.

Pestaña Consumo por NIF

Llamadas y errores de los últimos 30 días, agrupados por emisor (NIF + razón social). Útil para ver qué cliente está más activo o cuál está fallando más de la cuenta. Las llamadas que no van ligadas a un emisor concreto (p. ej. GET /api/v1/account) no aparecen aquí — sí cuentan en el total de la pestaña Peticiones y del Resumen.

Pestaña Auditoría

El registro legal de tu cuenta, con cadena de hashes encadenados (cumple el Esquema Nacional de Seguridad, medida MP.SI.3 "Registro de actividad"): un evento no se puede alterar ni borrar sin que se note. Distinto de las pestañas de arriba, que son diagnóstico operativo — esto es el histórico inalterable de qué pasó.

Qué queda registrado:

EventoCuándo
Envío a la AEAT / Error de envíoCada factura enviada, aceptada, rechazada o con error — incluida cada anulación
Facturae generadoCada Facturae firmado que se genera (no se envía a la AEAT)
Envío a FACeCada presentación en FACe (con su número de registro), cada fallo al presentar y cada solicitud de anulación
Cambio de configuraciónAlta de emisor, certificado subido/borrado (del emisor, del representante o de la plataforma FACe), logo subido
Inicio de sesiónCada login en el panel
Clave regeneradaCada vez que regeneras tu clave live o test
Suscripción activada / canceladaAltas y bajas de tu plan de pago

Los eventos que no son de un entorno concreto (login, certificado representante, suscripción) se ven en las dos pestañas live y test — no son ni de uno ni de otro, son de la cuenta.

Pestaña Alertas

"Esto necesita tu atención" — distinto del histórico completo de Auditoría y de los conteos del Resumen: aquí solo lo que requiere que hagas algo, filtrado a tus propios NIFs y tus propias facturas, nunca agregados de toda la plataforma.

TipoCuándo se genera
Certificado por caducarEl tuyo de representante, o el de un emisor en modo Propio, a menos de 30 días de caducar
Factura rechazadaLa AEAT devolvió RECHAZADO para un envío
Factura con errorEl envío falló de forma definitiva (sin representación vigente, certificado ilegible, rechazo total del lote) — no incluye fallos transitorios de red que ya se reintentaron solos
FACe: factura rechazadaEl organismo rechazó en FACe una factura presentada
Certificado de plataforma FACeEl de tu plataforma FACe está a menos de 30 días de caducar, o ya ha caducado

Cada certificado o factura genera como mucho una alerta: no se repite en cada ciclo del monitor. No hay forma de marcarla como resuelta todavía — es un historial, no una bandeja de tareas.

Emisores (clientes)

El listado de emisores del panel es informativo. Tus clientes los da de alta tu software con POST /api/v1/emisores y aparecen aquí automáticamente. La gestión (alta, cert por cliente en modo Propio, baja) se hace por API:

AcciónModo PropioModo Representado
Alta del clientePOST /api/v1/emisores {certMode:"PROPIO"}POST /api/v1/emisores {certMode:"REPRESENTADO"}
CertificadoPOST /api/v1/emisores/:id/certificate (cert del cliente)Ninguno por cliente — usa tu cert representante
Emitir facturaPOST /api/v1/invoices {emisorNif}igual

Logo del emisor: además, desde el panel puedes subir el logo de cada emisor (botón de la imagen en su fila). Se imprime en el PDF de sus facturas, bajo el nombre de la empresa. Lo mismo por API con POST /api/v1/emisores/:id/logo.

Eliminar un cliente: el botón de la papelera de su fila. Pide confirmación y avisa de qué va a pasar antes de hacerlo, porque depende de si ese emisor tiene facturas:

  • Sin facturas → se borra su ficha por completo.
  • Con facturas → su ficha se conserva desactivada, porque las facturas son registros fiscales que deben seguir teniendo un emisor identificable.

En los dos casos se borran su certificado, su contraseña y su logo. Equivale a DELETE /api/v1/emisores/:id.

El panel cubre tu cuenta; el ciclo de vida de cada cliente es 100% API.

Autenticación

Todas las peticiones a la API requieren autenticación mediante API Key.

Header requerido

x-api-key: vsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Obtener API key

  1. Inicia sesión en el dashboard
  2. En la tarjeta API Key de tu cuenta, pulsa Generar / Regenerar
  3. Cópiala (se muestra una sola vez; puedes regenerarla cuando quieras)

Seguridad: La API key tiene el mismo nivel de sensibilidad que una contraseña. Nunca la expongas en código frontend ni la subas a repositorios públicos.

Idempotencia

En POST /api/v1/invoices puedes enviar el header Idempotency-Key con un valor único por factura (un UUID). Si tu ERP reintenta (timeout, red) con la misma clave, la API devuelve la misma factura en vez de duplicarla. Recomendado en producción.

Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7

Planes

PlanPrecioIncluye
Prueba0 €1 NIF live · 10 llamadas API (total, no mensual) · clave de test siempre gratis y sin tope
Pay-as-you-godesde 6,90 €/NIF/mes500 facturas/NIF/mes · Facturae · envío AEAT

El precio es escalonado por tramos: cada tramo se factura a su propia tarifa, así que la tarifa marginal baja al crecer (6,90 € los 5 primeros NIFs … 1,40 € a partir de 101). Consulta el importe exacto en la calculadora.

Extras opcionales: soporte prioritario (49 €/mes fijos, de 9:00 a 17:00 en días laborables), soporte hostelería (149 €/mes fijos, de 9:00 a 0:00 todos los días, con disponibilidad del 99,5 %) y facturas ilimitadas por NIF (quita el tope de 500/NIF/mes; también escalonado, desde 0,90 €/NIF).

Si un NIF pasa de 500 facturas en el mes sin el extra de ilimitadas, no se corta la emisión: se añaden 1,80 € (más impuestos) a tu próxima factura, una sola vez por NIF y mes, y te avisamos por correo y en el panel. Si te pasa a menudo, te sale más barato el extra de ilimitadas. Sin suscripción de pago, al llegar a 500 la API responde 402 INVOICE_CAP_REACHED.

Todos los precios son sin IVA: se añade el 21% en el cobro, salvo empresas de otros países de la UE con NIF-IVA válido (inversión del sujeto pasivo) y clientes de Canarias, Ceuta y Melilla, que quedan fuera del ámbito del IVA.

Sandbox: entornos test y live

URL base de la API: https://factuneo.es/api/v1, la misma con la clave de test y con la de live. El estado del servicio está aparte, en GET https://factuneo.es/health.

Cada cuenta tiene dos API keys independientes, siempre — no hay que pedir ni activar nada aparte. La que uses en el header x-api-key decide el entorno, con la misma URL base para las dos:

ClavePrefijoA dónde vaCoste
Livevsk_live_…AEAT real. Con validez fiscal.Se factura por NIF (ver Planes)
Testvsk_test_…AEAT de preproducción. Sin validez fiscal.Siempre gratis, sin tope de facturas
# Con la clave de test: crea, prueba, rompe cosas — no pasa nada.
curl -X POST https://factuneo.es/api/v1/emisores \
  -H "x-api-key: vsk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"nif":"B12345678","nombreRazon":"Empresa de prueba SL"}'

Por qué existe

Antes de este sandbox, las únicas "pruebas" disponibles eran las llamadas del plan de prueba, y esas llamadas iban contra la AEAT real: probar tu integración significaba generar registros fiscales de verdad. El sandbox separa completamente eso: puedes construir y romper tu integración sin miedo, y solo cuando estés listo cambias a la clave live.

Aislamiento: dos mundos que no se tocan

  • Los emisores son distintos por entorno. Puedes dar de alta el mismo NIF en test y en live: son dos fichas independientes, con su propio certificado y su propia cadena de huellas. Nunca se mezclan.
  • La clave de test solo ve datos de test. GET /api/v1/emisores con la clave test nunca devuelve tus emisores live, y viceversa. Ni por accidente ni adivinando un id.
  • Las facturas de test no cuentan para nada facturable. No suman al nº de NIFs facturados, no tienen el tope de 500/mes, y no consumen las llamadas del plan de prueba.
  • El QR de una factura de test apunta a preproducción. Si lo escaneas, no cotejará en la sede real de la AEAT — es la prueba de que nunca podría confundirse con una factura real.

Qué NO cambia entre entornos

  • El certificado. Si usas modo Representado, el mismo certificado sirve para las dos claves — la preproducción de la AEAT también autentica por certificado, así que no hace falta uno "de pruebas" aparte. En modo Propio, cada emisor (test o live) necesita el suyo igualmente. Como lo comparten los dos entornos, subirlo, sustituirlo o borrarlo exige la clave live (o el panel): con la de test esas llamadas devuelven 403 LIVE_KEY_REQUIRED. Lo mismo vale para el certificado de la plataforma FACe y el domicilio del representante (PATCH /api/v1/account). Así una clave de test filtrada no puede dejar sin emitir a producción.
  • El formato de las respuestas. Un 200, un 402, un 404… responden exactamente igual en los dos entornos. Tu código no necesita ninguna rama especial para "modo test".

Dónde conseguir las claves

Desde el panel, tarjeta API Keys: ahí ves las dos, generas o regeneras cada una por separado. También se generan automáticamente al crear una cuenta por API (POST /api/v1/accounts), que devuelve las dos en la misma respuesta.

Trátalas con el mismo cuidado que la live: la clave de test no puede emitir facturas reales, pero sigue dando acceso a tu cuenta — quien la tenga puede leer y modificar tus datos de sandbox. No la publiques ni la subas a un repositorio.

Certificados y firma

Cada emisor firma sus registros ante la AEAT con un certificado cualificado. Hay dos modos, configurables por emisor con el campo certMode:

ModoQuién aporta el certificadoCuántosEndpoint
PROPIO (default)Cada emisor su propio cert1 por emisorPOST /api/v1/emisores/:id/certificate
REPRESENTADOEl integrador, un único cert para todos1 por cuentaPOST /api/v1/account/certificate

PROPIO

El obligado tiene su certificado y tú lo subes por él. Al subirlo se valida la contraseña, la caducidad y que el NIF del certificado coincida con el del emisor.

Comprobación contra la AEAT

Que un certificado sea válido no significa que la AEAT lo acepte: hace falta además que el obligado esté en el censo y, en modo Representado, que exista apoderamiento. Por eso, al subirlo hacemos una consulta de solo lectura a la AEAT con ese certificado (no registra nada) y te devolvemos el resultado en aeatAutorizacion:

ValorQué significa
AUTORIZADOLa AEAT responde correctamente. Puedes facturar
NO_AUTORIZADOEl certificado se guarda, pero las facturas fallarán. El motivo, en aeatDetalle
NO_VERIFICADONo se pudo contactar con la AEAT. No dice nada del certificado

Esta comprobación existe porque la AEAT no devuelve un error legible cuando rechaza un certificado: responde HTTP 200 con una página HTML. Sin mirar el cuerpo, un rechazo parece un envío correcto — y el problema se descubría factura a factura. Si recibes NO_AUTORIZADO, revisa el censo y el apoderamiento antes de emitir.

La comprobación nunca impide guardar el certificado: si la AEAT no responde, el resultado es NO_VERIFICADO y puedes seguir.

REPRESENTADO (colaborador social / apoderamiento)

Subes un solo certificado (el del integrador) a nivel de cuenta y firma por todos los emisores en este modo. El obligado no entrega su certificado: solo te apodera en la Sede de la AEAT para el trámite VeriFactu. Cambia el modo de un emisor con PATCH /api/v1/emisores/:id { "certMode": "REPRESENTADO" }.

Para usar REPRESENTADO, el integrador debe ser colaborador social o estar apoderado por el obligado. La AEAT valida la representación en cada envío.

Custodia del certificado

Un certificado de firma es la credencial más sensible que nos entregas, así que esto es exactamente lo que le pasa:

CifradoAES-256-GCM. La clave maestra se deriva con scrypt y vive solo en la configuración del servidor, nunca en la base de datos: un volcado de la BD no basta para descifrar nada.
UsoSe descifra únicamente en memoria, en el instante de firmar o de abrir la conexión mTLS con la AEAT. No se escribe descifrado en disco ni en logs.
Exposición por APINunca. Ningún endpoint devuelve los bytes: solo hasCertificate: true/false y la fecha de caducidad.
En tránsitoTLS 1.3.

Retirar el certificado

Lo que subes lo retiras tú, cuando quieras, sin escribirnos:

Qué quieresLlamada
Retirar el cert de un emisor (sigue siendo cliente)DELETE /api/v1/emisores/:id/certificate
Retirar tu cert representanteDELETE /api/v1/account/certificate
Eliminar a un cliente y su certificadoDELETE /api/v1/emisores/:id

También desde el panel, en las tarjetas correspondientes.

El borrado es permanente: se eliminan el .p12 y su contraseña de la base de datos. A partir de ahí ese emisor no puede firmar hasta que subas otro.

Lo que NO desaparece —y no puede desaparecer— son las facturas ya comunicadas a la AEAT: constan en su sistema por obligación legal, su cadena de huellas es inalterable, y la firma de cada una quedó dentro del XML en el momento de emitir, donde sigue siendo verificable. Borrar el certificado no invalida ni oculta nada de lo ya emitido; solo impide emitir en el futuro.

Por eso la obligación de conservación recae sobre las facturas, no sobre el certificado: una vez firmado y comunicado el registro, el certificado ya no cumple ninguna función sobre lo pasado.

Endpoints principales

Base URL: https://factuneo.es

Alta de cuenta (sin pasar por el panel)

MétodoEndpointDescripción
POST/api/v1/accountsCrea cuenta + primer emisor y devuelve la API key. No requiere autenticación y la key se muestra una sola vez

Emisores y cuenta

MétodoEndpointDescripción
POST/api/v1/emisoresAlta de emisor (obligado)
GET/api/v1/emisoresListar emisores de la cuenta
GET/api/v1/emisores/:idDetalle de un emisor
PATCH/api/v1/emisores/:idEditar domicilio / certMode
DELETE/api/v1/emisores/:idEliminar emisor y su certificado (conserva la ficha si tiene facturas)
POST/api/v1/emisores/:id/certificateSubir certificado P12 del emisor (modo PROPIO)
DELETE/api/v1/emisores/:id/certificateRetirar el certificado del emisor (sin borrar al emisor)
POST/api/v1/emisores/:id/logoSubir el logo del emisor (PNG/JPG), se imprime en el PDF
GET/api/v1/emisores/:id/logoDescargar el logo del emisor
DELETE/api/v1/emisores/:id/logoEliminar el logo del emisor
GET/api/v1/accountDatos de la cuenta, nº de emisores y webhookSecret
POST/api/v1/account/certificateCert representante de la cuenta (modo REPRESENTADO)
DELETE/api/v1/account/certificateRetirar tu certificado representante (corta la emisión REPRESENTADO)

Facturas VeriFactu

MétodoEndpointDescripción
POST/api/v1/invoicesCrear factura (indica emisorNif si hay varios emisores)
GET/api/v1/invoicesListar facturas (paginado)
GET/api/v1/invoices/:idObtener detalle
GET/api/v1/invoices/:id/xmlDescargar XML del RegistroAlta
GET/api/v1/invoices/:id/pdfDescargar el PDF imprimible de la factura (con QR y logo)
GET/api/v1/invoices/:id/qrObtener datos del QR
GET/api/v1/invoices/:id/statusEstado y respuesta de la AEAT (CSV, motivo de rechazo)
GET/api/v1/invoices/:id/consulta-aeatConsultar a la AEAT + cotejo de huella
POST/api/v1/invoices/:id/anularAnular factura (registro de anulación)
POST/api/v1/invoices/:id/facturaeGenerar XML Facturae firmado
POST/api/v1/invoices/:id/facePresentar la factura en FACe (Administraciones Públicas)
GET/api/v1/invoices/:id/faceEstado de la factura en FACe
POST/api/v1/invoices/:id/face/anulacionPedir la anulación en FACe
POST/api/v1/account/face-certificateSubir el certificado de tu plataforma FACe
POST/api/v1/account/face-certificate/verificarVolver a comprobarlo con FACe
DELETE/api/v1/account/face-certificateBorrar el certificado de plataforma
PATCH/api/v1/accountDomicilio del representante

Seguridad y auditoría

MétodoEndpointDescripción
GET/api/v1/security/complianceInforme de cumplimiento ENS
GET/api/v1/security/audit-integrityCadena de hashes: devuelve { validChain, checkedAt }

Monitorización

MétodoEndpointDescripción
GET/healthHealth check (DB + Redis + AEAT). Público, sin datos de cuentas

/metrics (Prometheus) es interno: no está publicado y requiere token propio. Para el estado del servicio usa /health.

En Referencia tienes cada endpoint con su cuerpo, su respuesta y sus errores.

Referencia por endpoint

Cada endpoint con lo que acepta, lo que devuelve y en qué puede fallar. Todos requieren x-api-key salvo donde se indique lo contrario.


Cuenta

POST /api/v1/accounts

Crea cuenta + primer emisor y devuelve la API key. No requiere autenticación: es el alta programática, para no pasar por el panel.

CampoTipoOblig.Notas
namestring(1-255)SíNombre del integrador
nifstring(1-9)SíNIF del primer emisor
razonSocialstring(1-255)SíRazón social del primer emisor
emailstringSíTu correo. Recibe el enlace que activa las llamadas de prueba
aceptaTerminostrueNoAceptas los términos y el contrato de encargo del tratamiento. Si no lo envías, se te pedirá en el panel
curl -X POST https://factuneo.es/api/v1/accounts \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Mi ERP",
    "nif": "B12345678",
    "razonSocial": "Cliente Uno SL",
    "email": "dev@miempresa.es",
    "aceptaTerminos": true
  }'
{
  "accountId": "9c1b…",
  "emisorNif": "B12345678",
  "apiKey": "vsk_live_…",
  "apiKeyTest": "vsk_test_…",
  "emailVerificado": false,
  "aviso": "Estas claves son provisionales: …"
}

Las dos claves se muestran una sola vez: solo guardamos su hash. Si las pierdes, regenéralas desde el panel — cada una por separado. Más sobre qué diferencia apiKey de apiKeyTest en Sandbox (test / live).

Las claves de esta respuesta son provisionales. Valen para probar en sandbox desde el primer momento, pero se anulan cuando verificas el correo: como esta llamada no requiere autenticación, verificar el correo es lo que demuestra que la cuenta es tuya. Después de verificar, entra en el panel y genera tus claves definitivas (test y live). Así nadie que haya dado de alta una cuenta con tu correo conserva acceso a ella.

Verifica el correo antes de llamar a la API. Con emailVerificado: false la key ya es válida, pero las llamadas del plan de prueba devuelven 403 EMAIL_NOT_VERIFIED hasta que pulses el enlace que te enviamos. Es lo que evita que se creen cuentas en cadena para acumular pruebas gratis. Las cuentas con suscripción activa no pasan por esta comprobación.

Errores: 409 NIF_EXISTS (ya hay una cuenta con ese NIF) · 409 EMAIL_EXISTS (ese correo ya tiene usuario: crea la cuenta desde el panel, con tu sesión) · 400 DISPOSABLE_EMAIL (correo de un servicio temporal) · 400 campos inválidos.

GET /api/v1/account

Datos de tu cuenta, nº de emisores facturables y el secreto de webhooks.

{
  "id": "9c1b…",
  "name": "Mi ERP",
  "emisorCount": 3,
  "environment": "live",
  "hasRepresentanteCert": true,
  "representanteNif": "12345678Z",
  "domicilioRepresentante": { "direccion": "Calle Mayor 1", "codigoPostal": "28001", "municipio": "Madrid", "provincia": "Madrid", "pais": "ESP" },
  "plataformaFace": { "nif": "B12345678", "nombre": "MI SOFTWARE SL", "caduca": "2028-01-01T00:00:00.000Z",
                      "verificadaPruebas": "2026-09-27T10:00:00.000Z", "verificadaProduccion": null },
  "webhookSecret": "a3f2…"
}

domicilioRepresentante y plataformaFace son null si no los has configurado. verificadaPruebas / verificadaProduccion es la última vez que FACe reconoció tu plataforma en cada entorno (null = no la reconoce o no se ha comprobado).

environment refleja qué clave usaste en esta llamada (live o test). emisorCount es siempre el nº de NIFs live facturables, aunque llames con la clave de test.

emisorCount es el número de NIFs activos: es la cantidad que se factura.

POST /api/v1/account/certificate

Sube el certificado del integrador (colaborador social / apoderado) que firmará por los emisores en modo REPRESENTADO. Se sube una sola vez por cuenta.

CampoTipoOblig.
certificateBase64stringSí
passwordstringSí
{ "success": true, "representanteNif": "12345678Z", "certRepresentanteExpira": "2028-12-31T00:00:00.000Z" }

Errores: 400 CERTIFICATE_INVALID (contraseña incorrecta o fichero ilegible) · 400 CERTIFICATE_EXPIRED.

DELETE /api/v1/account/certificate

Retira tu certificado representante. Es la contrapartida de subirlo: el material de firma que nos confías lo retiras tú, cuando quieras, sin pedírnoslo.

Borra de la base de datos, de forma permanente, el .p12, su contraseña, su fecha de caducidad y los datos del representante.

curl -X DELETE https://factuneo.es/api/v1/account/certificate \
  -H "x-api-key: TU_API_KEY"
{ "success": true, "hadCertificate": true, "emisoresAfectados": 12 }

⚠️ Corta la emisión de todos tus emisores en modo REPRESENTADO. emisoresAfectados te dice cuántos se quedan sin poder emitir, para que lo sepas en el acto y no lo descubras cuando falle la siguiente factura. Los emisores en modo PROPIO no se ven afectados: firman con el suyo.

Para volver a operar, sube otro con POST /api/v1/account/certificate. No hace falta tocar los emisores: siguen en modo REPRESENTADO con su representacionVigente intacta.

No toca las facturas ya emitidas ni su cadena de huellas.


Emisores

POST /api/v1/emisores

Da de alta un obligado. Al crearlo empieza a contar para la facturación.

El plan de prueba admite 1 NIF en live; el segundo devuelve 403 EMISOR_QUOTA_EXCEEDED (en el sandbox de test no hay tope). Los planes de pago no tienen tope: se factura por NIF. Eliminar o desactivar un emisor libera hueco, porque deja de facturarse.

CampoTipoOblig.Por defecto
nifstring(1-9)Sí
nombreRazonstring(1-120)Sí
tipoPersonaF o JNoJ
certModePROPIO o REPRESENTADONoPROPIO
representacionVigentebooleanNofalse
direccionstring(≤80)No*
codigoPostalstring(≤10)No*
municipiostring(≤50)No*
provinciastring(≤50)No*
paisstring(3)NoESP
  • Opcionales para VeriFactu, obligatorios para generar Facturae.

Quién aporta el certificado

Cada registro se remite a la AEAT autenticado por TLS mutuo, así que toda factura necesita un certificado. Hay dos modelos y los eliges por emisor:

certModeQuién firmaQué tienes que hacer
PROPIOEl propio obligadoQue suba su certificado a POST /emisores/:id/certificate
REPRESENTADOTú, por cuenta de élSubir tu certificado una vez a POST /account/certificate y marcar representacionVigente: true en cada emisor

En modo REPRESENTADO, representacionVigente declara que tienes poder legal (apoderamiento o colaboración social) sobre ese NIF. Sin él, la emisión devuelve 403 EMISOR_REPRESENTATION_NOT_VALID. Ponerlo a false revoca la representación y corta la emisión de ese emisor de inmediato.

curl -X POST https://factuneo.es/api/v1/emisores \
  -H "x-api-key: vsk_…" -H "Content-Type: application/json" \
  -d '{
    "nif": "B12345678",
    "nombreRazon": "Cliente Uno SL",
    "certMode": "PROPIO",
    "direccion": "Calle Ejemplo 123",
    "codigoPostal": "28001",
    "municipio": "Madrid",
    "provincia": "Madrid"
  }'

Devuelve 201 con el emisor. Los bytes del certificado nunca se devuelven: solo hasCertificate.

{
  "id": "7d2e…", "nif": "B12345678", "nombreRazon": "Cliente Uno SL",
  "tipoPersona": "J", "certMode": "PROPIO", "hasCertificate": false,
  "certExpirationDate": null, "representacionVigente": false, "hasLogo": false,
  "isActive": true, "createdAt": "2026-07-27T10:00:00.000Z", "environment": "live"
}

El emisor hereda el entorno de la clave con la que lo creas: con vsk_test_… sale "environment": "test", aislado de tus emisores live aunque uses el mismo NIF. Ver Sandbox (test / live).

Errores: 409 EMISOR_DUPLICADO (ese NIF ya está en tu cuenta en este entorno — puedes repetir el NIF en el otro) · 409 NIF_EN_OTRA_CUENTA (ese NIF ya emite en live desde otra cuenta: un mismo obligado solo puede tener una cadena de facturación) · 403 EMISOR_QUOTA_EXCEEDED (plan de prueba: 1 NIF en live).

GET /api/v1/emisores

Para qué sirve: ver tu cartera de obligados y su estado. Devuelve todos los emisores de la cuenta (activos y de baja), cada uno con hasCertificate, certExpirationDate, certMode, representacionVigente e isActive. Úsalo para sincronizar tu panel con Factuneo o para comprobar qué clientes tienen certificado antes de facturar por ellos. Misma forma que el objeto de POST /emisores.

GET /api/v1/emisores/:id

Para qué sirve: el detalle de un emisor concreto por su ID — p. ej. para refrescar su estado de certificado justo después de subirlo. 404 EMISOR_NOT_FOUND si no existe o no es tuyo.

PATCH /api/v1/emisores/:id

Edita el emisor. Todos los campos son opcionales; envía solo lo que cambies. Aceptan null para vaciarlos: direccion, codigoPostal, municipio, provincia.

CampoTipo
nombreRazonstring(1-120)
tipoPersonaF o J
direccion / codigoPostal / municipio / provinciastring o null
paisstring(3)
certModePROPIO o REPRESENTADO
representacionVigenteboolean (solo modo REPRESENTADO; false corta la emisión)
isActiveboolean
curl -X PATCH https://factuneo.es/api/v1/emisores/7d2e… \
  -H "x-api-key: vsk_…" -H "Content-Type: application/json" \
  -d '{"certMode":"REPRESENTADO"}'

Errores: 400 NO_CHANGES (cuerpo vacío) · 404 EMISOR_NOT_FOUND.

DELETE /api/v1/emisores/:id

Elimina al emisor y su material de firma. Deja de contar para la facturación.

El certificado y su contraseña se borran siempre, en los dos casos de abajo. Cuando un emisor deja de operar, ese material ya no tiene función —la firma de lo ya emitido vive dentro del XML almacenado— y conservarlo sería exposición sin motivo. La obligación legal de conservación recae sobre las facturas, no sobre el certificado.

Qué pasa con su ficha depende de si tiene facturas:

Sin facturasCon facturas
Ficha del emisorSe borra enteraSe conserva desactivada
Certificado + contraseñaSe borranSe borran
LogoSe borraSe borra
Facturas registradas en AEAT—Intactas

Por qué no se borra la ficha si tiene facturas: las facturas emitidas son registros fiscales con obligación de conservación, y deben seguir teniendo un emisor identificable. Borrar la ficha las dejaría huérfanas. Se conserva lo mínimo para identificar quién emitió, sin nada sensible.

{ "success": true, "deleted": false, "invoiceCount": 7, "hadCertificate": true }
CampoSignificado
deletedtrue si se borró la ficha entera; false si se conservó desactivada
invoiceCountFacturas que obligan a conservarla
hadCertificateSi había certificado que borrar

No es reversible. Antes esta llamada era una baja lógica que dejaba el certificado guardado. Ya no: si vuelves a operar con ese NIF, hay que subir el certificado otra vez. Para desactivar temporalmente sin borrar nada, usa PATCH /api/v1/emisores/:id con {"isActive": false}.

Errores: 404 EMISOR_NOT_FOUND.

POST /api/v1/emisores/:id/certificate

Sube el certificado del emisor. Solo para certMode: "PROPIO".

CampoTipoOblig.
certificateBase64string (el .p12/.pfx en base64)Sí
passwordstringSí

Al subirlo se valida la contraseña, la caducidad y que el NIF del certificado coincida con el del emisor. Se cifra con AES-256-GCM antes de guardarlo.

Errores: 400 CERTIFICATE_NIF_MISMATCH · 400 CERTIFICATE_EXPIRED · 400 CERTIFICATE_INVALID · 400 CERT_MODE_REPRESENTADO (el emisor usa el cert de la cuenta).

DELETE /api/v1/emisores/:id/certificate

Retira el certificado de un emisor sin borrar al emisor. Es lo que quieres cuando el certificado caduca, se revoca o se sustituye por otro, y el cliente sigue siendo tu cliente.

Borra de la base de datos, de forma permanente, el .p12 y su contraseña. El emisor se queda con todos sus datos pero no puede firmar ni emitir hasta que subas uno nuevo con POST /api/v1/emisores/:id/certificate.

curl -X DELETE https://factuneo.es/api/v1/emisores/{id}/certificate \
  -H "x-api-key: TU_API_KEY"
{ "success": true, "hadCertificate": true }

hadCertificate: false significa que ese emisor ya no tenía certificado: la llamada es idempotente, repetirla no da error.

No toca las facturas ya emitidas ni su cadena de huellas: la firma quedó dentro del XML en el momento de emitir, y ahí sigue siendo verificable.

Errores: 404 EMISOR_NOT_FOUND.

POST /api/v1/emisores/:id/logo

Sube el logo del emisor. Se imprime en el PDF de la factura, bajo el nombre de la empresa. No afecta al XML ni al envío a la AEAT: es solo para el documento imprimible que entregas a tu cliente.

CampoTipoOblig.
logoBase64string (la imagen PNG o JPG en base64)Sí
  • Formato: PNG o JPG. El tipo se detecta por los magic bytes del contenido, no por lo que declares; cualquier otro formato se rechaza.
  • Tamaño máximo: 2 MB.
  • Recomendación: un PNG con fondo transparente se ve mejor. Se escala automáticamente a un máximo de ~46 px de alto en el PDF, respetando la proporción.
# Convierte tu logo a base64 y súbelo
BASE64=$(base64 -w0 logo.png)
curl -X POST https://factuneo.es/api/v1/emisores/EMISOR_ID/logo \
  -H "x-api-key: vsk_live_..." \
  -H "Content-Type: application/json" \
  -d "{\"logoBase64\": \"$BASE64\"}"

Respuesta: { "success": true, "mimeType": "image/png", "bytes": 12345 }.

Errores: 400 LOGO_UNSUPPORTED_FORMAT (no es PNG/JPG) · 400 LOGO_TOO_LARGE (> 2 MB) · 400 LOGO_DIMENSIONS (más de 2.000 píxeles de ancho o de alto) · 400 LOGO_INVALID (vacío, base64 no válido o imagen dañada) · 404 EMISOR_NOT_FOUND.

GET /api/v1/emisores/:id/logo

Devuelve la imagen del logo con su Content-Type (image/png o image/jpeg). Útil para previsualizarla. 404 LOGO_NOT_FOUND si el emisor no tiene logo.

DELETE /api/v1/emisores/:id/logo

Elimina el logo del emisor. A partir de ahí, sus PDF salen sin logo.

El logo también se puede subir desde el panel (sección Emisores): botón de la imagen en cada emisor. Panel y API operan sobre el mismo dato.


Facturas

POST /api/v1/invoices

Crea el registro de alta VeriFactu: calcula la huella SHA-256 encadenada, genera el XML RegistroAlta, lo valida contra el XSD oficial y encola el envío a la AEAT (asíncrono). Responde al instante con la huella y el QR; el estado final llega por webhook o consultando la factura.

Cabeceras: x-api-key (obligatoria) y Idempotency-Key (recomendada, evita duplicados en reintentos).

Cuerpo

CampoTipoOblig.Notas
emisorNifstring(1-9)Solo si hay varios emisoresObligado bajo el que se emite
numeroSeriestring(1-60)SíSerie + número, único por emisor
fechaExpedicionYYYY-MM-DDSíDebe ser una fecha real del calendario
tipoFacturastring(2)SíVer tabla de valores abajo
nombreRazonEmisorstring(1-120)SíRazón social del emisor
descripcionOperacionstring(1-500)SíDescripción de la operación
baseImponiblenúmeroSíBase imponible total. Admite negativos (abonos, rectificativas por diferencias)
cuotaIvanúmeroSíSuma de las cuotas del desglose (IVA, IGIC o IPSI). No incluye el recargo de equivalencia
importeTotalnúmeroSíBase + cuotas + cuotas de recargo de equivalencia
fechaOperacionYYYY-MM-DDNoSolo si la operación es de otra fecha que la expedición (p. ej. factura emitida días después de la entrega)
rectificativaobjetoSí en R1-R5Tipo de rectificación y factura que se rectifica (ver Rectificativas)
facturasSustituidasarrayNoSolo en F3: { numeroSerie, fechaExpedicion } de las simplificadas que sustituye
destinatariosarraySegún tipo{ nombreRazon, nif } o, sin NIF español, { nombreRazon, idOtro } (ver Clientes extranjeros), más su domicilio (ver Factura completa). Obligatorio en F1/F3/R1-R4, se omite en F2/R5
desglosearray (1-12)SíUna línea por impuesto y tipo (ver abajo)
lineasarray (1-500)NoConceptos de la factura: descripción, cantidad, precio unitario y descuento. No van a la AEAT; los imprimen el PDF y el Facturae (ver Factura completa)
emitidaPorT / DNoSolo si no la expide el propio emisor: T la expide un tercero en su nombre (p. ej. una gestoría) · D la expide el destinatario (autofacturación). Si se omite, la expide el emisor
terceroobjetoCon emitidaPor: "T"Quién la expide materialmente: { nombreRazon, nif } o { nombreRazon, idOtro }. No puede ser el propio emisor
subsanacionS / NNoReenvío corregido de un registro previo
rechazoPrevioN / S / XNoCon S o X exige subsanacion: "S"
generateFacturaebooleanNoSi true, genera también el Facturae en la misma llamada (default false)

tipoFactura (catálogo AEAT):

ValorSignificado
F1Factura completa (identifica al destinatario)
F2Factura simplificada (ticket, sin destinatario)
F3Factura en sustitución de simplificadas
R1–R4Rectificativas (distintos supuestos del art. 80 LIVA)
R5Rectificativa en facturas simplificadas

desglose[] — una línea por impuesto y tipo impositivo (máximo 12, límite del XSD de la AEAT):

CampoTipoNotas
impuesto01 / 02 / 03 / 0501 IVA (por defecto si se omite) · 02 IPSI (Ceuta y Melilla) · 03 IGIC (Canarias) · 05 otros (ver IGIC e IPSI)
claveRegimenstringRégimen: 01 general, 02 exportación, 07 criterio de caja… (catálogo AEAT L8A con IVA, L8B con IGIC). Obligatoria salvo con impuesto: "05", que no la admite
calificacionOperacionS1 / S2 / N1 / N2S1 sujeta y no exenta · S2 inversión del sujeto pasivo · N1/N2 no sujeta. Excluyente con operacionExenta
operacionExentaE1…E8Causa de exención, en lugar de calificacionOperacion (ver Operaciones exentas). E7 y E8 solo con IGIC
tipoImpositivonúmero% del impuesto. Obligatorio en S1; 0 en S2; no se envía en exentas ni no sujetas
baseImponiblenúmeroBase de esta línea (con signo)
cuotaRepercutidanúmeroCuota del impuesto de la línea. Obligatoria en S1; 0 en S2; no se envía en exentas ni no sujetas
tipoRecargoEquivalencianúmero% de recargo de equivalencia (solo S1)
cuotaRecargoEquivalencianúmeroCuota del recargo (va junto a tipoRecargoEquivalencia)

Rectificativas (R1–R5)

Toda rectificativa lleva el bloque rectificativa. La AEAT rechaza una R1–R5 sin él.

CampoNotas
tipoS por sustitución: la factura lleva los importes nuevos completos · I por diferencias: solo la variación, con signo
facturasRectificadas[{ numeroSerie, fechaExpedicion }] de las facturas que corriges (del mismo emisor). Opcional para la AEAT, recomendable siempre
importeRectificacionSolo y obligatorio en S: { baseRectificada, cuotaRectificada, cuotaRecargoRectificado? } con los importes de la factura original
motivoCausa de la rectificación, en texto (hasta 500). No va a la AEAT, pero la factura rectificativa tiene que decirla: la imprime el PDF y pasa al Facturae. Sin ella, la respuesta trae el aviso FISCAL_021

Qué tipo usar: R1 error fundado en derecho y art. 80.Uno, Dos y Seis LIVA · R2 concurso de acreedores (art. 80.Tres) · R3 créditos incobrables (art. 80.Cuatro) · R4 resto de casos · R5 rectificativa de una simplificada (va sin destinatario).

Abono de 50 € de base sobre una factura anterior (por diferencias, importes en negativo):

{
  "numeroSerie": "R-2026-001",
  "fechaExpedicion": "2026-09-26",
  "tipoFactura": "R1",
  "rectificativa": {
    "tipo": "I",
    "facturasRectificadas": [{ "numeroSerie": "F-2026-120", "fechaExpedicion": "2026-09-02" }],
    "motivo": "Devolución de 5 unidades defectuosas"
  },
  "nombreRazonEmisor": "MI EMPRESA SL",
  "descripcionOperacion": "Devolución parcial de la factura F-2026-120",
  "destinatarios": [{ "nombreRazon": "CLIENTE SA", "nif": "A28000024" }],
  "desglose": [{ "claveRegimen": "01", "calificacionOperacion": "S1", "tipoImpositivo": 21, "baseImponible": -50, "cuotaRepercutida": -10.5 }],
  "baseImponible": -50, "cuotaIva": -10.5, "importeTotal": -60.5
}

Para sustituir la factura entera, usa "tipo": "S", pon en el desglose los importes correctos completos y añade "importeRectificacion": { "baseRectificada": 100, "cuotaRectificada": 21 } con los de la original. En las rectificativas por diferencias no se exige que cuota y base tengan el mismo signo (una R5 que solo corrige el tipo de IVA puede ir con base 0 y cuota negativa).

Para anular una factura que nunca debió existir no emitas una rectificativa: usa POST /api/v1/invoices/:id/anular.

Operaciones exentas

La línea lleva operacionExenta en lugar de calificacionOperacion, y sin tipoImpositivo, cuotaRepercutida ni recargo. El PDF imprime la mención legal de la exención.

ValorSupuesto
E1Exenta por el art. 20 LIVA (sanidad, educación, seguros, alquiler de vivienda…)
E2Exportación (art. 21). Va con claveRegimen: "02"
E3Operaciones asimiladas a la exportación (art. 22)
E4Regímenes aduaneros y fiscales (arts. 23 y 24)
E5Entrega intracomunitaria (art. 25). El cliente se identifica con su NIF-IVA europeo (idOtro, idType: "02")
E6Exenta por otros motivos
E7, E8Solo con IGIC (ver IGIC e IPSI)
"desglose": [{ "claveRegimen": "01", "operacionExenta": "E1", "baseImponible": 60 }]

Clientes extranjeros (sin NIF español)

En lugar de nif, el destinatario lleva idOtro:

CampoNotas
codigoPaisCódigo ISO de 2 letras (FR, DE, US…). Obligatorio salvo con idType: "02"
idType02 NIF-IVA (empresas de la UE) · 03 pasaporte · 04 documento oficial del país · 05 certificado de residencia · 06 otro documento · 07 no censado (solo con codigoPais: "ES")
idNúmero del documento (hasta 20 caracteres)
"destinatarios": [{ "nombreRazon": "KUNDE GMBH", "idOtro": { "idType": "02", "id": "DE123456789" } }]

Cada destinatario lleva nif o idOtro, nunca los dos.

Recargo de equivalencia

Para ventas a comerciantes minoristas en recargo de equivalencia, añade a la línea S1 el tipo y la cuota del recargo. La cuota del recargo no va en cuotaIva, pero sí suma en importeTotal.

IVARecargo
21 %5,2 % (1,75 % en tabaco)
10 %1,4 %
4 %0,5 %
"desglose": [{ "claveRegimen": "01", "calificacionOperacion": "S1", "tipoImpositivo": 21, "baseImponible": 100, "cuotaRepercutida": 21, "tipoRecargoEquivalencia": 5.2, "cuotaRecargoEquivalencia": 5.2 }],
"baseImponible": 100, "cuotaIva": 21, "importeTotal": 126.2

IGIC (Canarias) e IPSI (Ceuta y Melilla)

Cada línea del desglose indica su impuesto con impuesto. Si se omite, la AEAT entiende IVA, así que las facturas con IVA no cambian. En una misma factura puede haber líneas de impuestos distintos.

impuestoClaves de régimen admitidasNotas
01 IVAL8A: 01–11, 14, 15, 17–20Tipos limitados a 0, 4, 10 y 21 % (y los temporales en su periodo); recargo de equivalencia
03 IGICL8B: 01–11, 14, 15, 17–19, más 20 (sujetas al IPSI, calificación N2) y 21 (régimen simplificado)Cualquier tipo (0, 3, 7, 9,5, 15…). Exenciones E1–E8
02 IPSI01, 08, 11, 18, 19, 20Cualquier tipo. Desde el 1/1/2027 la AEAT rechaza el registro sin clave de régimen
05 OtrosNinguna (no se envía claveRegimen)
"desglose": [{ "impuesto": "03", "claveRegimen": "01", "calificacionOperacion": "S1", "tipoImpositivo": 7, "baseImponible": 100, "cuotaRepercutida": 7 }],
"baseImponible": 100, "cuotaIva": 7, "importeTotal": 107

cuotaIva es la suma de las cuotas de todas las líneas, sea cual sea su impuesto. El PDF nombra el impuesto en cada línea y en los totales («Cuota IGIC»), y cita la ley de cada exención (Ley 20/1991 del IGIC, Ley 8/1991 del IPSI…). En el Facturae cada impuesto va con su código (TaxTypeCode 01, 02 o 03).

Factura completa: domicilios y conceptos

El registro de la AEAT no lleva domicilios ni conceptos, pero la factura que entregas a tu cliente sí tiene que llevarlos (art. 6 del Reglamento de facturación, RD 1619/2012). Envíalos en la misma llamada y el PDF será una factura completa:

  • Domicilio del emisor: sale de la ficha del emisor (direccion, codigoPostal, municipio, provincia).
  • Domicilio del destinatario: destinatarios[].domicilio = { direccion, codigoPostal?, municipio, provincia?, pais? }.
  • Conceptos: lineas[], con precio unitario sin impuestos y descuentos.
Campo de lineas[]TipoOblig.Notas
descripcionstring(1-500)Sí
cantidadnúmeroSíDistinta de 0. Admite decimales y negativos (devoluciones)
precioUnitarionúmeroSíSin impuestos. Hasta 6 decimales
descuentonúmeroNoEn euros, sobre el total de la línea
impuesto01 / 02 / 03 / 05NoPor defecto 01 (IVA)
tipoImpositivonúmeroNo% de la línea. Omítelo en exentas y no sujetas

El importe de cada concepto es cantidad × precioUnitario − descuento, redondeado a céntimos. La suma de los conceptos tiene que ser la base del desglose; si todos llevan tipoImpositivo, además tienen que cuadrar tipo a tipo. Si no, la API responde 422 con FISCAL_020.

"destinatarios": [{
  "nombreRazon": "CLIENTE SA", "nif": "A28000024",
  "domicilio": { "direccion": "Av. del Comercio 5", "codigoPostal": "28001", "municipio": "Madrid", "provincia": "Madrid" }
}],
"lineas": [
  { "descripcion": "Horas de consultoría", "cantidad": 12.5, "precioUnitario": 60, "descuento": 50, "tipoImpositivo": 21 },
  { "descripcion": "Licencia anual", "cantidad": 1, "precioUnitario": 300, "tipoImpositivo": 21 }
],
"desglose": [{ "claveRegimen": "01", "calificacionOperacion": "S1", "tipoImpositivo": 21, "baseImponible": 1000, "cuotaRepercutida": 210 }],
"baseImponible": 1000, "cuotaIva": 210, "importeTotal": 1210

Si falta algún domicilio (o la causa en una rectificativa), la factura se emite igual y la respuesta lo avisa en warnings con el código FISCAL_021: el registro de la AEAT es válido, pero el PDF no sería una factura completa. Las simplificadas (F2, R5) no los necesitan.

Facturas expedidas por un tercero o por el destinatario

Si tu software emite facturas en nombre de tus clientes (gestorías, asesorías, plataformas), la AEAT pide indicarlo: "emitidaPor": "T" y en tercero los datos de quien la expide. En la autofacturación, cuando es el cliente quien expide la factura del proveedor, usa "emitidaPor": "D"; el PDF incluye la mención «Facturación por el destinatario».

"emitidaPor": "T",
"tercero": { "nombreRazon": "GESTORÍA LÓPEZ SL", "nif": "B87654321" }

Las facturas de 100.000.000 € o más llevan automáticamente la marca Macrodato que exige la AEAT.

Las líneas del desglose deben cuadrar con baseImponible/cuotaIva totales. Si no, la validación semántica devuelve 422 FISCAL_SEMANTIC_ERROR con el detalle en violations.

Corregir un registro (subsanación). Si la AEAT devolvió ACEPTADA_CON_ERRORES o RECHAZADO, reenvía la versión corregida con subsanacion: "S" y rechazoPrevio: "S" (o "X"). La API exige que S/X lleven siempre subsanacion: "S" (regla AEAT §3.1.1).

Respuesta 200

{
  "invoiceId": "3f8a…",
  "hash": "A5C114D6…",
  "qrUrl": "https://…/ValidarQR?…",
  "qrString": "https://…/ValidarQR?…",
  "warnings": []
}
  • hash: huella SHA-256 encadenada (64 hex en mayúsculas).
  • qrString = qrUrl: es lo que codificas en el QR impreso.
  • warnings: avisos semánticos no bloqueantes (la factura se creó y encoló).

La respuesta es inmediata; el envío a la AEAT va en segundo plano. El estado final (ACEPTADO / RECHAZADO…) llega por webhook o con GET /api/v1/invoices/:id.

Errores: 400 VALIDATION_ERROR · 400 EMISOR_CERTIFICATE_NOT_CONFIGURED (el emisor aún no tiene certificado) · 400 ACCOUNT_REPRESENTATIVE_CERT_NOT_CONFIGURED (modo REPRESENTADO sin certificado de la cuenta) · 402 INVOICE_CAP_REACHED · 400 XSD_VALIDATION_FAILED · 403 (emisor desactivado o sin representación vigente) · 409 LOCK_TIMEOUT (otra factura del mismo emisor en curso: reintenta) · 409 FACTURA_DUPLICADA (ya emitiste esa serie y fecha para este emisor; para corregirla, reenvíala con subsanacion: "S") · 422 FISCAL_SEMANTIC_ERROR · 429 (límite por minuto o cuota agotada).

GET /api/v1/invoices

Para qué sirve: recorrer o buscar tus facturas emitidas, con filtros por estado y fechas. Úsalo para pintar un listado en tu app, conciliar con tu contabilidad o localizar las facturas que quedaron pendientes o rechazadas. Listado paginado.

ParámetroTipoPor defecto
estadoEnvioPENDIENTE, ENVIADO, ACEPTADO, ACEPTADA_CON_ERRORES, RECHAZADO, ERRORtodos
fechaDesde / fechaHastaYYYY-MM-DDsin filtro
pageentero ≥ 11
limitentero 1-10020
{ "data": [ … ], "meta": { "total": 143, "page": 1, "limit": 20, "totalPages": 8 } }

GET /api/v1/invoices/:id

Para qué sirve: consultar el estado y los datos fiscales de UNA factura por su ID — la alternativa a los webhooks cuando prefieres sondear tú. Devuelve importes, estadoEnvio (dónde va el envío a la AEAT), hashActual, hashAnterior, qrUrl, tipoRegistro (ALTA/ANULACION), anulada y fechaHoraHusoGenRegistro. También origen: FACTUNEO si lo generó la API, o EXTERNO si lo generó otro sistema y solo lo remitimos (ver Registros de otro sistema); en ese caso, sistemaExterno dice qué sistema e instalación lo generó y si se remitió con incidencia.

Incluye el bloque aeat con lo que respondió la AEAT, para saber por qué se rechazó una factura sin depender de los webhooks:

"aeat": {
  "destino": "PRODUCCION",
  "csv": null,
  "estadoRegistro": "Incorrecto",
  "codigoError": "3239",
  "descripcionError": "Error en el bloque de Destinatario. NIF no identificado",
  "motivoError": null
}

csv es el Código Seguro de Verificación cuando la AEAT acepta. motivoError explica un fallo antes de llegar a la AEAT (certificado ilegible, emisor sin representación…). Mientras la factura está PENDIENTE, todo va a null.

GET /api/v1/invoices/:id/xml

Para qué sirve: obtener el XML RegistroAlta tal cual se remite a la AEAT. En la modalidad VERI*FACTU el registro no lleva firma electrónica: lo autentica el envío con el certificado del obligado (TLS mutuo) y lo encadena la huella. Úsalo para archivarlo, para auditorías o para aportarlo si Hacienda lo requiere. Se sirve como Content-Type: application/xml.

GET /api/v1/invoices/:id/pdf

Para qué sirve: descargar el PDF imprimible de la factura, listo para entregar a tu cliente. Se genera a partir del registro ya emitido (no recalcula nada ni vuelve a hablar con la AEAT) e incluye lo que pide el art. 6 del Reglamento de facturación: número y fecha, datos y domicilio del emisor y del destinatario, los conceptos con cantidad, precio unitario y descuento (si los enviaste en lineas), la base, el tipo y la cuota de cada impuesto (IVA, IGIC o IPSI), la fecha de operación, los totales, el QR de cotejo de la AEAT y la huella. Si no cabe en una página, sigue en la siguiente, con la huella y el número de página en cada una. Si el emisor tiene logo subido, aparece bajo el nombre de la empresa.

El QR se coloca como exige la especificación de la AEAT: al principio de la factura, centrado, de 38,8 mm, precedido de «QR tributario:» y seguido de «Factura verificable en la sede electrónica de la AEAT». El PDF añade las menciones obligatorias que correspondan: rectificativa, factura rectificada y causa de la rectificación, precepto de la exención, inversión del sujeto pasivo, regímenes especiales (criterio de caja, agencias de viajes, bienes usados), facturación por el destinatario o por un tercero.

Se sirve como Content-Type: application/pdf con Content-Disposition: attachment; filename="<numeroSerie>.pdf".

curl https://factuneo.es/api/v1/invoices/INVOICE_ID/pdf \
  -H "x-api-key: vsk_live_..." \
  -o factura.pdf

Errores: 404 INVOICE_NOT_FOUND (no existe o no es de tu cuenta).

GET /api/v1/invoices/:id/qr

Para qué sirve: recuperar la URL de cotejo de la AEAT si necesitas regenerar el QR de una factura (también la tienes en la respuesta de POST /invoices). Es la URL que se codifica en el QR impreso.

{ "qrUrl": "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=…", "numeroSerie": "FAC-2026-001" }

GET /api/v1/invoices/:id/status

Para qué sirve: saber en qué estado está una factura y qué respondió la AEAT, sin descargar la factura entera. Devuelve estadoEnvio, actualizadoEn y el mismo bloque aeat que GET /api/v1/invoices/:id (código y motivo de un rechazo, CSV). statuses es el historial de estados de Facturae/FACe (aceptación, pago…), si la factura se presentó por esa vía; en las facturas solo VeriFactu va vacío.

GET /api/v1/invoices/:id/consulta-aeat

Pregunta a la AEAT qué registro conserva y coteja la huella con la tuya. Prueba de integridad extremo a extremo. Solo disponible en VERI*FACTU.

{
  "invoiceId": "3f8a…",
  "encontrada": true,
  "estadoRegistro": "Correcto",
  "huellaAeat": "A5C114D6…",
  "huellaLocal": "A5C114D6…",
  "huellaCoincide": true,
  "timestampPresentacion": "2026-07-24T15:26:33+02:00",
  "codigoError": null,
  "descripcionError": null
}

huellaCoincide: false significa que lo que guarda la AEAT no es lo que tú tienes: revísalo, no lo ignores.

Errores: 502 AEAT_CONSULTA_ERROR si la AEAT no responde.

POST /api/v1/invoices/:id/anular

Genera el registro de anulación. Entra en la misma cadena de huellas que las altas, así que también se envía a la AEAT.

CampoTipoOblig.Notas
refExternastring(1-60)NoReferencia tuya
sinRegistroPrevioS o NNoS declara que no consta alta previa
curl -X POST https://factuneo.es/api/v1/invoices/3f8a…/anular \
  -H "x-api-key: vsk_…" -H "Content-Type: application/json" -d '{}'
{ "anulacionId": "6284…", "invoiceId": "3f8a…", "hash": "E6EF55D1…", "estadoEnvio": "PENDIENTE" }

Errores:

  • 409 INVOICE_NOT_REGISTERED_YET — la AEAT aún no la ha aceptado. Espera: si la anulación llegara antes que el alta, la rechazarían.
  • 409 INVOICE_ALREADY_CANCELLED — ya hay una anulación activa.
  • 409 INVOICE_NOT_REGISTERED — el alta fue rechazada; reintenta con sinRegistroPrevio: "S".
  • 409 NOT_AN_ALTA_RECORD — estás intentando anular una anulación.

POST /api/v1/invoices/:id/facturae

Devuelve el XML Facturae 3.2.2 firmado con XAdES-EPES conforme a la política de firma de Facturae v3.1 (Content-Type: application/xml): firma el documento, las propiedades de firma y el certificado, con rol de emisor. Admite compradores extranjeros (su domicilio va como dirección en el extranjero). El emisor necesita domicilio fiscal completo.

Las líneas del Facturae son los conceptos de la factura (lineas, con su precio unitario y descuento) o, si no se enviaron, una por línea del desglose. Los impuestos de la factura son exactamente los del registro de la AEAT, cada uno con su código (IVA, IGIC o IPSI).

Facturae y VeriFactu son dos cosas distintas, y este endpoint no habla con la AEAT.

VeriFactu es el registro de facturación que se remite a la AEAT por SOAP: es lo que hace POST /invoices. Facturae es un formato de fichero de factura electrónica (norma de facturae.gob.es, no de la AEAT), y este endpoint se limita a generarlo y firmarlo: no lo envía a ningún sitio. Se valida contra el XSD oficial 3.2.2 antes de devolvértelo.

Qué haces con el fichero depende de a quién factures: si es B2B, se lo entregas a tu cliente por el canal que uséis (el intercambio obligatorio del Real Decreto 238/2026, por la solución pública de la AEAT o una plataforma privada, con comunicación de estados, aún no lo ofrecemos); si es a una Administración Pública, la podemos presentar nosotros en FACe con POST /api/v1/invoices/:id/face (ver más abajo).

Una misma factura normalmente lleva las dos cosas: el registro VeriFactu a la AEAT y el Facturae para el destinatario. Son independientes.

Campo (dentro de buyer)TipoOblig.Por defecto
taxIdentificationNumberstring(1-30)Sí
personTypeCodeF (física) o J (jurídica)NoJ
residenceTypeCodeR (España) o U (UE) o E (resto)NoR
corporateNamestring(1-80)Si es J
name / firstSurnamestring(1-40)Si es F
addressobjetoSí, salvo que la factura se emitiera con el domicilio español completo del destinatarioSi se omite, se usa ese domicilio
address.addressstring(1-80)Sí
address.postCodestring(1-10)Sí
address.townstring(1-50)Sí
address.provincestring(1-50)Sí
address.countryCodestring(3)NoESP
dir3objetoSolo para Administraciones{ oficinaContable, organoGestor, unidadTramitadora }: códigos DIR3 del organismo. Obligatorio para FACe

Si el emisor es persona física, Facturae exige su nombre y apellidos por separado. Si firma con su propio certificado se toman de él; si no, envíalos en seller: { name, firstSurname, secondSurname? } (si faltan: 400 SELLER_NAME_REQUIRED).

Si el emisor está en modo REPRESENTADO, la factura la firma el representante de la cuenta y Facturae lo identifica como tercero (InvoiceIssuerType TE). Facturae exige su domicilio: envíalo en tercero: { address: { address, postCode, town, province, countryCode } }. Su NIF y nombre salen de la cuenta; si es persona física, el nombre y los apellidos se toman de su certificado o de tercero.name / firstSurname / secondSurname (si faltan: 400 THIRD_PARTY_DATA_REQUIRED).

curl -X POST https://factuneo.es/api/v1/invoices/3f8a…/facturae \
  -H "x-api-key: vsk_…" -H "Content-Type: application/json" \
  -d '{
    "buyer": {
      "personTypeCode": "J",
      "residenceTypeCode": "R",
      "taxIdentificationNumber": "B87654321",
      "corporateName": "Cliente Ejemplo SL",
      "address": {
        "address": "Av. del Comercio 5",
        "postCode": "28001",
        "town": "Madrid",
        "province": "Madrid",
        "countryCode": "ESP"
      }
    }
  }'

Rectificativas. Si la factura es rectificativa, el Facturae es una «original rectificativa» (InvoiceClass OR) con el bloque Corrective: número y fecha de la factura rectificada (del registro), motivo, periodo, método de corrección y la causa (rectificativa.motivo del alta). Admite importes negativos. En el cuerpo indica al menos el motivo con su código oficial de Facturae:

Campo (dentro de rectificativa)Oblig.Notas
motivoSí01–16 error en un dato de la factura (01 número, 03 fecha, 07 NIF del receptor, 09 domicilio del receptor, 10 detalle de la operación, 11 tipo impositivo, 16 base imponible…) · 80–85 art. 80 LIVA (82 devolución de envases, 83 descuentos y bonificaciones, 84 resolución firme, 85 impago con auto de concurso)
metodoNo01 íntegra · 02 por diferencias · 03 descuento por volumen · 04 autorizada por la AEAT. Por defecto, 01 si la rectificativa es por sustitución y 02 si es por diferencias
periodoNo{ desde, hasta } (YYYY-MM-DD). Por defecto, el mes de la factura rectificada
{ "buyer": { "taxIdentificationNumber": "A28000024" }, "rectificativa": { "motivo": "83" } }

Errores:

  • 400 SELLER_ADDRESS_NOT_CONFIGURED — falta el domicilio del emisor.
  • 400 BUYER_ADDRESS_REQUIRED — falta buyer.address y la factura no tiene guardado el domicilio del destinatario.
  • 400 FACTURAE_MOTIVO_RECTIFICACION_REQUERIDO — es una rectificativa y falta rectificativa.motivo.
  • 400 FACTURAE_FACTURA_RECTIFICADA_REQUERIDA — con los métodos 01 y 02 Facturae exige el número de la factura rectificada, y el registro no la cita.
  • 400 INVOICE_NUMBER_TOO_LONG_FOR_FACTURAE — Facturae limita el número de factura a 20 caracteres y VeriFactu permite 60. Usa series más cortas si vas a emitir Facturae.
  • 400 SELLER_NAME_REQUIRED — el emisor es persona física y no hay nombre y apellidos (ver seller).
  • 400 THIRD_PARTY_DATA_REQUIRED — modo REPRESENTADO sin el domicilio (o el nombre) del representante (ver tercero).
  • 500 SIGNING_ERROR — no se pudo firmar con el certificado.

POST /api/v1/invoices/:id/face

Para qué sirve: presentar la factura a una Administración Pública en FACe, el punto general de entrada de facturas electrónicas del Estado (obligatorio para facturar al sector público, Ley 25/2013). Genera el Facturae firmado igual que el endpoint anterior, con las unidades DIR3 del organismo, y lo remite a FACe. Devuelve el número de registro de FACe.

Quién firma y con qué plataforma. La factura la firma el emisor (o su representante) con su certificado, como en /facturae. El envío se autentica ante FACe con tu plataforma FACe: la que das de alta en el portal de integradores de FACe con tu certificado. Factuneo no firma tus facturas ni presenta con una plataforma propia. Ver FACe (Administraciones Públicas).

Con una clave de test se presenta en el entorno de pruebas de FACe (puedes usar la unidad de pruebas P00000010 como oficina contable, órgano gestor y unidad tramitadora); con una clave live, en FACe de producción. Cada entorno necesita tu plataforma dada de alta en él.

Cuerpo: el mismo que /facturae, con buyer.dir3 obligatorio, más:

CampoTipoOblig.Notas
emailstringSíCorreo al que FACe enviará los avisos de la factura
incluirPdfbooleanNoAdjunta el PDF de la factura como anexo (por defecto false)
curl -X POST https://factuneo.es/api/v1/invoices/3f8a…/face \
  -H "x-api-key: vsk_test_…" -H "Content-Type: application/json" \
  -d '{
    "buyer": {
      "taxIdentificationNumber": "11111111H",
      "corporateName": "UNIDAD DIR PRUEBAS 10",
      "dir3": { "oficinaContable": "P00000010", "organoGestor": "P00000010", "unidadTramitadora": "P00000010" }
    },
    "email": "facturas@miempresa.es",
    "incluirPdf": true
  }'

Respuesta 201:

{
  "registro": "REGAGE26e00000412428",
  "fechaRegistro": "2026-09-27T00:28:30.000Z",
  "entorno": "pruebas",
  "estado": { "codigo": "1200", "nombre": "Registrada", "comentario": null },
  "anulacion": { "codigo": "4100", "nombre": "No solicitada", "comentario": null },
  "dir3": { "oficinaContable": "P00000010", "organoGestor": "P00000010", "unidadTramitadora": "P00000010" }
}

Los códigos DIR3 te los da el organismo (suelen figurar en el pedido o el contrato) y se pueden consultar en el directorio de FACe.

Estados de FACe: 1200 registrada · 1300 registrada en el registro contable · 2400 en trámite (contabilizada) · 2500 pagada · 2600 rechazada · 3100 anulada.

Errores:

  • 409 FACE_YA_ENVIADA — la factura ya está en FACe. Solo se puede volver a presentar si FACe la rechazó o la anuló.
  • 422 FACE_RECHAZADA — FACe no la ha admitido; detalle trae su motivo (p. ej. una unidad DIR3 inexistente).
  • 422 FACE_PLATAFORMA_NO_REGISTRADA — FACe no reconoce el certificado con que se autentica el envío: da de alta tu plataforma con él.
  • 422 FACE_PLATAFORMA_NO_CONFIGURADA — no hay certificado con el que presentar (ni de plataforma ni del emisor).
  • 502 FACE_NO_RESPONDE — FACe no responde. Reintenta más tarde.
  • Además, los mismos errores de /facturae.

GET /api/v1/invoices/:id/face

Presentaciones de la factura en FACe con su estado, consultado a FACe en ese momento (registrada, en trámite, pagada, rechazada, anulada…). Si FACe no responde, devuelve el último estado conocido con "actualizado": false. No hace falta que lo consultes tú: el estado se sigue solo y cada cambio te llega por el webhook invoice.face_status_changed.

POST /api/v1/invoices/:id/face/anulacion

Pide al organismo que anule la factura en FACe, con el motivo: { "motivo": "Importe erróneo" }. La decisión es del organismo; se ve en anulacion (4200 solicitada, 4300 aceptada, 4400 rechazada). Para corregir una factura ya registrada en la AEAT, además, emite la rectificativa correspondiente.


Seguridad y auditoría

GET /api/v1/security/compliance

Para qué sirve: un informe de cumplimiento (ENS) de tu cuenta, en el entorno de la clave con la que lo pides (live o test; valen las dos). Devuelve una puntuación y el detalle por control. Úsalo para tu propia auditoría o para justificar el cumplimiento ante un cliente.

ControlQué comprueba
MP.SI.1.1Que la cuenta tiene clave de API en ese entorno
MP.SI.6.1Que el límite de peticiones por minuto está en un rango razonable
MP.SI.5.1Que cada emisor activo puede firmar: su certificado (modo Propio) o el del representante con la representación vigente (modo Representado). Si alguno no puede, lo dice con su NIF
MP.SI.5.2Que los certificados en uso están vigentes: FAIL si alguno ha caducado, WARN si caduca en menos de 30 días o no se conoce su caducidad
MP.SI.5.3Cómo se protegen las claves privadas (cifradas con AES-256-GCM, nunca se devuelven)
MP.SI.4.1Que la cuenta está activa
MP.SI.3.1Que la auditoría encadenada por hash está habilitada
{
  "tenantId": "3f8a…",
  "environment": "live",
  "overallStatus": "PARTIAL",
  "score": 86,
  "checkedAt": "2026-09-29T10:00:00.000Z",
  "controls": [
    { "code": "MP.SI.5.2", "name": "Vigencia de los certificados", "status": "WARN",
      "message": "Certificado que caduca en menos de 30 días en 1 emisor(es): B12345678.",
      "category": "CRIPTOGRAFIA" }
  ]
}

overallStatus es COMPLIANT si todo pasa, PARTIAL si hay avisos y NON_COMPLIANT si algún control falla.

GET /api/v1/security/audit-integrity

Para qué sirve: comprobar que la cadena de auditoría (los eventos del sistema, encadenados por hash) no ha sido manipulada (con la clave live o la de test). Recalcula la cadena y confirma que sigue íntegra: es la garantía de inalterabilidad que exige la normativa (ENS). La verificación se repite como mucho cada 10 minutos: checkedAt dice cuándo se hizo.

{ "validChain": true, "checkedAt": "2026-08-05T12:00:00.000Z" }

Monitorización

GET /health

Para qué sirve: comprobar el estado del servicio y sus dependencias (base de datos, Redis, AEAT). Público y sin autenticación — no expone datos de cuentas. Úsalo para tu monitorización de disponibilidad.

{
  "status": "healthy",
  "version": "…",
  "uptime": 138240.5,
  "checks": {
    "database": { "status": "pass", "responseTimeMs": 3 },
    "redis": { "status": "pass", "responseTimeMs": 1 },
    "aeat": { "status": "pass", "responseTimeMs": 210 }
  }
}

Devuelve 200 si tu infraestructura propia está sana (si solo la AEAT va lenta, el estado será degraded pero sigue siendo 200); 503 únicamente si fallan la base de datos o Redis. El endpoint /metrics (Prometheus) es interno y requiere token propio.

Recetas: hostelería y tickets

Peticiones completas para un TPV de restaurante, probadas contra la API: cada ejemplo pasa la validación fiscal, el XSD de la AEAT y genera su huella y su QR. Cópialas con tu emisorNif y tus series.

Lo básico

QuéValor
URL basehttps://factuneo.es/api/v1. La misma con la clave de test y la de live: la clave decide el entorno
AutenticaciónCabecera x-api-key
Salud del servicioGET https://factuneo.es/health (público, fuera de /api/v1)
FechasAAAA-MM-DD (2026-10-22). La de expedición no puede ser futura
ImportesNúmero JSON, no texto: 45, 5.82. Con signo menos en las rectificativas por diferencias
IVAEn desglose[] (obligatorio), una línea por tipo. En lineas[] (opcional) cada concepto lleva su tipoImpositivo y su precioUnitario sin IVA; si los envías, tienen que cuadrar con el desglose tipo a tipo (422 FISCAL_020)
ReintentosCabecera Idempotency-Key con un valor único por ticket: repetir la petición con la misma clave devuelve la misma factura (200), sin duplicarla

Modo representado: el cuerpo es el mismo. Cada ticket indica el restaurante en emisorNif y nombreRazonEmisor, y se envía con tu certificado de representante. El restaurante tiene que estar dado de alta con certMode: "REPRESENTADO" y representacionVigente: true; si no, 403 EMISOR_REPRESENTATION_NOT_VALID.

Ticket simplificado (F2)

Un ticket con el IVA de hostelería (10 %) y una venta para llevar al 21 %. Sin destinatarios: el ticket no identifica al cliente. Un ticket F2 no puede pasar de 3.000 € (base más cuotas): la AEAT lo rechaza (error 1150) y la API lo frena antes con 422 FISCAL_001. Por encima, emite una factura completa (F1).

{
  "emisorNif": "B12345674",
  "numeroSerie": "T1-2026-000123",
  "fechaExpedicion": "2026-10-22",
  "tipoFactura": "F2",
  "nombreRazonEmisor": "RESTAURANTE EJEMPLO SL",
  "descripcionOperacion": "Consumo en mesa 7",
  "lineas": [
    {
      "descripcion": "Menú del día",
      "cantidad": 2,
      "precioUnitario": 15,
      "tipoImpositivo": 10
    },
    {
      "descripcion": "Café",
      "cantidad": 2,
      "precioUnitario": 1.5,
      "tipoImpositivo": 10
    },
    {
      "descripcion": "Botella de vino para llevar",
      "cantidad": 1,
      "precioUnitario": 12,
      "tipoImpositivo": 21
    }
  ],
  "desglose": [
    {
      "claveRegimen": "01",
      "calificacionOperacion": "S1",
      "tipoImpositivo": 10,
      "baseImponible": 33,
      "cuotaRepercutida": 3.3
    },
    {
      "claveRegimen": "01",
      "calificacionOperacion": "S1",
      "tipoImpositivo": 21,
      "baseImponible": 12,
      "cuotaRepercutida": 2.52
    }
  ],
  "baseImponible": 45,
  "cuotaIva": 5.82,
  "importeTotal": 50.82
}
curl -X POST https://factuneo.es/api/v1/invoices \
  -H "x-api-key: vsk_test_..." \
  -H "Idempotency-Key: mesa7-20261022-1" \
  -H "Content-Type: application/json" \
  -d @ticket.json

Respuesta 200

{
  "invoiceId": "3f8a1c2e-…",
  "hash": "A5C114D6…",
  "qrUrl": "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=B12345674&numserie=T1-2026-000123&fecha=22-10-2026&importe=50.82",
  "qrString": "https://prewww2.aeat.es/wlpl/TIKE-CONT/ValidarQR?nif=B12345674&numserie=T1-2026-000123&fecha=22-10-2026&importe=50.82"
}

Qué trae la respuesta

CampoPara qué
invoiceIdId de la factura en Factuneo: para consultarla, anularla o descargar su PDF
hashHuella SHA-256 encadenada del registro
qrUrlURL de cotejo de la AEAT: es la URL de verificación de la factura
qrStringIgual que qrUrl. Es el texto que codificas en el QR del ticket
warningsAvisos no bloqueantes, si los hay (la factura se ha creado)

La respuesta no trae una URL del PDF: se descarga con GET /api/v1/invoices/{invoiceId}/pdf (con tu x-api-key). El resultado del envío a la AEAT llega después por webhook o con GET /api/v1/invoices/{invoiceId}.

Imprimir el QR en el ticket

Si imprimes tú el ticket, la AEAT fija cómo va el QR (especificación del código QR):

  • Entre 30×30 y 40×40 mm, norma ISO/IEC 18004:2015 y nivel M de corrección de errores.
  • Al principio del ticket, antes del resto del contenido, con al menos 2 mm de margen en blanco alrededor (se recomiendan 6 mm).
  • Encima, el texto «QR tributario:». Justo debajo, «Factura verificable en la sede electrónica de la AEAT» o «VERI*FACTU», con letra igual o mayor que la del resto del ticket.

Factura completa que sustituye al ticket (F3)

Cuando el cliente pide factura de un ticket ya emitido. Lleva los datos y el domicilio del cliente y, en facturasSustituidas, el ticket que sustituye.

{
  "emisorNif": "B12345674",
  "numeroSerie": "F-2026-000045",
  "fechaExpedicion": "2026-10-22",
  "tipoFactura": "F3",
  "nombreRazonEmisor": "RESTAURANTE EJEMPLO SL",
  "descripcionOperacion": "Consumo en mesa 7 (sustituye al ticket T1-2026-000123)",
  "facturasSustituidas": [
    {
      "numeroSerie": "T1-2026-000123",
      "fechaExpedicion": "2026-10-22"
    }
  ],
  "destinatarios": [
    {
      "nombreRazon": "CLIENTE EMPRESA SA",
      "nif": "A28000024",
      "domicilio": {
        "direccion": "Rúa do Vilar 10",
        "codigoPostal": "15705",
        "municipio": "Santiago de Compostela",
        "provincia": "A Coruña"
      }
    }
  ],
  "lineas": [
    {
      "descripcion": "Menú del día",
      "cantidad": 2,
      "precioUnitario": 15,
      "tipoImpositivo": 10
    },
    {
      "descripcion": "Café",
      "cantidad": 2,
      "precioUnitario": 1.5,
      "tipoImpositivo": 10
    },
    {
      "descripcion": "Botella de vino para llevar",
      "cantidad": 1,
      "precioUnitario": 12,
      "tipoImpositivo": 21
    }
  ],
  "desglose": [
    {
      "claveRegimen": "01",
      "calificacionOperacion": "S1",
      "tipoImpositivo": 10,
      "baseImponible": 33,
      "cuotaRepercutida": 3.3
    },
    {
      "claveRegimen": "01",
      "calificacionOperacion": "S1",
      "tipoImpositivo": 21,
      "baseImponible": 12,
      "cuotaRepercutida": 2.52
    }
  ],
  "baseImponible": 45,
  "cuotaIva": 5.82,
  "importeTotal": 50.82
}

El PDF de la factura completa: GET /api/v1/invoices/{invoiceId}/pdf.

Rectificativa de un ticket por diferencias (R5, tipo I)

Solo la variación, en negativo. Aquí, la devolución de un café:

{
  "emisorNif": "B12345674",
  "numeroSerie": "R-2026-000007",
  "fechaExpedicion": "2026-10-22",
  "tipoFactura": "R5",
  "rectificativa": {
    "tipo": "I",
    "facturasRectificadas": [
      {
        "numeroSerie": "T1-2026-000123",
        "fechaExpedicion": "2026-10-22"
      }
    ],
    "motivo": "Devolución de un café no servido"
  },
  "nombreRazonEmisor": "RESTAURANTE EJEMPLO SL",
  "descripcionOperacion": "Devolución parcial del ticket T1-2026-000123",
  "lineas": [
    {
      "descripcion": "Café",
      "cantidad": -1,
      "precioUnitario": 1.5,
      "tipoImpositivo": 10
    }
  ],
  "desglose": [
    {
      "claveRegimen": "01",
      "calificacionOperacion": "S1",
      "tipoImpositivo": 10,
      "baseImponible": -1.5,
      "cuotaRepercutida": -0.15
    }
  ],
  "baseImponible": -1.5,
  "cuotaIva": -0.15,
  "importeTotal": -1.65
}

Rectificativa de un ticket por sustitución (R5, tipo S)

Los importes correctos completos en el desglose y, en importeRectificacion, los del ticket original:

{
  "emisorNif": "B12345674",
  "numeroSerie": "R-2026-000008",
  "fechaExpedicion": "2026-10-22",
  "tipoFactura": "R5",
  "rectificativa": {
    "tipo": "S",
    "facturasRectificadas": [
      {
        "numeroSerie": "T1-2026-000123",
        "fechaExpedicion": "2026-10-22"
      }
    ],
    "importeRectificacion": {
      "baseRectificada": 45,
      "cuotaRectificada": 5.82
    },
    "motivo": "Se cobró un menú que no se sirvió"
  },
  "nombreRazonEmisor": "RESTAURANTE EJEMPLO SL",
  "descripcionOperacion": "Sustituye al ticket T1-2026-000123",
  "lineas": [
    {
      "descripcion": "Menú del día",
      "cantidad": 1,
      "precioUnitario": 15,
      "tipoImpositivo": 10
    },
    {
      "descripcion": "Café",
      "cantidad": 2,
      "precioUnitario": 1.5,
      "tipoImpositivo": 10
    },
    {
      "descripcion": "Botella de vino para llevar",
      "cantidad": 1,
      "precioUnitario": 12,
      "tipoImpositivo": 21
    }
  ],
  "desglose": [
    {
      "claveRegimen": "01",
      "calificacionOperacion": "S1",
      "tipoImpositivo": 10,
      "baseImponible": 18,
      "cuotaRepercutida": 1.8
    },
    {
      "claveRegimen": "01",
      "calificacionOperacion": "S1",
      "tipoImpositivo": 21,
      "baseImponible": 12,
      "cuotaRepercutida": 2.52
    }
  ],
  "baseImponible": 30,
  "cuotaIva": 4.32,
  "importeTotal": 34.32
}

Un ticket emitido por error (no hubo venta) no se rectifica: se anula con POST /api/v1/invoices/{invoiceId}/anular y cuerpo {} (ver POST /api/v1/invoices/:id/anular).

Si el restaurante se queda sin conexión

La huella y el QR se generan en la API en el momento de la llamada, así que cada ticket necesita conexión para emitirse con su QR. Según la AEAT, el registro de facturación se genera a la vez que la factura y el ticket que se entrega tiene que llevar ya el QR: no vale entregarlo sin QR y registrarlo después (aclaraciones a desarrolladores). Recomendamos una conexión de respaldo en cada local.

Si tu TPV es capaz de generar él mismo los registros durante la caída (con su propio sistema informático y su declaración responsable), puede enviárnoslos al volver la red y los remitimos sin tocarlos: ver Registros de otro sistema.

Las caídas de la AEAT no te afectan: la API responde al momento y reenvía el registro sola hasta que la AEAT lo acepta.

FACe: facturar a Administraciones Públicas

Facturae es el formato de factura electrónica (el XML firmado que genera POST /invoices/:id/facturae). FACe es la plataforma del Estado donde se presentan las facturas a los organismos públicos (Ley 25/2013). POST /invoices/:id/face genera el Facturae y lo presenta en FACe por ti.

FACe está incluido en todos los planes y es opcional: si no facturas a la Administración no tienes que configurar nada. Presenta facturas emitidas con Factuneo, así que requiere usar también VeriFactu con nosotros.

1. Da de alta tu plataforma en FACe (una vez)

FACe solo acepta envíos de plataformas dadas de alta en su portal de integradores, identificadas por su certificado. La plataforma es tuya: Factuneo presenta con ella, no con una propia.

  1. Entra en el portal de integradores con Cl@ve o certificado: pruebas se-integradores-face.redsara.es · producción integradores.face.gob.es. Son dos altas distintas: haz la de pruebas para tu clave de test y la de producción para la live.
  2. Firma la declaración de alta como integrador. Si vas a presentar facturas de tus clientes (software para terceros), marca «Servicios a terceros».
  3. En Certificados, sube la parte pública de tu certificado (.cer en base64/PEM).
  4. En Plataformas, crea una con servicio de envío y asóciale ese certificado.

2. Sube ese certificado a Factuneo

Con POST /api/v1/account/face-certificate ({ certificateBase64, password }, el .p12 con su clave privada) o desde la tarjeta FACe del panel. Lo comprobamos con FACe en pruebas y en producción y te decimos si lo reconoce:

{ "nif": "B12345678", "nombre": "MI SOFTWARE SL", "caduca": "2028-01-01T00:00:00.000Z",
  "verificacion": { "pruebas": "verificado", "produccion": "no_registrado" } }

no_registrado = da de alta la plataforma con ese certificado en ese entorno y vuelve a comprobarlo con POST /api/v1/account/face-certificate/verificar. GET /api/v1/account devuelve su estado en plataformaFace, y te avisamos antes de que caduque.

Qué certificado se usa para presentar, por orden: el de tu plataforma; si no lo has subido, el del propio emisor (que entonces tendrá que tener su plataforma dada de alta con él); y en emisores en modo Representado, el de representante de tu cuenta.

3. Presenta y olvídate

POST /api/v1/invoices/:id/face con las unidades DIR3 del organismo. Después, el estado se consulta solo en FACe (cada hora mientras pueda cambiar) y cada cambio te llega por el webhook invoice.face_status_changed: registrada, en trámite (contabilizada), pagada, rechazada o anulada, y la respuesta a una solicitud de anulación. Si el organismo la rechaza, además lo verás en las alertas del panel.

Emisores en modo Representado

La factura la firma el representante como tercero y Facturae exige su domicilio. Guárdalo una vez con PATCH /api/v1/account:

{ "domicilioRepresentante": { "direccion": "Calle Mayor 1", "codigoPostal": "28001",
  "municipio": "Madrid", "provincia": "Madrid" } }

Incidencias conocidas del portal de FACe

  • Al subir el certificado, el portal falla con ficheros en binario (DER): usa el .cer en base64 (el que empieza por -----BEGIN CERTIFICATE-----).
  • Si al marcar «Servicios a terceros» el portal devuelve un error, puedes seguir: no es necesario para presentar facturas.

Registros generados por otro sistema (TPV sin conexión)

Si el TPV de otro fabricante genera sus propios registros VeriFactu —por ejemplo, mientras el local no tiene internet—, al volver la conexión puede enviárnoslos y los remitimos a la AEAT tal cual, sin generar ni modificar nada. Es el papel de «instrumento para la remisión» que admiten las aclaraciones de la AEAT a desarrolladores (pregunta 5).

curl -X POST https://factuneo.es/api/v1/registros-externos \
  -H "x-api-key: vsk_…" -H "Content-Type: application/json" \
  -d '{ "incidencia": "S", "registros": [ { "xml": "<sum1:RegistroAlta …>…</sum1:RegistroAlta>" } ] }'
  • registros: hasta 100 RegistroAlta o RegistroAnulacion, en el orden en que se generaron y todos del mismo obligado (su NIF tiene que ser un emisor de tu cuenta).
  • incidencia: S si se generaron durante una incidencia (sin conexión, corte de corriente…). Se remiten con Incidencia = S en un envío aparte.

Qué comprobamos (sin tocar el registro)

Que cumple el esquema oficial de la AEAT, que es de un emisor de tu cuenta, que no lo generó Factuneo y que no duplica una factura ya existente (para la AEAT, la factura es NIF + serie + fecha, venga del sistema que venga). Si falla, ese registro vuelve con su motivo (XSD_INVALIDO, SISTEMA_PROPIO, FACTURA_DUPLICADA…) y los demás siguen adelante.

Si la huella o el encadenamiento no cuadran, lo remitimos igualmente y te avisamos (HUELLA_NO_CUADRA, CADENA_NO_CUADRA): la AEAT los acepta con error y hay que subsanarlos desde el sistema que los generó. Enviar dos veces el mismo registro no lo duplica.

Cada registro queda como una factura más, con origen: "EXTERNO": su estado, sus webhooks (invoice.accepted, invoice.rejected… con origin) y la consulta a la AEAT funcionan igual. Lo que corresponde al sistema que lo generó (su anulación, su PDF, su Facturae) responde 409 REGISTRO_EXTERNO. Cuentan para el límite de facturas de tu plan.

Lo que tiene que hacer el fabricante del TPV

  • Generar el registro, la huella encadenada y el ticket con QR en el momento de la venta.
  • Poner en los registros su bloque SistemaInformatico (su NIF, su identificador, su versión) y un NumeroInstalacion propio en cada TPV: cada TPV lleva así su propia cadena, separada de la de Factuneo y de la de los demás TPV.
  • Usar una serie propia para lo que emita sin conexión, para no repetir la de la API.
  • Tener su declaración responsable, que debe mencionar la versión de Factuneo que usa para remitir.
  • Enviarnos los registros todos y en orden en cuanto vuelva la conexión.

Webhooks

El envío a la AEAT es asíncrono. En vez de consultar el estado de cada factura, te avisamos con un POST a tu URL cuando cambia.

Configuración

Se configura en la tarjeta Webhook de tu panel. Déjala vacía si prefieres consultar tú con GET /api/v1/invoices/:id.

Eventos

El nombre del evento te dice qué ha pasado: enruta por event y no necesitas inspeccionar el cuerpo para saber a qué handler mandarlo.

EventoCuándo se emite
invoice.acceptedLa AEAT aceptó la factura (con o sin avisos)
invoice.rejectedLa AEAT la rechazó. Mira data.aeat
invoice.errorNo se pudo enviar. El porqué, en data.reason
invoice.cancelledLa AEAT aceptó una anulación
certificate.expiringUn certificado tuyo caduca pronto (30 días)
certificate.expiredUn certificado tuyo ya ha caducado
invoice.face_status_changedCambió el estado de una factura en FACe (registrada, en trámite, pagada, rechazada, anulada) o de su solicitud de anulación

Los de certificate.* solo se emiten en producción: los certificados de sandbox son de prueba y da igual que caduquen. data.certificateType dice cuál: EMISOR, REPRESENTANTE o FACE_PLATAFORMA.

En invoice.face_status_changed, data trae el estado nuevo y el anterior:

{
  "invoiceId": "3f8a...", "invoiceNumber": "FAC-2026-001", "registryCode": "REGAGE26e00000412428",
  "status": { "code": "2500", "name": "Pagada", "comment": null },
  "previousStatus": { "code": "2400", "name": "En trámite", "comment": null },
  "cancellation": { "code": "4100", "name": "No solicitada", "comment": null },
  "previousCancellation": { "code": "4100", "name": "No solicitada", "comment": null }
}

Valores de data.reason en invoice.error:

reasonQué ha pasado
ERROR_EMISOR_SIN_REPRESENTACIONEl emisor está en modo Representado sin representación vigente
ERROR_DESCIFRADO_CERTNo se pudo descifrar su certificado. Vuelve a subirlo
ERROR_LOTE_NO_REINTENTABLELa AEAT rechazó el envío completo del lote
ERROR_RESPUESTA_AEATLa AEAT respondió algo que no se pudo interpretar

Cuerpo que recibes

{
  "id": "evt_3f8a1c22-9d41-4b7e-8a55-1f2e3d4c5b6a",
  "event": "invoice.accepted",
  "environment": "live",
  "tenantId": "9c1b...",
  "timestamp": "2026-07-27T10:15:30.000Z",
  "data": {
    "invoiceId": "3f8a...",
    "invoiceNumber": "FAC-2026-001",
    "recordType": "ALTA",
    "origin": "FACTUNEO",
    "status": "ACEPTADO",
    "acceptedWithWarnings": false,
    "aeat": { "code": null, "message": null }
  }
}

id es el identificador del evento. Es el mismo si reenvías la entrega desde el panel, así que guárdalo y descarta los que ya hayas procesado: es lo que te protege de contabilizar dos veces.

environment distingue live de test. Y no hace falta que confíes solo en ese campo: el secreto de firma es distinto en cada entorno, así que un evento de sandbox no valida contra tu secreto de producción.

En invoice.cancelled, data.invoiceId es la factura original anulada —la que te devolvimos al crearla— y data.cancellationId es el registro de anulación. data.recordType vale ANULACION en los eventos que hablan de una anulación, incluidos los de rechazo o error: si una anulación falla, es la anulación la que falló, no la factura original.

data.origin vale EXTERNO cuando el registro lo generó otro sistema (por ejemplo, tu TPV durante una caída de red) y solo lo remitimos; FACTUNEO en el resto.

En certificate.*, data trae certificateType (EMISOR o REPRESENTANTE), emisorId, nif, expiresAt y daysUntilExpiry, negativo si el certificado ya caducó.

Cabeceras

CabeceraContenido
X-Webhook-SignatureHMAC-SHA256 del cuerpo, en hexadecimal
X-Webhook-IdId del evento (el mismo id del cuerpo)
X-Webhook-DeliveryId de esta entrega concreta. Cambia en cada reenvío
X-Webhook-EventNombre del evento
X-Webhook-Environmentlive o test
X-Webhook-AttemptNº de intento de entrega (1-5)

Las cabeceras están para que puedas enrutar sin parsear el JSON. Lo que va firmado es el cuerpo: si algo importa de verdad, léelo de ahí.

Verificar la firma

Tu secreto está en GET /api/v1/account, campo webhookSecret. Es fijo por cuenta y por entorno: la clave de test te devuelve el secreto de test y la de producción el de producción. Pídelos una vez y guárdalos como variables de entorno.

La firma es el HMAC-SHA256 del cuerpo tal cual llega, en hexadecimal. Compara en tiempo constante para no filtrar información por el tiempo de respuesta:

import crypto from 'crypto';

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const firma = req.headers['x-webhook-signature'];
  const esperada = crypto
    .createHmac('sha256', process.env.FACTUNEO_WEBHOOK_SECRET)
    .update(req.body)          // el cuerpo SIN parsear
    .digest('hex');

  const ok =
    firma?.length === esperada.length &&
    crypto.timingSafeEqual(Buffer.from(firma), Buffer.from(esperada));
  if (!ok) return res.status(401).send('firma inválida');

  const evento = JSON.parse(req.body);
  // Descarta lo ya procesado: un reenvio trae el MISMO evento.id.
  if (yaProcesado(evento.id)) return res.sendStatus(200);
  // Enruta por el nombre del evento: invoice.accepted, invoice.rejected...
  procesar(evento.event, evento.data);
  res.sendStatus(200);   // responde 2xx o reintentaremos
});

Responde con un 2xx lo antes posible: cortamos la conexión a los 10 segundos y eso cuenta como intento fallido. Si fallas, reintentamos hasta 5 veces con espera creciente. Si tu lógica es lenta, encola y responde ya.

SDKs y ejemplos de integración

Ofrecemos ejemplos oficiales en los lenguajes más populares. Aunque aún no hay SDKs empaquetados, los ejemplos de abajo son production-ready y cubren autenticación, creación de facturas, listado y verificación de webhooks.

¿Prefieres una guía paso a paso para tu stack? En el blog tienes integraciones completas para PHP/Laravel, Python/Django, Node.js, Odoo y .NET/C#.

Crear una factura VeriFactu

Calculamos la huella SHA-256 encadenada, generamos el QR y validamos el XML contra el esquema oficial. La respuesta es inmediata; el envío a la AEAT va en segundo plano.

curl -X POST https://factuneo.es/api/v1/invoices \
  -H "Content-Type: application/json" \
  -H "x-api-key: vsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -d '{
    "numeroSerie": "FAC-2026-001",
    "fechaExpedicion": "2026-06-08",
    "baseImponible": 100.00,
    "cuotaIva": 21.00,
    "importeTotal": 121.00,
    "tipoFactura": "F1",
    "nombreRazonEmisor": "Empresa Test SL",
    "descripcionOperacion": "Venta de productos",
    "destinatarios": [
      { "nombreRazon": "Cliente Ejemplo SL", "nif": "B87654321" }
    ],
    "desglose": [{
      "claveRegimen": "01",
      "calificacionOperacion": "S1",
      "tipoImpositivo": 21,
      "baseImponible": 100.00,
      "cuotaRepercutida": 21.00
    }]
  }'

Listar facturas con filtros

Obtén un listado paginado de facturas. Puedes filtrar por tipo, fecha, estado y más.

curl -X GET "https://factuneo.es/api/v1/invoices?page=1&limit=20&tipoFactura=F1" \
  -H "x-api-key: vsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

Verificar webhooks (HMAC-SHA256)

Implementa la verificación de firma en tu endpoint de webhooks para garantizar autenticidad e integridad.

# El webhook llega como POST a tu URL configurada
# Headers:
#   X-Webhook-Signature: <hex>   (sin prefijo)
#   X-Webhook-Event: invoice.accepted
#   X-Webhook-Attempt: 1

Manejo de errores

La API utiliza códigos HTTP estándar y devuelve respuestas JSON estructuradas.

Códigos HTTP

CódigoSignificado
200OK
201Creado
400Bad Request - Datos inválidos o schema incorrecto
401Unauthorized - API Key inválida o ausente
402Payment Required - Tope mensual de facturas por NIF alcanzado
403Forbidden - Sin permisos para este recurso
404Not Found - Recurso no existe
409Conflict - Conflicto (ej: serie duplicada, NIF exists)
413Payload Too Large - Cuerpo mayor que el permitido (1 MB; 4 MB en la subida de logos)
415Unsupported Media Type - Envía Content-Type: application/json
422Unprocessable Entity - Errores de validación fiscal
429Too Many Requests - Rate limit excedido o cuota de API agotada
500Internal Server Error - Error interno
502Bad Gateway - La AEAT no respondió (consulta)

Códigos de error más habituales

El campo error identifica la causa de forma estable (el message puede cambiar).

CódigoHTTPQué ha pasado
NO_EMISOR400La cuenta no tiene ningún emisor. Da uno de alta con POST /api/v1/emisores
EMISOR_REQUIRED400Tienes varios emisores: indica emisorNif en la petición
EMISOR_NOT_FOUND404No existe un emisor activo con ese NIF en tu cuenta
EMISOR_DUPLICADO409Ya existe un emisor con ese NIF en la cuenta
NIF_EN_OTRA_CUENTA409Ese NIF ya emite en live desde otra cuenta
FACTURA_DUPLICADA409Ya existe esa serie y fecha para el emisor, la generase la API u otro sistema (corrige con subsanacion: "S")
REGISTRO_EXTERNO409El registro lo generó otro sistema y solo lo remitimos: su anulación, su PDF o su Facturae le corresponden a ese sistema
VARIOS_OBLIGADOS400En POST /api/v1/registros-externos, registros de más de un obligado en la misma llamada
EMISOR_CERTIFICATE_NOT_CONFIGURED400El emisor está en modo PROPIO y aún no tiene certificado
ACCOUNT_REPRESENTATIVE_CERT_NOT_CONFIGURED400Modo REPRESENTADO sin certificado de representante en la cuenta
EMISOR_REPRESENTATION_NOT_VALID403El emisor no tiene representación vigente
CERTIFICATE_NIF_MISMATCH400El NIF del certificado no coincide con el del emisor
CERTIFICATE_EXPIRED / CERTIFICATE_INVALID400Certificado caducado o ilegible (¿contraseña correcta?)
FISCAL_SEMANTIC_ERROR422Incumple reglas fiscales de la AEAT (ver violations)
FACTURAE_MOTIVO_RECTIFICACION_REQUERIDO400Facturae de una rectificativa sin rectificativa.motivo
BUYER_ADDRESS_REQUIRED400Facturae sin domicilio del comprador (ni en la petición ni guardado en la factura)
SELLER_NAME_REQUIRED400Emisor persona física sin nombre y apellidos para el Facturae
FACE_YA_ENVIADA409La factura ya está presentada en FACe
FACE_RECHAZADA422FACe no ha admitido la factura (ver detalle)
FACE_PLATAFORMA_NO_REGISTRADA422FACe no reconoce el certificado del envío: da de alta tu plataforma con él
FACE_PLATAFORMA_NO_CONFIGURADA422No hay certificado con el que presentar en FACe
XSD_VALIDATION_FAILED400El XML no valida contra el esquema oficial
SELLER_ADDRESS_NOT_CONFIGURED400Falta el domicilio fiscal del emisor (obligatorio para Facturae)
INVOICE_NOT_FOUND404La factura no existe o no es de tu cuenta
INVOICE_NOT_REGISTERED_YET409Aún no la ha aceptado la AEAT: espera para anularla
INVOICE_ALREADY_CANCELLED409Ya existe un registro de anulación activo
INVOICE_CAP_REACHED402Sin suscripción de pago, alcanzado el tope de 500 facturas/NIF/mes. Con suscripción no se corta: el exceso se cobra (1,80 € por NIF y mes)
QUOTA_EXCEEDED429Agotadas las 10 llamadas del plan de prueba
EMAIL_NOT_VERIFIED403Verifica tu correo para activar las llamadas de prueba
EMISOR_QUOTA_EXCEEDED403El plan de prueba admite 1 NIF en live; suscríbete para más
DISPOSABLE_EMAIL400Correo de un servicio temporal: usa una dirección permanente
RATE_LIMIT_EXCEEDED429Más peticiones por minuto de las de tu cuenta (100 + 60 por NIF activo)
LOCK_TIMEOUT409Otra factura del mismo emisor se estaba procesando: reintenta
AEAT_CONSULTA_ERROR502La AEAT no respondió a la consulta
UNAUTHORIZED401Falta el header x-api-key o no es válido

Formato de error

Todos los errores traen error (el código estable, el que debes comprobar) y message (texto para personas, puede cambiar). Según el caso añaden más campos: violations en 422 FISCAL_SEMANTIC_ERROR, limit en 402 INVOICE_CAP_REACHED, details o detalle en otros. Algunos traen también statusCode, pero no todos: usa el código HTTP de la respuesta.

{
  "error": "FISCAL_SEMANTIC_ERROR",
  "message": "Se han detectado violaciones de las reglas semánticas de la AEAT. Corrija los errores antes de reintentar.",
  "violations": [
    {
      "code": "FISCAL_004",
      "field": "baseImponible",
      "message": "La factura de tipo F1 debe tener una base imponible mayor que cero. Valor actual: 0.00 €",
      "severity": "ERROR"
    }
  ]
}
{
  "error": "INVOICE_CAP_REACHED",
  "message": "Límite de 500 facturas este mes alcanzado para el NIF B12345674. Contrata un plan para seguir emitiendo.",
  "limit": 500
}

Límites

Son dos límites distintos:

LímiteValorAlcance
Cuota del plan de prueba10 llamadasTotal acumulado en live, no se renueva cada mes (test sin tope)
Peticiones por minuto100 + 60 por NIF activoPor cuenta y entorno (test y live por separado), según los emisores activos de ese entorno: con 50 restaurantes, 3.100 al minuto. Sin autenticar, 100 por IP

Al superar tu ritmo por minuto recibes un 429 RATE_LIMIT_EXCEEDED indicando en cuántos segundos reintentar. Al agotar las 10 llamadas de prueba recibes un 429 QUOTA_EXCEEDED: se soluciona suscribiéndote, que quita la cuota (el límite por minuto se mantiene).

Las cabeceras X-RateLimit-Limit, X-RateLimit-Remaining y X-RateLimit-Reset indican tu estado actual.

¿Ves algo incorrecto? Cuéntanos