formbasedocs
Ir a la appApp

Desarrolladores

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?

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 Webhooks personalizados.

Solicitud

POST <your-url> con Content-Type: application/json.

Cabeceras

  • Content-Type: application/json
  • X-formbase-Signature: t={timestamp},sha256={hex} — presente cuando se configura un secreto de firma (ver más abajo)

  • X-formbase-Event-Id y X-formbase-Event-Type — los mismos valores que id y type en el cuerpo, para que puedas deduplicar y enrutar antes de analizar. Un callback de solicitud envía las mismas dos.

  • Cualquier cabecera personalizada que añadas durante la configuración. Se incluyen tal cual, salvo Content-Type, que no se puede sobrescribir. Las suscripciones nativas creadas mediante webhooks.create no tienen cabeceras personalizadas.

Tipos de evento

Eventos de suscripción

Cuando configuras una integración de webhook, eliges qué evento activa las entregas:

EventoCuándo se activa
submission_createdUn respondente completa y envía el formulario. Este es el valor predeterminado.
submission_updatedUn respondente edita una respuesta que ya envió, cuando el formulario permite editar después de enviar.
submission_abandonedUn borrador de respuesta ha estado inactivo más allá del período configurado. Requiere seguimiento de respuestas parciales (Pro).
request_completedUn destinatario completa una solicitud del formulario. Lleva el bloque request y las respuestas.
request_expiredUna solicitud del formulario expira antes de que el destinatario la complete. Solo el bloque request.
request_canceledSe cancela una solicitud del formulario. Solo el bloque request.

Los tres eventos request_* se suscriben mediante webhooks.create y son lo que escuchan las apps de formbase para Zapier, Make y n8n. Cada uno entrega el mismo envoltorio que envía un callback de solicitud, firmado con el secreto propio de la suscripción. Las solicitudes de prueba nunca llegan a una suscripción, y requests.replayCallback reenvía solo el callback. Una suscripción, un evento: submission_created es tráfico de enlace público y request_completed es tráfico de solicitudes, así que una solicitud completada nunca dispara submission_created, y un formulario con ambas suscripciones recibe una sola entrega por finalización. Una edición solo llega a una suscripción submission_updated, nunca a una submission_created. 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 type.

Tipos de evento en el payload

El campo type en el cuerpo JSON indica qué ocurrió:

  • submission.completed — una nueva respuesta completada

  • submission.updated — una respuesta existente fue editada

  • submission.abandoned — un borrador fue abandonado tras el periodo de inactividad configurado

Un callback de solicitud y una suscripción request_* usan el mismo envoltorio con request.completed, request.expired y request.canceled, así que un solo analizador lee los seis.

Estructura del payload

POST body
json
{
  "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"
    }
  }
}
ClaveQué es
idEl id del evento. Los reintentos lo reutilizan — deduplica con él.
typeUno de los seis tipos de evento anteriores.
createdAtCuándo se puso en cola el evento, no cuándo se hizo este intento de entrega. Se mantiene igual entre reintentos.
apiVersionEl contrato del payload, como fecha. Cambia cuando se elimina una clave, se renombra o cambia de significado. Las claves nuevas llegan sin cambio de versión.
testtrue para un envío de muestra o de prueba, y para una solicitud creada en modo de prueba. Siempre presente.
data.form.snapshotIdLa versión publicada que respondió el respondente. Las claves de campo, títulos y tipos son fijos por snapshot.
data.submission.updatedAtCuándo el respondente editó la respuesta por última vez, o null hasta la primera edición.
data.submission.editCountCuántas veces el respondente editó la respuesta después de enviarla: 0 en submission.completed, 1 en la primera edición.
data.answersTodas las respuestas, indexadas por clave de campo. Cada respuesta aparece una vez.
data.displayTexto legible por humanos para cada respuesta, bajo las mismas claves.
data.schemaOpcional: la lista de campos (key, title, type, group, y las claves de opción o de fila y columna con sus etiquetas), cuando el webhook se configuró para enviarla.

answers y display

answers es el objeto plano { field key: value }, indexado por las claves de campo fijadas al publicar. Consúltalo cuando un flujo de trabajo ramifique o almacene un valor: answers.email, sin array que recorrer. Una respuesta de elección es la clave de la opción elegida — el key que fields.list 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 null.

display 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.

Un grupo repetitivo aparece en answers 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 — answers.attendees[0].attendee_name arriba — y en display como una sola línea con las filas unidas. Un miembro nunca se eleva al nivel superior. Los campos calculados aparecen en ambos mapas bajo el nombre del campo calculado como su clave (answers.total).

Reservas y pagos

Una pregunta de Programar cita y un campo de Pago llevan cada uno un objeto en answers, bajo la clave de campo de la pregunta, y una línea de texto en display. 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:

data.answers
json
{
  "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_..."
  }
}

El status de una reserva es confirmed, rescheduled, cancelled, rejected o no_show. El de un pago es paid, partially_refunded, refunded o disputed, y amount está en la unidad principal de la moneda: 40 son $40.00. Cuando Cal.com mueve una reserva o Stripe reembolsa un pago después del envío, formbase actualiza el objeto, así que submissions.list y los eventos posteriores muestran el estado actual; no se envía ningún evento nuevo por el cambio.

