Ir al contenido principal

Referencia de API de Socios

Integra Society Speaks programáticamente. Potencia tus integraciones, instantáneas e información editorial con APIs JSON construidas para flujos de trabajo de redacciones y velocidad de desarrollo.

Si eres nuevo aquí: los editores inician una discusión para cada tema de votación — generalmente alineada con una URL de artículo, pero puedes usar en su lugar solo tu external_id para centros o herramientas sin un enlace permanente. Las declaraciones son tuyas para redactar o pueden redactarse a partir del contenido que suministres; nada se infiere de tu página de inicio a menos que tu integración lo solicite. Definiciones formales más mapeo de campos API: Discusión vs declaraciones.

Inicio rápido

curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article"

Luego integra con el embed_url devuelto.

Para desarrolladores

Puntos finales predecibles, códigos de error claros y ejemplos listos para copiar y pegar.

Para líderes editoriales

Tú eliges cada pregunta, cómo se asigna a tus historias o ids, y si las líneas enviadas por lectores aparecen en el embed.

Para gobernanza

Límites de tasa, requisitos de atribución y una fuente única de verdad.

Probando la API

  • Portal de Socios: Crear un portal Portal de Socios:
  • Parque infantil interactivo: Playground API Abierto Playground interactivo:
  • Endpoints de escritura (POST/PATCH /api/partner/...) son solo de servidor a servidor y deben probarse desde tu backend o curl/Postman, no desde el navegador Swagger.
  • Desde la línea de comandos: utiliza los ejemplos curl en cada sección siguiente, reemplazando la URL base y los parámetros según sea necesario.

Mapa rápido del flujo de sala de prensa: external_id para mapeo CMS, devoluciones de llamada webhook para sincronización de sistemas y roles del Portal de Socios para control de acceso.

Autenticación y Entornos

Usa claves de prueba mientras ensayas en staging; cambia a claves en vivo una vez que la facturación está activa. Las claves se crean en el Partner Portal.

Clave de prueba: sspk_test_...
Clave en vivo: sspk_live_... (activado después de facturación)

Cada nombre de host donde alojas el iframe debe aparecer en Dominios después de la verificación de DNS — por separado para ensayo (test) y producción (live). Los sitios de noticias típicos registran tanto www. como el dominio corto si los lectores usan cualquiera.

Los lectores siempre interactúan con Society Speaks a través del tráfico web encriptado normal; tu equipo de alojamiento debe mantener nuestra dirección web pública (BASE_URL de nuestro lado) coincidiendo con lo que los navegadores esperan.

Solo servidor a servidor para operaciones de escritura

Create Discussion y las rutas de gestión de partners deben llamarse desde tu servidor, no desde JavaScript del lado del cliente (las solicitudes con encabezado Origin se rechazan con 403). Los endpoints de solo lectura (Lookup, Snapshot, oEmbed) funcionan desde navegadores sin clave API. Para solicitudes autenticadas desde una aplicación web, proxy a través de tu backend para que las claves API se mantengan en secreto.

HTTPS requerido

Todas las solicitudes de API deben realizarse sobre HTTPS y aplicarse en tu capa edge/proxy en producción. Nunca registres tu clave API ni la incluyas en URLs — siempre envíala en el encabezado X-API-Key.

Librerías de Cliente

v0.3.0

Clientes auxiliares listos para usar para Python y Node.js. Cada uno es un único archivo autocontenido — sin paso de compilación, sin dependencias transitivas más allá de requests (solo Python). Solo uso del lado del servidor — nunca expongas tu clave API en código del navegador.

1. Agrega a tu proyecto

Descarga el archivo y colócalo junto a tu código de aplicación.

Descargar societyspeaks_partner.py
# Place societyspeaks_partner.py anywhere in your project, then:
from societyspeaks_partner import SocietyspeaksPartnerClient, PartnerApiError

2. Instala la única dependencia

pip install requests

3. Inicializa y realiza tu primera llamada

from societyspeaks_partner import SocietyspeaksPartnerClient, PartnerApiError

client = SocietyspeaksPartnerClient(
    base_url="https://societyspeaks.io",
    api_key="sspk_live_your_key_here",  # never expose in browser code
)

try:
    # Look up an existing discussion by article URL
    result = client.lookup_by_article_url("https://yoursite.com/article")
    print(result["embed_url"])
