Référence API partenaire
Intégrez Society Speaks par programmation. Alimentez vos intégrations, snapshots et insights éditoriaux avec des API JSON construites pour les workflows de rédaction et la vélocité des développeurs.
Si vous êtes nouveau ici : les éditeurs lancent une discussion pour chaque sujet de vote — le plus souvent alignée avec une URL d'article, mais vous pouvez utiliser à la place uniquement votre propre external_id pour les hubs ou outils sans lien permanent. Les énoncés sont à rédiger ou peuvent être rédigés à partir du contenu que vous fournissez ; rien n'est déduit de votre page d'accueil à moins que votre intégration ne le demande.
Définitions formelles plus mappage des champs API : Discussion vs énoncés.
Démarrage rapide
curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article"
Puis intégrez avec le embed_url renvoyé.
Pour les développeurs
Points de terminaison prévisibles, codes d'erreur clairs et exemples copier-coller.
Pour les leaders éditoriaux
Vous choisissez chaque question, comment elle se rapporte à vos histoires ou identifiants, et si les lignes soumises par les lecteurs apparaissent dans l'intégration.
Pour la gouvernance
Limites de débit, exigences d'attribution et source unique de vérité.
Tester l'API
- Portail partenaire : Créer un portail Portail Partenaire :
- Aire de jeux interactive : Playground API ouvert Playground interactif :
- Les endpoints d'écriture (
POST/PATCH /api/partner/...) sont réservés aux communications serveur à serveur et doivent être testés à partir de votre backend, curl ou Postman, et non depuis Swagger dans le navigateur. - Depuis la ligne de commande : utilisez les exemples
curldans chaque section ci-dessous, en remplaçant l'URL de base et les paramètres selon vos besoins.
Carte rapide du workflow de la salle de rédaction : external_id pour le mappage CMS, callbacks webhook pour la synchronisation système, et rôles du Portail Partenaire pour le contrôle d'accès.
Authentification et environnements
Utilisez les clés de test pendant que vous répétez en environnement de test ; passez aux clés actives une fois la facturation active. Les clés sont créées dans le Portail Partenaire.
sspk_test_...
sspk_live_... (activé après facturation)
Chaque nom d'hôte où vous hébergez l'iframe doit apparaître sous Domaines après vérification DNS — séparément pour la répétition (test) et la production (live). Les sites d'actualités typiques enregistrent à la fois www. et le domaine court si les lecteurs utilisent l'un ou l'autre.
Les lecteurs interagissent toujours avec Society Speaks via un trafic web chiffré normal ; votre équipe d'hébergement doit maintenir notre adresse web publique (BASE_URL de notre côté) correspondant à ce que les navigateurs attendent.
Serveur à serveur uniquement pour les opérations d'écriture
Create Discussion et les routes de gestion des partenaires doivent être appelées depuis votre serveur, et non depuis du JavaScript côté client (les requêtes avec un en-tête Origin sont rejetées avec 403). Les points de terminaison en lecture seule (Lookup, Snapshot, oEmbed) fonctionnent depuis les navigateurs sans clé API. Pour les requêtes authentifiées depuis une application web, passez par votre serveur backend pour que les clés API restent confidentielles.
HTTPS obligatoire
Toutes les requêtes API doivent être effectuées via HTTPS et appliquées à votre couche edge/proxy en production. Ne journalisez jamais votre clé API et ne l'incluez pas dans les URL — envoyez-la toujours dans l'en-tête X-API-Key.
Bibliothèques clientes
v0.3.0
Clients helper prêts à l'emploi pour Python et Node.js. Chacun est un fichier unique autonome — aucune étape de compilation, aucune dépendance transitive au-delà de requests (Python uniquement). Utilisation côté serveur uniquement — n'exposez jamais votre clé API dans le code du navigateur.
1. Ajouter à votre projet
Téléchargez le fichier et placez-le à côté de votre code applicatif.
Télécharger societyspeaks_partner.py# Place societyspeaks_partner.py anywhere in your project, then: from societyspeaks_partner import SocietyspeaksPartnerClient, PartnerApiError
2. Installer la seule dépendance
pip install requests
3. Initialiser et faire votre premier appel
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
▶ Voir le code complet ▼ Masquer le code 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 de base
https://societyspeaks.io/api
Discussion vs énoncés
Nous utilisons ces mots de manière cohérente dans le Portail Partenaire, les réponses API et les documents d'assistance — lisez ceci une fois et le reste de la documentation reste prévisible.
- Discussion
- Le parapluie pour une expérience de vote destinée aux lecteurs : titre du titre, comment il s'ancre à votre site (
article_urlet/ouexternal_id), environnement partenaire (test/live), une URL d'intégration/consensus, et la participation combinée en dessous. Créer une discussion via API établit d'abord ce conteneur. - Affirmations
- Les invites individuelles sous ce parapluie — chacune collecte ses propres décomptes d'accord / désaccord / incertain. Remplissez-les lors de la création avec
seed_statementsou unexcerptqui alimente les graines IA ; ajoutez/modifiez-les par la suite dans le portail ; autorisez éventuellement les lecteurs anonymes à en proposer d'autres quand les soumissions d'intégration sont activées pour cette discussion.
Workflows courants
Le modèle habituel est « une discussion par URL publiée », mais c'est un choix, pas une obligation. Vous pouvez créer des discussions pour des pages d'article, joindre un identifiant CMS sans article public, ou combiner les deux afin que le rapportage reste cohérent dans votre pile.
Indépendamment des identifiants, rappelez-vous la division : la recherche/création retourne discussion_id ; les énoncés sont modélisés séparément et affichés via l'intégration, les outils de portail d'énoncés et les charges snapshot.
Intégration programmatique (recommandée)
- 1) Chercher une discussion par l'URL de l'article
- 2) S'il n'existe pas, créez l'enveloppe de discussion (énoncés de départ optionnellement dans le même appel)
- 3) Stockez
discussion_idet utilisezembed_url; examinez les énoncés dans le portail si vous avez besoin d'ajustements
# 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":"..."}'
Intégration manuelle (démarrage rapide)
Utilisez le générateur d'intégration pour créer des iframes ponctuelles. Il s'appuie sur les mêmes API et renvoie les mêmes discussion_id et embed_url.
Liste de contrôle simple de lancement
Les éditeurs et producteurs peuvent consulter rapidement la section ci-dessous ; partagez les notes d'ingénierie avec l'informatique.
Pour les équipes éditoriales et produit
- Lexique rapide : une discussion = un sujet de vote plus son cadre d'intégration ; plusieurs déclarations se trouvent dessous — les lignes auxquelles les lecteurs répondent par accord / désaccord / incertain. Explication plus détaillée →
- Vous décidez comment appeler chaque sujet de vote et ce que les lecteurs voient en premier — le lien vers l'article est optionnel ; les blogs en direct, les hubs et les projets spéciaux peuvent utiliser un identifiant stable au lieu d'une URL d'article.
- Chaque adresse web où l'extrait pourrait se charger doit être approuvée dans votre Portail Partenaire — les sites de répétition et les sites en direct sont séparés.
- Si vos lecteurs tapent à la fois
www.et les noms de domaine nus, inscrivez deux approbations. - Demandez à l'équipe technique d'ajouter la courte balise de suivi (
?ref=…) pour que les rapports restent attribuables. Que signifient les balises → - Optionnel : demandez la hauteur automatique de la boîte afin que les articles longs ne soient pas coupés (un petit script de votre côté). Comment ça fonctionne →
- Préférez les pings automatiques « quelque chose a changé » vers vos systèmes à la vérification manuelle. Vue d'ensemble des webhooks →
Pour l'ingénierie et l'hébergement
- Associez les clés API de test uniquement aux domaines de répétition ; les clés en direct uniquement aux domaines de production vérifiés.
- Les intégrations de répétition (
test) en production nécessitent toujours une page parent résolvable depuis le navigateur — même sansPARTNER_EMBED_REQUIRE_PARENT_ORIGIN. - Renforcement optionnel de l'env :
PARTNER_EMBED_REQUIRE_PARENT_ORIGIN=true(production, intégrations partenaires en direct uniquement) bloque le HTML iframe lorsque nous ne pouvons pas résoudre une page parent à partir deOriginouReferer— plus strict, et peut affecter les navigateurs peu courants ou les paramètres de confidentialité. - Les soumissions de lecteurs insérées depuis l'intégration respectent
EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTORetEMBED_STATEMENT_SUBMIT_RATE_LIMIT_IPdans la configuration (plus les limites par personne).
Recherche par URL d'article
Trouvez une discussion à partir de l'URL de votre article. La recherche retourne les métadonnées de la discussion (discussion_id, embed_url, etc.) ; les déclarations sur lesquelles les lecteurs votent arrivent avec l'intégration et les charges utiles d'instantané de cette discussion.
GET
/api/discussions/by-article-url
Paramètres de requête
| url | obligatoire | L'URL de l'article (encodée en URL) |
| ref | optionnel | Référence partenaire pour l'analytique |
Exemple de requête
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;
Réponse réussie (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"
}
Réponses d'erreur
400
missing_url - Le paramètre URL est obligatoire
400
invalid_url - L'URL n'a pas pu être analysée
403
partner_disabled - L'accès aux intégrations et API a été révoqué pour cette référence partenaire
404
no_discussion - Aucune discussion pour cette URL
429
rate_limited - Trop de requêtes ; réessayez plus tard
Obtenir un snapshot de discussion
Obtenez les nombres de participation et les métadonnées. N'inclut pas le contenu d'analyse.
GET
/api/discussions/{discussion_id}/snapshot
Paramètres de chemin
| discussion_id | obligatoire | L'ID de la discussion |
Exemple de requête
curl "https://societyspeaks.io/api/discussions/123/snapshot"
Réponse réussie (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"
}
Quand l'analyse n'est pas prête
{
"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"
}
Réponses d'erreur
403
partner_disabled - L'accès aux intégrations et API a été révoqué pour cette référence partenaire
403
forbidden - Clé API de test valide requise pour les discussions de test
404
discussion_not_found - La discussion n'existe pas
429
rate_limited - Trop de requêtes ; réessayez plus tard
Paramètres d'URL d'intégration
Personnalisez l'apparence de l'intégration avec les paramètres d'URL.
Les intégrations sont disponibles pour les discussions natives de Society Speaks.
https://societyspeaks.io/discussions/{discussion_id}/embed
| Paramètre | Description | Exemple |
|---|---|---|
| theme | Thème prédéfini : default, dark, editorial, minimal, bold, muted |
theme=editorial |
| primary | Couleur primaire (hexadécimal sans #) | primary=1e40af |
| bg | Couleur de fond (hexadécimal sans #) | bg=f9fafb |
| font | Famille de polices de la liste autorisée | font=Georgia |
| ref | Référence partenaire pour l'analytique | ref=observer |
Polices autorisées : system-ui, Georgia, Inter, Lato, Open Sans, Source Serif Pro
Intégration : suivi de session et confidentialité
L'intégration utilise un cookie propriétaire pour dédupliquer les votes anonymes entre les chargements de page. Lorsque l'intégration est chargée dans une iframe cross-domain (cas normal pour les sites partenaires), les navigateurs avec des paramètres de confidentialité stricts peuvent bloquer ce cookie en tant que cookie tiers.
Les discussions créées via votre API partenaire sont limitées aux partenaires : la page d'intégration vérifie l'origine du site parent (en utilisant Origin ou Referer le cas échéant) par rapport à vos domaines vérifiés pour l'environnement test/direct correspondant. Les discussions publiques natives ne sont pas soumises à ce filtre d'encadrage.
Aucune action requise. L'intégration bascule automatiquement vers un identifiant de session basé sur localStorage (embed_fingerprint) quand les cookies ne sont pas disponibles. La déduplication des votes fonctionne dans les deux modes.
Comment ça marche :
- L'intégration génère un ID de participant aléatoire et le stocke dans
localStorageà l'origine de l'intégration. - Cet ID est envoyé avec chaque vote en tant que
embed_fingerprintpour la déduplication côté serveur. - Si l'utilisateur efface les données du site ou utilise la navigation privée, une nouvelle session commence (il peut voter à nouveau).
- Safari, Firefox et Brave sont entièrement pris en charge via ce mécanisme de secours.
Événements PostMessage
L'intégration communique avec le cadre parent via postMessage.
societyspeaks:embed:loaded
Envoyé quand l'intégration termine le chargement.
{
"type": "societyspeaks:embed:loaded",
"discussionId": 123,
"statementCount": 5
}
societyspeaks:embed:resize
Envoyé quand la hauteur du contenu de l'intégration change. À utiliser pour redimensionner l'iframe.
{
"type": "societyspeaks:embed:resize",
"discussionId": 123,
"height": 450
}
Exemple : Redimensionnement automatique de l'iframe
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';
}
});
Fournisseur oEmbed
Activez l'intégration automatique des discussions de Society Speaks dans les plateformes qui prennent en charge oEmbed.
GET
/api/oembed
Paramètres de requête
| url | obligatoire | URL de la discussion Society Speaks |
| maxwidth | optionnel | Largeur maximale de l'intégration |
| maxheight | optionnel | Hauteur maximale de l'intégration |
| format | optionnel | Format de réponse (seul « json » est pris en charge) |
Exemple de requête
curl "https://societyspeaks.io/api/oembed?url=https://societyspeaks.io/discussions/123/my-discussion"
Réponse réussie (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
}
Découverte automatique : Les pages de discussion incluent une balise <link rel="alternate" type="application/json+oembed"> pour la découverte automatique d'oEmbed par les plateformes compatibles.
Créer une discussion (authentifiée)
Créez une discussion pour une histoire spécifique, ou pour tout contexte éditorial que vous pouvez nommer avec un identifiant stable. Fournissez article_url lorsque vous avez un lien public ; utilisez external_id lorsque vous n'en avez pas (ou utilisez les deux pour la traçabilité). Nécessite une clé API.
Ce point de terminaison crée toujours la discussion en premier. Regroupez les déclarations initiales dans la même demande via seed_statements ou fournissez un excerpt pour que nous puissions générer des semences ; sinon, ajoutez des déclarations après à partir du Portail Partenaire. Découvrez comment les discussions et les déclarations correspondent aux champs →
POST
/api/partner/discussions
En-têtes
| X-API-Key | obligatoire | Votre clé API partenaire |
| Content-Type | obligatoire | application/json |
Corps de la requête
| article_url | optionnel | URL de l'article canonique (normalisée si fournie) |
| external_id | optionnel | ID stable défini par le partenaire (obligatoire si article_url est omis) |
| title | obligatoire | Titre de la discussion (max 200 caractères) |
| excerpt | conditionnel | Extrait d'article pour les énoncés générés par IA |
| seed_statements | conditionnel | Tableau d'énoncés {content, position} |
| source_name | optionnel | Nom de votre publication pour l'attribution |
| embed_statement_submissions_enabled | optionnel | Autoriser les soumissions de lecteurs en ligne dans l'intégration pour cette discussion (false par défaut) |
Notes : Fournissez au moins un identifiant (article_url ou external_id) et soit excerpt soit seed_statements.
Recommandation : gardez embed_statement_submissions_enabled en false pour la plupart des pages d'article, et activez-le seulement pour les discussions où votre équipe souhaite recevoir des énoncés en ligne via l'intégration.
Exemple de requête
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;
Réponse réussie (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
}
Réponses d'erreur
401
invalid_api_key - Clé API manquante ou invalide
400
missing_identifier ou missing_content - Besoin de (article_url ou external_id) et excerpt/seed_statements
409
discussion_exists - Une discussion existe déjà pour cette URL
Mettre à jour la politique de discussion (Authentifié)
Utilisez ceci pour fermer/rouvrir des discussions, basculer le mode intégrité, et contrôler les soumissions en ligne de l'intégration.
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
}'
Par défaut recommandé pour la plupart des pages d'article : "embed_statement_submissions_enabled": false. Activez true pour les discussions sélectionnées à fort engagement (par exemple, les blogs en direct ou les centres de campagne).
Rappels webhook (Authentifié)
Configurez des rappels de cycle de vie signés pour éviter le sondage. Utilisez des appels serveur à serveur avec une clé émise par le portail.
GET /api/partner/webhooks - lister les points d'arrivée webhook
POST /api/partner/webhooks - créer un point d'arrivée et recevoir le secret de signature une fois
PATCH /api/partner/webhooks/{endpoint_id} - mettre en pause/reprendre et mettre à jour les abonnements aux événements
POST /api/partner/webhooks/{endpoint_id}/rotate-secret - faire tourner le secret de signature
DELETE /api/partner/webhooks/{endpoint_id} - supprimer le point d'arrivée
En-têtes de requête
Chaque livraison webhook inclut ces en-têtes :
| En-tête | Description |
|---|---|
| X-SocietySpeaks-Event | Type d'événement, par ex. discussion.created |
| X-SocietySpeaks-Event-Id | UUID d'événement unique — utilisez pour la déduplication |
| X-SocietySpeaks-Timestamp | Secondes Unix (UTC) au moment où l'événement a été envoyé |
| X-SocietySpeaks-Signature | Signature HMAC-SHA256 — vérifiez toujours ceci |
Toujours vérifier les signatures webhook
Sans vérification, n'importe quel tiers connaissant l'URL de votre point d'arrivée peut envoyer des événements falsifiés. Lisez le corps brut avant d'analyser le JSON, puis vérifiez la signature HMAC-SHA256 en utilisant votre secret de signature. Rejetez les requêtes dont les horodatages sont antérieurs à 5 minutes pour prévenir les attaques par rejeu.
Format de signature
Le contenu signé est {timestamp}.{raw_body} (secondes Unix, un point littéral, puis le corps brut de la requête en UTF-8). L'en-tête X-SocietySpeaks-Signature est sha256=<hex_digest>.
Exemple de vérification
# 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);
}
);
Vérification manuelle (sans le SDK)
Si vous n'utilisez pas notre SDK, suivez ces étapes exactement :
- Lisez le corps brut de la requête en octets — avant tout analyse JSON.
- Analysez
X-SocietySpeaks-Timestampcomme un entier. S'il est absent ou plus de 300 secondes dans le passé, rejetez avec HTTP 400. - Construisez le contenu signé :
{timestamp}+.+ octets du corps brut. - Calculez
HMAC-SHA256(key=signing_secret, msg=signed_payload)et encodez-le en hexadécimal. - Ajoutez
sha256=pour former la chaîne de signature attendue. - Comparez avec
X-SocietySpeaks-Signatureen utilisant une comparaison en temps constant —hmac.compare_digesten Python,crypto.timingSafeEqualen Node. Une simple vérification d'égalité de chaîne divulgue les informations de synchronisation.
Gestion des secrets
Le secret de signature est retourné une seule fois lors de la création d'un endpoint — stockez-le de manière sécurisée dans une variable d'environnement ou un gestionnaire de secrets, jamais dans le code source. Il n'est jamais affiché à nouveau dans le portail. Utilisez POST /api/partner/webhooks/{endpoint_id}/rotate-secret pour émettre un nouveau secret ; acceptez brièvement à la fois les anciennes et nouvelles signatures pendant la période de transition avant de désactiver l'ancienne.
Export d'analytiques (Authentifié)
Exportez les événements d'utilisation en JSON ou CSV pour vos pipelines 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"
Utilisez format=csv pour l'ingestion directe de fichiers, ou paginez avec format=json pour les consommateurs d'API.
Protection des coûts et des abus
Qui paie quoi : Les recherches, snapshots, oEmbed et la page intégrée n'utilisent pas notre LLM. Ils sont limités en débit par IP (et ref optionnel) pour prévenir les abus. Create Discussion est le seul endpoint qui peut déclencher des déclarations initiales générées par l'IA (OpenAI/Anthropic) ; il utilise nos clés API, pas les vôtres.
Les partenaires utilisent les clés que nous émettons. Seules les requêtes avec une X-API-Key valide (de notre liste d'autorisation) peuvent créer des discussions. Nous n'utilisons pas les clés LLM fournies par les partenaires pour le flux de création. Si une clé est mal utilisée, nous la révoquons.
Create Discussion est limité à 30 requêtes par heure par clé API. Combiné avec le contrôle des clés, cela plafonne le coût du LLM et de la base de données depuis l'endpoint de création.
Limites de débit
| Endpoint | Limite |
|---|---|
| Recherche par URL d'article | 60 req / min / IP |
| Obtenir un snapshot | 120 req / min / IP |
| Soumission de vote (par défaut) | 30 req / min / IP |
| Soumission de vote (mode intégrité) | 10 req / min / discussion |
| Création de déclaration (authentifiée) | 10 par heure |
| Création de déclaration (anonyme) | 5 par heure |
| Signaler une déclaration (depuis l'intégration) | 10 req / min / IP |
| Déclaration insérée depuis l'intégration (discussions limitées aux partenaires) | 25 par défaut / lecteur / heure + plafond IP ; voir env |
| Créer une discussion | 30 req / hr / key |
Les réponses incluent un 429 Too Many Requests avec un en-tête Retry-After indiquant le nombre de secondes avant la prochaine requête autorisée.
Mode intégrité : Certaines discussions ont des limites de débit de vote plus strictes activées pour la protection de l'intégrité. L'intégration gère cela de manière transparente — les utilisateurs voient un message « veuillez ralentir ».
Déclarer l'intégration insérée : Flask-Limiter utilise EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR et EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP (voir config.py). Des plafonds par utilisateur s'appliquent également avant que les déclarations ne soient stockées.
Format d'erreur
Toutes les erreurs retournent du JSON dans un format cohérent.
{
"error": "error_code",
"message": "Human-readable description of the error"
}
Des Questions ? Contactez-nous ou visitez le Portail partenaire.