# Referencia de webhooks

Esquema de payload, firma, reintentos y pruebas para los webhooks de Formstep.

## Referencia de webhooks

Esquema de payload, tipos de evento, firma, comportamiento de reintentos y endpoints REST de suscripción para Zapier y Make.

> ℹ️ **¿Buscas la guía de configuración?**
> <p>
>     Esta página documenta el contrato del payload y la API REST de suscripción. Para configurar un webhook personalizado para tu formulario
>     en la interfaz, consulta <a href="/es/integrations/webhooks">Webhooks personalizados</a>.
>   </p>

<h2 id="request">Solicitud</h2>
<p>
  <code>POST &lt;your-url&gt;</code> con <code>Content-Type: application/json</code>.
</p>

<h3 id="headers">Cabeceras</h3>
<ul>
  <li>
    <code>Content-Type: application/json</code>
  </li>
  <li>
    <code>X-Formstep-Signature: t=&#123;timestamp&#125;,sha256=&#123;hex&#125;</code> — presente cuando se configura un secreto de firma
    (ver más abajo)
  </li>
  <li>
    <code>X-Formstep-Event-Id</code> y <code>X-Formstep-Event-Type</code> — los mismos valores que <code>id</code> y <code>type</code> en el
    cuerpo, para que puedas deduplicar y enrutar antes de analizar. Un <a href="/es/requests/callbacks">callback de solicitud</a> envía las
    mismas dos.
  </li>
  <li>
    Cualquier cabecera personalizada que añadas durante la configuración. Se incluyen tal cual, salvo <code>Content-Type</code>, que no se
    puede sobrescribir. Las suscripciones nativas creadas mediante <code>webhooks.create</code> no tienen cabeceras personalizadas.
  </li>
</ul>

<h2 id="event-types">Tipos de evento</h2>

<h3 id="subscription-events">Eventos de suscripción</h3>
<p>Cuando configuras una integración de webhook, eliges qué evento activa las entregas:</p>

<p>
  Los tres eventos <code>request_*</code> se suscriben mediante <code>webhooks.create</code> y son lo que escuchan las apps de Formstep para
  Zapier, Make y n8n. Cada uno entrega el mismo envoltorio que envía un <a href="/es/requests/callbacks">callback de solicitud</a>, firmado
  con el secreto propio de la suscripción. Las solicitudes de prueba nunca llegan a una suscripción, y <code>requests.replayCallback</code>{' '}
  reenvía solo el callback. Una suscripción, un evento: <code>submission_created</code> es tráfico de enlace público y{' '}
  <code>request_completed</code> es tráfico de solicitudes, así que una solicitud completada nunca dispara <code>submission_created</code>,
  y un formulario con ambas suscripciones recibe una sola entrega por finalización. Una edición solo llega a una suscripción{' '}
  <code>submission_updated</code>, nunca a una <code>submission_created</code>. El webhook que configuras en Configuración del formulario no
  tiene elección de evento: recibe por igual las primeras respuestas y las ediciones, distinguidas por <code>type</code>.
</p>

<h3 id="payload-event-types">Tipos de evento en el payload</h3>
<p>
  El campo <code>type</code> en el cuerpo JSON indica qué ocurrió:
</p>
<ul>
  <li>
    <code>submission.completed</code> — una nueva respuesta completada
  </li>
  <li>
    <code>submission.updated</code> — una respuesta existente fue editada
  </li>
  <li>
    <code>submission.abandoned</code> — un borrador fue abandonado tras el periodo de inactividad configurado
  </li>
</ul>
<p>
  Un <a href="/es/requests/callbacks">callback de solicitud</a> y una suscripción <code>request_*</code> usan el mismo envoltorio con{' '}
  <code>request.completed</code>, <code>request.expired</code> y <code>request.canceled</code>, así que un solo analizador lee los seis.
</p>

<h2 id="payload">Estructura del payload</h2>

```
{
  "id": "evt_abc123",
  "type": "submission.completed",
  "createdAt": "2026-04-25T12:34:56.000Z",
  "apiVersion": "2026-09-24",
  "test": false,
  "data": {
    "form": { "id": "frm_...", "name": "Contact", "snapshotId": "snp_..." },
    "submission": {
      "id": "sub_...",
      "respondentEmail": "alice@example.com",
      "submittedAt": "2026-04-25T12:34:56.000Z",
      "updatedAt": null,
      "editCount": 0,
      "pdfUrl": null,
      "language": "en"
    },
    "answers": {
      "email": "alice@example.com",
      "plan": "pro",
      "attendees": [{ "attendee_name": "Grace Hopper" }, { "attendee_name": "Alan Turing" }]
    },
    "display": {
      "email": "alice@example.com",
      "plan": "Pro",
      "attendees": "Grace Hopper, Alan Turing"
    }
  }
}
```

