---
title: "Webhooks"
description: "Cómo registrar y consumir webhooks de Edugoverna: firma HMAC, sobre del evento, catálogo completo de los 29 eventos y garantías de entrega."
url: "https://docs.edugoverna.com/webhooks"
---
# Webhooks

Con los webhooks, tu aplicación se entera en el momento en que algo ocurre en Edugoverna — se registra una decisión de consentimiento, entra una solicitud ARCO por el portal, falla el envío de un mensaje — sin necesidad de consultar la API en un ciclo de polling. En esta guía verás cómo se firman las entregas, qué forma tiene el sobre de cada evento y el catálogo completo de eventos disponibles. {{ className: 'lead' }}

<Note>
  **Cambio en el esquema de firma.** La firma HMAC-SHA-256 ya no se calcula
  sobre el cuerpo a secas: ahora se calcula sobre la cadena
  `"{timestamp}.{rawBody}"`, donde `timestamp` es el valor de la cabecera
  `x-edugoverna-timestamp` (segundos Unix) y `rawBody` son los bytes exactos del
  cuerpo recibido. Si tu integración verificaba la firma del cuerpo solo, debes
  actualizarla: las firmas antiguas ya no coinciden. El detalle está en
  [Verificación de la firma](#verificacion-de-la-firma).
</Note>

## Registrar un endpoint

Los endpoints de webhook se administran desde la Consola (Integraciones → Webhooks) o mediante la [API de gestión de webhooks](/gestion/webhooks). Al crear un endpoint eliges un nombre, la URL de destino (solo `https://`), los [eventos](#catalogo-de-eventos) a los que te suscribes y, opcionalmente, un secreto propio. Si omites el secreto, el servidor genera uno con prefijo `whsec_` y lo devuelve **una única vez** en la respuesta de creación.

Los webhooks requieren que la organización tenga habilitado el módulo `integrations` en su licencia. Sin ese módulo, crear un endpoint o activarlo responde `409 INTEGRATIONS_MODULE_REQUIRED`, y la Consola lo avisa en Integraciones → Webhooks. Si el módulo se retira después de registrar endpoints, los eventos no se entregan y cada omisión queda en la auditoría de la organización (`outbound_webhook_event · skipped_module_disabled`). Un partner puede comprobar de antemano si los webhooks salen de una organización con `webhooks.available` en [la conexión](/autenticacion#la-conexion-vista-por-el-partner).

<div className="not-prose">
  <Button href="/gestion/webhooks" variant="text" arrow="right">
    <>Ver la API de gestión de endpoints</>
  </Button>
</div>

---

## Cabeceras de cada entrega

Cada entrega es un `POST` con `content-type: application/json` a la URL del endpoint, acompañado de tres cabeceras:

<Properties>
  <Property name="x-edugoverna-signature" type="string">
    Firma HMAC-SHA-256 en hexadecimal, calculada como
    `HMAC(secret, "{timestamp}.{rawBody}")`. El nombre de esta cabecera es
    configurable por endpoint (`signatureHeader`); el valor por defecto es
    `x-edugoverna-signature`.
  </Property>
  <Property name="x-edugoverna-event" type="string">
    El tipo de evento entregado, por ejemplo `consent.decision.recorded`.
  </Property>
  <Property name="x-edugoverna-timestamp" type="string">
    El instante en que se firmó la solicitud, en segundos Unix. Úsalo para
    reconstruir la cadena firmada y para rechazar entregas antiguas.
  </Property>
</Properties>

---

## Verificación de la firma

Para comprobar que una entrega proviene efectivamente de Edugoverna:

1. Lee la cabecera `x-edugoverna-timestamp` y el cuerpo **crudo** de la solicitud (los bytes exactos recibidos — no re-serialices el JSON ya parseado).
2. Construye la cadena firmada como `${timestamp}.${rawBody}`, con un punto literal entre el timestamp y el cuerpo.
3. Calcula `HMAC-SHA-256(secret, cadenaFirmada)` en hexadecimal y compáralo con la cabecera de firma usando una comparación de tiempo constante.
4. Rechaza la entrega si `|ahoraEnSegundos - timestamp| > 300` (5 minutos), para protegerte de la repetición de una solicitud capturada.

<CodeGroup title="Verificar una entrega">

```js {{ title: 'Node.js' }}
import crypto from "node:crypto"

const TOLERANCE_SECONDS = 300 // 5 minutos

function verifyWebhook({ rawBody, headers, secret }) {
  const timestamp = headers["x-edugoverna-timestamp"]
  const signature = headers["x-edugoverna-signature"]

  if (!timestamp || !signature) {
    return false
  }

  // Rechaza entregas demasiado antiguas (protección anti-replay).
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
    return false
  }

  // Recalcula la firma sobre `${timestamp}.${rawBody}`.
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex")

  // Comparación de tiempo constante.
  const a = Buffer.from(expected, "hex")
  const b = Buffer.from(signature, "hex")
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}
```

</CodeGroup>

### El mismo esquema para los conectores ARCO

Si además recibes acciones de derechos como **conector externo** (`external_post`, configurado por el colegio en su portal ARCO), la firma es idéntica: cada despacho llega con `x-edugoverna-timestamp` y la cabecera de firma que el colegio configuró para el conector, calculada como `HMAC(secret, "{timestamp}.{rawBody}")` con el secreto del conector. El cuerpo trae en `callback` la URL a la que respondes y el esquema (`signatureScheme`, `timestampHeader`, `toleranceSeconds`). Tu callback se firma igual, en sentido inverso: envía `x-edugoverna-timestamp` y `x-edugoverna-signature` sobre `"{timestamp}.{rawBody}"`; sin timestamp responde `401 ARCO_ACTION_CALLBACK_TIMESTAMP_REQUIRED`, fuera de la ventana de 300 s `401 ARCO_ACTION_CALLBACK_STALE`, y con firma incorrecta `401 ARCO_ACTION_CALLBACK_INVALID_SIGNATURE`. Un solo verificador sirve para ambos.

Guarda el secreto del webhook con el mismo cuidado que una credencial de API: quien lo conozca puede fabricar entregas válidas. Si sospechas que se filtró, [rota el secreto](/gestion/webhooks#rotar-el-secreto) — la rotación genera uno nuevo y lo devuelve una sola vez.

---

## El sobre del evento

Toda entrega comparte el mismo sobre. Los campos específicos del evento viajan dentro de `data`.

<Row>
  <Col>

    <Properties>
      <Property name="event" type="string">
        El tipo de evento, uno de los del [catálogo](#catalogo-de-eventos).
        Coincide con la cabecera `x-edugoverna-event`.
      </Property>
      <Property name="sourceType" type="string">
        La clase de recurso que originó el evento: `consent_request`,
        `consent_delivery`, `rights_request`, `security_incident` o
        `incident_notification`.
      </Property>
      <Property name="sourceId" type="string">
        El identificador del recurso de origen. Úsalo para consultar el estado
        actual por la API si lo necesitas.
      </Property>
      <Property name="occurredAt" type="string">
        Fecha y hora ISO 8601 en que se construyó la entrega.
      </Property>
      <Property name="context" type="object">
        Solo en algunos eventos. Hechos del evento que no viven en el recurso
        de origen: en `consent.recaptured`, `reason` y
        `supersededConsentRequestId`; en `consent.invalidated`,
        `invalidationReason`; en `rights_request.sla_*`, `stage` y
        `effectiveDueAt`. Escalares planos, nunca datos personales.
      </Property>
      <Property name="data" type="object | null">
        La proyección del recurso de origen en el momento de la entrega. Su
        forma depende de `sourceType` — ver
        [los payloads por sourceType](#payloads-por-source-type).
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    ```json {{ title: 'Sobre de ejemplo' }}
    {
      "event": "consent.decision.recorded",
      "sourceType": "consent_request",
      "sourceId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
      "occurredAt": "2026-08-25T14:03:22.511Z",
      "data": {
        "id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
        "status": "granted"
        // ... proyección completa según sourceType
      }
    }
    ```

  </Col>
</Row>

---

## Catálogo de eventos

Estos son los 29 tipos de evento del catálogo, agrupados igual que en el selector de la Consola. La suscripción es por lista explícita o mediante el [comodín `["*"]`](#suscripcion-comodin).

### Consentimiento

Eventos sobre el ciclo de vida de una solicitud de consentimiento. `sourceType` es `consent_request`. Ver [Consentimientos](/consentimientos).

<Row>
  <Col>

    <Properties>
      <Property name="consent.requested">
        Se creó una solicitud de consentimiento (desde la Consola, una campaña
        o la API) y sus entregas quedaron encoladas.
      </Property>
      <Property name="consent.decision.recorded">
        Se registró una decisión (otorgada o denegada), también cuando el
        titular acota o vuelve a otorgar desde el portal de transparencia. En
        campañas sobre padrones compartidos, este y todos los eventos de
        consentimiento se emiten para cada parte — ver
        [la proyección para partners](/webhooks#proyeccion-para-partners-shared-partner).
      </Property>
      <Property name="consent.revoked">
        El titular o un operador revocó un consentimiento otorgado.
        `context.revocationSource` dice por dónde: `portal` (el enlace del
        consentimiento), `transparency_self_service` (el portal de
        transparencia), `staff` (la Consola o la API de organización) o
        `partner` (la [Partner API](/consentimientos#revocar-una-solicitud)) o
        `registrar` (el registrador externo transmitió un retiro que la
        familia comunicó al colegio; ver
        [Consentimiento declarado](/guias/registrador-externo#consentimiento-declarado)).
        Una misma solicitud puede revocarse más de una vez si el titular
        vuelve a otorgar entre medio: cada retiro llega como su propio evento.
      </Property>
      <Property name="consent.expired">
        Una solicitud pendiente pasó su `expiresAt` sin decisión y fue marcada
        como expirada por el barrido programado.
      </Property>
      <Property name="consent.invalidated">
        Una solicitud fue anulada: `data.status` es `invalidated` y
        `context.invalidationReason` dice por qué. `"campaign_closed"`: se
        cerró su campaña antes de la decisión. `"superseded_by_campaign"`: una
        campaña nueva reemplazó la solicitud pendiente. `"version_superseded"`:
        se publicó una versión de la actividad que exige volver a consentir, y
        la anulada puede haber estado otorgada. `"superseded_by_titular"`: el
        titular, ya con edad para decidir, respondió una solicitud nueva y su
        decisión reemplazó el «sí» que había dado el apoderado.
        `"registration_rejected"`: el
        colegio rechazó un registro público ya otorgado. Las solicitudes
        anuladas no pasan después a `expired`, así que este es su evento
        terminal.
      </Property>
      <Property name="consent.recaptured">
        La plataforma anuló una solicitud y emitió una nueva sin que nadie la
        pidiera: una pendiente se redirigió a quien decide hoy porque el
        titular cruzó la edad en que eso cambia (`context.reason =
        "age_threshold"`, según la `ageTransitionPolicy` de la versión; ocurre
        en el próximo recordatorio o reenvío, o al intentar firmarla), o se
        publicó una versión del aviso que invalida las anteriores
        (`"notice_version"`). `data` es la
        solicitud nueva; `context.supersededConsentRequestId` es la anterior,
        cuando la hay. No se emite `consent.requested` por estas solicitudes.
      </Property>
      <Property name="consent.student_notified">
        En una campaña compartida con aviso al estudiante activo, EduGoverna
        procesó el aviso al estudiante después de que su apoderado otorgó.
        `context.outcome` es `sent` (se envió a su correo registrado) o
        `no_contact` (el estudiante no tiene correo; no se envió nada).
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    ```json {{ title: 'consent.decision.recorded' }}
    {
      "event": "consent.decision.recorded",
      "sourceType": "consent_request",
      "sourceId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
      "occurredAt": "2026-08-25T14:03:22.511Z",
      "data": {
        "id": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
        "status": "granted",
        "decisionActorType": "guardian",
        "requestedAt": "2026-08-20T12:00:00.000Z",
        "expiresAt": "2026-09-20T12:00:00.000Z",
        "deliveries": [
          {
            "channel": "email",
            "status": "opened",
            "destinationMasked": "m…a@example.cl"
          }
        ],
        "latestDecision": {
          "decision": "granted",
          "decidedAt": "2026-08-25T14:03:21.000Z",
          "captureMethod": "portal_link_otp"
        }
        // ... campos adicionales de la proyección
      }
    }
    ```

  </Col>
</Row>

### Envíos de consentimiento

Eventos sobre los mensajes individuales (email, WhatsApp) con que se solicita el consentimiento.

<Row>
  <Col>

    <Properties>
      <Property name="consent.delivery.updated">
        Una entrega cambió de estado (`queued`, `sent`, `delivered`, `opened`,
        `failed`, …), ya sea por confirmación del proveedor de mensajería o por
        una actualización manual. `sourceType` es `consent_delivery` y la
        entrega usa el nuevo estado como `occurrenceKey`, así que cada
        transición llega una vez.
      </Property>
      <Property name="consent.delivery.opened">
        El titular o apoderado abrió por primera vez el enlace del portal de
        consentimiento. `sourceType` es `consent_request`: el payload es la
        solicitud completa, no la fila de la entrega.
      </Property>
      <Property name="envelopes.job.completed">
        Terminó un [despacho de sobres por lote](/campanas-compartidas#despachar-sobres-por-lote-asincrono).
        `sourceType` es `consent_envelope_job` y `data` trae el estado y los
        conteos; los resultados por fila se leen en el job.
      </Property>
      <Property name="consent.delivery.failed">
        El proveedor reportó una falla definitiva para esa entrega. En
        WhatsApp, `failureReason` también puede indicar que el canal dejó de
        estar disponible (cuenta restringida, sin fondeo, mensaje no aprobado o
        tope de gasto alcanzado): son fallas permanentes que no se reintentan
        hasta que la organización las resuelva y use «Reenviar».
        `sourceType` es `consent_delivery`; se emite además del
        `consent.delivery.updated` correspondiente.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    ```json {{ title: 'consent.delivery.failed' }}
    {
      "event": "consent.delivery.failed",
      "sourceType": "consent_delivery",
      "sourceId": "7c1f00aa-2e43-4b7e-9f14-3d2e9a7c51d0",
      "occurredAt": "2026-08-25T09:12:44.008Z",
      "data": {
        "id": "7c1f00aa-2e43-4b7e-9f14-3d2e9a7c51d0",
        "consentRequestId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
        "channel": "whatsapp",
        "destinationMasked": "+569••••1234",
        "status": "failed",
        "failedAt": "2026-08-25T09:12:40.000Z",
        "failureReason": "Provider delivery failed."
        // ... campos adicionales de la fila de entrega
      }
    }
    ```

  </Col>
</Row>

### Padrones compartidos

Eventos de la relación con los colegios y registradores que te comparten datos. Llegan **sólo a tu espacio de trabajo** y su `data` es una proyección propia (`"projection": "partner_lifecycle"`): ids, estados, fechas y nombres de organizaciones y grupos, nunca personas. Ver [Registradores y solicitudes](/solicitudes).

<Properties>
  <Property name="share.granted">
    Un colegio o registrador te compartió un grupo. `sourceType` es
    `registry_share` y `data` trae el grupo (`groupName`, `groupKind`,
    `course`), el dueño (`owner`) y `memberCount`.
  </Property>
  <Property name="share.revoked">
    El dueño dejó de compartirte el grupo, directamente o al revocar la
    conexión. `context.reason` dice por qué cuando se sabe. Los
    consentimientos ya otorgados **siguen vigentes** (son evidencia). Lo que
    pierdes es el padrón.
  </Property>
  <Property name="share.members.changed">
    La composición de un grupo cambió en una carga del registrador.
    `context.addedCount` y `context.removedCount` traen los conteos; relee el
    grupo con `since` para saber quiénes.
  </Property>
  <Property name="share_request.decided">
    El colegio aprobó o rechazó tu solicitud de cursos. `sourceType` es
    `share_request`; `data.shareIds` son los shares creados y
    `data.externalRef`, el que mandaste al pedirlos.
  </Property>
  <Property name="registrar.completed">
    El registrador que invitaste cargó su primer curso: la invitación se
    cumplió. `sourceType` es `registrar_invite`. `data` es la proyección
    `partner_lifecycle` de la invitación: `inviteId`, `status`,
    `externalRef` (tu correlativo), `registryOrganizationId` (el registro
    creado), `schoolName` y `schoolRbd` (el colegio que declaró el
    registrador; `schoolName` cae al `schoolNameHint` de la invitación si
    aún no hay registro), `acceptedAt`, `processingActivityId` (la actividad
    de la invitación, `null` si no llevaba consentimiento declarado) y
    `shareIds` (los grupos que ya te comparte).
  </Property>
</Properties>

### Conexiones

<Properties>
  <Property name="connection.accepted">
    Un colegio aceptó la conexión (tu solicitud, incluida la hecha por RBD).
    `sourceType` es `partner_connection`; `data.organization` trae su
    `organizationId`. Si la conexión nació de una solicitud tuya,
    `data.connectionRequestId` y `data.externalRef` la identifican; ambos son
    `null` cuando fue el colegio quien te invitó.
  </Property>
  <Property name="connection.revoked">
    El colegio revocó la conexión. También recibes un `share.revoked` por cada
    grupo que estaba compartido.
  </Property>
  <Property name="dpa.activated">
    Entró en vigor un contrato de encargo (Art. 15 bis) con el colegio,
    también al aceptarse una conexión. `sourceType` es `dpa_contract`.
  </Property>
  <Property name="dpa.expired">
    Venció el contrato (`effectiveTo` pasó). Desde ese momento la API responde
    `403 PARTNER_DPA_NOT_ACTIVE` sobre ese colegio.
  </Property>
</Properties>

### Derechos ARCO

Eventos sobre solicitudes de derechos. `sourceType` es `rights_request` y `data` trae la proyección completa de la solicitud tal como la ve la Consola. Ver [Derechos ARCO](/derechos-arco).

<Row>
  <Col>

    <Properties>
      <Property name="rights_request.created">
        Entró una solicitud nueva, por ejemplo a través del portal ARCO
        público.
      </Property>
      <Property name="rights_request.updated">
        La solicitud recibió actividad nueva, como un mensaje de seguimiento
        del titular por el portal.
      </Property>
      <Property name="rights_request.document.added">
        El titular adjuntó uno o más documentos a la solicitud (al crearla o en
        un seguimiento posterior).
      </Property>
      <Property name="rights_request.export_ready">
        El paquete de exportación aprobado terminó de construirse y quedó
        disponible para descarga. Se entrega con `sourceType: "rights_request"`
        y un `occurrenceKey` único por construcción del paquete.
      </Property>
      <Property name="rights_request.closed">
        La solicitud fue cerrada (respondida o denegada) con su certificado de
        cierre.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    ```json {{ title: 'rights_request.created' }}
    {
      "event": "rights_request.created",
      "sourceType": "rights_request",
      "sourceId": "9e2a41d7-6c3b-4f0e-8a17-b5d4c2e8f901",
      "occurredAt": "2026-08-25T16:40:02.113Z",
      "data": {
        "id": "9e2a41d7-6c3b-4f0e-8a17-b5d4c2e8f901",
        "requestType": "access",
        "status": "received"
        // ... proyección completa de la solicitud
      }
    }
    ```

  </Col>
</Row>

### Plazos ARCO

Eventos sobre el plazo legal de respuesta de una solicitud de derechos. Se emiten en el mismo barrido que envía el recordatorio por correo al equipo responsable; `sourceType` es `rights_request` y `context` trae `stage` y `effectiveDueAt` (la fecha vigente, con prórroga si la hubo).

<Properties>
  <Property name="rights_request.sla_due_soon">
    Una solicitud abierta entró en la ventana previa a su vencimiento.
  </Property>
  <Property name="rights_request.sla_overdue">
    Una solicitud abierta superó su fecha de vencimiento sin cierre. Se repite
    cada tres días hábiles mientras siga abierta, cada vez con su propio
    `occurrenceKey` (`overdue`, `overdue_repeat:1`, `overdue_repeat:2`…) y con
    `context.repeatIndex` y `context.escalated` en las repeticiones.
  </Property>
</Properties>

### Incidentes

Eventos sobre incidentes de seguridad y sus notificaciones a titulares.

<Row>
  <Col>

    <Properties>
      <Property name="security.incident.created">
        Se registró un incidente de seguridad y se encolaron las notificaciones
        a los titulares afectados. `sourceType` es `security_incident`.
      </Property>
      <Property name="security.incident.notification.updated">
        La notificación de incidente a un titular cambió de estado según el
        proveedor de mensajería. `sourceType` es `incident_notification` y el
        nuevo estado viaja como `occurrenceKey`.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    ```json {{ title: 'security.incident.created' }}
    {
      "event": "security.incident.created",
      "sourceType": "security_incident",
      "sourceId": "4f8b2c6d-1a0e-4d3f-9c7b-2e5a8d1f6b09",
      "occurredAt": "2026-08-25T18:05:37.902Z",
      "data": {
        "id": "4f8b2c6d-1a0e-4d3f-9c7b-2e5a8d1f6b09"
        // ... fila del incidente
      }
    }
    ```

  </Col>
</Row>

---

## Payloads por sourceType

El contenido de `data` es la proyección del recurso de origen en el momento en que se construye la entrega (no en el momento del hecho: si el recurso cambió entre medio, verás su estado más reciente).

<Properties>
  <Property name="consent_request" type="object">
    La proyección completa de la solicitud de consentimiento: la fila principal
    (`id`, `status`, `decisionActorType`, `processingActivityVersionId`,
    `targetSubjectId`, `requestedAt`, `expiresAt`, …) más `deliveries[]`,
    `latestDecision` (con su desglose de opciones y evidencia) y `latestEvent`.
    Si la solicitud pertenece a una campaña se agregan `campaignId`,
    `campaignMode`, `campaignPublicSubjectFlow`,
    `campaignIdentityVerificationMode`, `campaignStatus`,
    `campaignContactEntryId` y `campaignContactEntry`.
  </Property>
  <Property name="consent_delivery" type="object">
    La fila de la entrega: `id`, `consentRequestId`, `channel`,
    `destinationMasked`, `status`, `deliveryProvider`, `providerMessageId`, las
    marcas de tiempo `queuedAt` / `sentAt` / `deliveredAt` / `openedAt` /
    `failedAt` y `failureReason`.
  </Property>
  <Property name="rights_request" type="object">
    La proyección completa de la solicitud de derechos, incluida su actividad
    asociada (documentos, mensajes, cierre), tal como la entrega la API de la
    Consola.
  </Property>
  <Property name="security_incident" type="object">
    La fila del incidente de seguridad.
  </Property>
  <Property name="incident_notification" type="object">
    La fila de la notificación de incidente a un titular, con su estado de
    entrega.
  </Property>
</Properties>

<Note>
  `data` puede llegar como `null` si el recurso de origen fue eliminado antes de
  que la entrega se construyera, o si el evento no tiene una proyección aprobada
  para la audiencia del endpoint (ver la sección siguiente). Trata `data: null`
  como una señal de "consulta el recurso por la API usando `sourceId`".
</Note>

---

## Proyección para partners (shared_partner)

En una campaña sobre un [padrón compartido](/campanas-compartidas), la campaña vive en la organización del partner mientras la solicitud de consentimiento vive en la del sostenedor de los datos. **Todo evento de consentimiento** de esa solicitud (`consent.requested`, `consent.decision.recorded`, `consent.revoked`, `consent.expired`, `consent.invalidated`, `consent.recaptured` y los tres de envío) se emite **para ambas partes** con el mismo `occurrenceKey`:

- La copia del sostenedor lleva la proyección completa descrita arriba.
- La copia que reciben los endpoints de la organización del partner lleva una proyección reducida, construida como lista blanca campo a campo. Se identifica a sí misma con `"projection": "shared_partner"` para que nunca se confunda con una proyección completa a la que "le faltan" campos.

En la copia del partner, `sourceType` es siempre `consent_request`, también en los eventos de envío (el partner no tiene la fila de la entrega). Esos eventos agregan en `context` el id de la entrega (`consentDeliveryId`, el mismo de `portalAccessLinks`), `deliveryChannel`, `deliveryStatus` y, si falló, `failureCode` (`destination_missing`, `dispatch_blocked` o `provider_failed`), nunca el texto del proveedor ni el destino.

En una campaña **segmentada por RUT** el partner no recibe nada de una solicitud hasta que haya una decisión: un `consent.requested` le diría qué RUT están en el padrón, que es justo lo que ese modo protege.

La proyección reducida se limita a lo que el partner ya puede leer por su propio endpoint de sujetos compartidos: **no** incluye RUT (ni enmascarado), contactos en ninguna forma, el nombre del titular, el id interno del sujeto en la organización dueña, ni el contenido de la evidencia o texto libre de operadores. Sí trae el id y el checksum del manifiesto sellado (`latestDecision.evidence`), que no nombran a nadie y se validan en [`/api/public/evidence/verify`](/campanas-compartidas#evidencia-de-un-sobre). `latestDecision.decisionAuthority` indica el **rol** que decidió (titular, apoderado), nunca quién.

`latestDecision.captureMethod` dice cómo se capturó la decisión: `portal_link_otp` (la familia, con el enlace y el código de un solo uso) o `registrar_attestation`, el [consentimiento declarado](/guias/registrador-externo#consentimiento-declarado) por el establecimiento mediante un registrador externo. Esta segunda **no** es una firma con código: su evidencia es una declaración jurada más la custodia del colegio, y tu integración debería mostrarla distinta. Los retiros que el registrador transmite llegan como `consent.revoked` con `context.revocationSource: "registrar"`.

```json {{ title: 'Payload shared_partner' }}
{
  "event": "consent.decision.recorded",
  "sourceType": "consent_request",
  "sourceId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
  "occurredAt": "2026-08-25T14:03:22.511Z",
  "data": {
    "projection": "shared_partner",
    "consentRequestId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
    "dataOwnerOrganizationId": "d290f1ee-6c54-4b01-90e6-d701748f0851",
    "registryShareId": "a3bb189e-8bf9-3888-9912-ace4e6543002",
    "campaignId": "16fd2706-8baf-433b-82eb-8c7fada847da",
    "campaignStatus": "active",
    "campaignMode": "per_subject",
    "campaignOrigin": "operator",
    "campaignContactEntryId": "886313e1-3b8a-5372-9b90-0c9aee199e5d",
    "partnerExternalId": "par_04421",
    "processingActivityVersionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "subject": {
      "referenceCode": "EST-04421",
      "subjectType": "student",
      "requestedForAgeBand": "under_14"
    },
    "status": "granted",
    "decisionActorType": "guardian",
    "requestedAt": "2026-08-20T12:00:00.000Z",
    "expiresAt": "2026-09-20T12:00:00.000Z",
    "updatedAt": "2026-08-25T14:03:21.000Z",
    "latestDecision": {
      "id": "0b8e4c2a-5d1f-4a6b-8c3e-7f9d2a1b6c4e",
      "decision": "granted",
      "decisionAuthority": "guardian",
      "captureMethod": "portal_link_otp",
      "decidedAt": "2026-08-25T14:03:21.000Z",
      "effectiveFrom": "2026-08-25T14:03:21.000Z",
      "effectiveTo": null,
      "revokedAt": null,
      "evidence": {
        "id": "…:manifest",
        "checksum": "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b"
      }
    }
  }
}
```

`campaignOrigin` dice quién originó la campaña: `operator` (la lanzaste tú) o `registrar_attestation` (los consentimientos que un registrador externo [declaró firmados](/guias/registrador-externo#consentimiento-declarado); no se enviaron mensajes). Es `null` si la solicitud no pertenece a una campaña. `campaignContactEntryId` es el id del sobre y `partnerExternalId` tu correlativo, tal como lo mandaste al despachar. Cualquier otro `sourceType` en una copia de audiencia partner no tiene proyección aprobada, y por diseño no se divulga nada: `data` llega `null`.

---

## Suscripción comodín

El valor almacenado `["*"]` suscribe el endpoint a **todos** los eventos, incluidos los que se agreguen al catálogo en el futuro. El comodín debe ir solo: `enabledEvents` acepta `["*"]` o una lista de tipos explícitos, nunca una mezcla. Si omites `enabledEvents` al crear el endpoint — o envías una lista vacía — el valor almacenado es `["*"]`.

---

## Requisitos y garantías de entrega

- **Solo HTTPS.** La URL del endpoint debe usar `https://` y no puede apuntar a hosts locales, direcciones privadas, link-local ni a la IP de metadatos de nube.
- **Secreto de una sola vista.** Si no envías un secreto al crear el endpoint, el servidor genera uno (`whsec_…`) y lo devuelve solo en esa respuesta. Puedes [rotarlo](/gestion/webhooks#rotar-el-secreto) cuando quieras; la rotación también lo devuelve una única vez.
- **Reintentos.** Una entrega se considera exitosa cuando tu endpoint responde con un estado 2xx. Si responde otra cosa o la conexión falla, el intento queda registrado como fallido y la cola vuelve a intentarlo; cada intento queda en el historial de entregas con su `attemptNumber`. Los destinos que ya recibieron con éxito una combinación (evento, origen, ocurrencia) no la vuelven a recibir en los reintentos.
- **Idempotencia.** Cada transición distinta de un mismo recurso viaja con su propia `occurrenceKey` interna, pero un reintento puede hacer que veas el mismo evento más de una vez. Deduplica por la tupla `event` + `sourceId` + el contenido relevante de `data` (por ejemplo, el `status` en los eventos de entrega) y responde 2xx rápido — procesa en segundo plano si tu trabajo es lento.
- **Historial auditable.** Cada intento queda en el registro de entregas del endpoint, consultable por la [API de gestión](/gestion/webhooks#consultar-un-endpoint): evento, origen, número de intento, estado y resumen del error (en un intento fallido, el resumen incluye el código con que respondió tu servidor). De la respuesta de tu servidor solo se conserva el código de estado, nunca el cuerpo.
- **Módulo requerido.** El despacho exige el módulo `integrations` activo en la licencia de la organización.
- **Endpoints por conexión.** Un endpoint puede quedar asociado a una [conexión de partner](/gestion/conexiones) mediante `organizationPartnerConnectionId`, para que el destino quede documentado junto a la relación que lo justifica.