except PartnerApiError as e:
    if e.status_code == 404:
        # Discussion doesn't exist yet — create one
        discussion = client.create_discussion(
            title="Should cities ban single-use plastics?",
            article_url="https://yoursite.com/article",
            excerpt="A new UN report finds that single-use plastic bans...",
            embed_statement_submissions_enabled=False,
        )
        print(discussion["embed_url"])
    else:
        raise
▶ Ver código completo societyspeaks_partner.py
"""Lightweight Society Speaks Partner API client.

Intended as a reference wrapper for partners integrating from backend services.
"""

from __future__ import annotations

import hashlib
import hmac as _hmac
import time
import uuid
from typing import Any, Dict, List, Optional

import requests

SDK_VERSION = "0.3.0"


class PartnerApiError(Exception):
    def __init__(self, status_code: int, error: str, message: str, retry_after: Optional[int] = None):
        super().__init__(f"{status_code} {error}: {message}")
        self.status_code = status_code
        self.error = error
        self.message = message
        self.retry_after = retry_after


class SocietyspeaksPartnerClient:
    def __init__(self, base_url: str, api_key: str, timeout: int = 15, max_retries: int = 2):
        self.base_url = base_url.rstrip("/")
        self.api_key = api_key
        self.timeout = timeout
        self.max_retries = max_retries

    @property
    def sdk_version(self) -> str:
        return SDK_VERSION

    @staticmethod
    def verify_webhook_signature(
        raw_body: bytes,
        signature_header: str,
        timestamp_header: str,
        secret: str,
        tolerance_seconds: int = 300,
    ) -> bool:
        """Verify an incoming webhook request signature.

        Always call this before processing any webhook payload.

        Args:
            raw_body: The raw request body **bytes** — read before parsing JSON.
            signature_header: Value of the ``X-SocietySpeaks-Signature`` header.
            timestamp_header: Value of the ``X-SocietySpeaks-Timestamp`` header.
            secret: The signing secret issued when the webhook endpoint was created.
            tolerance_seconds: Reject requests older than this many seconds to prevent
                replay attacks. Defaults to 300 (5 minutes).

        Raises:
            ValueError: If the timestamp is missing, malformed, or outside tolerance.

        Returns:
            ``True`` if the signature is valid, ``False`` otherwise.
        """
        try:
            ts = int(timestamp_header)
        except (TypeError, ValueError):
            raise ValueError("Invalid or missing X-SocietySpeaks-Timestamp header.")

        age = abs(int(time.time()) - ts)
        if age > tolerance_seconds:
            raise ValueError(
                f"Webhook timestamp is {age}s old (tolerance: {tolerance_seconds}s). "
                "Possible replay attack — reject this request."
            )

        if isinstance(raw_body, str):
            raw_body = raw_body.encode("utf-8")

        signed_payload = f"{ts}.".encode() + raw_body
        expected = "sha256=" + _hmac.new(
            secret.encode("utf-8"),
            signed_payload,
            hashlib.sha256,
        ).hexdigest()
        return _hmac.compare_digest(expected, signature_header)

    @staticmethod
    def _retry_after_seconds(response: requests.Response) -> Optional[int]:
        raw = response.headers.get("Retry-After")
        if not raw:
            return None
        try:
            return max(0, int(raw))
        except (TypeError, ValueError):
            return None

    def _request(
        self,
        method: str,
        path: str,
        *,
        json_body: Optional[Dict[str, Any]] = None,
        params: Optional[Dict[str, Any]] = None,
        extra_headers: Optional[Dict[str, str]] = None,
    ):
        url = f"{self.base_url}{path}"
        headers = {
            "X-API-Key": self.api_key,
            "Content-Type": "application/json",
        }
        if extra_headers:
            headers.update(extra_headers)
        attempt = 0
        resp: Optional[requests.Response] = None
        while True:
            attempt += 1
            try:
                resp = requests.request(
                    method=method,
                    url=url,
                    headers=headers,
                    json=json_body,
                    params=params,
                    timeout=self.timeout,
                )
            except requests.RequestException as exc:
                if attempt > self.max_retries:
                    raise PartnerApiError(0, "network_error", str(exc)) from exc
                time.sleep(0.5 * attempt)
                continue
            if resp.status_code < 500 or attempt > self.max_retries:
                break
            time.sleep(0.5 * attempt)

        if resp is None:
            raise PartnerApiError(0, "network_error", "Request failed before receiving a response.")

        if 200 <= resp.status_code < 300:
            if resp.headers.get("Content-Type", "").startswith("application/json"):
                return resp.json()
            return resp.text

        body = {}
        try:
            body = resp.json() or {}
        except Exception:
            body = {}
        raise PartnerApiError(
            status_code=resp.status_code,
            error=body.get("error", "request_failed"),
            message=body.get("message", "Request failed."),
            retry_after=self._retry_after_seconds(resp),
        )

    def lookup_by_article_url(self, url: str):
        return self._request("GET", "/api/discussions/by-article-url", params={"url": url})

    def create_discussion(
        self,
        *,
        title: str,
        article_url: Optional[str] = None,
        external_id: Optional[str] = None,
        excerpt: Optional[str] = None,
        seed_statements: Optional[list] = None,
        source_name: Optional[str] = None,
        idempotency_key: Optional[str] = None,
        embed_statement_submissions_enabled: Optional[bool] = None,
    ):
        if not article_url and not external_id:
            raise ValueError("Provide at least one identifier: article_url or external_id.")
        if not excerpt and not seed_statements:
            raise ValueError("Provide excerpt or seed_statements.")
        if embed_statement_submissions_enabled is not None and not isinstance(embed_statement_submissions_enabled, bool):
            raise ValueError("embed_statement_submissions_enabled must be a boolean when provided.")

        payload = {
            "title": title,
            "article_url": article_url,
            "external_id": external_id,
            "excerpt": excerpt,
            "seed_statements": seed_statements,
            "source_name": source_name,
            "embed_statement_submissions_enabled": embed_statement_submissions_enabled,
        }
        payload = {k: v for k, v in payload.items() if v is not None}

        return self._request(
            "POST",
            "/api/partner/discussions",
            json_body=payload,
            extra_headers={"Idempotency-Key": idempotency_key or f"idem_{uuid.uuid4().hex}"},
        )

    def get_discussion_by_external_id(self, external_id: str, env: Optional[str] = None):
        params = {"external_id": external_id}
        if env:
            params["env"] = env
        return self._request("GET", "/api/partner/discussions/by-external-id", params=params)

    def list_discussions(self, *, env: str = "all", page: int = 1, per_page: int = 30):
        return self._request(
            "GET",
            "/api/partner/discussions",
            params={"env": env, "page": page, "per_page": per_page},
        )

    def patch_discussion(
        self,
        discussion_id: int,
        *,
        is_closed: Optional[bool] = None,
        integrity_mode: Optional[bool] = None,
        embed_statement_submissions_enabled: Optional[bool] = None,
    ):
        payload: Dict[str, Any] = {}
        if is_closed is not None:
            if not isinstance(is_closed, bool):
                raise ValueError("is_closed must be a boolean when provided.")
            payload["is_closed"] = is_closed
        if integrity_mode is not None:
            if not isinstance(integrity_mode, bool):
                raise ValueError("integrity_mode must be a boolean when provided.")
            payload["integrity_mode"] = integrity_mode
        if embed_statement_submissions_enabled is not None:
            if not isinstance(embed_statement_submissions_enabled, bool):
                raise ValueError("embed_statement_submissions_enabled must be a boolean when provided.")
            payload["embed_statement_submissions_enabled"] = embed_statement_submissions_enabled
        if not payload:
            raise ValueError("Provide at least one field to patch.")
        return self._request("PATCH", f"/api/partner/discussions/{discussion_id}", json_body=payload)

    def list_webhooks(self):
        return self._request("GET", "/api/partner/webhooks")

    def create_webhook(self, *, url: str, event_types: List[str]):
        return self._request(
            "POST",
            "/api/partner/webhooks",
            json_body={"url": url, "event_types": event_types},
        )

    def update_webhook(
        self,
        endpoint_id: int,
        *,
        status: Optional[str] = None,
        event_types: Optional[List[str]] = None,
    ):
        payload: Dict[str, Any] = {}
        if status is not None:
            payload["status"] = status
        if event_types is not None:
            payload["event_types"] = event_types
        if not payload:
            raise ValueError("Provide status and/or event_types to update.")
        return self._request("PATCH", f"/api/partner/webhooks/{endpoint_id}", json_body=payload)

    def delete_webhook(self, endpoint_id: int):
        return self._request("DELETE", f"/api/partner/webhooks/{endpoint_id}")

    def rotate_webhook_secret(self, endpoint_id: int):
        return self._request("POST", f"/api/partner/webhooks/{endpoint_id}/rotate-secret")

    def export_usage(self, *, days: int = 30, env: str = "all", page: int = 1, per_page: int = 100):
        return self._request(
            "GET",
            "/api/partner/analytics/usage-export",
            params={"days": days, "env": env, "format": "json", "page": page, "per_page": per_page},
        )

