---
title: "Consentimientos · Partner API"
description: "Referencia de los endpoints de consentimiento de la API Partner: creación de solicitudes, sincronización incremental y campañas con audiencia en línea."
url: "https://docs.edugoverna.com/consentimientos"
---
# Consentimientos

Una solicitud de consentimiento le pregunta a quien tiene la autoridad — el propio titular o su apoderado, según la banda etaria — si acepta una actividad de tratamiento concreta. Edugoverna arma el aviso desde la versión publicada de la actividad, despacha la entrega por email o WhatsApp, captura la decisión con verificación de identidad y conserva la evidencia. En esta página revisamos los endpoints para crear solicitudes una a una, consultarlas de forma incremental y lanzar campañas sobre tu propia audiencia. {{ className: 'lead' }}

<Note>
  Todas las rutas de esta página cuelgan de la URL base
  `https://app.edugoverna.com/api/partner/v1/organizations/:organizationId`. Las
  decisiones y los cambios de estado llegan también como [webhooks](/webhooks)
  `consent.*`, que son la forma recomendada de enterarse de una decisión sin
  sondear. El flujo completo está descrito en la [guía de flujo de
  consentimiento](/guias/flujo-consentimiento).
</Note>

## El modelo de solicitud

Una solicitud (`consentRequest`) referencia a un titular objetivo (por su `referenceCode` del [padrón](/padron)) y a una versión de [actividad de tratamiento](/actividades-tratamiento). De ella cuelgan las **entregas** (`deliveries`, una por canal y destinatario) y, cuando alguien decide, la **decisión** (`latestDecision`) con su evidencia.

### Ciclo de vida (`status`)

Mientras la solicitud sigue abierta, su `status` es `pending`; el avance del envío (`queued`, `sent`, `delivered`, `opened`, `failed`) se lee en cada entrega (`deliveries[].status`), no en la solicitud. Los estados terminales de la solicitud son:

