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
curlem 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.
sspk_test_...
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 ▼ Ocultar código 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_urle/ouexternal_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_statementsou umexcerptque 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) Procure discussão pela URL do artigo
- 2) Se nenhum existir, crie o shell de discussão (declarações iniciais opcionalmente na mesma chamada)
- 3) Armazene
discussion_ide useembed_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 semPARTNER_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 deOriginouReferer— 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_ACTOReEMBED_STATEMENT_SUBMIT_RATE_LIMIT_IPna 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"
import requests
resp = requests.get(
"https://societyspeaks.io/api/discussions/by-article-url",
params={"url": "https://example.com/article"}
)
data = resp.json()
embed_url = data["embed_url"]
const resp = await fetch(
`https://societyspeaks.io/api/discussions/by-article-url?` +
new URLSearchParams({ url: "https://example.com/article" })
);
const data = await resp.json();
const embedUrl = data.embed_url;
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
localStoragesob a origem do embed. - Este ID é enviado com cada voto como
embed_fingerprintpara 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"
}'
import requests
resp = requests.post(
"https://societyspeaks.io/api/partner/discussions",
headers={
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
},
json={
"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",
},
)
data = resp.json()
discussion_id = data["discussion_id"]
embed_url = data["embed_url"]
const resp = await fetch("https://societyspeaks.io/api/partner/discussions", {
method: "POST",
headers: {
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
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",
}),
});
const data = await resp.json();
const { discussion_id, embed_url } = data;
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-Event | Tipo de evento, ex: discussion.created |
| X-SocietySpeaks-Event-Id | UUID de evento exclusivo — use para deduplicação |
| X-SocietySpeaks-Timestamp | Segundos Unix (UTC) quando o evento foi enviado |
| X-SocietySpeaks-Signature | Assinatura 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
// Express example — adapt raw-body reading to your framework
const express = require("express");
const { SocietyspeaksPartnerClient } = require("./societyspeaks_partner");
const app = express();
const WEBHOOK_SECRET = process.env.SS_WEBHOOK_SECRET; // store in env, never hardcode
// Use express.raw() so the body stays as a Buffer before any parsing
app.post(
"/webhooks/societyspeaks",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body; // Buffer — do NOT call JSON.parse before verifying
let valid;
try {
valid = SocietyspeaksPartnerClient.verifyWebhookSignature(
rawBody,
req.headers["x-societyspeaks-signature"] ?? "",
req.headers["x-societyspeaks-timestamp"] ?? "",
WEBHOOK_SECRET,
300, // reject events older than 5 minutes
);
} catch (err) {
// Timestamp missing, malformed, or outside the tolerance window
return res.status(400).send(err.message);
}
if (!valid) return res.status(401).send("Invalid webhook signature.");
const event = JSON.parse(rawBody);
const eventType = req.headers["x-societyspeaks-event"];
const eventId = req.headers["x-societyspeaks-event-id"];
// Deduplicate using eventId (store processed IDs in your DB or cache)
if (alreadyProcessed(eventId)) return res.sendStatus(200);
if (eventType === "discussion.created") {
handleDiscussionCreated(event.data);
}
res.sendStatus(200);
}
);
Verificação manual (sem o SDK)
Se você não está usando nosso SDK, siga estes passos exatamente:
- Leia o corpo bruto da requisição como bytes — antes de qualquer parsing de JSON.
- Faça parsing de
X-SocietySpeaks-Timestampcomo um inteiro. Se estiver ausente ou com mais de 300 segundos no passado, rejeite com HTTP 400. - Construa o payload assinado:
{timestamp}+.+ raw body bytes. - Calcule
HMAC-SHA256(key=signing_secret, msg=signed_payload)e codifique em hexadecimal. - Adicione
sha256=como prefixo para formar a string de assinatura esperada. - Compare com
X-SocietySpeaks-Signatureusando uma comparação constante em tempo —hmac.compare_digestem Python,crypto.timingSafeEqualem 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.