Ir para o conteúdo principal

Referência de API de Parceiros

Integre Society Speaks de forma programática. Potencialize seus embeds, snapshots e insights editoriais com APIs JSON construídas para fluxos de trabalho de redação e velocidade de desenvolvimento.

Se você é novo aqui: editores iniciam uma discussão para cada tópico de votação — na maioria das vezes alinhada com uma URL de artigo, mas você pode usar apenas seu próprio external_id para hubs ou ferramentas sem um permalink. As declarações são suas para redigir ou podem ser redigidas a partir de cópia que você fornece; nada é inferido de sua página inicial, a menos que sua integração solicite. Definições formais mais mapeamento de campos de API: Discussão vs declarações.

Início rápido

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

Em seguida, incorpore com a embed_url retornada.

Para desenvolvedores

Endpoints previsíveis, códigos de erro claros e exemplos copiar/colar.

Para líderes editoriais

Você escolhe cada pergunta, como ela se mapeia para suas histórias ou ids, e se linhas enviadas por leitores aparecem no embed.

Para governança

Limites de taxa, requisitos de atribuição e uma fonte única de verdade.

Testando a API

  • Portal de Parceiros: Criar um portal Portal de Parceiros:
  • Playground interativo: Open API Playground Playground interativo:
  • Endpoints de escrita (POST/PATCH /api/partner/...) são apenas servidor-a-servidor e devem ser testados a partir do seu backend ou curl/Postman, não do Swagger no navegador.
  • A partir da linha de comando: use os exemplos curl em cada seção abaixo, substituindo a URL base e os parâmetros conforme necessário.

Mapa rápido do fluxo de redação: external_id para mapeamento de CMS, callbacks de webhook para sincronização de sistema e funções do Portal de Parceiros para controle de acesso.

Autenticação e Ambientes

Use chaves de teste enquanto você ensaia no staging; mude para chaves ativas uma vez que o faturamento esteja ativo. As chaves são criadas no Partner Portal.

Chave de teste: sspk_test_...
Chave ativa: sspk_live_... (ativado após faturamento)

Cada nome de host onde você hospeda o iframe deve aparecer em Domínios após verificação de DNS — separadamente para ensaio (test) e produção (live). Sites de notícias típicos registram www. e o domínio curto se leitores usarem ambos.

Os leitores sempre interagem com Society Speaks por tráfego web criptografado normal; sua equipe de hospedagem deve manter nosso endereço web público (BASE_URL do nosso lado) correspondendo ao que os navegadores esperam.

Apenas servidor-para-servidor para operações de escrita

Create Discussion e rotas de gerenciamento de parceiros devem ser chamadas do seu servidor, não do JavaScript do lado do cliente (requisições com cabeçalho Origin são rejeitadas com 403). Endpoints de leitura (Lookup, Snapshot, oEmbed) funcionam em navegadores sem chave API. Para requisições autenticadas de um aplicativo web, proxie através do seu backend para manter as chaves API secretas.

HTTPS obrigatório

Todas as requisições da API devem ser feitas via HTTPS e aplicadas na sua camada de edge/proxy em produção. Nunca registre sua chave API ou a inclua em URLs — sempre envie-a no cabeçalho X-API-Key.

Bibliotecas de Cliente

v0.3.0

Clientes auxiliares prontos para uso em Python e Node.js. Cada um é um arquivo único e autossuficiente — sem etapa de build, sem dependências transitivas além de requests (apenas Python). Uso apenas no servidor — nunca exponha sua chave API em código de navegador.

1. Adicione ao seu projeto

Baixe o arquivo e coloque-o ao lado do código da sua aplicação.

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

2. Instale a única dependência

pip install requests

3. Inicialize e faça sua primeira chamada

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

Discussão vs declarações

Usamos essas palavras consistentemente no Partner Portal, respostas de API e materiais de suporte — dê uma olhada nisto uma vez e o resto dos documentos permanecerá previsível.

Discussão
O guarda-chuva para uma experiência de votação acessível ao leitor: título de manchete, como se ancora ao seu site (article_url e/ou external_id), ambiente de parceiro (test/live), URLs de um embed/consenso, e a participação combinada abaixo dele. Criar uma discussão via API estabelece este contêiner primeiro.
Afirmações
Prompts individuais sob esse guarda-chuva — cada um coleta suas próprias contagens de concordo / discordo / incerto. Preencha-os durante a criação com seed_statements ou um excerpt que alimenta sementes de IA; adicione/edite-os depois no portal; opcionalmente permita que leitores anônimos proponham mais quando envios de embed forem ativados para essa discussão.

Fluxos Comuns

O padrão usual é «uma discussão por URL publicada», mas isso é uma escolha, não um requisito. Você pode criar discussões para páginas de artigos, anexar um id de CMS sem um artigo público, ou misturar ambos para que o relatório permaneça consistente em sua pilha.