URL Base

https://societyspeaks.io/api

Discusión vs declaraciones

Usamos estas palabras de manera consistente en el Partner Portal, respuestas de API y materiales de soporte — échale un vistazo una vez y el resto de la documentación será predecible.

Discusión
El marco para una experiencia de votación de un lado del lector: titular, cómo se ancla a tu sitio (article_url y/o external_id), entorno asociado (test/live), una URL de embed/consenso, y la participación combinada debajo. Crear una discusión a través de API establece este contenedor primero.
Afirmaciones
Indicadores individuales dentro de ese marco — cada uno recopila sus propios recuentos de acuerdo / desacuerdo / inseguro. Complétalos durante la creación con seed_statements o un excerpt que alimente semillas de IA; agrégalos/edítalos después en el portal; opcionalmente permite que los lectores anónimos propongan más cuando los envíos de embed estén habilitados para esa discusión.

Flujos Comunes

El patrón usual es «una discusión por URL publicada», pero eso es una elección, no un requisito. Puedes crear discusiones para páginas de artículos, adjuntar un id de CMS sin un artículo público, o mezclar ambos para que la información permanezca consistente en tu stack.

Independientemente de los identificadores, recuerda la división: la búsqueda/creación devuelve discussion_id; las declaraciones se modelan por separado y se muestran a través del embed, herramientas de declaración del portal y cargas de instantánea.

