Ga naar hoofdinhoud

Partner API Reference

Integreer Society Speaks programmatisch. Power je embeds, snapshots en redactionele inzichten met JSON API's ontworpen voor redactionele workflows en ontwikkelaarssnelheid.

Als je nieuw bent: uitgevers starten een discussie voor elk stemonderwerp — meestal afgestemd op één artikel-URL, maar je kunt in plaats daarvan alleen je eigen external_id gebruiken voor hubs of tools zonder permanente link. Stellingen zijn voor jou om te formuleren of ze kunnen worden opgesteld op basis van tekst die je levert; niets wordt afgeleid van je homepage tenzij je integratie daar om vraagt. Formele definities plus API-veldtoewijzing: Discussie vs stellingen.

Snel starten

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

Integreer vervolgens met de geretourneerde embed_url.

Voor ontwikkelaars

Voorspelbare endpoints, duidelijke foutcodes en copy/paste voorbeelden.

Voor redactionele leiders

Je kiest elke vraag, hoe die aansluit op je verhalen of id's, en of ingediende regels van lezers in de embed verschijnen.

Voor bestuur

Tarieflimieten, attributievereisten en een duidelijke bron van waarheid.

De API testen

  • Partner Portal: Een portal maken Partnerportaal:
  • Interactieve speeltuin: Open API Playground Interactieve speelplaats:
  • Schrijf-eindpunten (POST/PATCH /api/partner/...) zijn alleen server-naar-server en moeten worden getest vanuit uw backend of curl/Postman, niet vanuit browser Swagger.
  • Vanaf de opdrachtregel: gebruik de curl-voorbeelden in elke sectie hieronder, vervang de basis-URL en parameters naar behoefte.

Redactiewerkstroom snelle kaart: external_id voor CMS-toewijzing, webhook-callbacks voor systeemsyncs, en Partnerportaal-rollen voor toegangsbeheer.

Verificatie en omgevingen

Gebruik testsleutels terwijl je repeteren doet op staging; schakel over naar liveSleutels zodra de facturering actief is. Sleutels worden aangemaakt in de Partner Portal.

Testsleutel: sspk_test_...
Live-sleutel: sspk_live_... (geactiveerd na facturering)

Elke hostnaam waar je de iframe host, moet onder Domeinen verschijnen na DNS-verificatie — afzonderlijk voor repeteren (test) en productie (live). Typische nieuwssites registreren zowel www. als het korte domein als lezers beide gebruiken.

Lezers communiceren altijd met Society Speaks via normale versleutelde webverkeer; je hostingteam moet ervoor zorgen dat ons openbare webadres (BASE_URL aan onze kant) aansluit op wat browsers verwachten.

Server-naar-server alleen voor schrijfbewerkingen

Create Discussion en partnerbeheerroutes moeten van uw server worden aangeroepen, niet vanuit client-side JavaScript (verzoeken met een Origin-header worden geweigerd met 403). Alleen-lezen eindpunten (Lookup, Snapshot, oEmbed) werken vanuit browsers zonder API-sleutel. Voor geverifieerde verzoeken vanuit een web-app stuurt u deze via uw backend zodat API-sleutels geheim blijven.

HTTPS vereist

Alle API-verzoeken moeten via HTTPS worden gedaan en afgedwongen op uw edge/proxy-laag in productie. Registreer uw API-sleutel nooit of voeg deze niet toe aan URL's — stuur deze altijd in de X-API-Key-header.

Clientbibliotheken

v0.3.0

Plug-and-play helpercliënten voor Python en Node.js. Elk is een afzonderlijk zelfstandig bestand — geen buildstap, geen transitieve afhankelijkheden behalve requests (alleen Python). Alleen servergebruik — stel uw API-sleutel nooit bloot aan browsercode.

1. Toevoegen aan uw project

Download het bestand en plaats het naast uw applicatiecode.

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

2. Installeer de enige afhankelijkheid

pip install requests

3. Initialiseer & maak uw eerste oproep

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
▶ Volledige bron weergeven 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},
        )

Basis-URL

https://societyspeaks.io/api

Discussie versus stellingen

We gebruiken deze woorden consistent in de Partner Portal, API-reacties en ondersteuningsmateriaal — blader hier eenmaal doorheen en de rest van de documentatie blijft voorspelbaar.

