Aller au contenu principal

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 curl dans 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.

Clé de test : sspk_test_...
Clé en direct : 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 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_url et/ou external_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_statements ou un excerpt qui 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. 1) Chercher une discussion par l'URL de l'article
  2. 2) S'il n'existe pas, créez l'enveloppe de discussion (énoncés de départ optionnellement dans le même appel)
  3. 3) Stockez discussion_id et utilisez embed_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 sans PARTNER_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 de Origin ou Referer — 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_ACTOR et EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP dans 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"

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

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-EventType d'événement, par ex. discussion.created
X-SocietySpeaks-Event-IdUUID d'événement unique — utilisez pour la déduplication
X-SocietySpeaks-TimestampSecondes Unix (UTC) au moment où l'événement a été envoyé
X-SocietySpeaks-SignatureSignature 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

Vérification manuelle (sans le SDK)

Si vous n'utilisez pas notre SDK, suivez ces étapes exactement :

  1. Lisez le corps brut de la requête en octets — avant tout analyse JSON.
  2. Analysez X-SocietySpeaks-Timestamp comme un entier. S'il est absent ou plus de 300 secondes dans le passé, rejetez avec HTTP 400.
  3. Construisez le contenu signé : {timestamp} + . + octets du corps brut.
  4. Calculez HMAC-SHA256(key=signing_secret, msg=signed_payload) et encodez-le en hexadécimal.
  5. Ajoutez sha256= pour former la chaîne de signature attendue.
  6. Comparez avec X-SocietySpeaks-Signature en utilisant une comparaison en temps constanthmac.compare_digest en Python, crypto.timingSafeEqual en 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.