Incrustación programática (recomendado)

  1. 1) Busca la discusión por URL del artículo
  2. 2) Si no existe, crea el shell de discusión (declaraciones iniciales opcionalmente en la misma llamada)
  3. 3) Almacena discussion_id y usa embed_url; revisa las declaraciones dentro del portal si necesitas ajustes
# 1) Lookup
curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article" \
  -H "X-API-Key: sspk_test_..."

# 2) If 404, create
curl -X POST "https://societyspeaks.io/api/partner/discussions" \
  -H "X-API-Key: sspk_test_..." \
  -H "Content-Type: application/json" \
  -d '{"article_url":"https://example.com/article","title":"Example title","excerpt":"..."}'

Incrustación manual (inicio rápido)

Usa el Generador de incrustaciones para crear iframes puntuales. Se basa en las mismas APIs y devuelve el mismo discussion_id y embed_url.

Lista de verificación de lanzamiento simple

Los editores y productores pueden revisar la sección a continuación; comparte las notas de ingeniería con IT.

Para equipos editoriales y de producto

  • Hoja de referencia de vocabulario: una discusión = un tema de votación más su contenedor integrado; varios enunciados se encuentran debajo — las líneas con las que los lectores responden estar de acuerdo / no estar de acuerdo / no estar seguro. Explicación más detallada →
  • Tú decides cómo se llama cada tema de votación y qué ven primero los lectores — el vínculo con el artículo es opcional; los blogs en directo, los centros y los proyectos especiales pueden usar un identificador estable en lugar de una URL de historia.
  • Cada dirección web donde el fragmento podría cargarse debe ser aprobada en tu Portal de Socio — los sitios de ensayo y los sitios en directo son separados.
  • Si tus lectores escriben tanto www. como nombres de dominio sin prefijo, registra dos aprobaciones.
  • Pide a ingeniería que agregue la etiqueta de seguimiento corta (?ref=…) para que los informes sigan siendo atribuibles. Qué significan las etiquetas →
  • Opcional: solicita altura automática en el cuadro para que los artículos largos no se corten (un pequeño script de tu lado). Cómo funciona →
  • Prefiere pings automáticos de «algo cambió» a tus sistemas frente a verificaciones manuales. Descripción general de Webhooks →