<h3 id="fields-vs-answers">answers y display</h3>
<p>
  <code>answers</code> es el objeto plano <code>{'{ field key: value }'}</code>, indexado por las claves de campo fijadas al publicar.
  Consúltalo cuando un flujo de trabajo ramifique o almacene un valor: <code>answers.email</code>, sin array que recorrer. Una respuesta de
  elección es la <strong>clave</strong> de la opción elegida — el <code>key</code> que <code>fields.list</code> indica para esa opción — así
  que es el mismo sea cual sea el idioma en que respondió el respondente. Una fecha es una cadena ISO, un número es un número, una selección
  múltiple es un array de claves de opción. Los campos sin responder se omiten, nunca se envían como <code>null</code>.
</p>
<p>
  <code>display</code> lleva las mismas claves con texto legible por humanos: la etiqueta de la opción en lugar de su clave, una fecha con
  formato, una lista unida. Consúltalo cuando una persona vaya a ver el valor — un mensaje de Slack, una celda de hoja de cálculo, un
  correo.
</p>
<p>
  Un grupo repetitivo aparece en <code>answers</code> una vez, bajo la propia clave de campo del grupo, como un array de objetos de fila
  indexados por la clave de campo de cada miembro — <code>answers.attendees[0].attendee_name</code> arriba — y en <code>display</code> como
  una sola línea con las filas unidas. Un miembro nunca se eleva al nivel superior.{' '}
  <a href="/es/building-forms/calculated-fields">Los campos calculados</a> aparecen en ambos mapas bajo el nombre del campo calculado como
  su clave (<code>answers.total</code>).
</p>
<h3 id="bookings-and-payments">Reservas y pagos</h3>
<p>
  Una pregunta de Programar cita y un campo de Pago llevan cada uno un objeto en <code>answers</code>, bajo la clave de campo de la
  pregunta, y una línea de texto en <code>display</code>. Las horas son instantes ISO, así que una hoja de cálculo o un flujo de trabajo
  puede interpretarlas sea cual sea el idioma en que respondió el respondente:
</p>

```
{
  "book_a_call": {
    "status": "confirmed",
    "start": "2026-09-29T07:00:00.000Z",
    "end": "2026-09-29T07:30:00.000Z",
    "timeZone": "Europe/Oslo",
    "attendee": { "name": "Grace Hopper", "email": "grace@example.com" },
    "meetingUrl": "https://app.cal.com/video/...",
    "provider": "cal.com",
    "providerBookingId": "...",
    "eventTitle": "Intro call"
  },
  "pay_the_fee": {
    "status": "paid",
    "amount": 40,
    "currency": "USD",
    "amountRefunded": 0,
    "receiptUrl": "https://pay.stripe.com/receipts/...",
    "paidAt": "2026-09-24T10:12:00.000Z",
    "refundedAt": null,
    "disputedAt": null,
    "provider": "stripe",
    "providerPaymentIntentId": "pi_..."
  }
}
```

<p>
  El <code>status</code> de una reserva es <code>confirmed</code>, <code>rescheduled</code>, <code>cancelled</code>, <code>rejected</code> o{' '}
  <code>no_show</code>. El de un pago es <code>paid</code>, <code>partially_refunded</code>, <code>refunded</code> o <code>disputed</code>,
  y <code>amount</code> está en la unidad principal de la moneda: <code>40</code> son $40.00. Cuando Cal.com mueve una reserva o Stripe
  reembolsa un pago después del envío, Formstep actualiza el objeto, así que <code>submissions.list</code> y los eventos posteriores
  muestran el estado actual; no se envía ningún evento nuevo por el cambio.
</p>
<p>
  Antes de <code>apiVersion</code> <code>2026-09-24</code>, una reserva se enviaba como una sola frase en <code>answers</code> y un pago no
  se enviaba en absoluto.
</p>

<p>
  El mapeo de campos del webhook se aplica a ambos mapas a la vez: elige campos "seleccionados" y los demás se omiten; renombra la columna
  de un campo y el nuevo nombre es su clave tanto en <code>answers</code> como en <code>display</code>. Un campo que el formulario publicó
  antes de que existieran las claves de campo sale bajo su id de elemento; vuelve a publicar el formulario para darle una clave legible.
</p>

<h3 id="schema">Títulos y tipos de campo</h3>
<p>
  El evento no repite el título ni el tipo de cada campo. Léelos desde <code>fields.list</code>, que es estable por{' '}
  <code>data.form.snapshotId</code>, así que puedes cachear la lista de campos y volver a pedirla solo cuando cambie el id del snapshot. Un
  receptor que no puede hacer una segunda llamada puede activar <strong>Enviar la lista de campos con cada evento</strong> en la
  configuración del webhook; el evento entonces lleva <code>data.schema</code>, una entrada por campo. Una pregunta de elección lista sus{' '}
  <code>options</code> y una matriz sus <code>rows</code> y <code>columns</code>, cada una como <code>{'{ key, label }'}</code>, así que las
  claves en <code>answers</code> se resuelven en etiquetas sin una segunda llamada:
