Empezar
- URL base:
https://app.broslunas.link/api/v1 - Formato: JSON en peticiones y respuestas (UTF-8). Envía
Content-Type: application/json. - Alcance: cada token da acceso a una sola página y solo a sus enlaces. No hay endpoints de perfil, tema, estadísticas ni dominio.
- Sin CORS: está pensada para servidores. No uses un token en código que corra en un navegador.
1. Entra en el panel → API y crea un token. 2. Pruébalo:
curl https://app.broslunas.link/api/v1/links \
-H "Authorization: Bearer $TOKEN"Autenticación
Crea tokens en el panel (Dashboard → API). Elige un nombre y un permiso:
| Permiso | Puede |
|---|---|
read | Solo lectura: GET |
write | Leer y modificar: GET, POST, PATCH, PUT, DELETE |
Envía el token en cada petición:
Authorization: Bearer bl_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx- El token (
bl_+ 43 caracteres) se muestra una sola vez. Solo guardamos su hash SHA-256; si lo pierdes, revócalo y crea otro. - Máximo 5 tokens por página. Se eliminan al borrar o transferir la página, y dejan de valer si la página se suspende.
- Guárdalo en variables de entorno, usa un token por integración y con el permiso mínimo. Si se filtra, revócalo: el efecto es inmediato.
- Sin cabecera, mal formada, token desconocido o revocado →
401conWWW-Authenticate: Bearer.
Límites
| Límite | Valor | Al superarlo |
|---|---|---|
| Peticiones por token | 60 por minuto | 429 + Retry-After: 60 |
| Peticiones con token no válido, por IP | 30 por minuto | 429 |
| Enlaces por página | 30 | 422 «Máximo 30 enlaces» |
| Tokens por página | 5 | Se rechaza en el panel |
Errores
Todos los errores tienen la misma forma:
{ "error": { "code": "invalid_link", "message": "URL no válida" } }| HTTP | code | Cuándo |
|---|---|---|
| 400 | invalid_body | El cuerpo no es JSON o no es un objeto |
| 401 | unauthorized | Token ausente, inválido o revocado; página suspendida |
| 403 | forbidden | Token de solo lectura en una operación de escritura |
| 404 | not_found | El enlace no existe en tu página (los ids de otras páginas también dan 404) |
| 422 | invalid_link | Validación: campo incorrecto, URL no admitida, máximo de enlaces… |
| 422 | invalid_order | Lista de ids de la reordenación incompleta, repetida o con ids ajenos |
| 429 | rate_limited | Límite de peticiones |
| 500 | internal | Error inesperado |
message está en español y se puede mostrar al usuario. Para la lógica usa code y el estado HTTP.
El objeto enlace
{
"id": "6720f1c2a9b3c4d5e6f70812",
"kind": "link",
"label": "Mi portfolio",
"url": "https://ejemplo.com",
"icon": "web",
"description": "Trabajos recientes",
"imageUrl": null,
"badge": "Nuevo",
"featured": false,
"social": false,
"sensitive": false,
"enabled": true,
"startAt": null,
"endAt": null,
"position": 0
}| Campo | Tipo | Notas |
|---|---|---|
id | string | Identificador de 24 caracteres hexadecimales |
kind | link · header · embed · feed | Se fija al crear y no se puede cambiar |
label | string | 1–80 caracteres. Obligatorio al crear |
url | string | Hasta 2000 caracteres. Vacío en los títulos de sección (header) |
icon | string | null | Ver iconos más abajo |
description | string | null | Hasta 120 caracteres |
imageUrl | string | null | https y de una imagen subida a tu propia página |
badge | string | null | Hasta 16 caracteres |
featured | boolean | Destacado. Solo kind link |
social | boolean | Se muestra como icono de red social. Solo link |
sensitive | boolean | Pide confirmación +18 antes de abrir. Solo link |
enabled | boolean | false lo oculta de la página pública |
startAt, endAt | ISO 8601 | null | Programan la visibilidad, con zona horaria (p. ej. 2026-12-01T09:00:00+01:00). endAt debe ser posterior a startAt |
position | number | Orden ascendente. Lo gestionan crear y reordenar |
Qué admite url según kind
| kind | url |
|---|---|
link | Obligatoria. Solo http, https, mailto y tel. Cualquier otro esquema se rechaza. |
header | Se ignora: es un título de sección. |
embed | Obligatoria. YouTube, Spotify, Apple Music, SoundCloud, Deezer, TIDAL, Audiomack, Mixcloud o un podcast. |
feed | Obligatoria. Dirección http o https de un feed. |
Los campos que no aplican a un tipo (por ejemplo featured en un embed) se descartan sin error.
Iconos e imágenes
- Catálogo:
web,whatsapp,instagram,tiktok,youtube,x,facebook,linkedin,github,email,phone,telegram,signal,discord,threads,bluesky,mastodon,twitch,pinterest,snapchat. - Lucide:
lucide:+ nombre en kebab-case, p. ej.lucide:map-pin. - Propio:
img:https://…, solo si es un icono que ya subiste a esta página desde el panel. imageUrlsolo acepta imágenes alojadas en el almacenamiento de tu página (las que genera el panel al subirlas). Una URL externa o de otra página da422«Imagen no válida». La API no sube archivos.
Listar enlaces
GET/links· token de lectura o escritura
Devuelve todos los enlaces, también los desactivados y los programados, ordenados por position.
curl https://app.broslunas.link/api/v1/links -H "Authorization: Bearer $TOKEN"{ "links": [ { "id": "…", "label": "…", "position": 0 } ] }Crear un enlace
POST/links· requiere escritura
Lo añade al final. Solo label es obligatorio (y url salvo en header). No acepta enabled ni position: se crea activo; ocúltalo después con PATCH.
curl -X POST https://app.broslunas.link/api/v1/links \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"label": "Reserva tu cita",
"url": "https://ejemplo.com/reservas",
"icon": "lucide:calendar",
"badge": "Nuevo",
"featured": true,
"startAt": "2026-12-01T00:00:00Z",
"endAt": "2026-12-31T23:59:59Z"
}'Respuesta 201 con { "link": { … } }. Errores: 400, 403, 422.
{ "kind": "header", "label": "Redes" }
{ "kind": "embed", "label": "Mi último vídeo", "url": "https://youtu.be/dQw4w9WgXcQ" }
{ "label": "Escríbeme", "url": "mailto:hola@ejemplo.com", "icon": "email" }
{ "label": "Llámame", "url": "tel:+34600123456", "icon": "phone" }Obtener un enlace
GET/links/:id· token de lectura o escritura
200 con { "link": { … } }. 404 si no existe o es de otra página.
Editar un enlace
PATCH/links/:id· requiere escritura
Edición parcial: solo cambian los campos que envíes. Acepta label, url, enabled, icon, description, imageUrl, badge, featured, social, sensitive, startAt y endAt. Envía null para vaciar un campo opcional. kind no se puede cambiar.
# Ocultar un enlace
curl -X PATCH https://app.broslunas.link/api/v1/links/6720f1c2a9b3c4d5e6f70812 \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "enabled": false }'
# Quitar la programación
-d '{ "startAt": null, "endAt": null }'200 con el enlace ya actualizado. Si cambias icon o imageUrl, el archivo propio anterior se elimina. Errores: 400, 403, 404, 422.
Borrar un enlace
DELETE/links/:id· requiere escritura
Borra el enlace y sus imágenes e iconos propios. Las estadísticas históricas de clics se conservan.
200 con { "ok": true }. Repetir un DELETE ya hecho devuelve 404.
Reordenar
PUT/links/order· requiere escritura
ids debe contener todos los enlaces de la página, una vez cada uno, en el orden deseado (el primero queda arriba). Si falta, sobra o se repite alguno no se cambia nada y se devuelve 422 invalid_order; así dos clientes a la vez no pueden dejar posiciones duplicadas.
curl -X PUT https://app.broslunas.link/api/v1/links/order \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "ids": ["6720…0813", "6720…0812", "6720…0814"] }'200 con { "links": [ … ] } ya ordenados. Para mover uno: GET, reordena el array de ids y envíalo.
Notas importantes
- Visibilidad pública: la página solo muestra enlaces con
enabled: truey dentro de su ventanastartAt/endAt. La API siempre devuelve todos. - Caché: cada escritura invalida la caché de tu página; el cambio se ve de inmediato.
- Concurrencia: no hay control de versiones; gana la última escritura.
- Reintentos: GET, PUT /links/order y PATCH con los mismos valores se pueden repetir. POST no: reintentar tras un timeout puede duplicar el enlace; comprueba con GET antes.
- Aislamiento: cada consulta lleva la página del token; nunca verá ni modificará datos de otra.
Ejemplos
const BASE = "https://app.broslunas.link/api/v1";
const headers = {
Authorization: `Bearer ${process.env.LINK_TOKEN}`,
"Content-Type": "application/json",
};
async function api(path, init) {
const res = await fetch(BASE + path, { ...init, headers });
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
return body;
}
const { link } = await api("/links", {
method: "POST",
body: JSON.stringify({ label: "Blog", url: "https://ejemplo.com/blog" }),
});
// Ponerlo el primero
const { links } = await api("/links");
const ids = [link.id, ...links.filter((l) => l.id !== link.id).map((l) => l.id)];
await api("/links/order", { method: "PUT", body: JSON.stringify({ ids }) });import os, requests
BASE = "https://app.broslunas.link/api/v1"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['LINK_TOKEN']}"
r = s.post(f"{BASE}/links", json={"label": "Podcast", "url": "https://ejemplo.com/podcast"})
r.raise_for_status()
print(r.json()["link"]["id"])