# Callbacks y firma

Reanuda tu flujo de trabajo cuando termina una solicitud, y demuestra que la llamada viene realmente de Formstep.

## Callbacks y firma

Cuando una solicitud llega a su final — completada, expirada o cancelada — Formstep 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.

<h2 id="what-fires">Qué se dispara, y cuándo</h2>

<p>
  Los tres llegan a la misma URL, así que decide según <code>type</code> 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ó.
</p>

<h2 id="payload">Lo que llega</h2>

```
{
  "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" }
  }
}
```

<ul>
  <li>
    <code>data.request</code> siempre está presente — incluidos tu <code>externalId</code>, <code>metadata</code> y <code>context</code>,
    sin cambios. Lleva la marca de tiempo del final que ocurrió (<code>completedAt</code>, <code>expiredAt</code>, o <code>canceledAt</code>{' '}
    con un <code>cancelReason</code> opcional).
  </li>
  <li>
    <code>outcome</code> solo está presente cuando el destinatario respondió una{' '}
    <a href="/es/requests/decisions-and-approvals">pregunta de decisión</a> — <code>approve</code>, <code>decline</code> o{' '}
    <code>changes</code>.
  </li>
  <li>
    <code>form</code>, <code>submission</code>, <code>answers</code> y <code>display</code> aparecen solo al completarse, exactamente con la
    forma que lleva un <a href="/es/developers/webhooks-reference#payload">webhook de respuesta</a>. <code>form.snapshotId</code> es la
    versión publicada exacta que respondió el destinatario; <code>submission.pdfUrl</code> es una URL solo cuando el formulario conserva un
    PDF de la respuesta, y null en caso contrario.
  </li>
  <li>
    <code>test</code> es <code>true</code> cuando la solicitud se creó en{' '}
    <a href="/es/requests/creating-requests#test-mode">modo de prueba</a> — bifurca a partir de él, o descarta el evento.
  </li>
  <li>
    <code>answers</code> se indexa por <a href="/es/requests/field-keys">clave de campo</a>, con los grupos repetibles anidados como un
    objeto por instancia. Una respuesta de elección es el <strong>key</strong> de la opción según <code>fields.list</code>, no su etiqueta;
    la etiqueta está en <code>display</code>, bajo la misma clave.
  </li>
  <li>
    El POST llega como <code>Content-Type: application/json</code> con <code>User-Agent: Formstep</code>, y lleva{' '}
    <code>X-Formstep-Event-Id</code>, <code>X-Formstep-Event-Type</code> y <code>X-Formstep-Signature</code> — para que puedas desduplicar y
    enrutar antes de analizar el cuerpo.
  </li>
</ul>

> ⚠️ **Desduplica por id**
> <p>
>     <code>id</code> 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.
>   </p>

<h2 id="verify">Verifica la firma</h2>

<p>
  Cada callback lleva una cabecera de firma, <code>X-Formstep-Signature: t=&#123;unix seconds&#125;,sha256=&#123;hex&#125;</code>. El hex es
  un HMAC-SHA256 de la marca de tiempo, un punto, y el cuerpo bruto de la solicitud, calculado con el{' '}
  <strong>secreto de firma de solicitudes</strong> de tu espacio de trabajo.
</p>

<p>Dos reglas, sea cual sea el lenguaje que uses:</p>

<ol>
  <li>
    Aplica el hash al cuerpo <strong>bruto</strong>, antes de cualquier análisis o reserialización. Un JSON reconvertido no son los mismos
    bytes.
  </li>
  <li>
    Compara en tiempo constante — <code>crypto.timingSafeEqual</code>, <code>hmac.compare_digest</code> — nunca con <code>==</code>.
  </li>
</ol>

  
    
```
import crypto from 'node:crypto'

  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_formstep_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_seconds
```

  

<h2 id="secret">El secreto de firma de solicitudes</h2>

<p>
  Un único secreto por espacio de trabajo firma todos los callbacks que salen de él. Lo encuentras en <strong>OAuth y claves de API</strong>{' '}
  en la barra lateral del espacio de trabajo, en la tarjeta <strong>Secreto de firma de solicitudes</strong>. Está oculto por defecto;{' '}
  <strong>Mostrar secreto</strong> 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 <code>callbackUrl</code> todavía no tiene ninguno.
</p>

> ❗ **Regenerarlo no tiene periodo de gracia**
> <p>
>     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. <strong>Actualiza primero tu receptor, y luego regenera.</strong> No
>     hay una ventana en la que se acepten ambos secretos.
>   </p>

<h2 id="retries">Reintentos</h2>

<p>
  Un callback tiene <strong>ocho intentos</strong>: el primero, y luego siete reintentos con al menos 1, 2, 4, 8, 16, 32 y 60 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 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 <code>id</code>: 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.
</p>

<p>
  Si se agota el presupuesto — tu receptor estuvo caído toda la tarde — el callback no se pierde. La solicitud recibe una insignia{' '}
  <strong>Callback fallido</strong>, 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 <code>requests.get</code>. Para reenviarlo, abre la solicitud en la{' '}
  <a href="/es/requests/managing-requests">página de Solicitudes</a> y pulsa <strong>Reproducir</strong>, o llama a{' '}
  <code>requests.replayCallback</code>. Reenvía el mismo payload congelado con el mismo <code>id</code>, que es exactamente lo que quiere un
  receptor que desduplica.
</p>

<h2 id="subscriptions">Las suscripciones escuchan los mismos eventos</h2>

<p>
  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 Formstep para Zapier y n8n hacen esto por ti, y <code>webhooks.create</code> lo hace desde código con{' '}
  <code>request_completed</code>, <code>request_expired</code> o <code>request_canceled</code> 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. <strong>Reproducir</strong> reenvía solo el callback; una suscripción reintenta por su cuenta y se
  pausa tras cinco intentos fallidos.
</p>

> 💡 **Una URL de reanudación no es autenticación**
> <p>
>     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.
>   </p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Solución de problemas](/es/requests/troubleshooting) — Cuando un callback sigue fallando.
  - [Referencia de webhooks](/es/developers/webhooks-reference) — Los mapas answers y display que lleva una finalización, completos.
</div>
