API v1 · lectura pública
API de StoryFeed Media
La API pública que alimenta al componente <story-feed>: feed vertical
paginado, detalle por slug, feed agregado por tema y health. Sin autenticación, CORS
abierto y respuestas JSON estables.
- Base:
https://api.storyfeedmedia.com/api/v1
- Auth: ninguna
- Formato: JSON (UTF-8)
- Versión: v1
Base URL y acceso
Todas las rutas documentadas acá cuelgan de
https://api.storyfeedmedia.com/api/v1. El host
storyfeedmedia.com sirve la API con el mismo path, así que ambos prefijos
son intercambiables.
- No hay claves de API ni OAuth: la superficie es read-only y pública.
- Todas las respuestas llevan
Access-Control-Allow-Origin: *.
- Cada respuesta incluye
X-Request-Id (correlation ID) para soporte.
- El error siempre usa el mismo envelope (ver Errores).
# Primer request: última página del feed de un cliente
curl -s "https://api.storyfeedmedia.com/api/v1/feed?client=demo"
Endpoints
| Método | Ruta | Qué devuelve |
| GET | /feed | Página del feed (proyección light, sin content) |
| GET | /story/:slug | Story completa, con content si existe |
| GET | /theme/:theme/feed | Feed agregado de varios clients bajo un tema |
| GET | /theme/:theme/story/:slug | Detalle dentro de un tema |
| GET | /health | Estado del servicio |
Feed
GET
/api/v1/feed
Historias ordenadas por publishedAt descendente. Las que no tienen fecha van al final.
Query params
| Param | Tipo | Default | Notas |
client | string | — | Dataset del medio. Sin client se usa el dataset por defecto. |
cursor | string | — | Cursor opaco de la página anterior. Ver Paginación. |
tag | string | — | Filtro exacto sobre tags. Superficie enterprise. |
theme | string | — | Filtro exacto sobre theme. Superficie enterprise. |
limit | integer | ignorado | Deprecado: se acepta y se ignora. El tamaño de página lo define pageSize de la fuente (1–50). |
Respuesta 200
{
"items": [
{
"id": "a1b2c3",
"slug": "el-futuro-del-periodismo-movil",
"type": "article",
"title": "El futuro del periodismo móvil",
"excerpt": "Una mirada a los formatos que retienen...",
"image": { "src": "https://cdn.ejemplo.com/foto.jpg", "alt": "Redacción" },
"tags": ["tecnologia"],
"theme": "portada",
"section": "tecnologia",
"publishedAt": "2026-11-08T10:00:00.000Z",
"contentUrl": "https://medio.ejemplo.com/nota/..."
}
],
"nextCursor": "eyJvZmZzZXQiOjEwfQ",
"hasMore": true,
"publisher": { "name": "Diario Ejemplo", "logo": "https://medio.ejemplo.com/logo.png" }
}
Proyección light. Los items del feed no incluyen content: el cuerpo HTML se sirve solo en el detalle para no inflar cada página. items.length puede superar el pageSize si hay ad cards (type: "ad") intercaladas; los ads no cuentan contra el tamaño de página ni contra hasMore.
Errores propios
| Código | Status | Cuándo |
INVALID_CURSOR | 400 | El cursor no se puede decodificar. |
CLIENT_NOT_FOUND | 404 | El client no tiene ninguna fuente activa. Un client conocido sin artículos responde 200 con items: []. |
QUOTA_EXCEEDED | 403 | El agregado mensual de vistas del dueño alcanzó el 130% de su plan. |
Detalle por slug
GET
/api/v1/story/:slug
Devuelve el objeto Story completo, incluido content cuando el artículo tiene body.
| Param | Dónde | Notas |
slug | path | Identificador de la story (^[a-z0-9]+(-[a-z0-9]+)*$). |
client | query | Mismo client que se usó en el feed, para resolver el dataset correcto. |
curl -s "https://api.storyfeedmedia.com/api/v1/story/el-futuro-del-periodismo-movil?client=demo"
content es HTML del artículo con URLs relativas ya resueltas a absolutas.
excerpt y subtitle son resúmenes de hasta 280 caracteres; si
coinciden con el cuerpo completo, se omiten.
| Código | Status | Cuándo |
STORY_NOT_FOUND | 404 | El slug no existe en el dataset. |
ALLY_NO_DETAIL | 403 | El Origin pertenece a un aliado sin derecho a detalle. |
QUOTA_EXCEEDED | 403 | Cuota mensual del dueño agotada al 130%. |
Orden de evaluación: cuota → gate de aliado → lookup del slug. Con un Origin aliado, un slug inexistente responde 403, no 404.
Feeds por tema
GET
/api/v1/theme/:theme/feed
Agrupa los datasets de varios clients bajo una definición de tema y los sirve como un feed único.
| Param | Dónde | Default | Notas |
theme | path | — | ^[a-z0-9-]+$ (minúsculas, números y guiones). |
cursor | query | — | Cursor opaco, igual que en /feed. |
limit | query | 10 | Se clampa entre 1 y 50; nunca devuelve error por rango. |
tag | query | — | Filtro exacto sobre tags aplicado al merge. |
La respuesta usa el mismo contrato que /feed. Los clients sin contenido se
saltean; si ninguno tiene stories, responde 200 con items: [].
El orden se define por el bloque sort del tema (recency,
round-robin o alternating).
Detalle dentro del tema
GET
/api/v1/theme/:theme/story/:slug
Busca el slug entre los clients del tema y devuelve la primera coincidencia, con content si existe.
| Código | Status | Cuándo |
INVALID_THEME | 400 | El tema tiene caracteres inválidos o falta. |
THEME_NOT_FOUND | 404 | No existe la definición del tema. |
STORY_NOT_FOUND | 404 | El slug no existe en ningún client del tema. |
INVALID_CURSOR | 400 | Cursor no decodificable (solo en el feed del tema). |
Health
GET
/api/v1/health
Sin dependencias externas: no consulta la base de datos.
{ "status": "ok", "uptimeSeconds": 123 }
Modelo Story
Contrato en shared/schema/story.schema.json. Campos requeridos: id, slug, type.
| Campo | Tipo | Notas |
id | string | Identificador interno. |
slug | string | Clave pública para el detalle. |
type | string | article, reel o ad. |
title / subtitle / excerpt | string | subtitle y excerpt limitados a 280 caracteres. |
caption | string | Texto principal de un reel. |
image | object | { src, alt }. |
video | object | src, durationSeconds, videoProvider, videoId, isShort. |
theme / section / tags | string / array | Clasificación editorial. |
author | string | Firma del artículo. |
publishedAt / updatedAt | string | ISO-8601 UTC. |
readingTime | integer | Minutos estimados de lectura. |
ctaLabel | string | Texto del call-to-action del detalle. |
contentUrl | string | URL original del artículo en el publisher. |
content | string | HTML del cuerpo. Solo en el detalle. |
active | 0 | 1 | Moderación. Las stories inactivas nunca se sirven. |
Los campos _adOverlayId y _adDetailId son internos del componente y no forman parte del contrato público.
Paginación y cursor
La paginación es por cursor, no por offset numérico. Pedí la primera página sin
cursor; para la siguiente pasá el nextCursor de la respuesta.
# Página 1
curl -s "https://api.storyfeedmedia.com/api/v1/feed?client=demo"
# Página 2 — usar el nextCursor devuelto
curl -s "https://api.storyfeedmedia.com/api/v1/feed?client=demo&cursor=eyJvZmZzZXQiOjEwfQ"
hasMore: false indica que no hay más páginas.
- El cursor es opaco: no lo parsees ni lo construyas a mano.
- El cursor codifica un offset sobre contenido real (Base64URL). Es una deuda técnica intencional: con escrituras concurrentes puede saltear o repetir items.
- Un cursor inválido responde
400 INVALID_CURSOR; pedí la primera página de nuevo.
Errores
Todas las respuestas de error comparten el mismo envelope:
{
"error": {
"code": "STORY_NOT_FOUND",
"message": "Story not found",
"status": 404,
"requestId": "req-8f3a..."
}
}
requestId coincide con el header X-Request-Id.
- Los errores no se cachean con el
ETag del recurso.
- Ante
429, respetá Retry-After y aplicá backoff.
Rate limits
Límites por IP + path, ventana de 15 minutos (salvo demos). Toda respuesta incluye
RateLimit-Limit, RateLimit-Remaining y
RateLimit-Reset.
| Rutas | Presupuesto |
/api/v1/feed, /story/:slug, /theme/*, /health | 300 req / 15 min |
/.well-known/*, /openapi.json, /skills/* | 60 req / 15 min |
El bloqueo responde 429 con Retry-After. En producción,
Cloudflare es la capa que aplica el límite efectivo.
Cache y ETag
Las respuestas de feed y detalle incluyen ETag débil y
Cache-Control con max-age corto y s-maxage largo.
Para revalidar, mandá If-None-Match: si nada cambió, la API responde
304 sin body.
curl -s -H 'If-None-Match: W/"abc123"' \
"https://api.storyfeedmedia.com/api/v1/feed?client=demo"
La invalidación en producción es activa por Cache-Tag en cada escritura, no solo por TTL.
Recursos para agentes
El contrato humano vive en docs/api.md del repo. La especificación
openapi.json es la fuente máquina.
Embed
El componente consume esta API. Un snippet mínimo:
<script src="https://storyfeedmedia.com/js/story-feed.min.js" defer></script>
<story-feed endpoint="https://api.storyfeedmedia.com/api/v1/feed"
client="tu-medio"></story-feed>
Guía de integración y personalización: Configurador ·
Feeds RSS · Métricas GA4.