- `granted` — el consentimiento fue otorgado.
- `denied` — fue denegado.
- `revoked` — fue otorgado y luego revocado.
- `expired` — venció sin decisión.
- `invalidated` — la solicitud fue anulada (por ejemplo, porque se publicó una nueva versión de la actividad que invalida los consentimientos previos, porque una campaña posterior reemplazó una pendiente o porque su campaña se [cerró](#cerrar-una-campana)). El campo `invalidationReason` explica el motivo, y toda anulación emite `consent.invalidated` con `context.invalidationReason` (ver [Webhooks](/webhooks)).

### Qué cambia un consentimiento otorgado

Una campaña es una herramienta de envío: crearla, cerrarla, cambiar su cierre o reenviar sus sobres nunca cambia un `granted`. El `expiresAt` de una solicitud es el plazo para decidir y sólo vence una `pending`. Un `granted` cambia únicamente por:

- **El titular**, que lo retira o lo ajusta en el portal o en la página de retiro, o pide al colegio o a ti que lo registren (consola o [`POST …/revoke`](#revocar-una-solicitud)). Emite `consent.revoked`.
- **La decisión del propio titular** cuando ya tiene edad para decidir. Cumplir años no anula el «sí» del apoderado; pero si una campaña nueva sobre la misma actividad le pide la decisión al titular (según la [`ageTransitionPolicy`](/actividades-tratamiento) de la versión), cuando él decide —sí o no— su decisión pasa a ser la vigente y la del apoderado se cierra. Emite `consent.invalidated` (`"superseded_by_titular"`). Mientras no responde, `GET …/consents/status` sigue informando el `granted` del apoderado.
- **Una versión nueva de la actividad** publicada con `invalidatesPreviousConsents: true`. Emite `consent.invalidated` (`"version_superseded"`).
- **El rechazo de un registro público** en la revisión del colegio. Emite `consent.invalidated` (`"registration_rejected"`).

### Autorización del apoderado (`requestRole`)

Cuando la versión de la actividad tiene `requiresGuardianAuthorization` (salidas, giras) y la solicitud la decide el propio titular por su edad, el colegio abre una segunda solicitud para que el apoderado **autorice la actividad**. Son dos actos distintos: el titular consiente el tratamiento de sus datos; el apoderado autoriza su participación.

- `requestRole` — `consent` en toda solicitud corriente; `guardian_authorization` en la del apoderado.
- `companionOfRequestId` — en la del apoderado, el `id` de la solicitud del titular; `null` en las demás.
- `guardianAuthorization` — en la solicitud del titular, `{ requestId, status, decidedAt }` de la autorización vigente del apoderado; `null` si no hay.

Las solicitudes del apoderado sólo las abre el colegio desde su consola: las que creas por esta API siempre vuelven con `requestRole: "consent"`, `companionOfRequestId: null` y `guardianAuthorization: null`. Tampoco aparecen en los listados, en `GET …/consents/status` ni en los [webhooks](/webhooks); sólo se ven pidiéndolas por su `id`.

### Idempotencia

Los endpoints de escritura aceptan `idempotencyKey` (entre 8 y 120 caracteres, único por credencial). Reintentar una llamada con la misma llave devuelve la solicitud original con `idempotentReplay: true` y **sin** tokens de entrega nuevos, al estilo de los reintentos seguros de pago.

---

## Crear una solicitud de consentimiento {{ tag: 'POST', label: '/v1/organizations/:organizationId/consents/requests' }}

<Row>
  <Col>

    Crea una solicitud para un titular del padrón y encola de inmediato sus entregas. Responde `201` con el paquete completo de la solicitud y emite el webhook `consent.requested`.

    El token del portal viaja en `deliveryAccessLinks` **solo en esta respuesta**: ni la repetición idempotente (`idempotentReplay: true`, con `deliveryAccessLinks: []`) ni el detalle lo devuelven. Si lo pierdes, o el apoderado vuelve pasados los siete días de vigencia, [reemite el enlace](#reemitir-el-enlace-de-una-entrega) en vez de crear otra solicitud.

    Permiso requerido: `partner.consents.write` · Alcance de conexión: `consents:write`.

    La actividad de tratamiento se referencia de una de dos formas: por `processingActivityVersionId` (UUID exacto de la versión) o por `processingActivityCode` (más `processingActivityVersionNumber` opcional; si se omite, se usa la versión vigente). Omitir ambas responde `422`; si ambas vienen y no resuelven a la misma versión, `422 PROCESSING_ACTIVITY_REFERENCE_MISMATCH`.

    Las URLs de redirección deben estar registradas en la lista de destinos permitidos de tu conexión; una URL no registrada rechaza la llamada antes de escribir nada.

    ### Atributos requeridos

    <Properties>
      <Property name="targetSubjectReferenceCode" type="string">
        Código de referencia del titular objetivo (típicamente el estudiante).
        Si no existe, `404 TARGET_SUBJECT_NOT_FOUND`.
      </Property>
    </Properties>

    ### Atributos opcionales

    <Properties>
      <Property name="processingActivityVersionId" type="string">
        UUID de la versión de la actividad. Requerido si no viene
        `processingActivityCode`.
      </Property>
      <Property name="processingActivityCode" type="string">
        Código de la actividad. Requerido si no viene
        `processingActivityVersionId`.
      </Property>
      <Property name="processingActivityVersionNumber" type="integer">
        Número de versión a usar junto con `processingActivityCode`. Por
        defecto, la versión vigente.
      </Property>
      <Property name="successRedirectUrl" type="string">
        URL a la que se envía al firmante tras otorgar. Máximo 2048 caracteres.
      </Property>
      <Property name="declineRedirectUrl" type="string">
        URL a la que se envía al firmante tras denegar.
      </Property>
      <Property name="redirectMode" type="string">
        `auto` (redirección inmediata) o `bridge` (pantalla intermedia de
        Edugoverna antes de redirigir).
      </Property>
      <Property name="partnerExternalId" type="string">
        Identificador tuyo para correlacionar la solicitud (1–160 caracteres).
        Filtrable en el listado.
      </Property>
      <Property name="idempotencyKey" type="string">
        Llave de idempotencia (8–120 caracteres).
      </Property>
      <Property name="channels" type="string[]">
        Canales por los que Edugoverna envía la solicitud: `email`, `whatsapp`
        o ambos. `whatsapp` exige que la organización tenga su número de
        WhatsApp conectado en Ajustes → WhatsApp y los mensajes aprobados por
        Meta; si no, la llamada responde `409 WHATSAPP_CHANNEL_UNAVAILABLE`
        con `details.reason` (`not_licensed`, `not_connected`, `setup_pending`, `setup_failed`, `disconnected`, `account_restricted`, `not_funded`, `templates_pending` o `spend_limit_reached`). Ausente, se usa el contacto primario de cada apoderado. Un
        arreglo **vacío** (`[]`) significa «solo enlace»: se acuña el token y
        no se envía nada — la entrega queda con canal `partner_link` y estado
        `handed_off`, y tú muestras el enlace dentro de tu app. El colegio la
        ve como «Enlace entregado», no como una entrega manual que imprimir.
        El código de verificación del portal va al contacto registrado del
        decisor, correo primero.
      </Property>
      <Property name="expiresAt" type="timestamp">
        Vencimiento de la solicitud (ISO 8601, en el futuro). Pasado ese
        instante sin decisión, el barrido la marca `expired` y emite
        `consent.expired`.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="POST" label="/v1/.../consents/requests">

    ```bash {{ title: 'cURL' }}
    curl https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/requests \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -H "Content-Type: application/json" \
      -d '{
        "targetSubjectReferenceCode": "EST-2026-0142",
        "processingActivityCode": "plataforma-lectura",
        "successRedirectUrl": "https://app.milectura.cl/bienvenida",
        "redirectMode": "auto",
        "partnerExternalId": "lic-88231",
        "idempotencyKey": "lic-88231-consent-v1"
      }'
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/requests',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${keyId}.${secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          targetSubjectReferenceCode: 'EST-2026-0142',
          processingActivityCode: 'plataforma-lectura',
          successRedirectUrl: 'https://app.milectura.cl/bienvenida',
          redirectMode: 'auto',
          partnerExternalId: 'lic-88231',
          idempotencyKey: 'lic-88231-consent-v1',
        }),
      },
    )
    const consentRequest = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "id": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "organizationId": "org_2f7a",
      "processingActivityVersionId": "3f2e1d0c-9b8a-4765-b432-1a0f9e8d7c6b",
      "targetSubjectId": "9c1f6d1e-1c9d-4f6a-9a44-1f2ab5c0d9e1",
      "requestedForAgeBand": "under_14",
      "decisionActorType": "guardian",
      "status": "pending",
      "requestedAt": "2026-08-25T12:30:00.000Z",
      "expiresAt": null,
      "successRedirectUrl": "https://app.milectura.cl/bienvenida",
      "declineRedirectUrl": null,
      "redirectMode": "auto",
      "partnerExternalId": "lic-88231",
      "idempotencyKey": "lic-88231-consent-v1",
      "deliveries": [
        {
          "id": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6",
          "channel": "email",
          "status": "queued",
          "destinationMasked": "c***a.pino@familia.cl",
          "recipientSummary": null
        }
      ],
      "latestDecision": null,
      "deliveryAccessLinks": [
        {
          "consentDeliveryId": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6",
          "recipientSubjectId": "5d2a8c3b-77e1-4b0e-9f1d-3c4e5f6a7b8c",
          "channel": "email",
          "token": "cdt_..."
        }
      ],
      "idempotentReplay": false
      // ...
    }
    ```

  </Col>
</Row>

---

## Listar solicitudes de consentimiento {{ tag: 'GET', label: '/v1/organizations/:organizationId/consents/requests' }}

<Row>
  <Col>

    Devuelve una página de solicitudes de la organización, ordenadas de la más reciente a la más antigua. A diferencia del detalle, el listado devuelve las filas base de cada solicitud (sin entregas ni decisión); usa el detalle para el paquete completo.

    Permiso requerido: `partner.consents.read` · Alcance de conexión: `consents:read`.

    `since` acota a las solicitudes **creadas** después del instante dado (`requestedAt`). Para una **conciliación incremental** usa `updatedSince`: devuelve sólo las que **cambiaron** desde ese instante (una decisión, un retiro, un vencimiento, una anulación), ordenadas de la más antigua a la más nueva por `updatedAt`. Guarda el mayor `updatedAt` que leíste y úsalo como el próximo `updatedSince`. El filtro es inclusivo, así que la última fila puede repetirse en la lectura siguiente: deduplica por `id` + `updatedAt`. Una solicitud que cambia mientras recorres las páginas pasa al final del recorrido, así que no se pierde.

    Combina cualquiera de los dos con `cursor` para recorrer páginas. El cursor está firmado contra la organización y el juego de filtros con que se emitió: reutilizarlo con filtros distintos responde `422 INVALID_CURSOR`. Las solicitudes cuyo titular fue suprimido en origen se omiten de la respuesta. El listado trae las solicitudes que viven en la organización de la URL. Las de una [campaña compartida](/campanas-compartidas) viven en el colegio o en el registrador: léelas con [consentimientos de un grupo compartido](/campanas-compartidas#consentimientos-de-un-grupo-compartido) o síguelas por [webhooks](/webhooks#proyeccion-para-partners-shared-partner).

    Cada fila trae exactamente los campos del ejemplo: `id`, `organizationId`, `processingActivityVersionId`, `targetSubjectId`, `requestedForAgeBand`, `decisionActorType`, `status`, `requestedAt`, `expiresAt`, `invalidatedAt`, `invalidationReason`, `partnerExternalId`, `idempotencyKey`, `consentCampaignId`, `redirectMode`, `successRedirectUrl`, `declineRedirectUrl`, `isSandbox`, `createdAt` y `updatedAt`.

    ### Parámetros de consulta opcionales

    <Properties>
      <Property name="status" type="string">
        Filtra por estado (por ejemplo `granted`, `pending`).
      </Property>
      <Property name="processingActivityCode" type="string">
        Filtra por actividad de tratamiento (todas sus versiones).
      </Property>
      <Property name="partnerExternalId" type="string">
        Filtra por tu identificador externo.
      </Property>
      <Property name="referenceCode" type="string">
        Filtra por el `referenceCode` del titular objetivo. Un código que no
        existe devuelve una página vacía. Para saber solo si hay una decisión
        vigente, usa [el estado por titular](#consultar-el-estado-vigente).
      </Property>
      <Property name="since" type="timestamp">
        Sólo solicitudes creadas después de este instante (ISO 8601).
      </Property>
      <Property name="updatedSince" type="timestamp">
        Sólo solicitudes cuyo `updatedAt` sea igual o posterior a este instante
        (ISO 8601). Cambia el orden a `updatedAt` ascendente.
      </Property>
      <Property name="limit" type="integer">
        Tamaño de página, entre 1 y 200. Por defecto 50.
      </Property>
      <Property name="cursor" type="string">
        Cursor de la página anterior (`nextCursor`). Ver
        [Paginación](/paginacion).
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="GET" label="/v1/.../consents/requests">

    ```bash {{ title: 'cURL' }}
    curl -G https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/requests \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -d status=granted \
      -d since=2026-08-01T00:00:00Z \
      -d limit=50
    ```

    ```js
    const params = new URLSearchParams({
      status: 'granted',
      since: '2026-08-01T00:00:00Z',
      limit: '50',
    })
    const response = await fetch(
      `https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/requests?${params}`,
      { headers: { Authorization: `Bearer ${keyId}.${secret}` } },
    )
    const { consentRequests, nextCursor } = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "consentRequests": [
        {
          "id": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
          "organizationId": "org_2f7a",
          "processingActivityVersionId": "3f2e1d0c-9b8a-4765-b432-1a0f9e8d7c6b",
          "targetSubjectId": "9c1f6d1e-1c9d-4f6a-9a44-1f2ab5c0d9e1",
          "requestedForAgeBand": "under_14",
          "decisionActorType": "guardian",
          "status": "granted",
          "requestedAt": "2026-08-20T15:02:11.000Z",
          "expiresAt": null,
          "invalidatedAt": null,
          "invalidationReason": null,
          "partnerExternalId": "lic-88231",
          "idempotencyKey": null,
          "consentCampaignId": null,
          "redirectMode": "auto",
          "successRedirectUrl": null,
          "declineRedirectUrl": null,
          "isSandbox": false,
          "createdAt": "2026-08-20T15:02:11.000Z",
          "updatedAt": "2026-08-21T09:40:03.000Z"
        }
      ],
      "nextCursor": "eyJ2IjoxLCJzY29wZSI6InBhcnRuZXItY29uc2VudC1yZXF1ZXN0cyJ9"
    }
    ```

  </Col>
</Row>

---

## Obtener una solicitud de consentimiento {{ tag: 'GET', label: '/v1/organizations/:organizationId/consents/requests/:consentRequestId' }}

<Row>
  <Col>

    Devuelve el paquete completo de una solicitud: sus entregas con resumen del destinatario, la última decisión con su desglose por permiso (cuando la actividad usa consentimiento granular) y su evidencia, y el último evento del historial.

    Permiso requerido: `partner.consents.read` · Alcance de conexión: `consents:read`.

    Si la solicitud no existe — o si su titular fue suprimido en la organización de origen — la respuesta es `404 CONSENT_REQUEST_NOT_FOUND` en ambos casos, deliberadamente indistinguibles. El resumen de un apoderado suprimido vuelve en `null` aunque la solicitud siga visible.

    Desde tu workspace también puedes leer las solicitudes de tus [campañas compartidas](/campanas-compartidas), aunque vivan en el colegio o en el registrador. Esas no traen el paquete completo sino la proyección `shared_partner`, la misma del [webhook](/webhooks#proyeccion-para-partners-shared-partner): sin nombre, RUT, contactos ni entregas. En una campaña segmentada por RUT, una solicitud sin decisión responde `404 CONSENT_REQUEST_NOT_FOUND`, igual que una inexistente, para no confirmar qué RUT están en el padrón. Para leer muchas a la vez está [consentimientos de un grupo compartido](/campanas-compartidas#consentimientos-de-un-grupo-compartido).

    Campos destacados del paquete:

    <Properties>
      <Property name="targetSubjectSummary" type="object">
        Resumen de visualización del titular objetivo.
      </Property>
      <Property name="deliveries" type="array">
        Entregas con canal, estado, destino enmascarado y `recipientSummary`.
      </Property>
      <Property name="latestDecision" type="object">
        Última decisión: `decision`, `options` (desglose por permiso, `null`
        si el consentimiento es en bloque), `optionsSummary`, `evidence` y
        `missingEvidenceTypes`.
      </Property>
      <Property name="latestEvent" type="object">
        Último evento del ciclo de vida, con su `payload`.
      </Property>
      <Property name="requestRole / companionOfRequestId / guardianAuthorization" type="varios">
        La [autorización del apoderado](#autorizacion-del-apoderado-request-role)
        que el colegio abre junto a la solicitud del titular. En las solicitudes
        creadas por esta API: `consent`, `null` y `null`.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="GET" label="/v1/.../consents/requests/c4a1b2d3...">

    ```bash {{ title: 'cURL' }}
    curl https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/requests/c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d \
      -H "Authorization: Bearer {keyId}.{secret}"
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/requests/c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
      { headers: { Authorization: `Bearer ${keyId}.${secret}` } },
    )
    const consentRequest = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "id": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "status": "granted",
      "requestedForAgeBand": "under_14",
      "decisionActorType": "guardian",
      "requestRole": "consent",
      "companionOfRequestId": null,
      "guardianAuthorization": null,
      "targetSubjectSummary": {
        "displayName": "Martina Rojas Pino",
        "subjectType": "student"
      },
      "deliveries": [
        {
          "id": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6",
          "channel": "email",
          "status": "delivered",
          "destinationMasked": "c***a.pino@familia.cl",
          "deliveredAt": "2026-08-20T15:04:40.000Z",
          "recipientSummary": { "displayName": "Carolina Pino Soto" }
        }
      ],
      "latestDecision": {
        "decision": "granted",
        "decidedAt": "2026-08-20T16:12:03.000Z",
        "options": null,
        "optionsSummary": null,
        "decisionMakerSummary": { "displayName": "Carolina Pino Soto" },
        "evidence": [
          {
            "id": "ev_01",
            "artifactId": "art_7c2d",
            "evidenceType": "signed_manifest",
            "checksum": "sha256:…",
            "capturedAt": "2026-08-20T16:12:03.000Z"
          }
        ],
        "missingEvidenceTypes": []
      },
      "latestEvent": {
        "eventType": "granted",
        "payload": { }
      }
      // ...
    }
    ```

  </Col>
</Row>

---

## Consultar el estado vigente {{ tag: 'GET', label: '/v1/organizations/:organizationId/consents/status' }}

<Row>
  <Col>

    La pregunta que una plataforma se hace antes de mostrar una funcionalidad — «¿este titular tiene consentimiento para esta actividad?» — respondida en una llamada, sin recorrer el listado.

    Permiso requerido: `partner.consents.read` · Alcance de conexión: `consents:read`.

    «Vigente» es la solicitud más reciente del titular sobre cualquier versión de la actividad que **no haya sido invalidada** (una recaptura o una reemisión invalida a la anterior). `expiresAt` es el plazo para **decidir**: una `pending` que lo pasa se informa como `expired`, pero un `granted` sigue `granted` aunque su campaña haya terminado. Un «sí» vale hasta que el titular lo retire o una regla lo reemplace (ver [qué cambia un consentimiento otorgado](#que-cambia-un-consentimiento-otorgado)). `isCurrentVersion` distingue una decisión sobre la versión publicada de una sobre un aviso anterior: sigue siendo válida, pero no es la versión vigente.

    Titular inexistente (o suprimido en origen): `404 TARGET_SUBJECT_NOT_FOUND`. Actividad inexistente: `404 PROCESSING_ACTIVITY_NOT_FOUND`.

    ### Parámetros de consulta requeridos

    <Properties>
      <Property name="referenceCode" type="string">
        Código de referencia del titular.
      </Property>
      <Property name="processingActivityCode" type="string">
        Código de la actividad de tratamiento.
      </Property>
    </Properties>

    ### Campos de la respuesta

    <Properties>
      <Property name="status" type="string">
        `granted`, `denied`, `pending`, `revoked`, `expired` o `none` (nunca
        se pidió, o todas las solicitudes fueron invalidadas).
      </Property>
      <Property name="consentRequestId" type="string | null">
        La solicitud que sostiene el estado; `null` con `none`.
      </Property>
      <Property name="processingActivityVersionNumber" type="integer | null">
        Versión sobre la que se decidió, junto a `currentVersionNumber` e
        `isCurrentVersion`.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="GET" label="/v1/.../consents/status">

    ```bash {{ title: 'cURL' }}
    curl "https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/status?referenceCode=EST-2026-0142&processingActivityCode=plataforma-lectura" \
      -H "Authorization: Bearer {keyId}.{secret}"
    ```

    ```js
    const params = new URLSearchParams({
      referenceCode: 'EST-2026-0142',
      processingActivityCode: 'plataforma-lectura',
    })
    const response = await fetch(
      `https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/status?${params}`,
      { headers: { Authorization: `Bearer ${keyId}.${secret}` } },
    )
    const status = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "referenceCode": "EST-2026-0142",
      "processingActivityCode": "plataforma-lectura",
      "status": "granted",
      "consentRequestId": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "processingActivityVersionId": "3f2e1d0c-9b8a-4765-b432-1a0f9e8d7c6b",
      "processingActivityVersionNumber": 2,
      "currentVersionNumber": 2,
      "isCurrentVersion": true,
      "requestedAt": "2026-08-25T12:30:00.000Z",
      "expiresAt": null
    }
    ```

  </Col>
</Row>

---

## Reemitir el enlace de una entrega {{ tag: 'POST', label: '/v1/organizations/:organizationId/consents/requests/:consentRequestId/deliveries/:consentDeliveryId/reissue' }}

<Row>
  <Col>

    Acuña un token nuevo para una entrega de una solicitud **pendiente**. El anterior deja de servir en el mismo instante: una plataforma que ofrece «copiar enlace» tiene siempre un único enlace vivo. Responde `201` con el token y su vencimiento (siete días), y deja el evento de auditoría `consent_delivery · portal_token_reissued` con tu cuenta de partner como actor.

    Permiso requerido: `partner.consents.write` · Alcance de conexión: `consents:write`.

    Una solicitud ya decidida, revocada, invalidada o vencida responde `409 CONSENT_REQUEST_NOT_OPEN`: reemitir su enlace abriría un portal sin nada que decidir. Una entrega que no pertenece a la solicitud, `404 CONSENT_DELIVERY_NOT_FOUND`; una solicitud cuyo titular fue suprimido en origen, `404 CONSENT_REQUEST_NOT_FOUND`.

    Esta llamada no reenvía nada: si quieres que Edugoverna vuelva a escribir al apoderado, el canal sigue siendo el de la solicitud. Sirve para el enlace que muestras tú.

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="POST" label="/v1/.../deliveries/d1e2f3a4.../reissue">

    ```bash {{ title: 'cURL' }}
    curl -X POST https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/requests/c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d/deliveries/d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6/reissue \
      -H "Authorization: Bearer {keyId}.{secret}"
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/requests/c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d/deliveries/d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6/reissue',
      { method: 'POST', headers: { Authorization: `Bearer ${keyId}.${secret}` } },
    )
    const link = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "consentRequestId": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "consentDeliveryId": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6",
      "recipientSubjectId": "5d2a8c3b-77e1-4b0e-9f1d-3c4e5f6a7b8c",
      "channel": "email",
      "token": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6.…",
      "tokenExpiresAt": "2026-09-01T12:30:00.000Z"
    }
    ```

  </Col>
</Row>

---

## Revocar una solicitud {{ tag: 'POST', label: '/v1/organizations/:organizationId/consents/requests/:consentRequestId/revoke' }}

<Row>
  <Col>

    Registra el **retiro** de un consentimiento otorgado cuando el titular te lo pidió a ti. Es la misma revocación que hace el colegio desde la Consola: la solicitud pasa a `revoked`, queda sellada la evidencia del retiro y se emite [`consent.revoked`](/webhooks#consentimiento) con `context.revocationSource = "partner"`. La respuesta es la solicitud, con la misma forma que [`GET …/requests/:consentRequestId`](#obtener-una-solicitud-de-consentimiento).

    Permiso requerido: `partner.consents.write` · Alcance de conexión: `consents:write`.

    Qué solicitudes puedes revocar:

    - **En tu workspace**: cualquiera de las suyas y las de tus [campañas compartidas](/campanas-compartidas), aunque vivan en el colegio o en el registrador. La actividad es tuya y eres el responsable nombrado en el aviso.
    - **En la organización de un colegio**: sólo las que emitió tu credencial, directas o de una campaña en línea tuya. Las del colegio responden `404 CONSENT_REQUEST_NOT_FOUND`, igual que una solicitud inexistente.

    Una solicitud que no está otorgada (pendiente, rechazada, vencida o anulada) responde `409 ACTIVE_CONSENT_NOT_FOUND`. Revocar una ya revocada responde `200` con la solicitud tal cual y **no** emite otro evento, así que puedes reintentar sin miedo después de un timeout. Si el titular vuelve a otorgar después (por ejemplo en el portal de transparencia) y retira de nuevo, ese segundo retiro trae su propio `consent.revoked`.

    El cuerpo es opcional.

    <Properties>
      <Property name="reason" type="string">
        Opcional (1–500). Motivo del retiro; queda en el historial de la
        solicitud. Nunca se le muestra al titular.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="POST" label="/v1/.../consents/requests/c4a1b2d3.../revoke">

    ```bash {{ title: 'cURL' }}
    curl -X POST https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/requests/c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d/revoke \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -H "Content-Type: application/json" \
      -d '{ "reason": "El titular lo pidió por correo" }'
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/requests/c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d/revoke',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${keyId}.${secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ reason: 'El titular lo pidió por correo' }),
      },
    )
    const consentRequest = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "id": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "status": "revoked",
      "latestDecision": {
        "decision": "granted",
        "revokedAt": "2026-10-02T14:03:22.511Z"
        // ...
      }
      // ...
    }
    ```

  </Col>
</Row>

---

## Crear una campaña con audiencia en línea {{ tag: 'POST', label: '/v1/organizations/:organizationId/consents/campaigns' }}

<Row>
  <Col>

    Crea y publica en una sola llamada una campaña de consentimiento sobre una lista de estudiantes que viaja en el propio cuerpo (audiencia "en línea"), creando una solicitud por estudiante. De pasada puede hacer upsert del estudiante, del apoderado y del vínculo de cada entrada, con los mismos esquemas del [padrón](/padron).

    Permiso requerido: `partner.consents.write` · Alcance de conexión: `consents:write`.

    La audiencia en línea acepta **hasta 100 estudiantes por llamada** — la creación es síncrona y alguien espera la respuesta. Para lotes mayores, pagina: cada página es idempotente por su `idempotencyKey`, así que reenviar una página no duplica nada. Para campañas sobre padrones compartidos por un colegio, usa [Campañas compartidas](/campanas-compartidas).

    El procesamiento es **por estudiante y tolerante a fallos**: un error en una entrada no detiene el resto. Cada resultado vuelve con `status` `created`, `idempotent_replay` o `error` (con `error.code` y `error.message`); las entradas con error quedan también visibles en el embudo de la campaña. Si el correo o teléfono de una entrada ya es de otro titular de la organización, el código es `SUBJECT_CONTACT_CONFLICT`; en tu propio workspace el error trae además `error.holderReferenceCode`, el `referenceCode` del titular que ya lo tiene (ver [padrón](/padron)). Por cada solicitud creada se encolan las entregas y se emite el webhook `consent.requested`.

    **Quien ya otorgó no recibe otra solicitud.** Si el titular ya tiene un `granted` para la misma versión de la actividad —de otra campaña, aunque esa ya haya terminado—, la entrada vuelve `idempotent_replay` con el `consentRequestId` de ese consentimiento, no se envía nada y el embudo lo muestra como «Ya otorgado». Una `pending` de otra campaña sí se reemplaza por la nueva, y la reemplazada emite `consent.invalidated` con `context.invalidationReason = "superseded_by_campaign"`.

    La actividad tiene que exigir consentimiento: si su base de licitud es otra, la llamada responde `409 CONSENT_NOT_REQUIRED` antes de crear la campaña.

    Usar URLs de redirección exige una conexión de partner (`403 PARTNER_CONNECTION_REQUIRED` en su ausencia) y que todas las URLs del lote estén en la lista de destinos permitidos — se validan completas antes de escribir nada.

    **Retiro sin RUT.** El portal de transparencia de la organización de la campaña (`/transparencia/{slug}/retirar`) encuentra al titular por RUT o, si no tiene RUT registrado, por el **correo de contacto** que le diste: el código llega sólo a esa dirección. Un titular que creas con un seudónimo en `referenceCode` y un correo puede revisar y retirar su consentimiento sin pedírselo a nadie, y el comprobante de cada decisión trae el enlace a ese portal. Sin RUT ni correo no hay forma de identificarlo ahí: para ese caso, registra tú el retiro con [`POST …/revoke`](#revocar-una-solicitud).

    ### Atributos requeridos

    <Properties>
      <Property name="students" type="array">
        Entre 1 y 100 entradas. Cada una acepta: `referenceCode` (requerido),
        `partnerExternalId`, `successRedirectUrl`, `declineRedirectUrl`,
        `idempotencyKey` propio, `student` (upsert de estudiante), `guardian`
        (upsert de apoderado, con `referenceCode` requerido) y `guardianship`
        (upsert del vínculo entre ambos).
      </Property>
    </Properties>

    Además debe venir `processingActivityVersionId` **o** `processingActivityCode`, igual que en la creación individual.

    ### Atributos opcionales

    <Properties>
      <Property name="name" type="string">
        Nombre de la campaña (2–160). Por defecto, un nombre generado con la
        fecha.
      </Property>
      <Property name="description" type="string">
        Descripción (máx. 2000).
      </Property>
      <Property name="channels" type="array">
        Canales de despacho: `["email"]`, `["whatsapp"]` o ambos. `whatsapp`
        requiere el número de WhatsApp conectado de la organización y mensajes
        aprobados por Meta; si no, `409 WHATSAPP_CHANNEL_UNAVAILABLE` (ver
        `details.reason`). Si se omite,
        cada solicitud usa el canal del contacto primario del destinatario. Un
        arreglo **vacío** (`[]`) es «solo enlace», igual que en una solicitud
        suelta: no se envía nada y cada resultado trae `portalAccessLinks` para
        que entregues el enlace tú. El código de verificación del portal va al
        contacto registrado del decisor (correo primero), así que alcanza con
        que tenga un correo. Un estudiante de 16 años o más con `birthDate`
        decide por sí mismo; el payload mínimo es `referenceCode` + `birthDate`
        + un contacto.
      </Property>
      <Property name="endsAt" type="timestamp">
        Cierre de la campaña (ISO 8601, debe ser futuro). Se copia como
        `expiresAt` de cada solicitud: es el plazo para decidir, y no vence un
        consentimiento ya otorgado. Puedes extenderlo o adelantarlo después,
        ya publicada, con
        [`PATCH /consents/campaigns/:campaignId`](#cambiar-el-cierre-de-una-campana).
      </Property>
      <Property name="reminderDayOffsets" type="array">
        Hasta 3 recordatorios, en días desde el envío (1–30, estrictamente
        crecientes). Por ejemplo `[3, 7]`.
      </Property>
      <Property name="redirectMode" type="string">
        `auto` o `bridge`, para toda la campaña.
      </Property>
      <Property name="defaultSuccessRedirectUrl" type="string">
        URL de éxito por defecto; cada estudiante puede traer la suya.
      </Property>
      <Property name="defaultDeclineRedirectUrl" type="string">
        URL de rechazo por defecto.
      </Property>
      <Property name="idempotencyKey" type="string">
        Llave de idempotencia **de la campaña** (8–120). Reintentar con la
        misma llave reutiliza la campaña ya creada y deduplica por estudiante
        (`{llave}:{referenceCode}` cuando el estudiante no trae la suya).
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="POST" label="/v1/.../consents/campaigns">

    ```bash {{ title: 'cURL' }}
    curl https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/campaigns \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -H "Content-Type: application/json" \
      -d '{
        "processingActivityCode": "plataforma-lectura",
        "name": "Alta plataforma de lectura 2026",
        "channels": ["email"],
        "reminderDayOffsets": [3, 7],
        "idempotencyKey": "alta-lectura-2026-p1",
        "students": [
          {
            "referenceCode": "EST-2026-0142",
            "partnerExternalId": "lic-88231",
            "student": { "legalGivenNames": "Martina", "legalFamilyNames": "Rojas Pino" },
            "guardian": {
              "referenceCode": "APO-889",
              "legalGivenNames": "Carolina",
              "legalFamilyNames": "Pino Soto",
              "contact": { "contactType": "email", "value": "carolina.pino@familia.cl" }
            },
            "guardianship": { "relationshipType": "madre" }
          }
        ]
      }'
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/campaigns',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${keyId}.${secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          processingActivityCode: 'plataforma-lectura',
          name: 'Alta plataforma de lectura 2026',
          channels: ['email'],
          reminderDayOffsets: [3, 7],
          idempotencyKey: 'alta-lectura-2026-p1',
          students: [
            {
              referenceCode: 'EST-2026-0142',
              partnerExternalId: 'lic-88231',
              guardian: {
                referenceCode: 'APO-889',
                contact: {
                  contactType: 'email',
                  value: 'carolina.pino@familia.cl',
                },
              },
              guardianship: { relationshipType: 'madre' },
            },
          ],
        }),
      },
    )
    const { campaignId, results } = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "campaignId": "a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7",
      "processingActivityVersionId": "3f2e1d0c-9b8a-4765-b432-1a0f9e8d7c6b",
      "results": [
        {
          "referenceCode": "EST-2026-0142",
          "partnerExternalId": "lic-88231",
          "consentRequestId": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
          "portalAccessLinks": [
            {
              "consentDeliveryId": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6",
              "channel": "email",
              "token": "cdt_..."
            }
          ],
          "status": "created"
        },
        {
          "referenceCode": "EST-2026-0198",
          "partnerExternalId": null,
          "consentRequestId": null,
          "portalAccessLinks": [],
          "status": "error",
          "error": {
            "code": "PARTNER_SUBJECT_NOT_FOUND",
            "message": "student subject not found for reference code EST-2026-0198."
          }
        }
      ]
    }
    ```

  </Col>
</Row>

---

## Cambiar el cierre de una campaña {{ tag: 'PATCH', label: '/v1/organizations/:organizationId/consents/campaigns/:campaignId' }}

<Row>
  <Col>

    Extiende o adelanta el `endsAt` de una campaña con audiencia en línea, también ya publicada. Las solicitudes **pendientes** pasan a vencer en la fecha nueva; las que ya vencieron no reviven. Al pasar la fecha nueva, el barrido marca las pendientes `expired` (con su `consent.expired`), el portal deja de aceptar decisiones y los recordatorios no se envían. La campaña sigue `published`: para cerrarla de inmediato usa [`…/close`](#cerrar-una-campana).

    Permiso requerido: `partner.consents.write` · Alcance de conexión: `consents:write`.

    Actúa sobre las campañas creadas con [`POST …/consents/campaigns`](#crear-una-campana-con-audiencia-en-linea): en tu workspace, cualquiera de las tuyas; en la organización de un colegio conectado, solo las que creó tu cuenta de partner — las demás responden `404 CONSENT_CAMPAIGN_NOT_FOUND`. Una campaña sobre padrón compartido responde `409 PARTNER_CAMPAIGN_FAMILY_MISMATCH`: se gestiona en [`/shared-campaigns/:campaignId`](/campanas-compartidas#cambiar-el-cierre-de-una-campana).

    La fecha debe ser futura (`422 VALIDATION_ERROR`) y la campaña no puede estar cerrada (`409 CONSENT_CAMPAIGN_CLOSED`).

    <Properties>
      <Property name="endsAt" type="timestamp">
        Requerido. ISO 8601 con zona horaria.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="PATCH" label="/v1/.../consents/campaigns/a9b8c7d6...">

    ```bash {{ title: 'cURL' }}
    curl -X PATCH https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7 \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -H "Content-Type: application/json" \
      -d '{ "endsAt": "2026-11-15T03:00:00Z" }'
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7',
      {
        method: 'PATCH',
        headers: {
          Authorization: `Bearer ${keyId}.${secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ endsAt: '2026-11-15T03:00:00Z' }),
      },
    )
    const campaign = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "campaignId": "a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7",
      "status": "published",
      "endsAt": "2026-11-15T03:00:00.000Z"
    }
    ```

  </Col>
</Row>

---

## Cerrar una campaña {{ tag: 'POST', label: '/v1/organizations/:organizationId/consents/campaigns/:campaignId/close' }}

<Row>
  <Col>

    Cierra la campaña **de inmediato**, con la misma cascada que el botón «Cerrar campaña» de la Consola:

    - las solicitudes pendientes pasan a `invalidated` (`invalidationReason: "campaign_closed"`) y cada una emite [`consent.invalidated`](/webhooks#consentimiento);
    - sus enlaces dejan de abrir el formulario y no salen más recordatorios;
    - la campaña queda `closed` y ya no se puede editar (`409 CONSENT_CAMPAIGN_CLOSED`).

    Las decisiones ya registradas no se tocan: quien otorgó o rechazó conserva su comprobante, y un consentimiento otorgado sigue vigente hasta que se revoque. Cerrar no se deshace.

    Permiso requerido: `partner.consents.write` · Alcance de conexión: `consents:write`. Mismas reglas de alcance que el [PATCH](#cambiar-el-cierre-de-una-campana). Cerrar una campaña ya cerrada responde `409 CONSENT_CAMPAIGN_ALREADY_CLOSED`, así que un reintento después de un timeout se reconoce por ese código.

    El cuerpo es opcional. La respuesta no trae conteos: cada solicitud anulada te llega por su propio `consent.invalidated`.

    <Properties>
      <Property name="reason" type="string">
        Opcional (1–500). Motivo del cierre; queda en el evento de auditoría
        `closed`. Nunca se le muestra al titular.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="POST" label="/v1/.../consents/campaigns/a9b8c7d6.../close">

    ```bash {{ title: 'cURL' }}
    curl -X POST https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/close \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -H "Content-Type: application/json" \
      -d '{ "reason": "Proceso de admisión cerrado" }'
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_2f7a/consents/campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/close',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${keyId}.${secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ reason: 'Proceso de admisión cerrado' }),
      },
    )
    const campaign = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "campaignId": "a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7",
      "status": "closed",
      "closedAt": "2026-10-02T14:03:22.511Z"
    }
    ```

  </Col>
</Row>
