Publicación mediante API
Publica artículos en tu sitio o CMS mediante solicitudes API firmadas.
Papigrows envía solicitudes POST firmadas al receptor HTTPS que alojas. Tu receptor guarda y publica el artículo y responde con su URL pública.
Configurar publicación mediante APIConecta tu endpoint de publicación
Crea un receptor que guarde y publique artículos en tu sitio. Añade el endpoint y secreto compartido en los ajustes del CMS. Probar y guardar envía connection.test con datos vacíos; responde con HTTP 200 y ok: true. Solo los propietarios y administradores pueden cambiar esta conexión.
Usa un endpoint HTTPS público sin parámetros, fragmentos ni credenciales incrustadas.
Hay un secreto aleatorio listo. Puedes sustituirlo por uno propio de 32 a 256 caracteres. Cópialo en tu receptor y guárdalo en el servidor.
{
"version": 1,
"event": "connection.test",
"id": "<event-id>",
"data": {}
}
HTTP/1.1 200 OK
Content-Type: application/json
{ "ok": true }Permite que el receptor acepte el secreto antiguo y el nuevo mientras pruebas y guardas. Papigrows usa el secreto guardado hasta que la nueva conexión supera la prueba y se guarda. Retira el antiguo cuando terminen las entregas en curso.
Cabeceras de la solicitud
Estas cabeceras acompañan tanto la prueba de conexión como las entregas de artículos. Sus nombres no distinguen mayúsculas de minúsculas.
| Nombre de cabecera | Valor de ejemplo | Significado |
|---|---|---|
Content-Type | application/json | Cuerpo de la solicitud en JSON. |
Accept | application/json | Se espera una confirmación en JSON. |
X-Papigrows-Event-Id | <event-id> | Coincide con el id del sobre. |
X-Papigrows-Timestamp | <unix-seconds> | Hora Unix en segundos para este intento. |
X-Papigrows-Signature | v1=<hex-hmac-sha256> | v1= seguido del HMAC-SHA256 hexadecimal de la marca de tiempo + '.' + el cuerpo exacto sin procesar. |
Idempotency-Key | <event-id> | El mismo id estable del sobre en los reintentos. |
Verifica cada solicitud
Antes de analizar el JSON, verifica la firma v1 HMAC-SHA256 sobre la marca de tiempo, un punto y el cuerpo original exacto. Compara en tiempo constante, rechaza marcas de más de cinco minutos o futuras y conserva el secreto en el servidor. HTTPS y la firma no sustituyen la protección contra duplicados.
Ejemplo de solicitud
POST /your-publishing-endpoint
Content-Type: application/json
X-Papigrows-Event-Id: <event-id>
X-Papigrows-Timestamp: <unix-seconds>
X-Papigrows-Signature: v1=<hex-hmac-sha256>
Idempotency-Key: <event-id>
{
"version": 1,
"event": "article.upsert",
"id": "<event-id>",
"data": {
"id": "<permanent-article-id>",
"version": 7,
"title": "Example",
"html": "<p>...</p>",
"slug": "example",
"excerpt": "...",
"coverImageUrl": "https://example.com/cover.jpg",
"locale": "en",
"hreflangAlternates": [
{ "locale": "sv", "url": "https://example.com/sv/blog/example" }
]
}
}Campos del cuerpo de la solicitud
Estos campos describen article.upsert. Para connection.test, data está vacío. La versión del protocolo del sobre es distinta de la versión del contenido del artículo.
| Nombre del campo | Tipo de valor | Presencia | Significado |
|---|---|---|---|
version | integer | Siempre | Versión del protocolo: 1 para el formato original o 2 tras una actualización explícita del receptor. |
event | string | Siempre | Tipo de evento: article.upsert o connection.test. |
id | string | Siempre | ID estable de entrega compartido con ambas cabeceras de identidad. Guárdalo para evitar escrituras duplicadas. |
data | object | Siempre | Contenido del evento. Vacío para connection.test. |
data.id | string | Siempre | ID permanente de esta variante de idioma. Úsalo como clave para crear o actualizar. |
data.version | integer | Siempre | Revisión almacenada del contenido como entero no negativo. Rechaza revisiones anteriores. |
data.title | string | Siempre | Título del artículo. |
data.html | string | Siempre | HTML para publicar, incluidos los enlaces hreflang disponibles. |
data.slug | string | Cuando esté disponible | Slug de URL sugerido. Mantén una URL pública estable en futuras ediciones. |
data.excerpt | string | Cuando esté disponible | Extracto del artículo, cuando esté disponible. |
data.coverImageUrl | string | Cuando esté disponible | URL de la imagen de portada, cuando esté disponible. |
data.locale | string | Cuando esté disponible | Código de idioma de esta variante del artículo. |
data.hreflangAlternates | array | Cuando esté disponible | Otras variantes de idioma publicadas, cuando estén disponibles. |
data.hreflangAlternates[].locale | string | Para cada variante de idioma | Código de idioma de la variante enlazada. |
data.hreflangAlternates[].url | string | Para cada variante de idioma | URL pública de esa variante publicada. |
Versión 2 y compatibilidad
La versión 2 incluye texto sin formato, identificadores del espacio de trabajo y de la audiencia, resúmenes, secciones y etiquetas. El receptor debe confirmar expresamente la versión 2 durante la prueba de conexión. Los receptores existentes conservan la versión 1 hasta que elijas, pruebes correctamente y guardes otra versión.
{
"version": 2,
"event": "connection.test",
"id": "<event-id>",
"data": {}
}
HTTP/1.1 200 OK
Content-Type: application/json
{ "ok": true, "version": 2 }POST /your-publishing-endpoint
Content-Type: application/json
X-Papigrows-Event-Id: <event-id>
X-Papigrows-Timestamp: <unix-seconds>
X-Papigrows-Signature: v1=<hex-hmac-sha256>
Idempotency-Key: <event-id>
{
"version": 2,
"event": "article.upsert",
"id": "<event-id>",
"data": {
"id": "<permanent-article-id>",
"groupId": "<permanent-article-group-id>",
"workspaceId": "<owning-workspace-id>",
"primaryAudienceId": "<saved-audience-id>",
"businessAreaId": "<saved-business-area-id>",
"text": "...",
"summary": "...",
"section": "Advice",
"tags": ["Families"],
"version": 7,
"title": "Example",
"html": "<p>...</p>",
"slug": "example",
"excerpt": "...",
"coverImageUrl": "https://example.com/cover.jpg",
"locale": "en",
"hreflangAlternates": [
{ "locale": "sv", "url": "https://example.com/sv/blog/example" }
]
}
}La versión 2 conserva los campos originales y añade estos campos obligatorios. Los identificadores de audiencia permanecen en null para artículos antiguos sin segmentación guardada.
| Nombre del campo | Tipo de valor | Significado |
|---|---|---|
data.groupId | UUID | Grupo estable compartido por las versiones lingüísticas. |
data.workspaceId | UUID | Identificador guardado del espacio de trabajo propietario. |
data.primaryAudienceId | UUID | null | Identificador guardado de la audiencia principal, o null para un artículo antiguo sin segmentación. |
data.businessAreaId | UUID | null | Identificador guardado del área de negocio, o null si falta. |
data.text | string | Texto sin formato derivado del HTML depurado, no del Markdown original. No se descargan los enlaces. |
data.summary | string | null | Resumen guardado del artículo, o null si falta. |
data.section | string | null | Sección registrada para esta publicación, o null. |
data.tags | string[] | Etiquetas registradas para esta publicación; una lista vacía indica que no hay etiquetas. |
Fuente pública de contenido
Activa la conexión API de Papigrows para exponer los artículos publicados. Solicita explícitamente la versión 2; sin versión, se conserva la respuesta original. Los espacios privados y los artículos sin publicar siguen inaccesibles.
/api/content/feed/<workspace-slug>?version=2&locale=en&limit=20Cada artículo incluye sus identificadores guardados, versión de contenido, texto sin formato y resumen. La sección y las etiquetas proceden solo del destino registrado al publicar. Las etiquetas son null si no existe esa prueba. Conserva version=2 y locale en cada solicitud con cursor.
En la fuente pública, text es null si la conversión no está disponible. textUnavailable es body_too_large por encima de 1 MiB o conversion_failed si falla la conversión; en otro caso es null. Se conservan el HTML y la paginación.
Confirma la publicación
Cuando el artículo sea público, devuelve HTTP 200 o 201 con el mismo ID, estado published y URL HTTPS pública. Una confirmación de cola, HTTP 202 o 204, redirección, borrador o respuesta inválida no cuenta como publicación. Confirma solo después de guardar y publicar.
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "<permanent-article-id>",
"status": "published",
"url": "https://example.com/blog/example"
}Entrega y reintentos
article.upsert crea o actualiza una variante de idioma con data.id como clave permanente. Guarda data.version y el ID del evento de forma atómica con el cambio. Los duplicados reciben la confirmación guardada; versiones antiguas nunca deben sobrescribir contenido más reciente. Hasta tres intentos de cinco segundos cada uno. Los errores de red, HTTP 429 y HTTP 5xx se reintentan tras 250 ms y 1 segundo. Cuerpo e ID permanecen iguales; marca de tiempo y firma se renuevan.
Los cuerpos de las entregas están limitados a 1 MiB y las respuestas JSON a 16 KiB. Limita el cuerpo sin procesar antes de interpretarlo.
Qué incluye esta integración
Solo artículos, incluidas modificaciones publicadas de nuevo desde Papigrows. No incluye eventos de temas o borrado, colas en segundo plano ni lectura de cambios remotos. La edición por respuesta de correo no puede actualizar un webhook con seguridad; edita y publica en Papigrows. Siguen vigentes el plan, la calidad y los ajustes de automatización. Tras un fallo incierto, revisa el receptor antes de reintentar manualmente; no hay un bucle infinito.
Ejemplo de verificación de firma
Este ayudante Node.js solo verifica la autenticidad. El receptor también debe limitar el tamaño, validar el esquema, gestionar duplicados y versiones en almacenamiento persistente, publicar y devolver la confirmación siguiente.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(headers, rawBody, secret) {
const timestamp = headers.get("x-papigrows-timestamp") ?? "";
const signature = headers.get("x-papigrows-signature") ?? "";
if (!/^\d{10}$/.test(timestamp)) return false;
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age < 0 || age > 300) return false;
if (!/^v1=[a-f0-9]{64}$/.test(signature)) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + ".").update(rawBody).digest();
const received = Buffer.from(signature.slice(3), "hex");
if (received.length !== expected.length
|| !timingSafeEqual(received, expected)) return false;
try {
const event = JSON.parse(rawBody.toString());
return typeof event.id === "string"
&& /^[a-f0-9]{64}$/.test(event.id)
&& headers.get("x-papigrows-event-id") === event.id
&& headers.get("idempotency-key") === event.id;
} catch { return false; }
}