---
title: "Campañas compartidas"
description: "Endpoints de campañas compartidas de la Partner API: registros compartidos, campañas sobre padrones ajenos y destinos por estudiante."
url: "https://docs.edugoverna.com/campanas-compartidas"
---
# Campañas compartidas

Un colegio o un registrador externo puede compartir contigo un grupo de su padrón — un curso, un segmento, una generación — mediante un **registro compartido** (`registry_share`). Sobre esos grupos puedes lanzar campañas de consentimiento que viven en tu propio espacio de trabajo, mientras las solicitudes se crean en el padrón del dueño de los datos y Edugoverna hace todas las entregas. En esta página revisamos los endpoints de lectura de grupos compartidos, la creación de campañas y el envío por sobres. {{ className: 'lead' }}

<Note>
  Las rutas de esta página cuelgan de la URL base
  `https://app.edugoverna.com/api/partner/v1/organizations/:organizationId`,
  pero **cuidado con qué organización va en la ruta**: en `GET /shares` y `GET
  /shares/:shareId/subjects`, `:organizationId` es la organización **dueña del
  padrón** (el colegio o registro). En todas las rutas `/shared-campaigns`,
  `:organizationId` es **tu propia organización de espacio de trabajo** —
  apuntarlas a la organización del dueño responde `403
  PARTNER_WORKSPACE_REQUIRED`. La guía paso a paso está en [Guía de campañas
  compartidas](/guias/campanas-compartidas).
</Note>

## El modelo