Antes de apiVersion 2026-09-24, una reserva se enviaba como una sola frase en answers y un pago no se enviaba en absoluto.

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 answers como en display. 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.

Títulos y tipos de campo

El evento no repite el título ni el tipo de cada campo. Léelos desde fields.list, que es estable por data.form.snapshotId, 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 Enviar la lista de campos con cada evento en la configuración del webhook; el evento entonces lleva data.schema, una entrada por campo. Una pregunta de elección lista sus options y una matriz sus rows y columns, cada una como { key, label }, así que las claves en answers se resuelven en etiquetas sin una segunda llamada:

data.schema
json
[
  { "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" }
]

El bloque request

En el webhook personalizado configurado en la configuración del formulario, un envío que respondió a una solicitud lleva un objeto extra dentro de data, request. 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 request.completed, que lleva el bloque request completo.

Añadido a data
json
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
  • id — la solicitud que respondió este envío. Pásala a requests.get para ver el panorama completo.

  • externalId y metadata — tu propia contabilidad, tal como la diste en requests.create. Cada una solo está presente si se estableció.

Los webhooks no son callbacks

Un webhook de envío se dispara en un envío; el bloque request solo nombra la solicitud que respondió. Un callback se dispara cuando una solicitud termina — completada, expirada o cancelada — y lleva context y outcome. 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 request_completed, request_expired o request_canceled mediante webhooks.create.

PDF de la respuesta

data.submission.pdfUrl 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 null cuando la respuesta no tiene PDF.

Idioma de la respuesta

data.submission.language es el código BCP-47 del idioma en que el respondente envió el formulario (para formularios traducidos). Es null para formularios de un único idioma. Úsalo para enrutar o ramificar según el idioma del respondente sin necesidad de una consulta adicional.

Payloads de respuestas abandonadas

Cuando te suscribes a submission_abandoned, formbase comprueba borradores inactivos cada hora. Si un borrador ha estado inactivo más allá del período de inactividad configurado, formbase realiza una entrega.

Las integraciones nativas de la API deben enviar idleWindow a webhooks.create. Los valores aceptados son 12h, 1d, 3d y 1w. No hay un valor predeterminado implícito; una suscripción a abandono sin valor se rechaza.

Período de inactividadDescripción
12 horasPara seguimientos el mismo día
1 díaUn intervalo razonable antes de hacer un recordatorio
3 díasPara formularios menos urgentes
1 semanaPara formularios de baja frecuencia

La estructura del payload es idéntica a la de una respuesta completada. Hay dos diferencias:

  • Las respuestas pueden ser escasas — solo aparecen en answers y display las preguntas que el respondente respondió.

  • submittedAt

    — usa la marca de tiempo de envío como alternativa, ya que el respondente nunca envió formalmente la respuesta.

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.

Firma

Cada webhook tiene su propio secreto de firma — se establece en la interfaz cuando configuras un webhook personalizado, o con el parámetro opcional signingSecret (32–255 caracteres) en webhooks.create. No es el secreto de firma de solicitudes del espacio de trabajo usado para los callbacks de solicitud, pero la cabecera y el algoritmo son idénticos, así que un solo verificador se encarga de ambos.

Toda entrega firmada lleva X-formbase-Signature: t={seconds},sha256={hex}. El digest es un HMAC-SHA256 de {t}.{raw body}, con tu secreto como clave. Dos reglas: calcula el hash del cuerpo sin procesar antes de cualquier análisis o reserialización, y compara en tiempo constante.

verify.ts
ts
import crypto from 'node:crypto'

export function verifyFormbaseWebhook(rawBody: string, header: string | undefined, secret: string, toleranceSeconds = 300): boolean {
  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
}
verify.py
python
import hashlib, hmac, time
def verify_formbase_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

formbase 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.

Reintentos

formbase realiza hasta 5 intentos por entrega — 1 inicial y 4 reintentos — con al menos 1, 2, 4 y 8 minutos de diferencia entre sí. formbase 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 Retry-After en tu respuesta cuando pide más tiempo que el siguiente paso de retroceso. Una entrega se considera fallida si tu endpoint:

  • Devuelve un estado que no es 2xx
  • Se agota el tiempo de espera
  • Restablece la conexión

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.

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.

Los reintentos de entrega del mismo evento reutilizan el mismo id, así que deduplica almacenando los ids procesados. Un evento genuinamente nuevo — por ejemplo, cuando una persona edita su envío — llega con un id nuevo y type: “submission.updated”: en el webhook de Configuración del formulario, o en una suscripción submission_updated. El createdAt 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 data.submission.editCount: aumenta con cada edición, y data.submission.updatedAt indica cuándo ocurrió la última.

Pruebas

Tanto el panel de configuración como el panel de detalle de la integración tienen un botón Enviar prueba. 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 submissions.sample y requests.sample.

Para el desarrollo local, expón tu servidor de desarrollo con un túnel:

bash
# ngrok
ngrok http 3000

# cloudflare tunnel

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

Desarrollo local

Usa la URL del túnel como tu endpoint de webhook y luego haz clic en Enviar prueba para verificar de extremo a extremo.

Próximos pasos