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/jsonX-formbase-Signature: t={timestamp},sha256={hex}— presente cuando se configura un secreto de firma (ver más abajo)X-formbase-Event-IdyX-formbase-Event-Type— los mismos valores queidytypeen 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 mediantewebhooks.createno tienen cabeceras personalizadas.
Tipos de evento
Eventos de suscripción
Cuando configuras una integración de webhook, eliges qué evento activa las entregas:
| Evento | Cuándo se activa |
|---|---|
| submission_created | Un respondente completa y envía el formulario. Este es el valor predeterminado. |
| submission_updated | Un respondente edita una respuesta que ya envió, cuando el formulario permite editar después de enviar. |
| submission_abandoned | Un borrador de respuesta ha estado inactivo más allá del período configurado. Requiere seguimiento de respuestas parciales (Pro). |
| request_completed | Un destinatario completa una solicitud del formulario. Lleva el bloque request y las respuestas. |
| request_expired | Una solicitud del formulario expira antes de que el destinatario la complete. Solo el bloque request. |
| request_canceled | Se 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 completadasubmission.updated— una respuesta existente fue editadasubmission.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
{
"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"
}
}
}| Clave | Qué es |
|---|---|
| id | El id del evento. Los reintentos lo reutilizan — deduplica con él. |
| type | Uno de los seis tipos de evento anteriores. |
| createdAt | Cuándo se puso en cola el evento, no cuándo se hizo este intento de entrega. Se mantiene igual entre reintentos. |
| apiVersion | El 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. |
| test | true para un envío de muestra o de prueba, y para una solicitud creada en modo de prueba. Siempre presente. |
| data.form.snapshotId | La versión publicada que respondió el respondente. Las claves de campo, títulos y tipos son fijos por snapshot. |
| data.submission.updatedAt | Cuándo el respondente editó la respuesta por última vez, o null hasta la primera edición. |
| data.submission.editCount | Cuántas veces el respondente editó la respuesta después de enviarla: 0 en submission.completed, 1 en la primera edición. |
| data.answers | Todas las respuestas, indexadas por clave de campo. Cada respuesta aparece una vez. |
| data.display | Texto legible por humanos para cada respuesta, bajo las mismas claves. |
| data.schema | Opcional: 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:
{
"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:
[
{ "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.
"request": {
"id": "kd7...",
"externalId": "run-42",
"metadata": { "crm_record": "rec_88" }
}id— la solicitud que respondió este envío. Pásala arequests.getpara ver el panorama completo.externalIdymetadata— tu propia contabilidad, tal como la diste enrequests.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 inactividad | Descripción |
|---|---|
| 12 horas | Para seguimientos el mismo día |
| 1 día | Un intervalo razonable antes de hacer un recordatorio |
| 3 días | Para formularios menos urgentes |
| 1 semana | Para 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
answersydisplaylas 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.
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
}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_secondsformbase 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.
Verifica siempre en producción
Sin verificación, cualquiera que descubra tu URL puede enviar respuestas falsas.
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:
# ngrok
ngrok http 3000
# cloudflare tunnel
cloudflared tunnel --url http://localhost:3000Desarrollo 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.