- **Registro compartido (share)**: la concesión con que el dueño te comparte un grupo. Tiene `groupKind` (`course`, `segment` o `cohort`) y un `memberCount`. El dueño puede revocarla en cualquier momento; una share revocada deja de resolver titulares.
- **Lectura ofuscada**: los titulares de un grupo compartido se leen con obfuscación estructural — nombre, banda etaria, RUT enmascarado (`maskedRut`), **presencia** de contactos (tipo y primario) y si una campaña alcanzaría a quien decide por el titular (`consentReach`), nunca los valores. Edugoverna despacha todas las entregas, así que tu integración no necesita el email ni el teléfono de nadie. La divulgación del RUT completo existe como excepción explícita y auditada (ver `disclosure=full`).
- **Campaña compartida**: se crea y publica en una sola llamada sobre uno o más shares. En modo `bulk` (por defecto) el roster completo se despacha al publicar; en modo `per_subject` el roster se materializa como **sobres** (`envelopes`) — uno por familia — y tú los despachas uno a uno o por lotes, cada uno con su propia URL de destino.
- **Campaña declarada**: cuando un registrador externo declara consentimientos que el colegio ya tiene firmados, esos consentimientos viven en una campaña de tu espacio de trabajo con `origin: "registrar_attestation"`. No envía mensajes, así que todo lo que la haría enviar está bloqueado con `409 CONSENT_CAMPAIGN_ATTESTED_READ_ONLY`: publicarla, reenviarla, los sobres, recuperar errores, sincronizar su audiencia y corregir destinos (redirects). Solo se puede editar su nombre, su descripción y `endsAt`; cambiar metadatos, recordatorios, canales o destinos responde el mismo `409`. Es una campaña `bulk` sin sobres, así que despachar o reenviar sobres responde también `409 CONSENT_CAMPAIGN_DISPATCH_MODE_UNSUPPORTED`. Cerrarla sí se permite. Ver [Consentimiento declarado](/guias/registrador-externo#consentimiento-declarado).
- **Destinos (redirects)**: al decidir, el firmante puede ser redirigido a tu aplicación. `redirectMode` es `auto` (redirección inmediata) o `bridge` (pantalla intermedia de Edugoverna). Los pares RUT→URL se pueden fijar al crear, al sincronizar audiencia o corregirse después; los RUT que no calcen con el padrón **se ignoran en silencio** — ninguna respuesta confirma cuáles calzaron.
- **Contrato de no divulgación**: en campañas segmentadas por RUT, las respuestas nunca revelan cuántos ni cuáles RUT resolvieron a titulares reales. Los únicos números que se devuelven son los que tú mismo enviaste.

---

## Listar registros compartidos {{ tag: 'GET', label: '/v1/organizations/:organizationId/shares' }}

<Row>
  <Col>

    Devuelve los registros compartidos **activos** que esta organización dueña le concedió a tu cuenta de partner. `:organizationId` es la organización del dueño del padrón.

    Un share es exactamente un grupo. Cuando el grupo es un curso (`groupKind: "course"`), `course` trae `schoolYear`, `gradeLevel` y `courseCode` (la letra o sección), si el dueño los cargó. Si no es un curso, `course` es `null`.

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

  </Col>
  <Col sticky>

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

    ```bash {{ title: 'cURL' }}
    curl https://app.edugoverna.com/api/partner/v1/organizations/org_colegio/shares \
      -H "Authorization: Bearer {keyId}.{secret}"
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_colegio/shares',
      { headers: { Authorization: `Bearer ${keyId}.${secret}` } },
    )
    const { shares } = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "shares": [
        {
          "id": "shr_1a2b3c",
          "groupType": "segment",
          "groupKind": "course",
          "groupName": "5° Básico B",
          "course": { "schoolYear": 2026, "gradeLevel": "5° Básico", "courseCode": "B" },
          "memberCount": 34,
          "grantSource": "school_dashboard",
          "createdAt": "2026-03-15T10:00:00.000Z"
        },
        {
          "id": "shr_4d5e6f",
          "groupKind": "cohort",
          "groupName": "Generación 2032"
          // ...
        }
      ]
    }
    ```

  </Col>
</Row>

---

## Listar titulares de un grupo compartido {{ tag: 'GET', label: '/v1/organizations/:organizationId/shares/:shareId/subjects' }}

<Row>
  <Col>

    Devuelve una página de los titulares del grupo compartido, con la proyección ofuscada. `:organizationId` es la organización dueña; si la share no pertenece a ella, `404 REGISTRY_SHARE_NOT_FOUND`; si fue revocada, la lectura se rechaza.

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

    Con `disclosure=full` la respuesta agrega el campo `rut` con el RUT completo normalizado — lo que necesitas para cruzar el grupo contra tu propia base y segmentar por RUT. Requiere que el dueño tenga habilitada la divulgación de RUT para esa share (si la apagó, `403 SHARE_RUT_DISCLOSURE_DISABLED`) y **cada lectura ampliada queda auditada** en la organización del dueño y en la tuya, con los titulares divulgados. Los valores de contacto siguen ocultos en cualquier modo; el único que puede salir es el correo del estudiante, [de a un sobre y con consentimiento otorgado](#correo-del-estudiante).

    Cada titular trae `enrollment`: el curso del share (`courseName`, `schoolYear`, `gradeLevel`, `courseCode`), o `null` si el grupo no es un curso.

    `contacts` son los canales **propios del estudiante**, y no dicen si una campaña lo alcanza: la solicitud va a quien decide, que bajo 14 años (bajo 16 si tu finalidad trata datos sensibles) es su apoderado. Eso lo dice `consentReach`, separado en `general` y `sensitive` porque entre 14 y 15 años difieren. Cada uno trae `decidedBy` (`self`, `guardian`, o `null` si falta la fecha de nacimiento), `status` (`ok`, `guardian_missing`, `authority_undetermined`) y `channels`: los canales de campaña (`email`, `whatsapp`) por los que se alcanza a alguien que puede decidir. Si el estudiante tiene varios apoderados con autoridad, la solicitud les llega a todos y decide el primero; basta con alcanzar a uno.

    `meta.missingContactCount` cuenta, en todo el grupo, los titulares cuyo `channels` queda vacío en al menos uno de los dos tipos; `meta.unreachableCount` trae el mismo conteo separado (`{ general, sensitive }`). Con `since`, un contacto o apoderado nuevo no marca al titular como cambiado: vuelve a leer sin `since` para refrescar el alcance.

    ### Parámetros de consulta opcionales

    <Properties>
      <Property name="offset" type="integer">
        Desplazamiento de la página. Por defecto 0. Se ignora si mandas
        `cursor`.
      </Property>
      <Property name="cursor" type="string">
        Paginación por llave: devuelve los titulares con `subjectId` mayor que
        éste (el orden es estable por id). Usa el `meta.nextCursor` de la
        página anterior; es `null` cuando no hay más.
      </Property>
      <Property name="since" type="string">
        ISO 8601. Sincronización incremental: sólo los titulares que entraron
        al grupo o cuya ficha cambió desde esa fecha, y en `removed` los que
        salieron (`{ subjectId, removedAt }`). Pasa el `meta.syncedAt` de tu
        lectura anterior: es la hora del servidor tomada antes de leer, así
        que no pierdes lo que cambie mientras paginas ni dependes de tu reloj.
        Con `since`, `totalCount` cuenta sólo los cambios.
      </Property>
      <Property name="limit" type="integer">
        Tamaño de página, entre 1 y 200. Por defecto 50.
      </Property>
      <Property name="disclosure" type="string">
        `full` para la lectura ampliada con RUT completo.
      </Property>
      <Property name="include" type="string">
        `nationalIdHash`: cada titular trae `nationalIdPartnerHash` en vez del
        RUT. Ver [Hash de RUT por partner](#hash-de-rut-por-partner).
      </Property>
    </Properties>

    ### Hash de RUT por partner

    Con `include=nationalIdHash` cada titular trae `nationalIdPartnerHash`: un HMAC del RUT con una clave propia de tu cuenta. El mismo RUT da el mismo hash para ti y otro distinto para cualquier otro partner, y rotar claves no lo cambia. Sirve para cruzar el grupo contra tu base sin guardar RUT en claro. Para hashear RUTs que tú tienes (por ejemplo, el que escribe un estudiante en tu página) está `POST /v1/organizations/:organizationId/shares/:shareId/national-id-hash` con `{ "ruts": [...] }` (hasta 100); responde `{ "hashes": [...] }`, con `null` para un RUT inválido, y consume el mismo presupuesto diario por share que la segmentación por RUT (`429 SHARE_RUT_LOOKUP_BUDGET_EXCEEDED`).

    No hace falta `disclosure=full`, pero **sí** que el dueño mantenga habilitada la divulgación de RUT para esa share: aunque no revela el RUT, un hash comparable responde la misma pregunta de pertenencia —«¿este RUT está en el curso?»—, así que la decide el mismo interruptor. Si está apagado, ambas vías responden `403 SHARE_RUT_DISCLOSURE_DISABLED`; en ese curso sincroniza con `subjectId` y `maskedRut`. Cada lectura queda auditada como divulgación.

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="GET" label="/v1/.../shares/shr_1a2b3c/subjects">

    ```bash {{ title: 'cURL' }}
    curl -G https://app.edugoverna.com/api/partner/v1/organizations/org_colegio/shares/shr_1a2b3c/subjects \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -d limit=50 \
      -d disclosure=full
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_colegio/shares/shr_1a2b3c/subjects?limit=50&disclosure=full',
      { headers: { Authorization: `Bearer ${keyId}.${secret}` } },
    )
    const { share, subjects, meta } = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "share": {
        "id": "shr_1a2b3c",
        "groupType": "segment",
        "groupKind": "course",
        "groupName": "5° Básico B",
        "memberCount": 34
      },
      "subjects": [
        {
          "subjectId": "9c1f6d1e-1c9d-4f6a-9a44-1f2ab5c0d9e1",
          "displayName": "Martina Rojas Pino",
          "subjectType": "student",
          "currentAgeBand": "under_14",
          "maskedRut": "**.***.435-2",
          "rut": "25678435-2",
          "contacts": [],
          "consentReach": {
            "general": { "decidedBy": "guardian", "status": "ok", "channels": ["email", "whatsapp"] },
            "sensitive": { "decidedBy": "guardian", "status": "ok", "channels": ["email", "whatsapp"] }
          },
          "enrollment": {
            "courseName": "5° Básico B",
            "schoolYear": 2026,
            "gradeLevel": "5° Básico",
            "courseCode": "B"
          }
        }
      ],
      "removed": [],
      "meta": {
        "totalCount": 34,
        "offset": 0,
        "limit": 50,
        "missingContactCount": 3,
        "unreachableCount": { "general": 3, "sensitive": 3 },
        "nextCursor": null,
        "since": null,
        "syncedAt": "2026-10-01T14:03:11.402Z"
      }
    }
    ```

  </Col>
</Row>

---

## Consentimientos de un grupo compartido {{ tag: 'GET', label: '/v1/organizations/:organizationId/shares/:shareId/consents' }}

<Row>
  <Col>

    Devuelve el estado de consentimiento de los titulares del grupo: una fila por solicitud de **cualquiera de tus campañas** sobre esta share —también las [declaradas por el registrador](/guias/registrador-externo#consentimiento-declarado), que no tienen sobres—. Es la otra mitad de [listar titulares](#listar-titulares-de-un-grupo-compartido): cada fila trae `subjectId`, el mismo identificador, para cruzar las dos lecturas.

    Cada fila es la misma proyección `shared_partner` que el [webhook](/webhooks#proyeccion-para-partners-shared-partner) y el detalle por id, más `subjectId`, `processingActivityCode`, `processingActivityVersionNumber` e `invalidatedAt`. Nunca trae RUT, contactos ni nombre del titular, y de la decisión trae el **rol** que decidió (`decisionAuthority`) y cómo se capturó (`captureMethod`: `portal_link_otp` o `registrar_attestation`), nunca quién.

    `:organizationId` es la organización dueña, como en el resto de las lecturas de shares. Una share de otra organización o de otro partner responde `404 REGISTRY_SHARE_NOT_FOUND`; una revocada, `409 REGISTRY_SHARE_REVOKED`.

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

    Las filas se ordenan por `updatedAt` ascendente: para conciliar, guarda el mayor `updatedAt` que leíste y úsalo como el próximo `updatedSince` (inclusivo: deduplica por `consentRequestId` + `updatedAt`). Una solicitud reemplazada por otra campaña sigue saliendo, con `invalidatedAt`; quédate con la vigente de cada titular y actividad. En una campaña segmentada por RUT, una solicitud sin decisión **no aparece** hasta que el titular decide, y un titular suprimido en origen no aparece nunca.

    ### Parámetros de consulta opcionales

    <Properties>
      <Property name="processingActivityCode" type="string">
        Sólo las solicitudes de esa actividad tuya (todas sus versiones).
      </Property>
      <Property name="status" type="string">
        `pending`, `granted`, `denied`, `revoked` o `expired`.
      </Property>
      <Property name="updatedSince" type="timestamp">
        Sólo las que cambiaron desde ese instante (ISO 8601, inclusivo).
      </Property>
      <Property name="limit" type="integer">
        Tamaño de página, entre 1 y 200. Por defecto 50.
      </Property>
      <Property name="cursor" type="string">
        `meta.nextCursor` de la página anterior. Firmado contra la share y los
        filtros: reutilizarlo con otros responde `422 INVALID_CURSOR`.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

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

    ```bash {{ title: 'cURL' }}
    curl -G https://app.edugoverna.com/api/partner/v1/organizations/org_colegio/shares/shr_1a2b3c/consents \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -d processingActivityCode=AULA_LEARNING \
      -d status=granted
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "share": { "id": "shr_1a2b3c" },
      "consents": [
        {
          "projection": "shared_partner",
          "consentRequestId": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
          "dataOwnerOrganizationId": "org_colegio",
          "registryShareId": "shr_1a2b3c",
          "campaignId": "03aa680e-f976-4e51-b58a-6944d78a9448",
          "campaignStatus": "published",
          "campaignMode": "targeted",
          "campaignOrigin": "registrar_attestation",
          "campaignContactEntryId": "6f0e2d1c-4b3a-4c2d-9e8f-7a6b5c4d3e2f",
          "partnerExternalId": null,
          "processingActivityVersionId": "ee13e852-ce7a-4fde-9c6c-9cc8a06f02d0",
          "processingActivityCode": "AULA_LEARNING",
          "processingActivityVersionNumber": 1,
          "subjectId": "9c1f6d1e-1c9d-4f6a-9a44-1f2ab5c0d9e1",
          "subject": {
            "referenceCode": "EST-0042",
            "subjectType": "student",
            "requestedForAgeBand": "under_14"
          },
          "status": "granted",
          "decisionActorType": "guardian",
          "requestedAt": "2026-10-15T20:26:02.914Z",
          "expiresAt": null,
          "invalidatedAt": null,
          "updatedAt": "2026-10-15T20:26:03.120Z",
          "latestDecision": {
            "id": "a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c0d",
            "decision": "granted",
            "decisionAuthority": "guardian",
            "captureMethod": "registrar_attestation",
            "decidedAt": "2026-10-15T20:26:02.914Z",
            "effectiveFrom": "2026-10-15T20:26:02.914Z",
            "effectiveTo": null,
            "revokedAt": null,
            "evidence": { "id": "a7b8c9d0-…:manifest", "checksum": "f542639105f83d66…" }
          }
        }
      ],
      "meta": { "nextCursor": null, "syncedAt": "2026-10-15T21:00:00.000Z" }
    }
    ```

  </Col>
</Row>

---

## Crear una campaña compartida {{ tag: 'POST', label: '/v1/organizations/:organizationId/shared-campaigns' }}

<Row>
  <Col>

    Crea **y publica** una campaña de consentimiento sobre uno o más registros compartidos. `:organizationId` es tu organización de espacio de trabajo; cada share del cuerpo se re-autoriza contra tu cuenta de partner. Responde `202 Accepted` con una forma idéntica resuelva lo que resuelva la audiencia — el contrato de no divulgación.

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

    Cada `target` apunta a un share y, opcionalmente, a una lista de RUTs dentro del grupo (segmentación por RUT, hasta 500 por share y 1000 por campaña). Sin `ruts`, la campaña cubre el grupo completo (modo `group`); con `ruts`, sólo los que calcen por índice ciego (modo `rut`, con proyección de no divulgación de membresía).

    Las URLs de redirección son tuyas y no llevan lista blanca en este flujo: en una campaña compartida el aviso que el titular lee lo firma tu organización, y el dueño lo aceptó al conectarse.

    ### Atributos requeridos

    <Properties>
      <Property name="processingActivityVersionId" type="string">
        UUID de la versión de tu
        [actividad de tratamiento](/actividades-tratamiento) — usa la versión
        publicada vigente: es el aviso que el titular leerá al decidir.
      </Property>
      <Property name="name" type="string">
        Nombre de la campaña (2–160).
      </Property>
      <Property name="targets" type="array">
        Entre 1 y 50 entradas `{ shareId, ruts? }`. Máximo 1000 RUTs sumados.
      </Property>
    </Properties>

    ### Atributos opcionales

    <Properties>
      <Property name="description" type="string">
        Descripción (máx. 2000).
      </Property>
      <Property name="channels" type="array">
        `["email"]`, `["whatsapp"]` o ambos. Por defecto `["email"]`.
        `whatsapp` sale desde el número de **tu propio** espacio de trabajo
        (no del colegio) y exige que lo tengas conectado en Ajustes →
        WhatsApp con 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`).
      </Property>
      <Property name="endsAt" type="timestamp">
        Cierre de la campaña (ISO 8601, futuro).
      </Property>
      <Property name="reminderDayOffsets" type="array">
        Hasta 3 recordatorios en días desde el envío (1–30), estrictamente crecientes.
      </Property>
      <Property name="dispatchMode" type="string">
        `bulk` (por defecto: despacha el roster completo al publicar) o
        `per_subject` (materializa sobres y espera tus envíos uno a uno).
      </Property>
      <Property name="defaultSuccessRedirectUrl" type="string">
        URL de éxito por defecto para toda la campaña.
      </Property>
      <Property name="defaultDeclineRedirectUrl" type="string">
        URL de rechazo por defecto.
      </Property>
      <Property name="redirectMode" type="string">
        `auto` o `bridge`.
      </Property>
      <Property name="redirects" type="array">
        Hasta 2500 pares decididos al crear. Cada par nombra al estudiante por
        **uno** de `rut` o `subjectId` (el id de la lectura del grupo) y fija
        sólo lo que trae: `successRedirectUrl`, `declineRedirectUrl`,
        `redirectMode`, `studentRedirectUrl` (el enlace del aviso al
        estudiante) y `partnerExternalId` (tu correlativo, que luego viaja en
        webhooks, en `eg_token` y en la lista de sobres). Quedan en el sobre de
        cada estudiante y cualquier vía de completación resuelve lo mismo. Los
        RUT o ids que no calcen se ignoran en silencio.
      </Property>
      <Property name="studentNotification" type="object">
        `{ enabled, urlTemplate? }`. Con `enabled: true`, cuando **decide el
        apoderado** y otorga, EduGoverna le escribe al **estudiante** a su
        correo registrado, con un texto propio ("Tu apoderado/a autorizó tu
        participación en …", con el nombre de su colegio y de la campaña) y tu
        enlace para continuar. El enlace es el
        `studentRedirectUrl` del sobre, o `urlTemplate` con `{envelopeId}` y
        `{partnerExternalId}` reemplazados. Sin correo del estudiante no se
        envía nada y la decisión no se ve afectada. En ambos casos recibes
        `consent.student_notified` con `context.outcome` (`sent` o
        `no_contact`). El apoderado sigue aterrizando en `successRedirectUrl`.
      </Property>
      <Property name="publicCompletionEnabled" type="boolean">
        Abre la puerta pública de completación: un titular del roster sin
        canal registrado puede identificarse con su RUT, verificar un contacto
        propio con OTP y firmar su propio sobre. La respuesta incluye
        `publicCompletionUrl`.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

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

    ```bash {{ title: 'cURL' }}
    curl https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -H "Content-Type: application/json" \
      -d '{
        "processingActivityVersionId": "3f2e1d0c-9b8a-4765-b432-1a0f9e8d7c6b",
        "name": "Alta 5° Básico B — Colegio San Martín",
        "dispatchMode": "per_subject",
        "defaultSuccessRedirectUrl": "https://app.milectura.cl/bienvenida",
        "redirectMode": "bridge",
        "publicCompletionEnabled": true,
        "targets": [{ "shareId": "shr_1a2b3c" }]
      }'
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${keyId}.${secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          processingActivityVersionId: '3f2e1d0c-9b8a-4765-b432-1a0f9e8d7c6b',
          name: 'Alta 5° Básico B — Colegio San Martín',
          dispatchMode: 'per_subject',
          defaultSuccessRedirectUrl: 'https://app.milectura.cl/bienvenida',
          redirectMode: 'bridge',
          publicCompletionEnabled: true,
          targets: [{ shareId: 'shr_1a2b3c' }],
        }),
      },
    )
    const { campaignId } = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "campaignId": "a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7",
      "submittedShareCount": 1,
      "publicCompletionEnabled": true,
      "publicCompletionUrl": "https://app.edugoverna.com/consents/campaigns/…",
      "status": "accepted"
    }
    ```

  </Col>
</Row>

---

## Obtener una campaña compartida {{ tag: 'GET', label: '/v1/organizations/:organizationId/shared-campaigns/:campaignId' }}

<Row>
  <Col>

    Devuelve el estado agregado de la campaña: el embudo de desenlaces (`outcomes`) y — en campañas por grupo — el total del roster y su desglose de cobertura. En campañas segmentadas por RUT la respuesta trae `decidedOnly: true` y omite denominadores, de nuevo por el contrato de no divulgación: sólo se describen los contactos que ya decidieron.

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

    <Properties>
      <Property name="origin" type="string">
        `operator` (una campaña que lanzaste tú) o `registrar_attestation` (los
        consentimientos que un registrador externo declaró firmados ante el
        colegio; no envía mensajes, así que no admite envío ni cambios de canales o destinos: ver [El modelo](#el-modelo)).
      </Property>
      <Property name="outcomes" type="object">
        `counts` (`granted`, `denied`, `revoked`, `expired`, `cancelled`,
        `errored`), `inProgress` y `total`.
      </Property>
      <Property name="coverage" type="object">
        Sólo en modo grupo: `totalStudents`, `deliverableStudents`,
        `manualDeliveryStudents`, `authorityUndetermined`, `guardianMissing`
        y, en modo sobre, `awaitingSendStudents`.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="GET" label="/v1/.../shared-campaigns/a9b8c7d6...">

    ```bash {{ title: 'cURL' }}
    curl https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7 \
      -H "Authorization: Bearer {keyId}.{secret}"
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7',
      { headers: { Authorization: `Bearer ${keyId}.${secret}` } },
    )
    const campaign = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "campaignId": "a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7",
      "name": "Alta 5° Básico B — Colegio San Martín",
      "status": "published",
      "origin": "operator",
      "dispatchMode": "per_subject",
      "targetingMode": "group",
      "channels": ["email"],
      "publishedAt": "2026-08-25T12:30:00.000Z",
      "closedAt": null,
      "endsAt": null,
      "publicCompletionEnabled": true,
      "publicCompletionUrl": "https://app.edugoverna.com/consents/campaigns/…",
      "outcomes": {
        "counts": {
          "granted": 12,
          "denied": 1,
          "revoked": 0,
          "expired": 0,
          "cancelled": 0,
          "errored": 0
        },
        "inProgress": 21,
        "total": 34
      },
      "rosterTotal": 34,
      "coverage": {
        "totalStudents": 34,
        "deliverableStudents": 13,
        "manualDeliveryStudents": 0,
        "authorityUndetermined": 0,
        "guardianMissing": 0,
        "awaitingSendStudents": 21
      }
    }
    ```

  </Col>
</Row>

---

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

<Row>
  <Col>

    Extiende o adelanta el `endsAt` de una campaña, también ya publicada. Las solicitudes **pendientes** pasan a vencer en la fecha nueva, incluidas las que viven en el padrón de cada colegio. Las que ya vencieron no reviven: para pedirlas de nuevo dentro del plazo nuevo está el [reenvío del sobre](#reenviar-un-sobre). Los sobres que se despachen después toman el `endsAt` nuevo.

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

    La fecha debe ser futura (`422 VALIDATION_ERROR`) y la campaña no puede estar cerrada (`409 CONSENT_CAMPAIGN_CLOSED`). Cada cambio deja el evento de auditoría `updated` con el `endsAt` nuevo y cuántas solicitudes se reprogramaron.

    Solo actúa sobre campañas compartidas. Una campaña de tu workspace creada con [audiencia en línea](/consentimientos#crear-una-campana-con-audiencia-en-linea) responde `409 PARTNER_CAMPAIGN_FAMILY_MISMATCH` y se gestiona con [`PATCH /consents/campaigns/:campaignId`](/consentimientos#cambiar-el-cierre-de-una-campana).

    La respuesta trae `origin` (`operator` o `registrar_attestation`), el mismo valor de la lectura de la campaña. En una campaña declarada (`registrar_attestation`) esta es una de las pocas ediciones permitidas, junto al nombre y la descripción.

    Adelantar el cierre corta los recordatorios y el portal al llegar la fecha nueva, pero no es inmediato y la campaña sigue `published`. Para terminarla ya, [ciérrala](#cerrar-una-campana-compartida).

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

  </Col>
  <Col sticky>

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

    ```bash {{ title: 'cURL' }}
    curl -X PATCH https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-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_milectura/shared-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",
      "origin": "operator"
    }
    ```

  </Col>
</Row>

---

## Cerrar una campaña compartida {{ tag: 'POST', label: '/v1/organizations/:organizationId/shared-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, también sobre las solicitudes que viven en el padrón de cada colegio:

    - las solicitudes pendientes pasan a `invalidated` (`invalidationReason: "campaign_closed"`) y cada una emite [`consent.invalidated`](/webhooks#consentimiento) — al colegio y, con la [proyección para partners](/webhooks#proyeccion-para-partners-shared-partner), a ti;
    - sus enlaces dejan de abrir el formulario, no salen más recordatorios y la [completación pública](#crear-una-campana-compartida) se cierra;
    - los sobres que nunca se despacharon quedan cancelados, y [despachar](#despachar-sobres) o [reenviar](#reenviar-un-sobre) un sobre responde `409 CONSENT_CAMPAIGN_CLOSED`;
    - la campaña queda `closed` y ya no se puede editar.

    Las decisiones ya registradas no se tocan: quien otorgó o rechazó conserva su comprobante y la [evidencia](#evidencia-de-un-sobre) sigue disponible; un consentimiento otorgado sigue vigente hasta que se revoque. Si el titular te pide el retiro a ti, regístralo con [`POST /consents/requests/:consentRequestId/revoke`](/consentimientos#revocar-una-solicitud) sobre tu workspace. Cerrar no se deshace.

    Permiso requerido: `partner.shared_campaigns.write` · Alcance de conexión: `consents:write`. La organización de la URL es tu workspace (`403 PARTNER_WORKSPACE_REQUIRED`). Una campaña con audiencia en línea responde `409 PARTNER_CAMPAIGN_FAMILY_MISMATCH` y se cierra con [`POST /consents/campaigns/:campaignId/close`](/consentimientos#cerrar-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.

    La respuesta trae también `origin`, para que una campaña declarada no se confunda con una lanzada por ti; cerrar una campaña declarada está permitido. El cuerpo es opcional. La respuesta no trae conteos —en una campaña segmentada por RUT dirían cuántos de los RUT enviados estaban en el padrón—; cada solicitud anulada te llega por su propio `consent.invalidated`. En una campaña segmentada por RUT no recibes ninguno: solo se anulan solicitudes sin decisión, y eso es justo lo que ese modo no revela.

    <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/.../shared-campaigns/a9b8c7d6.../close">

    ```bash {{ title: 'cURL' }}
    curl -X POST https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-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_milectura/shared-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",
      "origin": "operator"
    }
    ```

  </Col>
</Row>

---

## Listar sobres de una campaña {{ tag: 'GET', label: '/v1/organizations/:organizationId/shared-campaigns/:campaignId/envelopes' }}

<Row>
  <Col>

    Devuelve el roster de la campaña sobre a sobre, con el estado de cada uno y su destino planificado. Es tu tablero de seguimiento en modo `per_subject`. En campañas segmentadas por RUT esta lectura se rechaza con `409 CONSENT_CAMPAIGN_RUT_MODE_RESTRICTED` — el estado por titular revelaría membresía.

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

    El campo `completedVia` de un sobre completado distingue `dispatch` (firmó por su enlace despachado) de `public_completion` (firmó por la puerta pública; en ese caso `reviewStatus` refleja la revisión del staff: `pending`, `verified` o `rejected`).

    ### Parámetros de consulta opcionales

    <Properties>
      <Property name="status" type="string">
        Filtra por estado del sobre (por ejemplo `awaiting_send`, `queued`,
        `completed`, `error`).
      </Property>
      <Property name="since" type="string">
        ISO 8601. Sólo los sobres que cambiaron desde esa fecha: el sobre o su
        solicitud (una decisión, una revocación, una expiración). Cada sobre
        trae su `updatedAt`. Usa el `meta.syncedAt` de una lectura como
        `since` de la siguiente: se toma antes de leer, así que no se pierde
        nada que cambie mientras paginas.
      </Property>
      <Property name="limit" type="integer">
        Tamaño de página, hasta 500. Sin `limit` se devuelve todo el roster,
        como siempre.
      </Property>
      <Property name="cursor" type="string">
        Paginación por llave (orden estable por `envelopeId`): pasa el
        `meta.nextCursor` de la página anterior; es `null` cuando no hay más.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="GET" label="/v1/.../shared-campaigns/a9b8c7d6.../envelopes">

    ```bash {{ title: 'cURL' }}
    curl -G https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/envelopes \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -d status=awaiting_send
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/envelopes?status=awaiting_send',
      { headers: { Authorization: `Bearer ${keyId}.${secret}` } },
    )
    const { envelopes } = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "campaignId": "a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7",
      "envelopes": [
        {
          "envelopeId": "env_7f8e9d",
          "subjectId": "9c1f6d1e-1c9d-4f6a-9a44-1f2ab5c0d9e1",
          "referenceCode": null,
          "partnerExternalId": null,
          "status": "awaiting_send",
          "errorCode": null,
          "errorMessage": null,
          "consentRequestId": null,
          "requestStatus": null,
          "sentAt": null,
          "successRedirectUrl": null,
          "plannedSuccessRedirectUrl": "https://app.milectura.cl/bienvenida?e=88231",
          "completedVia": null,
          "reviewStatus": null
        }
      ]
    }
    ```

  </Col>
</Row>

---

## Despachar sobres {{ tag: 'POST', label: '/v1/organizations/:organizationId/shared-campaigns/:campaignId/envelopes' }}

<Row>
  <Col>

    Despacha uno o varios sobres de una campaña `per_subject` publicada, cada uno con su propia URL de destino. Acepta **un sobre** (el cuerpo es el objeto directamente) o **un lote** (`{ "envelopes": [...] }`, hasta 200). Responde `202` con el resultado por sobre.

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

    Cada sobre identifica al estudiante por **exactamente uno** de `subjectId` (el ID de la lectura del grupo) o `rut`. Los estados posibles del resultado:

    - `sent` — se creó la solicitud y se encoló su entrega (webhook `consent.requested` en la organización del titular). Trae además `portalAccessLinks`: el token del portal por entrega, para que muestres el enlace dentro de tu app sin esperar a que el apoderado abra el correo.
    - `already_sent` — el sobre ya tenía solicitud; se devuelve la existente. Reintentar es seguro: hay una solicitud por sobre, garantizada por esquema.
    - `already_granted` — el titular ya otorgó esta versión de la actividad en otra campaña: no se le pide de nuevo ni se envía nada, y `consentRequestId` es el de ese consentimiento.
    - `not_in_campaign` — el titular no está en el roster. En un envío individual esto es `404 CONSENT_CAMPAIGN_CONTACT_NOT_FOUND`; en un lote, cada fila lleva su propio desenlace.
    - `share_revoked` — el dueño revocó la share; el sobre no se envía.
    - `authority_unresolved` — todavía nadie puede decidir por este titular: `errorCode` dice por qué (`CONSENT_AUTHORITY_UNDETERMINED` si falta la fecha de nacimiento, `GUARDIAN_AUTHORITY_REQUIRED` si no hay apoderado vigente, `SUBJECT_PROCESSING_OPPOSED` si el titular se opuso a esta finalidad). El resto del lote se envía igual. La autoridad se re-evalúa en cada despacho: cuando el colegio corrija el padrón, reenvía el mismo sobre. En un envío individual esto es un `409` con ese mismo código.

    Requisitos de la campaña: publicada (`409 CONSENT_CAMPAIGN_NOT_PUBLISHED`), no cerrada (`409 CONSENT_CAMPAIGN_CLOSED`), modo `per_subject` (`409 CONSENT_CAMPAIGN_DISPATCH_MODE_UNSUPPORTED`) y no segmentada por RUT. Cada despacho deja el evento de auditoría `envelopes_dispatched` con los conteos del lote.

    ### Atributos por sobre

    <Properties>
      <Property name="subjectId" type="string">
        UUID del titular (excluyente con `rut`).
      </Property>
      <Property name="rut" type="string">
        RUT del estudiante (excluyente con `subjectId`).
      </Property>
      <Property name="successRedirectUrl" type="string">
        URL de destino tras otorgar; prevalece sobre la planificada.
      </Property>
      <Property name="declineRedirectUrl" type="string">
        URL de destino tras denegar.
      </Property>
      <Property name="redirectMode" type="string">
        `auto` o `bridge`.
      </Property>
      <Property name="partnerExternalId" type="string">
        Tu correlativo para este sobre (1–160). Queda en el sobre y viaja en
        webhooks, en `eg_token` y en la lista de sobres.
      </Property>
      <Property name="studentRedirectUrl" type="string">
        Tu enlace para el aviso al estudiante de este sobre, si la campaña
        tiene `studentNotification` activo. Prevalece sobre `urlTemplate`.
      </Property>
      <Property name="idempotencyKey" type="string">
        Llave de idempotencia (8–120).
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="POST" label="/v1/.../shared-campaigns/a9b8c7d6.../envelopes">

    ```bash {{ title: 'cURL' }}
    curl https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/envelopes \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -H "Content-Type: application/json" \
      -d '{
        "rut": "25678435-2",
        "successRedirectUrl": "https://app.milectura.cl/bienvenida?e=88231",
        "partnerExternalId": "lic-88231"
      }'
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/envelopes',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${keyId}.${secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          rut: '25678435-2',
          successRedirectUrl: 'https://app.milectura.cl/bienvenida?e=88231',
          partnerExternalId: 'lic-88231',
        }),
      },
    )
    const result = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "subjectId": "9c1f6d1e-1c9d-4f6a-9a44-1f2ab5c0d9e1",
      "rut": "25678435-2",
      "envelopeId": "env_7f8e9d",
      "consentRequestId": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "status": "sent",
      "portalAccessLinks": [
        {
          "consentDeliveryId": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6",
          "recipientSubjectId": "5d2a8c3b-77e1-4b0e-9f1d-3c4e5f6a7b8c",
          "channel": "email",
          "token": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6.…"
        }
      ]
    }
    ```

  </Col>
</Row>

---

## Reenviar un sobre {{ tag: 'POST', label: '/v1/organizations/:organizationId/shared-campaigns/:campaignId/envelopes/:envelopeId/resend' }}

<Row>
  <Col>

    Reenvía la entrega de un sobre, opcionalmente con un destino nuevo. Hay una solicitud por sobre a la vez, así que un reenvío con URL distinta **no crea una segunda solicitud**: actualiza el destino de la existente y re-encola su entrega — el titular recibe el mismo enlace de siempre, ahora apuntando al destino corregido. Si el sobre nunca se había enviado, esta llamada equivale a su primer despacho. Responde `202` con el resultado del sobre.

    **Solicitud vencida.** Si la solicitud del sobre venció sin respuesta y la campaña sigue en fecha (por ejemplo, porque se extendió su `endsAt`), el reenvío emite una **solicitud nueva** para el mismo titular, con el destino de la anterior salvo que mandes otro. La respuesta trae el `consentRequestId` nuevo, sus `portalAccessLinks` y `previousConsentRequestId`; la vencida sigue en la campaña con su historial. Una decisión ya tomada no se vuelve a pedir: quien dijo que no puede cambiar de opinión en el portal de transparencia.

    **Sin nada que reenviar** la respuesta es `status: "not_resendable"` con `reason`, nunca `sent`:

    - `already_decided` — la solicitud ya tiene decisión (otorgada, rechazada o revocada) o se anuló.
    - `campaign_ended` — venció junto con la campaña; no hay plazo para pedirla de nuevo.
    - `nothing_queued` — sigue vigente, pero ninguna entrega quedó en cola (quien decide no tiene un canal utilizable).

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

    Un `:envelopeId` que no pertenece a la campaña responde `404 CONSENT_CAMPAIGN_CONTACT_NOT_FOUND`.

    La respuesta trae `portalAccessLinks` con el token vigente de cada entrega reenviada — el mismo que recibe el apoderado: se conserva mientras siga válido y se acuña uno nuevo si venció.

    ### Atributos opcionales

    <Properties>
      <Property name="successRedirectUrl" type="string">
        Nuevo destino tras otorgar.
      </Property>
      <Property name="declineRedirectUrl" type="string">
        Nuevo destino tras denegar.
      </Property>
      <Property name="redirectMode" type="string">
        `auto` o `bridge`.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="POST" label="/v1/.../envelopes/env_7f8e9d/resend">

    ```bash {{ title: 'cURL' }}
    curl https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/envelopes/env_7f8e9d/resend \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -H "Content-Type: application/json" \
      -d '{ "successRedirectUrl": "https://app.milectura.cl/bienvenida?e=88231&retry=1" }'
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/envelopes/env_7f8e9d/resend',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${keyId}.${secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          successRedirectUrl:
            'https://app.milectura.cl/bienvenida?e=88231&retry=1',
        }),
      },
    )
    const result = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "subjectId": "9c1f6d1e-1c9d-4f6a-9a44-1f2ab5c0d9e1",
      "rut": null,
      "envelopeId": "env_7f8e9d",
      "consentRequestId": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "status": "sent",
      "portalAccessLinks": [
        {
          "consentDeliveryId": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6",
          "recipientSubjectId": "5d2a8c3b-77e1-4b0e-9f1d-3c4e5f6a7b8c",
          "channel": "email",
          "token": "d1e2f3a4-b5c6-4d7e-8f90-a1b2c3d4e5f6.…"
        }
      ]
    }
    ```

  </Col>
</Row>

---

## Despachar sobres por lote asíncrono {{ tag: 'POST', label: '/v1/organizations/:organizationId/shared-campaigns/:campaignId/envelope-jobs' }}

<Row>
  <Col>

    Para más de 200 sobres: hasta **5.000** en una llamada. Responde `202` con el job. Edugoverna los despacha en tramos de 200 con el mismo proceso que el envío síncrono, así que cada sobre tiene los mismos desenlaces (`sent`, `already_sent`, `authority_unresolved`, …) y reintentar es igual de seguro. Al terminar recibes `envelopes.job.completed` con los conteos.

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

    Mandar la cabecera `Idempotency-Key` hace que repetir la llamada devuelva el mismo job en vez de crear otro. Los requisitos de la campaña (publicada, `per_subject`, no segmentada por RUT) se validan al crear el job, no a mitad de camino. Si la campaña se cierra mientras corre, el job termina en `failed` con `errorCode`.

    `GET …/envelope-jobs/:jobId` devuelve el estado (`queued`, `running`, `completed` o `failed`), los conteos y los resultados por fila en el orden en que los mandaste (`position`), paginados con `cursor` y `limit` (hasta 500).

  </Col>
  <Col sticky>

    ```json {{ title: 'GET …/envelope-jobs/:jobId' }}
    {
      "jobId": "7a3f…",
      "campaignId": "a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7",
      "status": "completed",
      "totalCount": 3000,
      "processedCount": 3000,
      "sentCount": 2994,
      "authorityUnresolvedCount": 6,
      "errorCode": null,
      "createdAt": "2026-09-25T12:00:00.000Z",
      "completedAt": "2026-09-25T12:04:10.000Z",
      "results": [
        {
          "position": 0,
          "subjectId": "9c1f6d1e-1c9d-4f6a-9a44-1f2ab5c0d9e1",
          "envelopeId": "886313e1-3b8a-5372-9b90-0c9aee199e5d",
          "consentRequestId": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
          "status": "sent"
        }
      ],
      "meta": { "nextCursor": 199 }
    }
    ```

  </Col>
</Row>

---

## Correo del estudiante {{ tag: 'GET', label: '/v1/organizations/:organizationId/shared-campaigns/:campaignId/envelopes/:envelopeId/student-contact' }}

<Row>
  <Col>

    El correo primario del **estudiante** de un sobre, para que le escribas sobre lo que su familia autorizó. Nunca el de un apoderado, y nunca un teléfono. Se lee de a un sobre. Responde `{ envelopeId, email }` con `Cache-Control: no-store`.

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

    Condiciones, todas a la vez:

    - El dueño del padrón habilitó la entrega en el share (**apagada por defecto**; el colegio la enciende en su consola y el registrador en su portal). Si no, `403 SHARE_STUDENT_EMAIL_DISCLOSURE_DISABLED`.
    - El consentimiento del sobre está **otorgado y vigente**. Sin eso, o si el sobre no existe o el estudiante no tiene correo, responde `404 CONSENT_ENVELOPE_STUDENT_CONTACT_NOT_AVAILABLE`, sin distinguir entre los casos.

    Cada lectura (y cada negativa) deja una fila de divulgación en la Auditoría del dueño y otra en la tuya.

    <Note>
      **Uso transaccional, sin registro.** El correo es para el envío que motivó la lectura: no lo guardes en tu base ni lo copies a otros sistemas. Vuelve a leerlo cada vez que lo necesites. Si el consentimiento se revoca (`consent.revoked`), deja de estar disponible y no debes conservar ninguna copia.
    </Note>

  </Col>
  <Col sticky>

    ```json {{ title: 'Response' }}
    {
      "envelopeId": "886313e1-3b8a-5372-9b90-0c9aee199e5d",
      "email": "estudiante@colegio.cl"
    }
    ```

  </Col>
</Row>

---

## Evidencia de un sobre {{ tag: 'GET', label: '/v1/organizations/:organizationId/shared-campaigns/:campaignId/envelopes/:envelopeId/evidence' }}

<Row>
  <Col>

    La prueba del consentimiento de un sobre ya decidido, para exhibirla ante un apoderado o un fiscalizador sin pedírsela al colegio. No trae datos de quien decidió, solo el **rol** (`decisionAuthority`). Qué se decidió, cuándo, sobre qué versión del aviso (`notice.noticeChecksum`), y el id, tipo y checksum de cada artefacto sellado: el manifiesto firmado (`sealed_manifest`) y sus recibos. Cada artefacto trae su `verificationUrl`.

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

    Un sobre sin decisión responde `404 CONSENT_ENVELOPE_DECISION_NOT_FOUND`, igual que uno que no existe.

    `captureMethod` es `portal_link_otp` cuando la familia decidió con su enlace y código, y `registrar_attestation` cuando el consentimiento fue [declarado por el establecimiento](/guias/registrador-externo#consentimiento-declarado). En ese caso el manifiesto cita la declaración jurada del registrador (`registrarDeclaration`) y el colegio conserva los originales firmados: no es una firma con código de un solo uso.

    ### Verificar un checksum

    `GET /api/public/evidence/verify?checksum=<sha256>` es público y no revela de quién es la evidencia. Responde `{ "valid": false }` o `{ "valid": true, evidenceType, capturedAt, signedAt, algorithm, kid }`. EduGoverna re-hashea el artefacto guardado y verifica la firma Ed25519 del manifiesto con la clave publicada en [`/api/public/jwks.json`](#verificar-el-redirect-eg-token), no con la que trae el propio documento. El mismo checksum llega en los webhooks (`latestDecision.evidence`).

  </Col>
  <Col sticky>

    ```json {{ title: 'Response' }}
    {
      "envelopeId": "886313e1-3b8a-5372-9b90-0c9aee199e5d",
      "consentRequestId": "c4a1b2d3-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
      "requestStatus": "granted",
      "decision": "granted",
      "decisionAuthority": "guardian",
      "captureMethod": "portal_link_otp",
      "decidedAt": "2026-09-25T14:03:21.000Z",
      "revokedAt": null,
      "notice": {
        "processingActivityVersionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
        "versionNumber": 3,
        "noticeChecksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
      },
      "evidence": [
        {
          "id": "…:manifest",
          "type": "sealed_manifest",
          "checksum": "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b",
          "algorithm": "Ed25519",
          "kid": "v1",
          "capturedAt": "2026-09-25T14:03:21.000Z",
          "verificationUrl": "https://app.edugoverna.com/api/public/evidence/verify?checksum=3a7b…4f1b"
        }
      ]
    }
    ```

  </Col>
</Row>

---

## Sincronizar la audiencia {{ tag: 'POST', label: '/v1/organizations/:organizationId/shared-campaigns/:campaignId/sync-audience' }}

<Row>
  <Col>

    La segunda ola: cuando el dueño del padrón agrega estudiantes nuevos a un curso ya compartido, esta llamada re-resuelve los grupos guardados de la campaña contra el registro de **hoy** y agrega sólo los contactos faltantes. En una campaña `bulk` publicada, además emite y despacha sus solicitudes por tramos; en modo `per_subject`, los nuevos quedan `awaiting_send` para que tú los despaches. Responde `202`.

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

    Sobre una campaña declarada (`origin: "registrar_attestation"`) responde `409 CONSENT_CAMPAIGN_ATTESTED_READ_ONLY`: agregaría al curso entero, también a quienes el colegio no marcó, y les enviaría la solicitud.

        El comando es **repetible por diferencia**: si `pendingRequestCount` vuelve mayor que cero, quedan solicitudes por emitir de esta ola — vuelve a llamar hasta que llegue a cero. Los `redirects` del cuerpo aplican sólo a los contactos que **esta pasada** agrega; para corregir destinos de sobres ya existentes está el endpoint de redirects.

    ### Atributos opcionales

    <Properties>
      <Property name="redirects" type="array">
        Hasta 2500 pares `{ rut, successRedirectUrl, declineRedirectUrl?,
        redirectMode? }` para los contactos nuevos de esta ola.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="POST" label="/v1/.../shared-campaigns/a9b8c7d6.../sync-audience">

    ```bash {{ title: 'cURL' }}
    curl https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/sync-audience \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -H "Content-Type: application/json" \
      -d '{
        "redirects": [
          { "rut": "26011822-5", "successRedirectUrl": "https://app.milectura.cl/bienvenida?e=90112" }
        ]
      }'
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/sync-audience',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${keyId}.${secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          redirects: [
            {
              rut: '26011822-5',
              successRedirectUrl:
                'https://app.milectura.cl/bienvenida?e=90112',
            },
          ],
        }),
      },
    )
    const result = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "addedContactCount": 2,
      "materializedRequestCount": 0,
      "pendingRequestCount": 0,
      "submittedRedirectCount": 1
    }
    ```

  </Col>
</Row>

---

## Corregir destinos planificados {{ tag: 'POST', label: '/v1/organizations/:organizationId/shared-campaigns/:campaignId/redirects' }}

<Row>
  <Col>

    Re-escribe las URLs planificadas de los sobres que **aún no tienen solicitud** (por ejemplo, los `awaiting_send` de una campaña `per_subject`, o los pendientes de una `bulk` aún en materialización). Un sobre ya despachado no cambia retroactivamente por esta vía — para ese existe el reenvío con destino nuevo.

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

    Sólo disponible en campañas por grupo (no segmentadas por RUT) y no cerradas (`409 CONSENT_CAMPAIGN_CLOSED`). Por cada RUT gana el último par de la lista; los RUT que no calcen se ignoran en silencio.

    ### Atributos requeridos

    <Properties>
      <Property name="redirects" type="array">
        Entre 1 y 2500 pares `{ rut, successRedirectUrl, declineRedirectUrl?,
        redirectMode? }`.
      </Property>
    </Properties>

  </Col>
  <Col sticky>

    <CodeGroup title="Request" tag="POST" label="/v1/.../shared-campaigns/a9b8c7d6.../redirects">

    ```bash {{ title: 'cURL' }}
    curl https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/redirects \
      -H "Authorization: Bearer {keyId}.{secret}" \
      -H "Content-Type: application/json" \
      -d '{
        "redirects": [
          { "rut": "25678435-2", "successRedirectUrl": "https://app.milectura.cl/bienvenida?e=88231-b" }
        ]
      }'
    ```

    ```js
    const response = await fetch(
      'https://app.edugoverna.com/api/partner/v1/organizations/org_milectura/shared-campaigns/a9b8c7d6-e5f4-4321-a0b1-c2d3e4f5a6b7/redirects',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${keyId}.${secret}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          redirects: [
            {
              rut: '25678435-2',
              successRedirectUrl:
                'https://app.milectura.cl/bienvenida?e=88231-b',
            },
          ],
        }),
      },
    )
    const result = await response.json()
    ```

    </CodeGroup>

    ```json {{ title: 'Response' }}
    {
      "submittedRedirectCount": 1,
      "updatedContactCount": 1
    }
    ```

  </Col>
</Row>

---

## Verificar el redirect (eg_token)

Cuando una solicitud viaja por una conexión con tu cuenta, el redirect posterior a la decisión agrega `eg_token` a tu URL, además de los `consent_id` y `decision` de siempre (esos siguen en claro y sin firma, así que no les creas por sí solos). `eg_token` es un **JWS compacto EdDSA (Ed25519)** que puedes verificar sin llamar a la API:

1. Lee el `kid` del encabezado (`typ: "eg-redirect+jwt"`) y toma esa clave de `GET https://app.edugoverna.com/api/public/jwks.json`. Puedes cachear el JWKS; si aparece un `kid` desconocido, vuelve a pedirlo.
2. Verifica la firma y exige `iss = "edugoverna"`, `aud =` tu `partnerAccountId` y `exp` en el futuro (vive 10 minutos; tolera un par de minutos de desfase de reloj).
3. Usa las claims: `consentRequestId`, `envelopeId`, `partnerExternalId`, `decision`, `decisionAuthority`, `decisionActorType`, `decidedAt` y `jti` (único por token, útil para rechazar reusos).

Cambiar cualquier parámetro invalida la firma. Sin `eg_token` (una solicitud que no viaja por tu conexión, o un redirect más viejo que 10 minutos), consulta el estado por API o espera el webhook.

```js {{ title: 'Verificación con jose' }}
import { createRemoteJWKSet, jwtVerify } from 'jose'

const jwks = createRemoteJWKSet(
  new URL('https://app.edugoverna.com/api/public/jwks.json'),
)

const { payload } = await jwtVerify(egToken, jwks, {
  issuer: 'edugoverna',
  audience: partnerAccountId,
  typ: 'eg-redirect+jwt',
  clockTolerance: 120,
})
// payload.envelopeId, payload.decision, payload.decisionAuthority, …
```