Para ingeniería y hosting

  • Empareja claves API de prueba solo con dominios de ensayo; claves en directo solo con dominios de producción verificados.
  • Los embeds de ensayo (test) en producción siempre necesitan una página padre resoluble desde el navegador — incluso sin PARTNER_EMBED_REQUIRE_PARENT_ORIGIN.
  • Endurecimiento ambiental opcional: PARTNER_EMBED_REQUIRE_PARENT_ORIGIN=true (producción, embeds de socio en directo únicamente) bloquea HTML iframe cuando no podemos resolver una página padre desde Origin o Referer — más estricto y puede afectar navegadores poco comunes o configuraciones de privacidad.
  • Los envíos de lectores en línea desde el embed respetan EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR y EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP en la configuración (más límites por persona).

Búsqueda por URL de Artículo

Encuentra una discusión por la URL de tu artículo. La búsqueda devuelve metadatos de discusión (discussion_id, embed_url, etc.); los enunciados en los que los lectores votan llegan con el embed y las cargas útiles de captura de esa discusión.

GET /api/discussions/by-article-url

Parámetros de consulta

url requerido La URL del artículo (codificada en URL)
ref opcional Referencia de partner para análisis

Solicitud de ejemplo

curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article"

Respuesta exitosa (200)

{
  "discussion_id": 123,
  "slug": "should-we-reform-housing-policy",
  "title": "Should we reform housing policy?",
  "embed_url": "https://societyspeaks.io/discussions/123/embed",
  "consensus_url": "https://societyspeaks.io/discussions/123/should-we.../consensus",
  "snapshot_url": "https://societyspeaks.io/api/discussions/123/snapshot",
  "source": "rss",
  "env": "live"
}

Respuestas de error

400 missing_url - Se requiere parámetro URL
400 invalid_url - La URL no se pudo analizar
403 partner_disabled - Acceso a incrustación y API revocado para esta referencia de partner
404 no_discussion - No hay discusión para esta URL
429 rate_limited - Demasiadas solicitudes; reintentar más tarde

Obtener instantánea de discusión

Obtén conteos de participación y metadatos. No incluye contenido de análisis.

GET /api/discussions/{discussion_id}/snapshot

Parámetros de ruta

discussion_id requerido ID de la discusión

Solicitud de ejemplo

curl "https://societyspeaks.io/api/discussions/123/snapshot"

Respuesta exitosa (200)

{
  "discussion_id": 123,
  "discussion_title": "Should we reform housing policy?",
  "participant_count": 847,
  "statement_count": 12,
  "has_analysis": true,
  "opinion_groups": 3,
  "analyzed_at": "2026-02-05T10:30:00Z",
  "consensus_url": "https://societyspeaks.io/discussions/123/.../consensus",
  "teaser_text": "Housing reform debate reveals surprising common ground"
}

Cuando el análisis no está listo

{
  "discussion_id": 123,
  "discussion_title": "Should we reform housing policy?",
  "participant_count": 23,
  "statement_count": 5,
  "has_analysis": false,
  "consensus_url": "https://societyspeaks.io/discussions/123/.../consensus"
}

Respuestas de error

403 partner_disabled - Acceso a incrustación y API revocado para esta referencia de partner
403 forbidden - Se requiere una clave API de prueba válida para discusiones de prueba
404 discussion_not_found - La discusión no existe
429 rate_limited - Demasiadas solicitudes; reintentar más tarde

Parámetros de URL de Inserción

Personaliza la apariencia del embed con parámetros de URL.

Los embeds están disponibles para discusiones nativas de Society Speaks.