Independentemente dos identificadores, lembre-se da divisão: lookup/create retorna discussion_id; as declarações são modeladas separadamente e exibidas através do embed, ferramentas de declarações do portal e payloads de snapshot.

Embed programático (recomendado)

  1. 1) Procure discussão pela URL do artigo
  2. 2) Se nenhum existir, crie o shell de discussão (declarações iniciais opcionalmente na mesma chamada)
  3. 3) Armazene discussion_id e use embed_url; revise as declarações dentro do portal se precisar de 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":"..."}'

Embed manual (início rápido)

Use o Gerador de Embed para criar iframes únicos. Ele usa as mesmas APIs e retorna o mesmo discussion_id e embed_url.

Lista de verificação de lançamento simples

Editores e produtores podem revisar a seção abaixo; compartilhe as notas de engenharia com TI.

Para equipes editoriais e de produto

  • Guia rápido de vocabulário: uma discussão = um tema de votação mais seu widget; vários comentários ficam abaixo — as linhas que leitores respondem com concordo / discordo / não tenho certeza. Explicação mais detalhada →
  • Você decide como cada tema de votação é chamado e o que os leitores veem primeiro — vinculação com artigo é opcional; blogs ao vivo, hubs e projetos especiais podem usar um id estável em vez de URL de matéria.
  • Cada endereço web onde o snippet pode carregar precisa ser aprovado no seu Partner Portal — sites de ensaio e sites ao vivo são separados.
  • Se seus leitores digitam tanto www. quanto nomes de domínio sem prefixo, registre duas aprovações.
  • Peça ao time de engenharia para adicionar a tag de rastreamento curta (?ref=…) para que os relatórios permaneçam atribuíveis. O que as tags significam →
  • Opcional: peça altura automática na caixa para que artigos longos não sejam cortados (um pequeno script do seu lado). Como funciona →
  • Prefira pings automáticos de 「algo mudou」 para seus sistemas em vez de verificação manual. Visão geral de webhooks →

Para engenharia e hospedagem

  • Corresponda chaves de API de teste apenas com domínios de ensaio; chaves ao vivo apenas com domínios de produção verificados.
  • Embeds de ensaio (test) em produção sempre precisam de uma página pai resolvível do navegador — mesmo sem PARTNER_EMBED_REQUIRE_PARENT_ORIGIN.
  • Endurecimento ambiental opcional: PARTNER_EMBED_REQUIRE_PARENT_ORIGIN=true (produção, embeds parceiros ao vivo apenas) bloqueia HTML de iframe quando não conseguimos resolver uma página pai de Origin ou Referer — mais rigoroso e pode afetar navegadores incomuns ou configurações de privacidade.
  • Envios inline de leitores a partir do embed respeitam EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR e EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP na configuração (além de limites por pessoa).

Pesquisar por URL de Artigo

Encontre uma discussão pela URL do seu artigo. A busca retorna metadados da discussão (discussion_id, embed_url, etc.); os comentários que os leitores votam chegam com o embed e payloads de snapshot dessa discussão.

GET /api/discussions/by-article-url

Parâmetros de Query

url obrigatório A URL do artigo (codificada em URL)
ref opcional Referência de parceiro para análise

Exemplo de Requisição

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

Resposta de Sucesso (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"
}

Respostas de Erro

400 missing_url - Parâmetro URL obrigatório
400 invalid_url - URL não pôde ser analisada
403 partner_disabled - Acesso a embed e API revogado para esta referência de parceiro
404 no_discussion - Nenhuma discussão para esta URL
429 rate_limited - Muitas requisições; tente novamente mais tarde

Obter Snapshot de Discussão

Obtenha contagens de participação e metadados. Não inclui conteúdo de análise.

GET /api/discussions/{discussion_id}/snapshot

Parâmetros de Rota

discussion_id obrigatório O ID da discussão

Exemplo de Requisição

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

Resposta de Sucesso (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"
}

Quando a Análise Não Está Pronta

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

Respostas de Erro

403 partner_disabled - Acesso a embed e API revogado para esta referência de parceiro
403 forbidden - Chave de API de teste válida necessária para discussões de teste
404 discussion_not_found - A discussão não existe
429 rate_limited - Muitas requisições; tente novamente mais tarde

Parâmetros de URL de Incorporação

Personalize a aparência do embed com parâmetros de URL.

Embeds estão disponíveis para discussões nativas do Society Speaks.

