La API es REST sobre HTTP/HTTPS. El base URL es la raíz del servidor; en
desarrollo local http://localhost:3001, en producción el dominio que tengas
configurado (https://azkintun.tudominio.com). Todos los cuerpos son JSON
salvo los endpoints de import/export.
La API soporta dos mecanismos en paralelo:
Cookie httpOnly (para browsers): el login setea una cookie access_token
(HttpOnly, SameSite=Strict). El browser la envía automáticamente en cada
request. No es accesible desde JS — protege contra XSS.
Header Bearer (para clientes de API): el token también viene en el body
del login. Se envía como Authorization: Bearer <token>.
El token es un JWT HS256 con TTL configurable (default 24h). Todos los
endpoints salvo /api/health y /api/auth/login requieren autenticación.
Sin token válido se recibe 401 Unauthorized.
Todos los errores devuelven JSON con la forma:
{ "error": "descripción del error" }Códigos usados:
| Código | Significado |
|---|---|
400 |
Bad Request — parámetro faltante o inválido |
401 |
Unauthorized — token ausente, expirado o inválido |
404 |
Not Found — el recurso no existe |
409 |
Conflict — ya existe (nombre/URL duplicado) |
500 |
Internal Server Error |
Inicia sesión. Devuelve el token y setea la cookie.
Body:
{
"username": "admin",
"password": "tu-contraseña"
}Respuesta 200:
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 86400,
"user": {
"id": 1,
"username": "admin"
}
}curl -c cookies.txt -X POST https://azkintun.tudominio.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"tu-contraseña"}'Con Bearer token en lugar de cookies:
TOKEN=$(curl -s -X POST https://azkintun.tudominio.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"tu-contraseña"}' \
| jq -r .token)
# Usar en requests siguientes:
curl -H "Authorization: Bearer $TOKEN" https://azkintun.tudominio.com/api/statsDevuelve el usuario autenticado.
Respuesta 200:
{ "id": 1, "username": "admin" }curl -b cookies.txt https://azkintun.tudominio.com/api/auth/meExpira la cookie de sesión.
Respuesta 200:
{ "success": true }curl -b cookies.txt -c cookies.txt -X POST \
https://azkintun.tudominio.com/api/auth/logoutCambia la contraseña del usuario autenticado.
Body:
{
"currentPassword": "contraseña-actual",
"newPassword": "nueva-contraseña-min-8-chars"
}Respuesta 200:
{ "success": true }Errores: 400 si la nueva contraseña tiene menos de 8 caracteres.
401 si la contraseña actual es incorrecta.
curl -b cookies.txt -X POST https://azkintun.tudominio.com/api/auth/change-password \
-H "Content-Type: application/json" \
-d '{"currentPassword":"vieja","newPassword":"nuevapass123"}'Endpoint público para healthchecks. No requiere token.
Respuesta 200:
{ "status": "ok" }curl https://azkintun.tudominio.com/api/healthEstadísticas generales de la instancia.
Respuesta 200:
{
"totalArticles": 48230,
"totalSources": 1390,
"totalFolders": 30,
"unreadArticles": 7703,
"lastFetch": "2026-08-04T20:15:33Z"
}curl -b cookies.txt https://azkintun.tudominio.com/api/statsLista todas las carpetas con contadores.
Respuesta 200:
[
{
"id": 1,
"name": "Seginfo-CVE",
"sourceCount": 12,
"unreadCount": 342
},
{
"id": 2,
"name": "Home Lab",
"sourceCount": 8,
"unreadCount": 54
}
]curl -b cookies.txt https://azkintun.tudominio.com/api/foldersCrea una carpeta nueva.
Body:
{ "name": "Mi nueva carpeta" }Respuesta 200: objeto Folder creado.
Errores: 400 si el nombre está vacío. 409 si ya existe una carpeta
con ese nombre.
curl -b cookies.txt -X POST https://azkintun.tudominio.com/api/folders \
-H "Content-Type: application/json" \
-d '{"name":"Inteligencia Artificial"}'Renombra una carpeta.
Body:
{ "name": "Nuevo nombre" }Respuesta 200: objeto Folder actualizado.
curl -b cookies.txt -X PATCH https://azkintun.tudominio.com/api/folders/1 \
-H "Content-Type: application/json" \
-d '{"name":"CVEs Críticos"}'Borra la carpeta. Los feeds que estaban en ella quedan sin carpeta (no se borran). Sus artículos tampoco se borran.
Respuesta 200:
{ "success": true }curl -b cookies.txt -X DELETE https://azkintun.tudominio.com/api/folders/1Marca como leídos todos los artículos de los feeds de esa carpeta.
Respuesta 200:
{ "success": true, "updated": 342 }curl -b cookies.txt -X POST \
https://azkintun.tudominio.com/api/folders/1/mark-all-readLista todas las fuentes. Opcionalmente filtrá por carpeta.
Query params:
| Param | Tipo | Descripción |
|---|---|---|
folderId |
int | Solo devuelve fuentes de esa carpeta |
Respuesta 200:
[
{
"id": 42,
"name": "Krebs on Security",
"rssUrl": "https://krebsonsecurity.com/feed/",
"folderId": 1,
"folderName": "Seginfo-Noticias",
"active": true,
"custom": false,
"articleCount": 1240,
"lastFetch": "2026-08-04T20:00:00Z",
"lastError": null
}
]# Todas las fuentes
curl -b cookies.txt https://azkintun.tudominio.com/api/sources
# Solo de una carpeta
curl -b cookies.txt "https://azkintun.tudominio.com/api/sources?folderId=1"Agrega una fuente nueva.
Body:
{
"name": "Dark Reading",
"rssUrl": "https://www.darkreading.com/rss.xml",
"folderId": 1
}También podés pasar folderName en lugar de folderId. Si la carpeta con
ese nombre no existe, se crea automáticamente:
{
"name": "Dark Reading",
"rssUrl": "https://www.darkreading.com/rss.xml",
"folderName": "Seginfo-Noticias"
}Respuesta 200: objeto Source creado.
Errores: 400 si faltan name o rssUrl, o si la carpeta no existe.
409 si ya hay una fuente con esa URL.
curl -b cookies.txt -X POST https://azkintun.tudominio.com/api/sources \
-H "Content-Type: application/json" \
-d '{"name":"Dark Reading","rssUrl":"https://www.darkreading.com/rss.xml","folderId":1}'Actualiza una fuente. Todos los campos son opcionales.
Body (todos opcionales):
{
"name": "Nuevo nombre",
"active": false,
"folderId": 2
}Para quitar una fuente de cualquier carpeta (dejarla sin categoría), mandá
"folderId": 0.
Respuesta 200: objeto Source actualizado.
# Desactivar una fuente (deja de scrapearse)
curl -b cookies.txt -X PATCH https://azkintun.tudominio.com/api/sources/42 \
-H "Content-Type: application/json" \
-d '{"active":false}'
# Mover a otra carpeta
curl -b cookies.txt -X PATCH https://azkintun.tudominio.com/api/sources/42 \
-H "Content-Type: application/json" \
-d '{"folderId":3}'
# Quitar de carpeta
curl -b cookies.txt -X PATCH https://azkintun.tudominio.com/api/sources/42 \
-H "Content-Type: application/json" \
-d '{"folderId":0}'Borra la fuente y todos sus artículos.
Respuesta 200:
{ "success": true }curl -b cookies.txt -X DELETE https://azkintun.tudominio.com/api/sources/42Lista artículos con filtros opcionales. Ordenados por fecha de publicación descendente (más nuevos primero).
Query params:
| Param | Tipo | Descripción |
|---|---|---|
folderId |
int | Artículos de fuentes en esa carpeta |
sourceId |
int | Artículos de esa fuente |
search |
string | Busca en título y resumen |
unreadOnly |
bool | Solo no leídos |
starredOnly |
bool | Solo favoritos |
limit |
int | Máximo de resultados (default 50, máx 500) |
offset |
int | Para paginación |
Respuesta 200:
[
{
"id": 9876,
"title": "Critical CVE-2026-1234 in OpenSSL",
"summary": "A vulnerability classified as critical has been found...",
"sourceId": 42,
"sourceName": "vuldb.com",
"folderId": 1,
"folderName": "Seginfo-CVE",
"severity": "critical",
"publishedAt": "2026-08-04T18:30:00Z",
"readTime": "2 MIN READ",
"url": "https://vuldb.com/?id.12345",
"imageUrl": "https://vuldb.com/img/logo.png",
"isRead": false,
"isStarred": false
}
]Valores posibles de severity: critical, high, medium, low.
# Últimos 100 artículos
curl -b cookies.txt "https://azkintun.tudominio.com/api/articles?limit=100"
# No leídos de una carpeta
curl -b cookies.txt \
"https://azkintun.tudominio.com/api/articles?folderId=1&unreadOnly=true&limit=50"
# Buscar
curl -b cookies.txt \
"https://azkintun.tudominio.com/api/articles?search=openssl&limit=20"
# Favoritos
curl -b cookies.txt \
"https://azkintun.tudominio.com/api/articles?starredOnly=true"
# Paginación (página 2 de 50)
curl -b cookies.txt \
"https://azkintun.tudominio.com/api/articles?limit=50&offset=50"Devuelve un artículo por su ID.
Respuesta 200: objeto Article (misma forma que en la lista).
curl -b cookies.txt https://azkintun.tudominio.com/api/articles/9876Actualiza el estado de un artículo. Campos opcionales.
Body:
{
"isRead": true,
"isStarred": false
}Respuesta 200: objeto Article actualizado.
# Marcar como leído
curl -b cookies.txt -X PATCH https://azkintun.tudominio.com/api/articles/9876 \
-H "Content-Type: application/json" \
-d '{"isRead":true}'
# Agregar a favoritos
curl -b cookies.txt -X PATCH https://azkintun.tudominio.com/api/articles/9876 \
-H "Content-Type: application/json" \
-d '{"isStarred":true}'Marca como leídos todos los artículos (o los de una carpeta/fuente).
Query params (opcionales):
| Param | Tipo | Descripción |
|---|---|---|
folderId |
int | Solo los artículos de esa carpeta |
sourceId |
int | Solo los artículos de esa fuente |
Respuesta 200:
{ "success": true, "updated": 7703 }# Marcar todos como leídos
curl -b cookies.txt -X POST \
https://azkintun.tudominio.com/api/articles/mark-all-read
# Solo los de una carpeta
curl -b cookies.txt -X POST \
"https://azkintun.tudominio.com/api/articles/mark-all-read?folderId=1"Dispara un scrape manual de todos los feeds activos. La respuesta es inmediata — el scrape corre en background. Consultá el status para saber cuándo termina.
Respuesta 200:
{ "success": true, "message": "Scraping started" }curl -b cookies.txt -X POST https://azkintun.tudominio.com/api/scrapeEstado actual del scrape.
Respuesta 200:
{
"scraping": true,
"startedAt": "2026-08-04T20:15:00Z",
"lastFinishedAt": "2026-08-04T19:00:00Z",
"lastTotalNew": 143,
"lastErrors": [
"feed https://ejemplo.com/rss: connection timeout"
]
}curl -b cookies.txt https://azkintun.tudominio.com/api/scrape/statusPatrón de polling para esperar que termine:
while true; do
STATUS=$(curl -s -b cookies.txt https://azkintun.tudominio.com/api/scrape/status)
SCRAPING=$(echo $STATUS | jq -r .scraping)
[ "$SCRAPING" = "false" ] && break
echo "Scraping... $(echo $STATUS | jq -r .startedAt)"
sleep 2
done
echo "Terminó: $(echo $STATUS | jq .lastTotalNew) artículos nuevos"Los endpoints de import reciben el archivo como multipart/form-data. El
nombre del campo puede ser cualquiera (file, upload, etc.).
Importa suscripciones desde un archivo OPML. Compatible con exports de Inoreader, Feedly, Miniflux y la mayoría de lectores RSS.
Respuesta 200:
{
"foldersCreated": 3,
"sourcesCreated": 42,
"sourcesSkipped": 5,
"errors": [
"Línea 7: URL inválida 'not-a-url'"
]
}curl -b cookies.txt -X POST https://azkintun.tudominio.com/api/import/opml \
-F "file=@mis-feeds.opml"Importa suscripciones desde CSV. El archivo debe tener headers; se detectan por nombre (no por posición). Columnas reconocidas:
| Columna | Alias aceptados |
|---|---|
| Nombre | name, title, nombre |
| URL | url, feed url, rss_url, xmlurl |
| Carpeta | folder, category, carpeta, categoría |
Compatible con el export CSV del propio Azkintun y con exports de Inoreader.
Respuesta 200: igual que OPML.
curl -b cookies.txt -X POST https://azkintun.tudominio.com/api/import/csv \
-F "file=@feeds.csv"Ejemplo de CSV válido:
Name,Feed URL,Folder
Krebs on Security,https://krebsonsecurity.com/feed/,Seginfo-Noticias
Dark Reading,https://www.darkreading.com/rss.xml,Seginfo-Noticias
Hacker News,https://hnrss.org/frontpage,Los endpoints de export devuelven el archivo como descarga
(Content-Disposition: attachment). Requieren autenticación.
Exporta todas las suscripciones en formato OPML 1.0, agrupadas por carpeta.
curl -b cookies.txt -o mis-feeds.opml \
https://azkintun.tudominio.com/api/export/opmlExporta todas las suscripciones como CSV con columnas Name,Feed URL,Folder.
curl -b cookies.txt -o feeds.csv \
https://azkintun.tudominio.com/api/export/csv{
"id": 1,
"name": "Seginfo-CVE",
"sourceCount": 12,
"unreadCount": 342
}{
"id": 42,
"name": "Krebs on Security",
"rssUrl": "https://krebsonsecurity.com/feed/",
"folderId": 1,
"folderName": "Seginfo-Noticias",
"active": true,
"custom": false,
"articleCount": 1240,
"lastFetch": "2026-08-04T20:00:00Z",
"lastError": null
}custom: true indica que fue agregada manualmente por el usuario.
custom: false indica que viene de los feeds sembrados por defecto.
{
"id": 9876,
"title": "Critical CVE-2026-1234 in OpenSSL",
"summary": "Resumen en texto plano, máx ~280 caracteres.",
"sourceId": 42,
"sourceName": "vuldb.com",
"folderId": 1,
"folderName": "Seginfo-CVE",
"severity": "critical",
"publishedAt": "2026-08-04T18:30:00Z",
"readTime": "2 MIN READ",
"url": "https://vuldb.com/?id.12345",
"imageUrl": "https://vuldb.com/img/logo.png",
"isRead": false,
"isStarred": false
}severity se infiere del título del artículo por keywords. Valores:
critical— contiene "critical", "0-day", "zero-day", "actively exploited"high— contiene "high", "exploit", "rce", "ransomware"medium— contiene "medium", "cve-", "vulnerability", "advisory"low— todo lo demás
imageUrl puede ser null si el feed no trae imagen.
publishedAt es ISO 8601 UTC, puede ser null si el feed no trae fecha.
Flujo mínimo para una UI funcional:
1. GET /api/auth/me
→ 200: ya hay sesión, saltar al paso 4
→ 401: mostrar login
2. POST /api/auth/login {username, password}
→ guardar el token (cookie automática en browser,
o guardar en memoria para usar como Bearer)
3. Cargar datos iniciales en paralelo:
GET /api/folders
GET /api/sources
4. Cargar artículos:
GET /api/articles?limit=100
5. Al abrir un artículo:
PATCH /api/articles/:id {isRead: true}
6. Al hacer click en estrella:
PATCH /api/articles/:id {isStarred: true/false}
7. Para actualizar feeds manualmente:
POST /api/scrape
→ poll GET /api/scrape/status hasta que scraping === false
→ recargar artículos
El token tiene TTL de 24h por defecto. Cuando cualquier request devuelva
401, redirigir al login.
Las cookies son SameSite=Strict, así que si el frontend está en un dominio
diferente al backend, necesitás usar el header Authorization: Bearer.
Si frontend y backend están en el mismo dominio (nginx proxy), las cookies
funcionan automáticamente.