Discussie
De verzamelnaam voor één lezer-gericht stemmingservaring: koplijntitel, hoe die aansluit op je site (article_url en/of external_id), partneromgeving (test/live), één embed/consensusURL's en de gecombineerde deelname daaronder. Een discussie via API aanmaken bouwt eerst deze container op.
Stellingen
Individuele vragen in die verzameling — elk verzamelt zijn eigen mee eens/oneens/onzeker-aantallen. Vul ze in bij aanmaak met seed_statements of een excerpt dat AI-zaadjes voedt; voeg ze daarna toe/bewerk ze in de portal; sta anonieme lezers optioneel toe meer voor te stellen wanneer indieningen zijn ingeschakeld voor die discussie.

Algemene workflows

Het gebruikelijke patroon is «één discussie per gepubliceerde URL», maar dat is een keuze, geen vereiste. Je kunt discussies aanmaken voor artikelpagina's, een CMS-id zonder openbaar artikel toevoegen, of beide mengen zodat rapportage consistent blijft in je stack.

Ongeacht identifiers, onthoud de verdeling: lookup/create geeft discussion_id terug; stellingen zijn afzonderlijk gemodelleerd en worden weergegeven via de embed, portalstellingsgereedschappen en snapshotladingen.

Programmatische embed (aanbevolen)

  1. 1) Zoek discussie op artikel-URL
  2. 2) Maak zo nodig de discussieschaal aan (openingsstatementen optioneel in dezelfde aanroep)
  3. 3) Sla discussion_id op en gebruik embed_url; controleer stellingen in de portal als je aanpassingen nodig hebt
# 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":"..."}'

Handmatige embed (snelstart)

Gebruik de Embed Generator om eenmalige iframes te maken. Deze is afhankelijk van dezelfde API's en retourneert dezelfde discussion_id en embed_url.

Eenvoudige startlijst

Redacteuren en producers kunnen de onderstaande sectie snel doornemen; deel de technische aantekeningen met IT.

Voor redactie- en productteams

  • Woordenboek: één discussie = één stemmingsonderwerp plus de embed-wrapper; meerdere statements staan eronder — de regels waarop lezers mee/oneens/onzeker reageren. Uitgebreidere uitleg →
  • Jij bepaalt hoe elk stemmingsonderwerp heet en wat lezers eerst zien — artikel-koppeling is optioneel; livebloggen, hubs en speciale projecten kunnen een stabiele id gebruiken in plaats van een artikel-URL.
  • Elk webadres waar het snippet kan laden moet in je Partner Portal worden goedgekeurd — oefensites en live-sites zijn gescheiden.
  • Als je lezers zowel www. als domeinnamen zonder www typen, registreer twee goedkeuringen.
  • Vraag engineering om de korte trackingtag toe te voegen (?ref=…) zodat rapporten traceerbaar blijven. Wat de tags betekenen →
  • Optioneel: vraag om automatische hoogte voor het vak zodat lange artikelen niet worden afgekapt (één klein script aan jouw kant). Hoe het werkt →
  • Voorzie automatische 'iets is veranderd'-meldingen aan je systemen boven handmatig controleren. Webhooks overzicht →

Voor engineering en hosting

  • Koppel test-API-sleutels alleen aan oefendomeinen; live-sleutels alleen aan geverifieerde productiedomeinen.
  • Oefening (test) embeds in productie hebben altijd een oplosbare bovenliggende pagina nodig vanuit de browser — ook zonder PARTNER_EMBED_REQUIRE_PARENT_ORIGIN.
  • Optionele versterkingsom geving: PARTNER_EMBED_REQUIRE_PARENT_ORIGIN=true (productie, alleen live partner-embeds) blokkeert iframe HTML wanneer we een bovenliggende pagina niet kunnen oplossen via Origin of Referer — strenger en kan uncommon browsers of privacy-instellingen beïnvloeden.
  • Inlinestatements van lezers in de embed respecteren EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR en EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP in de configuratie (plus limieten per persoon).

Opzoeken op artikel-URL

Zoek een discussie op via de URL van je artikel. De lookup retourneert discussiemetadata (discussion_id, embed_url, enz.); de statements waarop lezers stemmen komen met die discussie's embed en snapshot-payloads.