https://societyspeaks.io/discussions/{discussion_id}/embed
Parámetro Descripción Ejemplo
theme Tema preestablecido: default, dark, editorial, minimal, bold, muted theme=editorial
primary Color primario (hexadecimal sin #) primary=1e40af
bg Color de fondo (hexadecimal sin #) bg=f9fafb
font Familia de fuentes de la lista permitida font=Georgia
ref Referencia de partner para análisis ref=observer

Fuentes permitidas: system-ui, Georgia, Inter, Lato, Open Sans, Source Serif Pro

Embed: Seguimiento de sesión y privacidad

El embed utiliza una cookie de primera parte para deduplicar votos anónimos entre cargas de página. Cuando el embed se carga en un iframe de dominio cruzado (el caso normal para sitios asociados), los navegadores con configuración de privacidad estricta pueden bloquear esta cookie como una cookie de terceros.

Las discusiones creadas a través de tu API de socio tienen ámbito de socio: la página de embed verifica el origen del sitio padre (usando Origin o Referer cuando sea necesario) contra tus dominios verificados para el entorno de prueba/en directo coincidente. Las discusiones públicas nativas no están sujetas a esa puerta de encuadre.

No se requiere acción. El embed se retrocede automáticamente a un identificador de sesión basado en localStorage (embed_fingerprint) cuando las cookies no están disponibles. La deduplicación de votos funciona en ambos modos.

Cómo funciona:

  • El embed genera un ID de participante aleatorio y lo almacena en localStorage bajo el origen del embed.
  • Este ID se envía con cada voto como embed_fingerprint para deduplicación del lado del servidor.
  • Si el usuario borra datos del sitio o utiliza navegación privada, comienza una nueva sesión (puede votar nuevamente).
  • Safari, Firefox y Brave son totalmente compatibles a través de este método de retroceso.

Eventos PostMessage

El embed se comunica con el marco principal a través de postMessage.

societyspeaks:embed:loaded

Se envía cuando el embed termina de cargar.

{
  "type": "societyspeaks:embed:loaded",
  "discussionId": 123,
  "statementCount": 5
}

societyspeaks:embed:resize

Se envía cuando la altura del contenido del embed cambia. Úsalo para redimensionar el iframe.

{
  "type": "societyspeaks:embed:resize",
  "discussionId": 123,
  "height": 450
}

Ejemplo: Redimensionar iframe automáticamente

window.addEventListener('message', (event) => {
  if (event.data.type === 'societyspeaks:embed:resize') {
    const iframe = document.querySelector('iframe[src*="societyspeaks"]');
    if (iframe) iframe.style.height = event.data.height + 'px';
  }
});

Proveedor oEmbed

Habilita la inserción automática de discusiones de Society Speaks en plataformas que admiten oEmbed.

GET /api/oembed

Parámetros de consulta

url requerido URL de discusión de Society Speaks
maxwidth opcional Ancho de embed máximo
maxheight opcional Alto de embed máximo
format opcional Formato de respuesta (solo se admite "json")

Solicitud de ejemplo

curl "https://societyspeaks.io/api/oembed?url=https://societyspeaks.io/discussions/123/my-discussion"

Respuesta exitosa (200)

{
  "type": "rich",
  "version": "1.0",
  "title": "Should we reform housing policy?",
  "provider_name": "Society Speaks",
  "provider_url": "https://societyspeaks.io",
  "html": "<iframe src=\"https://societyspeaks.io/discussions/123/embed\" ...></iframe>",
  "width": 600,
  "height": 400,
  "cache_age": 3600
}

Autodescubrimiento: Las páginas de discusión incluyen una etiqueta <link rel="alternate" type="application/json+oembed"> para el descubrimiento automático de oEmbed por plataformas compatibles.

Crear discusión (autenticado)

Crea una discusión para una historia específica o para cualquier contexto editorial que puedas nombrar con un identificador estable. Proporciona article_url cuando tengas un enlace público; usa external_id cuando no lo tengas (o usa ambos para trazabilidad). Requiere clave API.

Este endpoint siempre crea primero la discusión. Agrupa enunciados iniciales en la misma solicitud mediante seed_statements o proporciona un excerpt para que podamos generar semillas; de lo contrario, agrega enunciados después desde el Portal de Socio. Lee cómo se asignan discusión vs enunciados a campos →

POST /api/partner/discussions

Encabezados

X-API-Key requerido Tu clave API de partner
Content-Type requerido application/json

Cuerpo de la solicitud

article_url opcional URL canónica del artículo (normalizada si se proporciona)
external_id opcional ID estable definido por el partner (requerido si article_url se omite)
title requerido Título de la discusión (máx. 200 caracteres)
excerpt condicional Fragmento de artículo para declaraciones generadas por IA
seed_statements condicional Array de declaraciones {content, position}
source_name opcional Nombre de tu publicación para atribución
embed_statement_submissions_enabled opcional Permitir envíos de lectores en línea en el embed para esta discusión (por defecto falso)

Notas: Proporciona al menos un identificador (article_url o external_id) y excerpt o seed_statements.

Recomendación: mantén embed_statement_submissions_enabled como false en la mayoría de las páginas de artículos, y actívalo solo para discusiones donde tu equipo quiere recopilar declaraciones en línea desde el embed.

Solicitud de ejemplo

curl -X POST "https://societyspeaks.io/api/partner/discussions" \
  -H "X-API-Key: your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: idem_$(openssl rand -hex 16)" \
  -d '{
    "article_url": "https://example.com/article/urban-cars",
    "title": "Should cities ban cars from downtown areas?",
    "excerpt": "A new study shows that car-free zones improve air quality...",
    "source_name": "Example News"
  }'

Respuesta de éxito (201)

{
  "discussion_id": 456,
  "slug": "should-cities-ban-cars-from-downtown-areas",
  "title": "Should cities ban cars from downtown areas?",
  "partner_article_url": "https://example.com/article/urban-cars",
  "external_id": "observer-cms-12345",
  "embed_statement_submissions_enabled": false,
  "embed_url": "https://societyspeaks.io/discussions/456/embed",
  "consensus_url": "https://societyspeaks.io/discussions/456/.../consensus",
  "snapshot_url": "https://societyspeaks.io/api/discussions/456/snapshot",
  "source": "partner",
  "env": "live",
  "statement_count": 5
}

Respuestas de error

401 invalid_api_key - Clave API ausente o inválida
400 missing_identifier o missing_content - Se requieren (article_url o external_id) y excerpt/seed_statements
409 discussion_exists - Ya existe una discusión para esta URL

Actualizar política de discusión (Autenticado)

Utiliza esto para cerrar/reabrir discusiones, alternar modo de integridad y controlar envíos en embed en línea.

curl -X PATCH "https://societyspeaks.io/api/partner/discussions/456" \
  -H "X-API-Key: your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "is_closed": false,
    "integrity_mode": true,
    "embed_statement_submissions_enabled": true
  }'