https://societyspeaks.io/discussions/{discussion_id}/embed
Parâmetro Descrição Exemplo
theme Tema predefinido: default, dark, editorial, minimal, bold, muted theme=editorial
primary Cor primária (hex sem #) primary=1e40af
bg Cor de fundo (hex sem #) bg=f9fafb
font Família de fontes da lista de permissões font=Georgia
ref Referência de parceiro para análise ref=observer

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

Embed: Rastreamento de Sessão & Privacidade

O embed usa um cookie de primeira parte para desduplicar votos anônimos entre carregamentos de página. Quando o embed é carregado em um iframe de domínio cruzado (o caso normal para sites parceiros), navegadores com configurações de privacidade rigorosas podem bloquear esse cookie como um cookie de terceiros.

Discussões criadas através de sua API de parceiro têm escopo de parceiro: a página de embed verifica a origem do site pai (usando Origin ou Referer quando necessário) contra seus domínios verificados para o ambiente de teste/ao vivo correspondente. Discussões públicas nativas não estão sujeitas a esse gate de framing.

Nenhuma ação necessária. O embed cai automaticamente para um identificador de sessão baseado em localStorage (embed_fingerprint) quando cookies não estão disponíveis. A desduplicação de votos funciona nos dois modos.

Como funciona:

  • O embed gera um ID de participante aleatório e o armazena em localStorage sob a origem do embed.
  • Este ID é enviado com cada voto como embed_fingerprint para desduplicação no servidor.
  • Se o usuário limpar dados do site ou usar navegação privada, uma nova sessão começa (ele pode votar novamente).
  • Safari, Firefox e Brave são totalmente compatíveis via este fallback.

Eventos PostMessage

O embed se comunica com o frame pai via postMessage.

societyspeaks:embed:loaded

Enviado quando o embed termina de carregar.

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

societyspeaks:embed:resize

Enviado quando a altura do conteúdo do embed muda. Use para redimensionar iframe.

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

Exemplo: Redimensionar iframe automaticamente

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';
  }
});

Provedor oEmbed

Ative o embedding automático de discussões do Society Speaks em plataformas que suportam oEmbed.

GET /api/oembed

Parâmetros de Query

url obrigatório URL da discussão do Society Speaks
maxwidth opcional Largura máxima do embed
maxheight opcional Altura máxima do embed
format opcional Formato de resposta (apenas "json" suportado)

Exemplo de Requisição

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

Resposta de Sucesso (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
}

Auto-descoberta: Páginas de discussão incluem uma tag <link rel="alternate" type="application/json+oembed"> para descoberta automática de oEmbed por plataformas compatíveis.

Criar Discussão (Autenticada)

Crie uma discussão para uma matéria específica ou para qualquer contexto editorial que você possa nomear com um id estável. Forneça article_url quando você tiver um link público; use external_id quando não tiver (ou use ambos para rastreabilidade). Requer chave de API.

Este endpoint sempre cria a discussão primeiro. Agruppe comentários iniciais na mesma requisição via seed_statements ou forneça um excerpt para que possamos gerar sementes; caso contrário, adicione comentários depois a partir do Partner Portal. Leia como discussão vs comentários mapeiam para campos →

POST /api/partner/discussions

Cabeçalhos

X-API-Key obrigatório Sua chave de API de parceiro
Content-Type obrigatório application/json

Corpo da Requisição

article_url opcional URL canônica do artigo (normalizada se fornecida)
external_id opcional ID estável definido pelo parceiro (necessário se article_url for omitido)
title obrigatório Título da discussão (máx. 200 caracteres)
excerpt condicional Trecho de artigo para declarações geradas por IA
seed_statements condicional Array de declarações {content, position}
source_name opcional Nome da sua publicação para atribuição
embed_statement_submissions_enabled opcional Permitir envios de leitores em linha no embed para esta discussão (padrão falso)

Notas: Forneça pelo menos um identificador (article_url ou external_id) e excerpt ou seed_statements.

Recomendação: mantenha embed_statement_submissions_enabled como false para a maioria das páginas de artigos, e ative apenas para discussões onde sua equipe deseja coletar declarações no embed.

Exemplo de Requisição

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"
  }'

Resposta de sucesso (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
}

Respostas de Erro

401 invalid_api_key - Chave de API ausente ou inválida
400 missing_identifier ou missing_content - Necessário (article_url ou external_id) e excerpt/seed_statements
409 discussion_exists - Discussão já existe para esta URL

Atualizar política de discussão (Autenticado)

Use isto para fechar/reabrir discussões, alternar modo de integridade e controlar envios em linha no embed.

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
  }'

Padrão recomendado para a maioria das páginas de artigos: "embed_statement_submissions_enabled": false. Ative true para discussões selecionadas de alto engajamento (por exemplo, blogs ao vivo ou hubs de campanha).

Callbacks de webhook (Autenticado)

Configure callbacks de ciclo de vida assinados para evitar polling. Use chamadas servidor-para-servidor com uma chave emitida pelo portal.

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

POST /api/partner/webhooks - criar endpoint e receber segredo de assinatura uma vez

PATCH /api/partner/webhooks/{endpoint_id} - pausar/retomar e atualizar inscrições de eventos