GET /api/discussions/by-article-url

Queryparameters

url vereist De artikel-URL (URL-gecodeerd)
ref optioneel Partnerreferentie voor analyses

Voorbeeldverzoek

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

Succesvolle respons (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"
}

Foutresponsen

400 missing_url - URL-parameter vereist
400 invalid_url - URL kon niet worden geparsed
403 partner_disabled - Embed- en API-toegang ingetrokken voor deze partnerreferentie
404 no_discussion - Geen discussie voor deze URL
429 rate_limited - Te veel verzoeken; probeer het later opnieuw

Discussie-snapshot ophalen

Haal deelname- en metadata op. Bevat geen analysecontent.

GET /api/discussions/{discussion_id}/snapshot

Padparameters

discussion_id vereist De discussie-ID

Voorbeeldverzoek

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

Succesvolle respons (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"
}

Wanneer analyse niet klaar is

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

Foutresponsen

403 partner_disabled - Embed- en API-toegang ingetrokken voor deze partnerreferentie
403 forbidden - Geldige test API-sleutel vereist voor testdiscussies
404 discussion_not_found - Discussie bestaat niet
429 rate_limited - Te veel verzoeken; probeer het later opnieuw

URL-parameters insluiten

Pas het uiterlijk van de embed aan met URL-parameters.

Embeds zijn beschikbaar voor Society Speaks native discussies.

