Encontrar el contacto por su correo
GET /[email protected] devuelve { "items": [ … ] }.
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.
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.
[ Mis facturas ] → tu servidor pide el enlace → rediriges al usuarioEl usuario pulsa y aterriza en su portal. No escribe nada, no recuerda ninguna contraseña y no busca ningún correo.
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.
Autenticación en ambos con la cabecera Authorization: Bearer <TOKEN>, sobre https://api.holded.com/api/v2.
GET /[email protected] devuelve { "items": [ … ] }.
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"
}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.
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);?email=, cualquiera escribe el de otra persona y ve su facturación entera.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.
Comprobado en la referencia oficial, para que nadie pierda la tarde buscándolo:
https://xxxxxxxx.holded.com/portal— no se puede averiguar por API. Se copia a mano una vez.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.
Factulink convierte cada cobro de Stripe en su factura en Holded, con el IVA que corresponde y el cliente identificado.