</p>

```
[
  { "key": "email", "title": "Email", "type": "email", "group": null },
  { "key": "plan", "title": "Plan", "type": "radio", "group": null, "options": [{ "key": "free", "label": "Free" }, { "key": "pro", "label": "Pro" }] },
  { "key": "attendee_name", "title": "Attendee name", "type": "text", "group": "attendees" }
]
```

<h3 id="request-block">El bloque request</h3>
<p>
  En el <a href="/es/integrations/webhooks">webhook personalizado</a> configurado en la configuración del formulario, un envío que respondió
  a una <a href="/es/requests/overview">solicitud</a> lleva un objeto extra dentro de <code>data</code>, <code>request</code>. Está ausente
  en todo envío de enlace público, así que su presencia es la forma en que ese receptor distingue los dos canales. Una suscripción de
  Zapier, Make o n8n nunca lo ve: el tráfico de solicitudes llega a una suscripción como <code>request.completed</code>, que lleva el bloque
  request completo.
</p>

```
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
```

<ul>
  <li>
    <code>id</code> — la solicitud que respondió este envío. Pásala a <code>requests.get</code> para ver el panorama completo.
  </li>
  <li>
    <code>externalId</code> y <code>metadata</code> — tu propia contabilidad, tal como la diste en <code>requests.create</code>. Cada una
    solo está presente si se estableció.
  </li>
</ul>

> ℹ️ **Los webhooks no son callbacks**
> <p>
>     Un webhook de envío se dispara en un envío; el bloque request solo nombra la solicitud que respondió. Un{' '}
>     <a href="/es/requests/callbacks">callback</a> se dispara cuando una solicitud termina — completada, expirada o cancelada — y lleva{' '}
>     <code>context</code> y <code>outcome</code>. La expiración y la cancelación no tienen envío, así que nunca se dispara un webhook de
>     envío para ellas. Para escuchar el fin de una solicitud sin una URL de callback, suscríbete a <code>request_completed</code>,{' '}
>     <code>request_expired</code> o <code>request_canceled</code> mediante <code>webhooks.create</code>.
>   </p>

<h3 id="submission-pdf">PDF de la respuesta</h3>
<p>
  <code>data.submission.pdfUrl</code> es un enlace al PDF de la respuesta. Un formulario con un webhook personalizado o una suscripción de
  Zapier, Make o n8n conserva un PDF de cada respuesta, así que sus eventos incluyen el enlace. Solo es <code>null</code> cuando la
  respuesta no tiene PDF.
</p>

<h3 id="submission-language">Idioma de la respuesta</h3>
<p>
  <code>data.submission.language</code> es el código BCP-47 del idioma en que el respondente envió el formulario (para formularios
  traducidos). Es <code>null</code> para formularios de un único idioma. Úsalo para enrutar o ramificar según el idioma del respondente sin
  necesidad de una consulta adicional.
</p>

<h2 id="abandoned-submissions">Payloads de respuestas abandonadas</h2>
<p>
  Cuando te suscribes a <code>submission_abandoned</code>, Formstep comprueba borradores inactivos cada hora. Si un borrador ha estado
  inactivo más allá del período de inactividad configurado, Formstep realiza una entrega.
</p>
<p>
  Las integraciones nativas de la API deben enviar <code>idleWindow</code> a <code>webhooks.create</code>. Los valores aceptados son{' '}
  <code>12h</code>, <code>1d</code>, <code>3d</code> y <code>1w</code>. No hay un valor predeterminado implícito; una suscripción a abandono
  sin valor se rechaza.
</p>

<p>La estructura del payload es idéntica a la de una respuesta completada. Hay dos diferencias:</p>
<ul>
  <li>
    <strong>Las respuestas pueden ser escasas</strong> — solo aparecen en <code>answers</code> y <code>display</code> las preguntas que el
    respondente respondió.
  </li>
  <li>
    <strong>
      <code>submittedAt</code>
    </strong>{' '}
    — usa la marca de tiempo de envío como alternativa, ya que el respondente nunca envió formalmente la respuesta.
  </li>
</ul>
<p>
  Cada integración se activa como máximo una vez por borrador abandonado. Tras la entrega, el borrador queda excluido de futuros ciclos de
  comprobación.
</p>

<h2 id="signing">Firma</h2>
<p>
  Cada webhook tiene su propio secreto de firma — se establece en la interfaz cuando configuras un webhook personalizado, o con el parámetro
  opcional <code>signingSecret</code> (32–255 caracteres) en <code>webhooks.create</code>. No es el secreto de firma de solicitudes del
  espacio de trabajo usado para los <a href="/es/requests/callbacks">callbacks de solicitud</a>, pero la cabecera y el algoritmo son
  idénticos, así que un solo verificador se encarga de ambos.