Por defecto recomendado para la mayoría de páginas de artículos: "embed_statement_submissions_enabled": false. Activa true para discusiones seleccionadas de alto engagement (por ejemplo, blogs en vivo o hubs de campañas).

Callbacks de webhook (Autenticado)

Configura callbacks de ciclo de vida firmados para evitar encuestas. Utiliza llamadas de servidor a servidor con una clave emitida por el portal.

GET /api/partner/webhooks - listar endpoints de webhook

POST /api/partner/webhooks - crear endpoint y recibir secreto de firma una sola vez

PATCH /api/partner/webhooks/{endpoint_id} - pausar/reanudar y actualizar suscripciones de eventos

POST /api/partner/webhooks/{endpoint_id}/rotate-secret - rotar secreto de firma

DELETE /api/partner/webhooks/{endpoint_id} - eliminar endpoint

Encabezados de solicitud

Cada entrega de webhook incluye estos encabezados:

Encabezado Descripción
X-SocietySpeaks-EventTipo de evento, p. ej. discussion.created
X-SocietySpeaks-Event-IdUUID de evento único — utiliza para deduplicación
X-SocietySpeaks-TimestampSegundos Unix (UTC) cuando se envió el evento
X-SocietySpeaks-SignatureFirma HMAC-SHA256 — siempre verifica esto

Siempre verifica firmas de webhook

Sin verificación, cualquier parte que conozca tu URL de endpoint puede enviar eventos falsificados. Lee el cuerpo sin procesar antes de analizar JSON, luego verifica la firma HMAC-SHA256 usando tu secreto de firma. Rechaza solicitudes con marcas de tiempo anteriores a 5 minutos para prevenir ataques de repetición.

Formato de firma

La carga útil firmada es {timestamp}.{raw_body} (segundos Unix, un punto literal, luego el cuerpo de solicitud UTF-8 sin procesar). El encabezado X-SocietySpeaks-Signature es sha256=<hex_digest>.

Ejemplo de verificación

# Flask example — adapt raw-body reading to your framework
from flask import Flask, request, abort
from societyspeaks_partner import SocietyspeaksPartnerClient
import os

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["SS_WEBHOOK_SECRET"]  # store in env, never hardcode

@app.route("/webhooks/societyspeaks", methods=["POST"])
def handle_webhook():
    raw_body = request.get_data()  # read raw bytes BEFORE any JSON parsing

    try:
        valid = SocietyspeaksPartnerClient.verify_webhook_signature(
            raw_body=raw_body,
            signature_header=request.headers.get("X-SocietySpeaks-Signature", ""),
            timestamp_header=request.headers.get("X-SocietySpeaks-Timestamp", ""),
            secret=WEBHOOK_SECRET,
            tolerance_seconds=300,  # reject events older than 5 minutes
        )
    except ValueError as exc:
        # Timestamp missing, malformed, or outside the tolerance window
        abort(400, str(exc))

    if not valid:
        abort(401, "Invalid webhook signature.")

    event      = request.get_json()
    event_type = request.headers.get("X-SocietySpeaks-Event")
    event_id   = request.headers.get("X-SocietySpeaks-Event-Id")

    # Deduplicate using event_id (store processed IDs in your DB or cache)
    if already_processed(event_id):
        return "", 200

    if event_type == "discussion.created":
        handle_discussion_created(event["data"])

    return "", 200