https://societyspeaks.io/discussions/{discussion_id}/embed
Parameter Beschrijving Voorbeeld
theme Vooringesteld thema: default, dark, editorial, minimal, bold, muted theme=editorial
primary Primaire kleur (hex zonder #) primary=1e40af
bg Achtergrondkleur (hex zonder #) bg=f9fafb
font Lettertypefamilie uit toegestane lijst font=Georgia
ref Partnerreferentie voor analyses ref=observer

Toegestane lettertypen: system-ui, Georgia, Inter, Lato, Open Sans, Source Serif Pro

Embed: sessietracking & privacy

De embed gebruikt een first-party cookie om anonieme stemmen over paginaladen te dedupliceren. Wanneer de embed wordt geladen in een cross-domain iframe (normaal geval voor partnerwebsites), kunnen browsers met strikte privacy-instellingen deze cookie als third-party cookie blokkeren.

Discussies die via je partner-API zijn aangemaakt, hebben partner-scope: de embed-pagina controleert de bovenliggende site-origin (met Origin of Referer indien nodig) tegen je geverifieerde domeinen voor de overeenkomstige test-/live-omgeving. Inheemse openbare discussies zijn niet aan die framing-beperking onderhevig.

Geen actie vereist. De embed schakelt automatisch over op een op localStorage gebaseerde sessie-ID (embed_fingerprint) wanneer cookies niet beschikbaar zijn. Stemdeduplicering werkt in beide modi.

Hoe het werkt:

  • De embed genereert een willekeurige deelnemers-ID en slaat deze op in localStorage onder de embedorigin.
  • Deze ID wordt met elke stem verzonden als embed_fingerprint voor deduplicering aan serverzijde.
  • Als de gebruiker sitegegevens wist of privé browsen gebruikt, begint een nieuwe sessie (hij/zij kan opnieuw stemmen).
  • Safari, Firefox en Brave worden volledig ondersteund via deze fallback.

PostMessage-events

De embed communiceert met het parent frame via postMessage.

societyspeaks:embed:loaded

Verzonden wanneer de embed klaar is met laden.

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

societyspeaks:embed:resize

Verzonden wanneer de hoogte van de embed-inhoud verandert. Gebruik dit om de iframe te vergroten/verkleinen.

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

Voorbeeld: iframe automatisch aanpassen

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

oEmbed-provider

Schakel automatische insluiting van Society Speaks discussies in platforms die oEmbed ondersteunen in.

GET /api/oembed

Queryparameters

url vereist URL van Society Speaks discussie
maxwidth optioneel Maximale insluitingsbreedte
maxheight optioneel Maximale insluitingshoogte
format optioneel Responsindeling (alleen "json" ondersteund)

Voorbeeldverzoek

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

Succesvolle respons (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-discovery: Discussiepagina's bevatten een <link rel="alternate" type="application/json+oembed"> tag voor automatische oEmbed-detectie door compatibele platforms.

Discussie aanmaken (geverifieerd)

Maak een discussie voor een specifiek artikel of voor elke redactionele context die je kunt aanduiden met een stabiele id. Geef article_url op wanneer je een openbare link hebt; gebruik external_id wanneer dat niet het geval is (of gebruik allebei voor traceerbaarheid). Vereist API-sleutel.

Dit eindpunt maakt altijd eerst de discussie aan. Bundel initiële statements in dezelfde request via seed_statements of geef een excerpt op zodat we seeds kunnen genereren; voeg statements daarna toe via de Partner Portal. Lees hoe discussie versus statements tot velden toewijzen →

POST /api/partner/discussions

Headers

X-API-Key vereist Uw partner API-sleutel
Content-Type vereist application/json

Verzoekbody

article_url optioneel Canonical artikel-URL (genormaliseerd indien opgegeven)
external_id optioneel Stabiele partner-gedefinieerde ID (vereist als article_url wordt weggelaten)
title vereist Discussietitel (max. 200 tekens)
excerpt voorwaardelijk Artikel-uittreksel voor door AI gegenereerde uitspraken
seed_statements voorwaardelijk Array van {content, position} uitspraken
source_name optioneel Uw publicatienaam voor naamsvermelding
embed_statement_submissions_enabled optioneel Inline inzendingen van lezers in embed voor deze discussie toestaan (standaard false)

Opmerkingen: Geef minstens één identifier op (article_url of external_id) en ofwel excerpt ofwel seed_statements.

Aanbeveling: houd embed_statement_submissions_enabled op false voor de meeste artikelpagina's, en schakel het alleen in voor discussies waar uw team inline inzendingen van de embed wil ontvangen.

Voorbeeldverzoek

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

Succesvol antwoord (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
}

Foutresponsen

401 invalid_api_key - Ontbrekende of ongeldige API-sleutel
400 missing_identifier of missing_content - (article_url of external_id) en excerpt/seed_statements nodig
409 discussion_exists - Discussie bestaat al voor deze URL

Discussiebeleid bijwerken (geverifieerd)

Gebruik dit om discussies te sluiten/heropenen, integriteitmodus in/uit te schakelen en inline embed-inzendingen te beheren.

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

Aanbevolen standaard voor de meeste artikelpagina's: "embed_statement_submissions_enabled": false. Schakel true in voor geselecteerde discussies met veel betrokkenheid (bijvoorbeeld live blogs of campagnecentra).

Webhook-callbacks (geverifieerd)

Configureer ondertekende levenscyclus-callbacks om polling te vermijden. Gebruik server-naar-serveraanroepen met een portal-uitgegeven sleutel.

GET /api/partner/webhooks - webhook-eindpunten weergeven

POST /api/partner/webhooks - eindpunt maken en ondertekeningsgeheim eenmaal ontvangen

PATCH /api/partner/webhooks/{endpoint_id} - pauzeren/hervatten en gebeurtenisabonnementen bijwerken

POST /api/partner/webhooks/{endpoint_id}/rotate-secret - ondertekeningsgeheim roteren

DELETE /api/partner/webhooks/{endpoint_id} - eindpunt verwijderen

Aanvraagheaders

Elke webhook-bezorging bevat deze headers:

Header Beschrijving
X-SocietySpeaks-EventGebeurtenistype, bijvoorbeeld discussion.created
X-SocietySpeaks-Event-IdUnieke gebeurtenis-UUID — gebruiken voor deduplicatie
X-SocietySpeaks-TimestampUnix-seconden (UTC) wanneer de gebeurtenis werd verzonden
X-SocietySpeaks-SignatureHMAC-SHA256-ondertekening — altijd verifiëren

Webhook-ondertekeningen altijd verifiëren

Zonder verificatie kan elke partij die uw eindpunt-URL kent vervalste gebeurtenissen verzenden. Lees de ruwe body vóór JSON-parsing, verifieer vervolgens de HMAC-SHA256-ondertekening met uw ondertekeningsgeheim. Lehnen Sie Anfragen mit Zeitstempeln ab, die älter als 5 Minuten sind, um Replay-Angriffe zu verhindern.

Ondertekeningsformaat

De ondertekende payload is {timestamp}.{raw_body} (Unix-seconden, een letterlijke punt, gevolgd door de ruwe UTF-8-requestbody). De X-SocietySpeaks-Signature-header is sha256=<hex_digest>.

Verificatievoorbeeld

# 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

Handmatige verificatie (zonder de SDK)

Als u onze SDK niet gebruikt, volgt u deze stappen exact:

  1. Lees de ruwe requestbody als bytes — vóór eventuele JSON-parsing.
  2. Parse X-SocietySpeaks-Timestamp als een geheel getal. Indien afwezig of meer dan 300 seconden in het verleden, afwijzen met HTTP 400.
  3. Bouw de ondertekende payload: {timestamp} + . + ruwe body-bytes.
  4. Bereken HMAC-SHA256(key=signing_secret, msg=signed_payload) en hex-codeer dit.
  5. Voeg sha256= toe om de verwachte ondertekeningsstring te vormen.
  6. Vergelijk met X-SocietySpeaks-Signature met behulp van een constant-time-vergelijking — hmac.compare_digest in Python, crypto.timingSafeEqual in Node. Een eenvoudige string-gelijkheidscontrole lekt timinginformatie.

Geheimbeheersing

Het handtekeningsgeheim wordt eenmaal weergegeven wanneer u een eindpunt maakt — bewaar het veilig in een omgevingsvariabele of secrets manager, nooit in broncode. Het wordt nooit opnieuw in het portaal weergegeven. Gebruik POST /api/partner/webhooks/{endpoint_id}/rotate-secret om een nieuw geheim uit te geven; accepteer tijdens de overgangsperiode kort zowel de oude als de nieuwe handtekeningen voordat u de oude buiten bedrijf stelt.

Analytics-export (Geverifieerd)

Exporteer gebruiksgebeurtenissen in JSON of CSV voor BI-pijplijnen.

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"

Gebruik format=csv voor directe bestandsingestie, of gepagineerde format=json voor API-consumers.

Kosten- en misbruikbeveiliging

Wie betaalt wat: Lookup, snapshot, oEmbed en de inbedpagina gebruiken onze LLM niet. Ze zijn beperkt per IP (en optionele ref) om misbruik te voorkomen. Create Discussion is het enige eindpunt dat AI-gegenereerde starttaaluitingen kan activeren (OpenAI/Anthropic); het gebruikt onze API-sleutels, niet de jouwe.

Partners gebruiken sleutels die wij uitgeven. Alleen verzoeken met een geldige X-API-Key (van onze allowlist) kunnen discussies maken. We gebruiken geen door partners verstrekte LLM-sleutels voor de creatieve stroom. Als een sleutel wordt misbruikt, trekken we deze in.

Create Discussion is beperkt tot 30 verzoeken per uur per API-sleutel. Gecombineerd met sleutelcontrole beperkt dit de LLM- en databasekosten van het creatieve eindpunt.

Tarieflimieten

Eindpunt Limiet
Opzoeken op artikelen-URL 60 req / min / IP
Snapshot ophalen 120 req / min / IP
Stemmen indienen (standaard) 30 req / min / IP
Stemmen indienen (integriteitmodus) 10 req / min / discussie
Uitspraak aanmaken (geverifieerd) 10 per uur
Uitspraak aanmaken (anoniem) 5 per uur
Uitspraak markeren (van inbedding) 10 req / min / IP
Inlinestatement uit embed (partner-scoped discussies) Standaard 25 / lezer / uur + IP-plafond; zie env
Discussie aanmaken 30 req / uur / sleutel

Reacties bevatten een 429 Too Many Requests met een Retry-After-header die aangeeft hoeveel seconden tot het volgende verzoek is toegestaan.

Integriteitmodus: Sommige discussies hebben strengere stemratebeperkingen ingeschakeld ter bescherming van de integriteit. De inbedding verwerkt dit transparant — gebruikers zien een bericht “alstublieft langzamer”.

Inline statements uit embed: Flask-Limiter gebruikt EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR en EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP (zie config.py). Per-gebruikerscaps gelden ook voordat statements worden opgeslagen.

Foutindeling

Alle fouten retourneren JSON met een consistent formaat.

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

Vragen? Neem contact met ons op of bezoek de Partner Hub.