Solicitudes
Callbacks y firma
Cuando una solicitud llega a su final — completada, expirada o cancelada — formbase envía por POST una notificación firmada a la URL que le dio tu automatización. Esa llamada es la que reanuda la ejecución.
Qué se dispara, y cuándo
| Evento | Cuándo |
|---|---|
| request.completed | El destinatario envió el formulario. Incluye las respuestas. |
| request.expired | El plazo pasó mientras la solicitud seguía pendiente. |
| request.canceled | Tú o tu automatización la retiraron. |
Los tres llegan a la misma URL, así que decide según type antes de asumir que hay respuestas. Ese es todo el sentido de
dispararse en cada final: un flujo de trabajo aparcado en un cliente se reanuda tanto si respondió, como si te ignoró, o si se anuló.
Lo que llega
{
"id": "evt_kj7...",
"type": "request.completed",
"createdAt": "2026-03-04T09:31:40.000Z",
"apiVersion": "2026-09-24",
"test": false,
"data": {
"request": {
"id": "kd7...",
"externalId": "run-42",
"status": "completed",
"outcome": "approve",
"language": "en",
"recipient": { "email": "ada@acme.com", "name": "Ada" },
"metadata": { "runId": "run-42" },
"context": { "case_id": "CASE-9" },
"createdAt": "2026-03-04T09:20:00.000Z",
"completedAt": "2026-03-04T09:31:40.000Z"
},
"form": { "id": "j57...", "name": "Vendor onboarding", "snapshotId": "kx2..." },
"submission": {
"id": "jd7...",
"respondentEmail": "ada@acme.com",
"submittedAt": "2026-03-04T09:31:40.000Z",
"updatedAt": null,
"editCount": 0,
"pdfUrl": null,
"language": "en"
},
"answers": { "company_name": "Acme", "contacts": [{ "name": "Ada" }] },
"display": { "company_name": "Acme", "contacts": "Ada" }
}
}data.requestsiempre está presente — incluidos tuexternalId,metadataycontext, sin cambios. Lleva la marca de tiempo del final que ocurrió (completedAt,expiredAt, ocanceledAtcon uncancelReasonopcional).outcomesolo está presente cuando el destinatario respondió una pregunta de decisión —approve,declineochanges.form,submission,answersydisplayaparecen solo al completarse, exactamente con la forma que lleva un webhook de respuesta.form.snapshotIdes la versión publicada exacta que respondió el destinatario;submission.pdfUrles una URL solo cuando el formulario conserva un PDF de la respuesta, y null en caso contrario.testestruecuando la solicitud se creó en modo de prueba — bifurca a partir de él, o descarta el evento.answersse indexa por clave de campo, con los grupos repetibles anidados como un objeto por instancia. Una respuesta de elección es el key de la opción segúnfields.list, no su etiqueta; la etiqueta está endisplay, bajo la misma clave.El POST llega como
Content-Type: application/jsonconUser-Agent: formbase, y llevaX-formbase-Event-Id,X-formbase-Event-TypeyX-formbase-Signature— para que puedas desduplicar y enrutar antes de analizar el cuerpo.
Desduplica por id
id es estable en cada reintento y cada repetición del mismo evento. Si tu receptor pudiera actuar dos veces sobre el mismo
id — una factura duplicada, un ticket duplicado — recuerda los ids que ya has procesado.
Verifica la firma
Cada callback lleva una cabecera de firma, X-formbase-Signature: t={unix seconds},sha256={hex}. El hex es
un HMAC-SHA256 de la marca de tiempo, un punto, y el cuerpo bruto de la solicitud, calculado con el
secreto de firma de solicitudes de tu espacio de trabajo.
Dos reglas, sea cual sea el lenguaje que uses:
Aplica el hash al cuerpo bruto, antes de cualquier análisis o reserialización. Un JSON reconvertido no son los mismos bytes.
Compara en tiempo constante —
crypto.timingSafeEqual,hmac.compare_digest— nunca con==.
import crypto from 'node:crypto'
export function verifyFormbaseCallback(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
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_callback(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_secondsEl secreto de firma de solicitudes
Un único secreto por espacio de trabajo firma todos los callbacks que salen de él. Lo encuentras en OAuth y claves de API
en la barra lateral del espacio de trabajo, en la tarjeta Secreto de firma de solicitudes. Está oculto por defecto;
Mostrar secreto lo revela y el botón de copiar lo copia. No es un valor de un solo uso — puedes volver y leerlo de nuevo.
El secreto se genera la primera vez que hace falta, así que un espacio de trabajo que nunca ha abierto esa tarjeta ni ha creado una
solicitud con callbackUrl todavía no tiene ninguno.
Regenerarlo no tiene periodo de gracia
Solo la persona propietaria del espacio de trabajo puede regenerar el secreto, y en el momento en que lo hace, el antiguo deja de funcionar — incluso para callbacks que ya se están reintentando. Actualiza primero tu receptor, y luego regenera. No hay una ventana en la que se acepten ambos secretos.
Reintentos
Un callback tiene ocho intentos: el primero, y luego siete reintentos con al menos 1, 2, 4, 8, 16, 32 y 60 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 intervalo, y el último intento llega unas cuatro horas después de que terminó la solicitud. Cada intento lleva los mismos
bytes y el mismo id: el payload se congela en el momento en que terminó la solicitud, así que un reintento describe lo que
pasó entonces, no cómo se ve la solicitud ahora. La URL de destino y el secreto de firma se leen en cada intento, no se congelan con él.
| Tu respuesta | Qué hace formbase |
|---|---|
| 2xx | Hecho. El callback se marca como entregado. |
| 408, 429, 5xx | Reintenta, respetando Retry-After cuando lo envías. |
| Otro 4xx | Se detiene. Tu endpoint rechazó la llamada; reintentar el mismo cuerpo no puede ayudar. |
| Tiempo de espera agotado o error de conexión | Reintenta según el mismo calendario. |
| URL bloqueada | Se detiene de inmediato. Un host que no resuelve, una dirección privada, o una URL sin HTTPS nunca pueden llegar a permitirse. Las redirecciones nunca se siguen, así que un 3xx también se detiene. |
Si se agota el presupuesto — tu receptor estuvo caído toda la tarde — el callback no se pierde. La solicitud recibe una insignia
Callback fallido, se avisa una vez por correo a la persona propietaria del espacio de trabajo con el host, el motivo y el
número de intentos, y las respuestas siguen legibles a través de requests.get. Para reenviarlo, abre la solicitud en la
página de Solicitudes y pulsa Reproducir, o llama a
requests.replayCallback. Reenvía el mismo payload congelado con el mismo id, que es exactamente lo que quiere un
receptor que desduplica.
Las suscripciones escuchan los mismos eventos
Una URL de callback pertenece a una sola solicitud. Cuando cada solicitud de un formulario debe llegar al mismo receptor, suscríbete una
vez en su lugar: las apps de formbase para Zapier y n8n hacen esto por ti, y webhooks.create lo hace desde código con
request_completed, request_expired o request_canceled como tipo de evento. Una suscripción recibe
este mismo envoltorio, firmado con su propio secreto en lugar del secreto de firma de solicitudes del espacio de trabajo, con su propio id
de evento y su propio presupuesto de reintentos. Una solicitud que tiene tanto una URL de callback como una suscripción correspondiente se
dispara dos veces, una a cada uno. Reproducir reenvía solo el callback; una suscripción reintenta por su cuenta y se
pausa tras cinco intentos fallidos.
Una URL de reanudación no es autenticación
Las herramientas de flujos de trabajo te dan una URL de reanudación difícil de adivinar, y es tentador tratarla como una prueba. Es un secreto portador — puede filtrarse en registros, y no te dice que el cuerpo no fue manipulado. Verifica también la firma en la rama reanudada.