Verificación manual (sin el SDK)

Si no estás usando nuestro SDK, sigue estos pasos exactamente:

  1. Lee el cuerpo de solicitud sin procesar como bytes — antes de cualquier análisis JSON.
  2. Analiza X-SocietySpeaks-Timestamp como un entero. Si está ausente o tiene más de 300 segundos en el pasado, rechaza con HTTP 400.
  3. Construye la carga útil firmada: {timestamp} + . + bytes del cuerpo sin procesar.
  4. Calcula HMAC-SHA256(key=signing_secret, msg=signed_payload) y codifica en hexadecimal.
  5. Antepón sha256= para formar la cadena de firma esperada.
  6. Compara con X-SocietySpeaks-Signature usando una comparación constante en tiempohmac.compare_digest en Python, crypto.timingSafeEqual en Node. Una comparación de igualdad de cadena simple filtra información de tiempo.

Gestión de secretos

El secreto de firma se devuelve una sola vez cuando creas un endpoint — guárdalo de forma segura en una variable de entorno o gestor de secretos, nunca en el código fuente. Nunca se muestra nuevamente en el portal. Usa POST /api/partner/webhooks/{endpoint_id}/rotate-secret para emitir un nuevo secreto; acepta brevemente tanto las firmas antiguas como las nuevas durante el período de transición antes de retirar la antigua.

Exportación de análisis (Autenticada)

Exporta eventos de uso en JSON o CSV para pipelines de BI.

curl "https://societyspeaks.io/api/partner/analytics/usage-export?days=30&env=all&format=json&page=1&per_page=100" \
  -H "X-API-Key: your_api_key"

Usa format=csv para ingesta directa de archivos, o format=json paginado para consumidores de API.

Protección de costos y abuso

Quién paga qué: Lookup, snapshot, oEmbed y la página embed no usan nuestro LLM. Están limitados por tasa por IP (y ref opcional) para prevenir abuso. Create Discussion es el único endpoint que puede desencadenar declaraciones de semilla generadas por IA (OpenAI/Anthropic); usa nuestras claves de API, no las tuyas.

Los socios usan claves que nosotros emitimos. Solo las solicitudes con un X-API-Key válido (de nuestra lista de permitidos) pueden crear discusiones. No usamos claves de LLM proporcionadas por socios para el flujo de creación. Si una clave se abusa, la revocamos.

Create Discussion está limitado a 30 solicitudes por hora por clave de API. Combinado con el control de claves, esto limita el costo de LLM y base de datos del endpoint de creación.

Límites de Tasa

Endpoint Límite
Búsqueda por URL de artículo 60 req / min / IP
Obtener snapshot 120 req / min / IP
Envío de voto (por defecto) 30 req / min / IP
Envío de voto (modo integridad) 10 req / min / discusión
Creación de declaración (autenticada) 10 por hora
Creación de declaración (anónima) 5 por hora
Marcar declaración (desde embed) 10 req / min / IP
Enunciado en línea desde embed (discusiones con ámbito de socio) Predeterminado 25 / lector / hora + límite de IP; ver env
Crear discusión 30 req / hr / clave

Las respuestas incluyen un 429 Too Many Requests con un encabezado Retry-After indicando segundos hasta que se permita la siguiente solicitud.

Modo integridad: Algunas discusiones tienen límites de tasa de voto más estrictos habilitados para protección de integridad. El embed lo maneja de forma transparente — los usuarios ven un mensaje "por favor ve más lentamente".

Enunciados en línea de embed: Flask-Limiter usa EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR y EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP (ver config.py). Los límites por usuario también se aplican antes de que los enunciados se almacenen.

Formato de Error

Todos los errores devuelven JSON con un formato consistente.

{
  "error": "error_code",
  "message": "Human-readable description of the error"
}

¿Preguntas? Contáctanos o visita Centro de socios.