API y MCP
Lee las facturas, los gastos y las conciliaciones de tu cuenta desde tu ERP, tu CRM o tus propios scripts. Recibe un aviso firmado cuando algo cambia. Y conecta un asistente de IA por MCP contra los mismos datos, con la misma clave.
Autenticación
Toda la API usa una clave de tipo bearer. Se crea en Ajustes → API y webhooks, y se muestra una sola vez: de la clave solo guardamos un hash, así que si se pierde hay que crear otra. Cada clave pertenece a una empresa y no puede ver datos de ninguna otra.
curl "https://www.whatthefactura.es/api/v1/invoices?limit=5" \
-H "Authorization: Bearer wtf_live_tu_clave_aqui"La clave nunca viaja en la URL: acabaría en los registros de cualquier proxy por el que pase. Usa una clave por integración, así puedes revocar una sin cortar las demás. Revocar tiene efecto inmediato.
Permisos
Cada clave lleva los permisos que le marques. Son todos de lectura; no existe ningún permiso de escritura porque la API no escribe.
| Permiso | Da acceso a |
|---|---|
read:invoices | Facturas emitidas |
read:expenses | Gastos y facturas recibidas |
read:reconciliations | Conciliaciones y sus albaranes |
read:catalog | Proveedores y clientes |
Errores de autenticación
| HTTP | code | Cuándo |
|---|---|---|
| 401 | missing_api_key | No mandas cabecera Authorization, o mandas «Bearer» sin clave detrás |
| 401 | invalid_api_key | La cabecera no tiene la forma esperada, la clave está mal formada, o no existe |
| 401 | revoked_api_key | La clave fue revocada |
| 401 | expired_api_key | La clave tenía caducidad y ya pasó |
| 403 | insufficient_scope | La clave es válida pero no tiene el permiso que pide ese endpoint |
| 429 | rate_limited | Demasiadas peticiones con la misma clave |
Formato de respuesta
URL base: https://www.whatthefactura.es/api/v1. Todo es JSON, todo es GET, y todas las respuestas llevan Cache-Control: no-store.
{ "data": { "id": "…", "reference": "FAC-2026-00042", … } }{ "data": [ … ], "next_cursor": "eyJ…" }next_cursor siempre está presente en las listas, y vale null cuando ya no hay más páginas.
{ "error": { "code": "not_found", "message": "Invoice not found." } }| HTTP | code | Cuándo |
|---|---|---|
| 400 | invalid_request | Un parámetro no es válido. Nunca lo ignoramos en silencio |
| 404 | not_found | No existe, o pertenece a otra empresa. La respuesta es idéntica en los dos casos |
| 500 | internal_error | Fallo nuestro. El detalle queda en nuestros registros, no en la respuesta |
Paginación y filtros
| Parámetro | Por defecto | Reglas |
|---|---|---|
limit | 50 | Entero entre 1 y 200 |
cursor | — | El next_cursor de la respuesta anterior, tal cual. Es opaco: no lo interpretes ni lo construyas |
since | — | YYYY-MM-DD o una marca de tiempo ISO |
El orden es siempre por fecha de creación descendente, y la paginación es por cursor, no por offset: si se crean registros mientras recorres las páginas, no se te duplican ni se te saltan.
let cursor = null;
do {
const url = new URL("https://www.whatthefactura.es/api/v1/invoices");
url.searchParams.set("limit", "200");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { data, next_cursor } = await res.json();
for (const invoice of data) process(invoice);
cursor = next_cursor;
} while (cursor);Sobre qué fecha filtra since
No es la misma columna en todos los recursos, porque no significan lo mismo:
| Recurso | since filtra por |
|---|---|
| invoices | issue_date |
| expenses | issue_date |
| reconciliaciones | period_start |
| suppliers, clients | created_at |
expenses.issue_date admite nulos (un gasto recién capturado por OCR puede no tenerla todavía), y una comparación con since no incluye los nulos. Si necesitas absolutamente todos los gastos, recorre sin since y filtra tú.Filtros adicionales
Solo hay uno: status en /invoices, con valores draft, sent, paid, overdue y cancelled. Cualquier otro valor da un 400 en vez de ignorarse.
Endpoints
| Endpoint | Permiso |
|---|---|
GET /api/v1/invoices | read:invoices |
GET /api/v1/invoices/{id} | read:invoices |
GET /api/v1/expenses | read:expenses |
GET /api/v1/expenses/{id} | read:expenses |
GET /api/v1/reconciliaciones | read:reconciliations |
GET /api/v1/reconciliaciones/{id} | read:reconciliations |
GET /api/v1/suppliers | read:catalog |
GET /api/v1/clients | read:catalog |
/api/v1/albaranes (los albaranes salen dentro del detalle de una conciliación), ni detalle individual de proveedor o cliente, ni ningún método que no sea GET. Un id mal formado y un id inexistente devuelven el mismo 404.{
"data": {
"id": "8f2c…",
"period_start": "2026-08-01",
"period_end": "2026-08-31",
"status": "discrepancy",
"supplier_id": "1a9e…",
"supplier": { "id": "1a9e…", "name": "Distribuciones Ibiza SL", "cif_nif": "B07…" },
"expected_total": 4210.55,
"computed_total": 4188.20,
"variance": -22.35,
"invoice_expense_id": null,
"closed_at": null,
"created_at": "2026-09-01T08:12:44.120Z",
"updated_at": "2026-09-02T10:03:01.880Z",
"albaranes": [
{
"id": "c31d…",
"albaran_number": "ALB-40921",
"supplier_id": "1a9e…",
"sucursal_id": null,
"issue_date": "2026-08-04",
"received_date": "2026-08-05",
"subtotal": 180.00,
"tax_total": 18.00,
"total": 198.00,
"currency": "EUR",
"status": "matched",
"source": "ocr",
"created_at": "…",
"updated_at": "…"
}
]
}
}albaranes solo aparece en el detalle: en la lista el campo no viene. Y ojo con el nombre del impuesto en un albarán, que es tax_total y no total_iva.
Objetos
Los importes son números, nunca cadenas. Las fechas simples van como YYYY-MM-DD y las marcas de tiempo en ISO 8601.
Factura
| Campo | Tipo | Nota |
|---|---|---|
id | uuid | Estable desde que se crea |
reference | string | El número de factura. Es la clave que guardas en tu sistema |
invoice_number | string | |
series | string | null | |
issue_date | date | |
due_date | date | null | |
status | string | draft, sent, paid, overdue, cancelled |
subtotal, total_iva, total_irpf, total | number | |
currency | string | |
sucursal_id | uuid | null | Sede emisora |
aeat_submission_status | string | null | not_submitted, pending, submitted, rejected, anulada |
client | objeto | null | id, name, cif_nif |
created_at, updated_at | timestamp | Usa updated_at para sincronizar incrementalmente |
Gasto
id, reference, expense_number, original_invoice_number, status, category, issue_date, due_date, subtotal, total_iva, total_irpf, total, currency, sucursal_id, source, paid_at, supplier, created_at, updated_at.
reference puede ser null: es el número que imprimió el proveedor en su factura y un gasto recién capturado puede no tenerlo todavía. Para cruzar registros usa id, que siempre existe.Proveedor y cliente
Tienen exactamente la misma forma: id, name, cif_nif, email, phone, address (con line1, line2, city, postal_code, province, country), created_at y updated_at.
Webhooks
Damos de alta la URL en Ajustes, marcas los eventos que te interesan y te mandamos un POST firmado cuando ocurren. Solo https.
| Evento | Cuándo se dispara |
|---|---|
invoice.created | Al emitir una factura. Los borradores no se envían; si un borrador pasa a emitido, se envía en ese momento |
invoice.aeat_accepted | Cuando la AEAT acepta el registro de esa factura |
expense.created | Al crearse un gasto, incluidos los que entran por OCR pendientes de revisar |
reconciliation.closed | Al cerrar una conciliación. Una discrepancia no dispara evento |
{
"id": "3f0b…", // id de la entrega: úsalo como clave de idempotencia
"type": "invoice.created",
"created_at": "2026-09-10T07:15:22.008Z",
"organization_id": "7f28…",
"data": { "reference": "FAC-2026-00042", "id": "…", "total": 1210, … }
}id es el mismo en todos los reintentos de una misma entrega, así que es lo que debes usar para no procesar dos veces lo mismo. created_at, en cambio, es la hora del intento, y cambia en cada reintento.Cabeceras
| Cabecera | Contenido |
|---|---|
X-WTF-Signature | t=<unix>,v1=<hex> |
X-WTF-Event | El nombre del evento |
X-WTF-Delivery | El id de la entrega |
User-Agent | WTF-Webhooks/1 |
Verificar la firma
Firmamos con HMAC-SHA256 la cadena `${t}.${cuerpo_sin_tocar}`, usando el secreto de ese endpoint. Verifica sobre el cuerpo tal y como llega, antes de parsearlo: si lo parseas y lo vuelves a serializar, la firma ya no cuadra.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=").map((s) => s.trim())),
);
const t = Number(parts.t);
if (!Number.isFinite(t)) return false;
// Rechaza envíos viejos: es lo que impide reenviar un aviso capturado antes.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1 ?? "", "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}Compara en tiempo constante, como en el ejemplo. Un === de cadenas se corta en el primer byte distinto y filtra cuántos caracteres acertaste.
Reintentos
Consideramos entregado cualquier 2xx. Si no, reintentamos hasta 5 veces con esperas de 1 minuto, 5, 30 y 2 horas, lo que cubre unas 2 horas y media de caída tuya. Las esperas se redondean al siguiente ciclo de 5 minutos del planificador. Cada intento corta a los 10 segundos.
| Estado | Significa |
|---|---|
pending | En cola o esperando su próximo reintento |
succeeded | Tu servidor respondió 2xx |
failed | Definitivo sin reintentos: URL rechazada, o respondiste 410 |
exhausted | Se agotaron los 5 intentos |
No seguimos redirecciones: apunta el endpoint a su URL final. Y rechazamos direcciones internas (localhost, rangos privados, enlace local) comprobando la IP a la que resuelve el nombre, no el texto de la URL.
Servidor MCP
El mismo contenido, en el protocolo que hablan los asistentes de IA. JSON-RPC 2.0 sobre POST, sin sesión y sin SSE, con la misma clave que la API REST.
{
"mcpServers": {
"whatthefactura": {
"type": "http",
"url": "https://www.whatthefactura.es/api/mcp",
"headers": { "Authorization": "Bearer wtf_live_tu_clave_aqui" }
}
}
}curl -sS https://www.whatthefactura.es/api/mcp \
-H "Authorization: Bearer wtf_live_tu_clave_aqui" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'| Herramienta | Permiso | Para qué |
|---|---|---|
list_invoices | read:invoices | Facturas emitidas, con filtros de fecha y estado |
get_invoice | read:invoices | Una factura completa con sus líneas, por referencia o por id |
list_reconciliations | read:reconciliations | Conciliaciones, filtrables por estado y proveedor |
get_reconciliation | read:reconciliations | Una conciliación con los albaranes que la componen |
list_suppliers | read:catalog | Proveedores, con búsqueda por nombre |
Los métodos implementados son initialize, ping, tools/list y tools/call. Los gastos no están expuestos por MCP, aunque el permiso read:expenses exista para la API REST.
-32003 y te dice cuál necesita. Y «no encontrado» no es un error: es un resultado correcto cuyo texto es {"error":"not_found"}, así que si solo miras isError lo darás por bueno.Estabilidad de las referencias
reference es el número de factura, y es idéntico en la API, en los webhooks y en el MCP. Es la clave pensada para que la guardes junto a tu apunte.
Conviene ser preciso sobre hasta dónde llega la garantía. Desde que la factura se emite y se presenta a la AEAT, el sistema impide cambiar su número o su serie e impide borrarla: es una exigencia legal de inalterabilidad, y una corrección obliga a emitir una rectificativa aparte. Antes de eso, mientras es un borrador, el número todavía se puede editar y la factura se puede borrar. Por eso los borradores no se envían por webhook.
Consecuencia práctica: id es estable desde que el registro nace y es lo que debes usar para cruzar filas. reference es lo que enseñas a una persona y lo que cuadra contra Hacienda. Guarda los dos.
Límites
Hay un límite de 120 peticiones por minuto y por clave. Es protección frente a un bucle o un reintento desbocado, no una cuota facturable, y no está garantizado al alza: si te acercas a ese número, pagina con limit=200 y usa since en vez de recorrerlo todo cada vez.
La API es de solo lectura por diseño. Si necesitas escribir en WhatTheFactura desde tu sistema, escríbenos a hola@whatthefactura.com y lo hablamos: preferimos entender el caso antes que abrir una superficie de escritura genérica.