</p>
<p>
  Toda entrega firmada lleva <code>X-Formstep-Signature: t=&#123;seconds&#125;,sha256=&#123;hex&#125;</code>. El digest es un HMAC-SHA256 de{' '}
  <code>&#123;t&#125;.&#123;raw body&#125;</code>, con tu secreto como clave. Dos reglas: calcula el hash del cuerpo{' '}
  <strong>sin procesar</strong> antes de cualquier análisis o reserialización, y compara en tiempo constante.
</p>

```
import crypto from 'node:crypto'

  if (!header) return false
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
  if (!parts.t || !parts.sha256) return false

const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.sha256, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false

return Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds
}
```

```
import hashlib, hmac, time
def verify_formstep_webhook(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
  parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
  t, received = parts.get('t'), parts.get('sha256')
  if not t or not received:
    return False
  expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
  if not hmac.compare_digest(expected, received):
    return False
  return abs(time.time() - int(t)) <= tolerance_seconds
```

<p>
  Formstep no impone una ventana contra repetición, así que la tolerancia anterior la eliges tú. Cambiar el secreto tiene efecto en el
  siguiente intento, incluidos los reintentos ya en curso — actualiza primero tu receptor.
</p>

> ⚠️ **Verifica siempre en producción**
> <p>Sin verificación, cualquiera que descubra tu URL puede enviar respuestas falsas.</p>

<h2 id="retries">Reintentos</h2>
<p>
  Formstep realiza hasta 5 intentos por entrega — 1 inicial y 4 reintentos — con al menos 1, 2, 4 y 8 minutos de diferencia entre sí.
  Formstep busca reintentos pendientes cada 30 minutos, así que un reintento puede llegar hasta media hora después de que termina su
  retroceso, y el último intento unas dos horas después del primero. Se respeta una cabecera <code>Retry-After</code> en tu respuesta cuando
  pide más tiempo que el siguiente paso de retroceso. Una entrega se considera fallida si tu endpoint:
</p>
<ul>
  <li>Devuelve un estado que no es 2xx</li>
  <li>Se agota el tiempo de espera</li>
  <li>Restablece la conexión</li>
</ul>
<p>
  Un caso nunca se reintenta: un destino bloqueado, no resoluble, o que resuelve a una dirección privada. La URL se revalida — DNS incluido
  — justo antes de cada intento, así que un host que deja de estar permitido falla la entrega de inmediato en lugar de gastar el presupuesto
  de reintentos.
</p>
<p>
  Tras 5 entregas consecutivas fallidas, la integración se pausa. Soluciona el endpoint y vuelve a habilitarla desde Configuración del
  formulario → Integraciones; una entrega exitosa reinicia el contador.
</p>
<p>
  Los reintentos de entrega del mismo evento reutilizan el mismo <code>id</code>, así que deduplica almacenando los ids procesados. Un
  evento genuinamente nuevo — por ejemplo, cuando una persona edita su envío — llega con un <code>id</code> nuevo y{' '}
  <code>type: "submission.updated"</code>: en el webhook de Configuración del formulario, o en una suscripción{' '}
  <code>submission_updated</code>. El <code>createdAt</code> es el momento en que se puso en cola el evento, no el del intento, así que se
  mantiene igual también entre reintentos. Para distinguir las ediciones, lee <code>data.submission.editCount</code>: aumenta con cada
  edición, y <code>data.submission.updatedAt</code> indica cuándo ocurrió la última.
</p>

<h2 id="testing">Pruebas</h2>
<p>
  Tanto el panel de configuración como el panel de detalle de la integración tienen un botón <strong>Enviar prueba</strong>. Envía una
  muestra del evento suscrito a tu URL para que puedas verificar la conexión sin esperar a una respuesta o solicitud real. Las mismas
  muestras están disponibles a través de la API como <code>submissions.sample</code> y <code>requests.sample</code>.
</p>
<p>Para el desarrollo local, expón tu servidor de desarrollo con un túnel:</p>

```
# ngrok
ngrok http 3000

# cloudflare tunnel

cloudflared tunnel --url http://localhost:3000
```

> 💡 **Desarrollo local**
> <p>Usa la URL del túnel como tu endpoint de webhook y luego haz clic en Enviar prueba para verificar de extremo a extremo.</p>

<h2 id="next-steps">Próximos pasos</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Configuración de webhooks](/es/integrations/webhooks) — Configura webhooks para tu formulario
  - [Planes y precios](/es/subscription-billing/plans-pricing) — Compara las funciones y límites de la API por plan
</div>
