Docs

Publicar con webhooks

Envía cada artículo que publicas en GrowWriter directamente a tu propio sitio web. Configura la integración de webhooks paso a paso, con código listo para usar.

Puede que tu sitio web no funcione con ninguna de las plataformas que GrowWriter conecta de serie. No pasa nada. Con la integración de webhooks, GrowWriter entrega cada artículo directamente en tu sitio en el momento en que pulsas Publicar. Stack propio, CMS casero, sitio estático, lo que sea. Si puede recibir una petición web, puede recibir tus artículos.

Esta guía te acompaña durante toda la configuración. No necesitas experiencia con webhooks. Al terminar, publicar un artículo en GrowWriter creará o actualizará una entrada en tu propio sitio web automáticamente.

¿Qué es un webhook?

Un webhook es la forma más simple de comunicar dos aplicaciones: cuando algo ocurre en una, esta envía un mensaje a una dirección web que tú eliges.

Piensa en un servicio de mensajería. Le das a GrowWriter tu dirección (una URL de tu sitio web). Cuando un artículo está listo, GrowWriter llama a esa puerta y entrega el artículo completo como datos estructurados. Tu sitio hace el resto: guardarlo, darle formato y publicarlo a tu manera.

Por qué es mejor que las alternativas:

  • Sin copiar y pegar. El artículo llega con su título, contenido, imágenes y campos SEO ya organizados.
  • Sin comprobar novedades. Tu sitio no tiene que preguntar "¿hay algo nuevo?" cada pocos minutos. GrowWriter solo llama cuando hay algo que entregar.
  • Sin depender de una plataforma. Los webhooks hablan HTTPS y JSON puros, que cualquier stack web entiende.

Cómo funciona

El flujo completo se entiende en veinte segundos:

  1. Le das a GrowWriter una URL de tu sitio, y GrowWriter te da una clave secreta a cambio.
  2. Cuando publicas un artículo, GrowWriter envía una petición POST a tu URL. El cuerpo de la petición es el artículo completo en JSON, y la petición va firmada con tu secreto para que puedas confirmar que viene de GrowWriter.
  3. Tu sitio verifica la firma, guarda el artículo y responde con un estado de éxito (cualquier 2xx). Listo.
Diagrama de flujo en tres pasos: al pulsar Publicar en GrowWriter se envía una petición JSON firmada que tu sitio web recibe y guarda como entrada publicada.
Un clic en GrowWriter, una petición a tu sitio, una entrada nueva.

Configúralo paso a paso

Conecta el webhook en GrowWriter

Abre la página Publicar en GrowWriter y busca la tarjeta Webhook en el grupo Automatización. Haz clic en Conectar.

Introduce tu URL del endpoint. Es la dirección de tu sitio web que recibirá los artículos, por ejemplo https://example.com/webhooks/growwriter. Dos reglas: debe empezar por https:// y debe ser accesible desde internet (una dirección localhost no funcionará).

¿Todavía no tienes el endpoint construido? No hay problema. GrowWriter no comprueba la URL en este punto, así que puedes conectar primero y escribir el código justo después.

Haz clic en Crear conexión.

La página Publicar de GrowWriter con la tarjeta Webhook en el grupo Automatización y su botón Conectar.
La tarjeta Webhook está en Automatización, dentro de la página Publicar.

Copia tu secreto de firma

El diálogo muestra ahora tu Secreto de firma. Empieza por whsec_ y demuestra que una petición viene realmente de GrowWriter.

Haz clic en Copiar secreto y guárdalo en un lugar seguro de tu servidor, normalmente como variable de entorno:

.env
GROWWRITER_WEBHOOK_SECRET=whsec_tu_secreto_aqui

Este secreto se muestra una sola vez

GrowWriter lo guarda cifrado y no puede mostrarlo de nuevo. Si lo pierdes, desconecta y vuelve a conectar el webhook para obtener uno nuevo. Reconectar siempre crea un secreto nuevo, y el anterior deja de funcionar.

Paso 2 del diálogo Conectar Webhook, con el campo Secreto de firma y los botones Copiar secreto y Enviar evento de prueba.
Copia el secreto antes de cerrar el diálogo. Solo se muestra una vez.

Construye tu receptor

Ahora añade el endpoint a tu sitio web. Tiene que hacer cuatro cosas: leer el cuerpo de la petición sin procesar, verificar la firma, responder rápido con un estado 2xx y guardar el artículo.

Ve a los ejemplos de código de más abajo y copia el que encaje con tu stack. Despliégalo en la URL que introdujiste en el paso 1.

Envía un evento de prueba

De vuelta en el diálogo de GrowWriter, haz clic en Enviar evento de prueba. GrowWriter envía un pequeño ping firmado a tu endpoint. Tiene esta pinta:

Payload del evento de prueba
{
  "version": "2026-08-01",
  "event": "ping",
  "deliveryId": "5f0c9a1e-4d2b-4f6a-9c3e-8b7d6a5e4f3c",
  "sentAt": "2026-09-01T09:30:00.000Z"
}

Fíjate en que no lleva ningún artículo. Así es como tu código distingue una prueba de una entrega real.

