Integraciones · Holded

Cómo dar acceso a tus clientes a sus facturas de Holded

Un botón en tu aplicación que lleva a cada usuario a su portal de Holded, con todas sus facturas. Son dos llamadas a la API y unas veinte líneas.

El problema

Facturas con Holded y tus clientes quieren descargarse las suyas: al cerrar el trimestre, cuando se lo pide su gestor, o simplemente porque perdieron el correo. Holded tiene un portal de cliente para eso, pero el enlace se genera por contacto y no hay una dirección fija que puedas publicar para todos.

Si tu aplicación tiene área privada, esto se resuelve en una tarde. La parte difícil —saber quién es quien pregunta— ya la tienes resuelta, porque esa persona ha iniciado sesión contigo. Lo demás son dos peticiones HTTP.

Lo que vas a montar

[ Mis facturas ]  →  tu servidor pide el enlace  →  rediriges al usuario

El usuario pulsa y aterriza en su portal. No escribe nada, no recuerda ninguna contraseña y no busca ningún correo.

Antes de empezar

Necesitas un token de Holded con el permiso contacts:contacts.read. Se genera en Holded, en Configuración → Desarrolladores.

Pide solo ese permiso. Si el token únicamente va a servir para esto, no tiene por qué poder tocar tu facturación.

El token nunca puede llegar al navegador.Quien lo tenga puede leer y escribir en toda tu contabilidad. Guárdalo como variable de entorno y haz todas las llamadas desde tu servidor. Ni una desde el cliente.

Los dos pasos

Autenticación en ambos con la cabecera Authorization: Bearer <TOKEN>, sobre https://api.holded.com/api/v2.

Pedir su enlace al portal

GET /contacts/{contactId}/portal-link devuelve la dirección y su caducidad. Rediriges ahí y ya está.

{
  "url": "https://app.holded.com/share/portal/ZA7tKC",
  "expires_at": "2026-08-05T20:59:18+00:00"
}

La regla que decide todo el diseño

No guardes esa URL en ningún sitio.Ni en la ficha del usuario, ni en una base de datos, ni en una plantilla de correo.

Fíjate en el expires_at de la respuesta: el enlace caduca. En nuestras pruebas salió a unas 48 horas, pero no lo trates como una constante — Holded documenta que los contactos con contraseña no tienen restricción temporal, así que el valor puede variar. El campo está ahí precisamente para no tener que suponerlo.

Una URL guardada en el perfil de alguien estará muerta la próxima vez que entre, y el fallo aparecerá semanas después sin que nadie sepa por qué. Pídela en el momento del clic: así la caducidad deja de importar, sea cual sea.

El código

const BASE = 'https://api.holded.com/api/v2';
const headers = {
  authorization: `Bearer ${process.env.HOLDED_API_KEY}`,
  accept: 'application/json',
};

/** Devuelve null cuando a ese cliente todavía no se le ha facturado nada. */
async function portalLinkForEmail(email) {
  const normalized = email.trim().toLowerCase();

  const search = await fetch(
    `${BASE}/contacts?email=${encodeURIComponent(normalized)}`,
    { headers },
  );
  if (!search.ok) throw new Error(`Holded respondió ${search.status}`);

  const { items = [] } = await search.json();

  // Confirmamos la coincidencia: el filtro del servidor no basta.
  const contact = items.find((c) => c.email?.toLowerCase() === normalized);
  if (!contact) return null;

  const link = await fetch(`${BASE}/contacts/${contact.id}/portal-link`, { headers });
  if (!link.ok) throw new Error(`Holded respondió ${link.status}`);

  const { url } = await link.json();
  return url ?? null;
}

Y el endpoint al que apunta el botón:

// GET /mis-facturas — detrás de tu login, como cualquier página privada.
const url = await portalLinkForEmail(usuarioAutenticado.email);

if (!url) {
  // No es un error: simplemente todavía no tiene facturas.
  return respuestaConMensaje('Todavía no tienes facturas.');
}

return redirigir(url);
El correo tiene que salir de la sesión, nunca de la URL.Si aceptas un ?email=, cualquiera escribe el de otra persona y ve su facturación entera.

Dos trampas

No te fíes del primer resultado. Comprueba tú que el correo coincide, como hace el ejemplo. Abrir el portal de quien no es no tiene arreglo posible.

«No hay contacto» no es un fallo. Significa que a esa persona aún no se le ha facturado nada. Díselo con naturalidad en lugar de enseñarle un error.

Qué hacer cuando algo falla

  • El contacto no existe: mensaje amable, aún no tiene facturas.
  • Holded responde 401 o 403: token caducado o sin permisos. Avísate a ti; al usuario, un mensaje genérico.
  • Holded no responde: no reintentes, hay una persona esperando. Mensaje y a otra cosa.

Lo que la API no permite

Comprobado en la referencia oficial, para que nadie pierda la tarde buscándolo:

  • No hay endpoint para crear contraseñas de contacto. El acceso permanente al portal existe, pero es el cliente quien se pone la contraseña desde dentro.
  • No hay endpoint de cuenta ni de empresa, así que la dirección del portal —https://xxxxxxxx.holded.com/portal— no se puede averiguar por API. Se copia a mano una vez.

Si tus facturas las genera Factulink

Esto es un extra, no un requisito.Con Factulink, cada cobro ya crea el contacto en Holded y le envía la factura al cliente con su PDF y su enlace al portal. Este botón es para quien además quiera entrar cuando le apetezca, sin buscar ningún correo.

Los contactos se buscan primero por NIF y luego por correo antes de crear uno nuevo. Por eso el portal enseña el histórico completo de cada cliente en vez de una factura suelta por pago: siempre cae en el mismo contacto.

La consecuencia práctica es que un cobro sin correo y sin NIF no tendrá portal, porque no hay forma de saber de quién es. Si tu pasarela permite pagar sin dejar correo, ahí tienes el hueco. Ver cómo funciona Factulink.

¿Y las facturas, quién las crea?

Factulink convierte cada cobro de Stripe en su factura en Holded, con el IVA que corresponde y el cliente identificado.

Ver cómo funciona