Saltar al contenido principal
Documentación de desarrollo

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.

Solo lecturaREST + cursorWebhooks firmadosMCP
01

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
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.

PermisoDa acceso a
read:invoicesFacturas emitidas
read:expensesGastos y facturas recibidas
read:reconciliationsConciliaciones y sus albaranes
read:catalogProveedores y clientes

Errores de autenticación

HTTPcodeCuándo
401missing_api_keyNo mandas cabecera Authorization, o mandas «Bearer» sin clave detrás
401invalid_api_keyLa cabecera no tiene la forma esperada, la clave está mal formada, o no existe
401revoked_api_keyLa clave fue revocada
401expired_api_keyLa clave tenía caducidad y ya pasó
403insufficient_scopeLa clave es válida pero no tiene el permiso que pide ese endpoint
429rate_limitedDemasiadas peticiones con la misma clave
Una clave inexistente y una clave con el secreto equivocado devuelven exactamente la misma respuesta, a propósito: así el endpoint no sirve para averiguar qué claves existen.
02

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.

Un objeto
{ "data": { "id": "…", "reference": "FAC-2026-00042", … } }
Una lista
{ "data": [ … ], "next_cursor": "eyJ…" }

next_cursor siempre está presente en las listas, y vale null cuando ya no hay más páginas.

Un error
{ "error": { "code": "not_found", "message": "Invoice not found." } }
HTTPcodeCuándo
400invalid_requestUn parámetro no es válido. Nunca lo ignoramos en silencio
404not_foundNo existe, o pertenece a otra empresa. La respuesta es idéntica en los dos casos
500internal_errorFallo nuestro. El detalle queda en nuestros registros, no en la respuesta
03

Paginación y filtros

ParámetroPor defectoReglas
limit50Entero entre 1 y 200
cursorEl next_cursor de la respuesta anterior, tal cual. Es opaco: no lo interpretes ni lo construyas
sinceYYYY-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.

Recorrer todas las páginas
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:

Recursosince filtra por
invoicesissue_date
expensesissue_date
reconciliacionesperiod_start
suppliers, clientscreated_at
Cuidado con los gastos sin fecha. 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.

04

Endpoints

EndpointPermiso
GET /api/v1/invoicesread:invoices
GET /api/v1/invoices/{id}read:invoices
GET /api/v1/expensesread:expenses
GET /api/v1/expenses/{id}read:expenses
GET /api/v1/reconciliacionesread:reconciliations
GET /api/v1/reconciliaciones/{id}read:reconciliations
GET /api/v1/suppliersread:catalog
GET /api/v1/clientsread:catalog
No hay más. En concreto: no existe /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.
GET /api/v1/reconciliaciones/{id}
{
  "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.

05

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

CampoTipoNota
iduuidEstable desde que se crea
referencestringEl número de factura. Es la clave que guardas en tu sistema
invoice_numberstring
seriesstring | null
issue_datedate
due_datedate | null
statusstringdraft, sent, paid, overdue, cancelled
subtotal, total_iva, total_irpf, totalnumber
currencystring
sucursal_iduuid | nullSede emisora
aeat_submission_statusstring | nullnot_submitted, pending, submitted, rejected, anulada
clientobjeto | nullid, name, cif_nif
created_at, updated_attimestampUsa 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.

En un gasto, 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.

06

Webhooks

Damos de alta la URL en Ajustes, marcas los eventos que te interesan y te mandamos un POST firmado cuando ocurren. Solo https.

EventoCuándo se dispara
invoice.createdAl emitir una factura. Los borradores no se envían; si un borrador pasa a emitido, se envía en ese momento
invoice.aeat_acceptedCuando la AEAT acepta el registro de esa factura
expense.createdAl crearse un gasto, incluidos los que entran por OCR pendientes de revisar
reconciliation.closedAl cerrar una conciliación. Una discrepancia no dispara evento
Cuerpo del envío
{
  "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

CabeceraContenido
X-WTF-Signaturet=<unix>,v1=<hex>
X-WTF-EventEl nombre del evento
X-WTF-DeliveryEl id de la entrega
User-AgentWTF-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.

Node.js
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.

EstadoSignifica
pendingEn cola o esperando su próximo reintento
succeededTu servidor respondió 2xx
failedDefinitivo sin reintentos: URL rechazada, o respondiste 410
exhaustedSe agotaron los 5 intentos
Tras 15 fallos seguidos desactivamos el endpoint y dejamos de enviarle. Lo verás marcado como desactivado en Ajustes, con el motivo, y se reactiva desde ahí. No lo apagamos en silencio.

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.

07

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.

Claude Code / Claude Desktop
{
  "mcpServers": {
    "whatthefactura": {
      "type": "http",
      "url": "https://www.whatthefactura.es/api/mcp",
      "headers": { "Authorization": "Bearer wtf_live_tu_clave_aqui" }
    }
  }
}
Comprobar desde la terminal
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"}'
HerramientaPermisoPara qué
list_invoicesread:invoicesFacturas emitidas, con filtros de fecha y estado
get_invoiceread:invoicesUna factura completa con sus líneas, por referencia o por id
list_reconciliationsread:reconciliationsConciliaciones, filtrables por estado y proveedor
get_reconciliationread:reconciliationsUna conciliación con los albaranes que la componen
list_suppliersread:catalogProveedores, 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.

Dos detalles que ahorran depuración. Un permiso que falta llega como error JSON-RPC con código -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.
08

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.

09

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.

¿Dudas? Pregunta a Facturito