Cuando tu endpoint responde con un 2xx, el diálogo muestra Evento de prueba entregado. Haz clic en Hecho y ya estás conectado. Si en cambio ves El evento de prueba falló, revisa la sección de solución de problemas y vuelve a intentarlo. Una prueba fallida nunca rompe la conexión, así que puedes reintentar tantas veces como quieras.

Publica un artículo

Abre uno de tus artículos terminados y publícalo. Elige tu webhook como destino y GrowWriter entregará el artículo completo a tu endpoint como un evento article.published.

Si vuelves a publicar el mismo artículo más adelante (tras editarlo, por ejemplo), tu endpoint recibirá un evento article.updated con el mismo id de artículo. Ese id es lo que permite a tu sitio actualizar la entrada existente en lugar de crear un duplicado.

Ejemplos de código

Los dos ejemplos de abajo hacen el trabajo completo: verifican la firma, responden al ping de prueba, contestan rápido y pasan el artículo a tu propia lógica de guardado.

Hay una regla que importa más que todas las demás: verifica la firma sobre el cuerpo de la petición sin procesar, exactamente como llegó. Si tu framework parsea el JSON primero y tú lo vuelves a serializar, los bytes cambian y la comprobación de la firma falla.

app/webhooks/growwriter/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

// Antigüedad máxima en segundos de una entrega antes de rechazarla.
// Protege contra la repetición de una petición antigua capturada.
const MAX_AGE_SECONDS = 5 * 60;

function verifySignature(rawBody: string, signatureHeader: string, secret: string) {
  // La cabecera tiene esta forma: t=1756718400,v1=5257a869e7...
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((part) => part.split('='))
  );

  // 1. El timestamp debe ser reciente. GrowWriter vuelve a firmar cada
  //    reintento, así que una entrega fresca siempre trae un timestamp fresco.
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!(age < MAX_AGE_SECONDS)) {
    return false;
  }

  // 2. Recalcula la firma: HMAC-SHA256 de "timestamp.cuerpoSinProcesar"
  //    con tu secreto de firma, en hexadecimal.
  const expected = createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

  // 3. Compara en tiempo constante para que la comprobación no se pueda medir.
  const provided = Buffer.from(parts.v1 ?? '', 'hex');
  const wanted = Buffer.from(expected, 'hex');
  return provided.length === wanted.length && timingSafeEqual(provided, wanted);
}