POST /api/partner/webhooks/{endpoint_id}/rotate-secret - rotacionar segredo de assinatura

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

Cabeçalhos de requisição

Toda entrega de webhook inclui estes cabeçalhos:

Cabeçalho Descrição
X-SocietySpeaks-EventTipo de evento, ex: discussion.created
X-SocietySpeaks-Event-IdUUID de evento exclusivo — use para deduplicação
X-SocietySpeaks-TimestampSegundos Unix (UTC) quando o evento foi enviado
X-SocietySpeaks-SignatureAssinatura HMAC-SHA256 — sempre verifique esta

Sempre verifique assinaturas de webhook

Sem verificação, qualquer parte que conheça a URL do seu endpoint pode enviar eventos falsificados. Leia o corpo bruto antes de fazer parsing de JSON, depois verifique a assinatura HMAC-SHA256 usando seu segredo de assinatura. Rejeite requisições com timestamps mais antigos que 5 minutos para prevenir ataques de replay.

Formato da assinatura

O payload assinado é {timestamp}.{raw_body} (segundos Unix, um ponto literal, depois o corpo da requisição UTF-8 bruto). O cabeçalho X-SocietySpeaks-Signature é sha256=<hex_digest>.

Exemplo de verificação

# 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

Verificação manual (sem o SDK)

Se você não está usando nosso SDK, siga estes passos exatamente:

  1. Leia o corpo bruto da requisição como bytes — antes de qualquer parsing de JSON.
  2. Faça parsing de X-SocietySpeaks-Timestamp como um inteiro. Se estiver ausente ou com mais de 300 segundos no passado, rejeite com HTTP 400.
  3. Construa o payload assinado: {timestamp} + . + raw body bytes.
  4. Calcule HMAC-SHA256(key=signing_secret, msg=signed_payload) e codifique em hexadecimal.
  5. Adicione sha256= como prefixo para formar a string de assinatura esperada.
  6. Compare com X-SocietySpeaks-Signature usando uma comparação constante em tempohmac.compare_digest em Python, crypto.timingSafeEqual em Node. Uma verificação simples de igualdade de string vaza informações de tempo.

Gestão de segredos

O segredo de assinatura é retornado uma única vez quando você cria um endpoint — armazene-o com segurança em uma variável de ambiente ou gerenciador de segredos, nunca em código-fonte. Ele nunca é exibido novamente no portal. Use POST /api/partner/webhooks/{endpoint_id}/rotate-secret para emitir um novo segredo; aceite brevemente as assinaturas antiga e nova durante o período de transição antes de desativar a antiga.

Exportação de análises (Autenticado)

Exporte eventos de uso em JSON ou 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"

Use format=csv para ingestão direta de arquivo, ou format=json paginado para consumidores de API.

Proteção de custo e abuso

Quem paga pelo quê: Lookup, snapshot, oEmbed e a página de incorporação não usam nosso LLM. Eles são limitados por taxa por IP (e ref opcional) para evitar abuso. Criar discussão é o único endpoint que pode disparar declarações iniciais geradas por IA (OpenAI/Anthropic); usa nossas chaves de API, não as suas.

Parceiros usam chaves que emitimos. Apenas solicitações com um X-API-Key válido (de nossa lista de permissão) podem criar discussões. Não usamos chaves de LLM fornecidas por parceiros para o fluxo de criação. Se uma chave for abusada, a revogamos.

Criar discussão é limitado a 30 solicitações por hora por chave de API. Combinado com controle de chave, isso limita o custo de LLM e banco de dados do endpoint de criação.

Limites de Taxa

Endpoint Limite
Lookup por URL do artigo 60 req / min / IP
Obter snapshot 120 req / min / IP
Envio de voto (padrão) 30 req / min / IP
Envio de voto (modo integridade) 10 req / min / discussão
Criação de declaração (autenticada) 10 por hora
Criação de declaração (anônima) 5 por hora
Sinalizar declaração (do embed) 10 req / min / IP
Comentário inline do embed (discussões com escopo de parceiro) Padrão 25 / leitor / hora + limite de IP; veja env
Criar discussão 30 req / hr / chave

As respostas incluem um 429 Too Many Requests com um cabeçalho Retry-After indicando segundos até a próxima solicitação permitida.

Modo integridade: Algumas discussões têm limites de taxa de voto mais rigorosos habilitados para proteção de integridade. O embed lida com isso transparentemente — os usuários veem uma mensagem "por favor, desacelere".

Comentários inline de embed: Flask-Limiter usa EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR e EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP (veja config.py). Limites por usuário também se aplicam antes de os comentários serem armazenados.

Formato de Erro

Todos os erros retornam JSON com um formato consistente.

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

Dúvidas? Entre em contato conosco ou visite o Partner Hub.