export async function POST(request: Request) {
  // Lee el cuerpo como texto sin procesar PRIMERO. No uses request.json()
  // antes de verificar: la firma cubre exactamente estos bytes.
  const rawBody = await request.text();

  const signature = request.headers.get('x-growwriter-signature') ?? '';
  const secret = process.env.GROWWRITER_WEBHOOK_SECRET ?? '';

  if (!verifySignature(rawBody, signature, secret)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const payload = JSON.parse(rawBody);

  // El botón "Enviar evento de prueba" manda un ping sin artículo.
  // Responder 200 aquí es lo que hace que la prueba tenga éxito.
  if (payload.event === 'ping') {
    return new Response('pong', { status: 200 });
  }

  // GrowWriter reintenta las entregas fallidas con el MISMO id de entrega.
  // Si ya has procesado este id, simplemente responde OK otra vez.
  const deliveryId = request.headers.get('x-growwriter-delivery');
  // if (await alreadyProcessed(deliveryId)) return new Response('OK');

  const { article } = payload;

  // Upsert sobre article.id: "article.published" significa que es nuevo,
  // "article.updated" significa que ya has visto este id antes.
  // Guarda lo que necesites. article.content te da el mismo artículo
  // como markdown, como HTML y como bloques estructurados. Elige uno.
  await saveArticle({
    externalId: article.id, // estable entre republicaciones
    title: article.title,
    slug: article.slug,
    html: article.content.html,
    metaDescription: article.metaDescription,
    publishedAt: article.dates.firstPublishedAt,
    updatedAt: article.dates.modifiedAt,
  });

  // GrowWriter espera como máximo 10 segundos. Responde rápido; haz el
  // trabajo pesado (como descargar imágenes) en segundo plano después.
  return new Response('OK', { status: 200 });
}

Qué contiene el payload

Cada entrega es un POST con Content-Type: application/json y tres cabeceras que te interesan:

CabeceraQué te dice
X-GrowWriter-Eventarticle.published, article.updated o ping
X-GrowWriter-DeliveryId único de esta entrega. Los reintentos mantienen el mismo id, úsalo para no procesar dos veces
X-GrowWriter-Signaturet=<timestamp>,v1=<firma> para la verificación de arriba

Aquí tienes un ejemplo recortado del cuerpo de un artículo real:

Payload de article.published (recortado)
{
  "version": "2026-08-01",
  "event": "article.published",
  "deliveryId": "9d2f7c1e-6a3b-4c8d-b5e0-1f2a3b4c5d6e",
  "sentAt": "2026-09-01T09:30:00.000Z",
  "publishRecord": {
    "firstPublishedAt": "2026-09-01T09:30:00.000Z",
    "lastPublishedAt": "2026-09-01T09:30:00.000Z"
  },
  "article": {
    "id": "cme8x2k9r0001l7042n5q8w3v",
    "language": "es",
    "title": "Cómo elegir zapatillas de running que de verdad te queden bien",
    "slug": "como-elegir-zapatillas-de-running",
    "metaDescription": "Aprende a elegir zapatillas de running que se adapten a tu pie y a tu zancada, con comprobaciones sencillas que puedes hacer en cualquier tienda.",
    "summary": "Una guía práctica para encontrar zapatillas de running...",
    "keywords": {
      "primary": "cómo elegir zapatillas de running",
      "secondary": ["ajuste zapatillas running", "guía zapatillas running"]
    },
    "takeaways": ["El ajuste importa más que la marca", "..."],
    "coverImage": {
      "url": "https://cdn.example.com/articleimages/.../cover.jpg",
      "altText": "Corredor atándose las zapatillas en un banco del parque"
    },
    "images": [
      {
        "url": "https://cdn.example.com/articleimages/.../gait-check.jpg",
        "altText": "Vista lateral de un corredor en plena zancada",
        "caption": "Una comprobación rápida de la pisada dice más que cualquier ficha técnica.",
        "sectionSlug": "comprueba-tu-pisada"
      }
    ],
    "content": {
      "markdown": "Encontrar el par adecuado empieza por...",
      "html": "<p>Encontrar el par adecuado empieza por...</p>",
      "blocks": [{ "type": "paragraph", "children": [{ "type": "text", "text": "..." }] }]
    },
    "dates": {
      "createdAt": "2026-08-28T14:00:00.000Z",
      "firstPublishedAt": "2026-09-01T09:30:00.000Z",
      "modifiedAt": "2026-09-01T09:29:45.000Z"
    },
    "brand": {
      "name": "Stride Lab",
      "websiteUrl": "https://stridelab.example.com"
    }
  }
}

Los campos que hacen el trabajo importante:

CampoCómo usarlo
article.idTu clave estable. Guárdala y actualiza la entrada existente cuando vuelva el mismo id
article.contentEl artículo en tres formatos: markdown, html listo para usar y blocks estructurados para renderizadores propios. Usa el que encaje con tu sitio
article.titleEl titular. No se repite dentro del contenido, así que renderízalo tú
article.metaDescriptionMáximo 160 caracteres, pensado para tu etiqueta <meta name="description">
article.datesAsigna firstPublishedAt a tu fecha de publicación y modifiedAt a la de actualización
article.coverImage / article.imagesLa portada más cada imagen del cuerpo en orden, con texto alternativo y pies de foto cuando existen

Conviene saber

  • Descarga las imágenes. Las URLs de las imágenes son públicas y funcionan en el momento de la entrega, pero GrowWriter no es un alojamiento de imágenes a largo plazo. Guarda cada imagen (portada e interiores) en tu propio almacenamiento, y recuerda que las mismas URLs también aparecen dentro de content.html y content.markdown, así que reescríbelas ahí también.
  • Las entregas se reintentan solas. Si tu endpoint está caído un momento, GrowWriter lo intenta hasta 3 veces (esperando 1 segundo y luego 3). Cada reintento va firmado de nuevo y mantiene el mismo id X-GrowWriter-Delivery.
  • Cualquier 2xx cuenta como éxito. Las redirecciones no. Si tu sitio responde 301 o 308, la entrega falla, así que apunta el webhook a la URL final.
  • No hay evento de borrado. GrowWriter solo crea y actualiza. Quitar una entrada de tu sitio siempre es decisión tuya.
  • Una conexión, un secreto. Desconectar y volver a conectar el webhook crea un secreto nuevo y retira el anterior. Actualiza tu variable de entorno cuando lo hagas.

Solución de problemas

"Tu endpoint no respondió a tiempo": GrowWriter espera 10 segundos por intento. Si tu código descarga imágenes o llama a otros servicios antes de responder, mueve ese trabajo a un proceso en segundo plano y responde 200 en cuanto el artículo esté guardado.

"Tu endpoint rechazó la entrega": tu endpoint respondió con algo distinto de un 2xx. Causas habituales: la comprobación de la firma falló porque el cuerpo se parseó antes de verificar (usa el cuerpo sin procesar), hay un secreto equivocado configurado (compáralo con el del diálogo) o la URL redirige (usa la dirección final directamente).

"La entrega falló tras varios intentos": GrowWriter no pudo alcanzar tu endpoint. Comprueba que la URL está activa, usa https:// y es accesible públicamente. Las direcciones de redes privadas o localhost están bloqueadas por seguridad. Mientras desarrollas en local, un servicio de túnel puede darle a tu servidor una dirección HTTPS pública temporal.

La prueba funciona pero los artículos reales fallan: el ping es diminuto; un artículo completo puede ocupar unos cientos de kilobytes. Comprueba que tu servidor acepta cuerpos JSON de ese tamaño y que tu lógica de guardado maneja el payload completo.

¿Sigues atascado? Escríbenos desde la aplicación y lo resolvemos juntos.

En esta página