Zum Hauptinhalt springen

Partner-API-Referenz

Integriere Society Speaks programmatisch. Befähige deine Embeds, Snapshots und Editorial Insights mit JSON APIs, die für Newsroom-Workflows und Developer Velocity konzipiert sind.

Falls Sie neu hier sind: Herausgeber starten eine Diskussion für jedes Abstimmungsthema — meist angepasst an eine Artikel-URL, aber Sie können stattdessen auch nur Ihre eigene external_id für Hubs oder Tools ohne Permalink verwenden. Statements können Sie selbst verfassen oder aus Text, den Sie bereitstellen, entwarf werden; nichts wird von Ihrer Startseite abgeleitet, es sei denn, Ihre Integration fragt danach. Formale Definitionen und API-Feldzuordnung: Diskussion vs. Statements.

Schnelleinstieg

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

Dann mit der zurückgegebenen embed_url einbetten.

Für Entwickler

Vorhersehbare Endpoints, klare Fehlercodes und Copy/Paste-Beispiele.

Für Editorial Leader

Sie wählen jede Frage, wie sie sich auf Ihre Geschichten oder IDs abbildet, und ob von Lesern eingereichte Zeilen im Embed angezeigt werden.

Für Governance

Rate Limits, Attributionsanforderungen und eine klare Quelle der Wahrheit.

API testen

  • Partner Portal: Erstelle ein Portal Partner Portal:
  • Interaktiver Spielplatz: Open API Playground Interaktiver Spielplatz:
  • Write Endpoints (POST/PATCH /api/partner/...) sind nur Server-zu-Server und sollten von deinem Backend oder curl/Postman aus getestet werden, nicht vom Browser Swagger.
  • Von der Befehlszeile: nutze die curl Beispiele in jedem Abschnitt unten und ersetze die Basis-URL und Parameter nach Bedarf.

Newsroom-Workflow Übersicht: external_id für CMS-Mapping, Webhook-Callbacks für Systemsynchronisation und Partner Portal Rollen für Zugriffskontrolle.

Authentifizierung & Umgebungen

Verwenden Sie Test-Schlüssel, während Sie auf Staging durchspielen; wechseln Sie zu Live-Schlüsseln, sobald die Abrechnung aktiv ist. Schlüssel werden im Partner Portal erstellt.

Test-Schlüssel: sspk_test_...
Live-Schlüssel: sspk_live_... (aktiviert nach Abrechnung)

Jeder Hostname, auf dem Sie den iframe hosten, muss unter Domains nach DNS-Verifizierung angezeigt werden — separat für Rehearsal (test) und Production (live). Typische News-Seiten registrieren sowohl www. als auch die Short-Domain, wenn Leser eine von beiden verwenden.

Leser interagieren immer mit Society Speaks über normalen verschlüsselten Web-Traffic; Ihr Hosting-Team sollte sicherstellen, dass unsere öffentliche Web-Adresse (BASE_URL auf unserer Seite) damit übereinstimmt, was Browser erwarten.

Server-zu-Server nur für Schreibvorgänge

Create Discussion und Partner-Management-Routes müssen von Ihrem Server aus aufgerufen werden, nicht von Client-seitigem JavaScript (Anfragen mit einem Origin-Header werden mit 403 abgelehnt). Read-only-Endpunkte (Lookup, Snapshot, oEmbed) funktionieren von Browsern aus ohne API-Schlüssel. Für authentifizierte Anfragen aus einer Web-App proxieren Sie über Ihr Backend, damit API-Schlüssel geheim bleiben.

HTTPS erforderlich

Alle API-Anfragen müssen über HTTPS erfolgen und auf Ihrer Edge/Proxy-Schicht in der Produktion erzwungen werden. Protokollieren Sie niemals Ihren API-Schlüssel und fügen Sie ihn nicht in URLs ein – senden Sie ihn immer im X-API-Key-Header.

Client-Bibliotheken

v0.3.0

Drop-in-Hilfsclient für Python und Node.js. Jeder ist eine einzelne eigenständige Datei – kein Build-Schritt, keine transitiven Abhängigkeiten außer requests (nur Python). Nur Server-seitige Nutzung – exponieren Sie Ihren API-Schlüssel niemals in Browser-Code.

1. Zu Ihrem Projekt hinzufügen

Laden Sie die Datei herunter und platzieren Sie sie neben Ihrem Anwendungscode.

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

2. Installieren Sie die eine Abhängigkeit

pip install requests

3. Initialisieren & tätigen Sie Ihren ersten Aufruf

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
▶ Vollständigen Quelltext anzeigen 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

Diskussion vs. Statements

Wir verwenden diese Begriffe konsequent im Partner Portal, in API-Antworten und in Support-Materialien — schauen Sie sich dies einmal an und der Rest der Dokumentation bleibt vorhersehbar.

Diskussion
Der Dachbegriff für eine leserseitig sichtbare Abstimmungserfahrung: Überschriftentitel, wie er sich auf Ihre Website bezieht (article_url und/oder external_id), Partner-Umgebung (test/live), eine Embed-/Consensus-URL und die kombinierte Beteiligung darunter. Das Erstellen einer Diskussion über die API etabliert zunächst diesen Container.
Aussagen
Einzelne Aufforderungen unter diesem Dach — jede sammelt ihre eigenen Zustimmungs-/Ablehnung-/Unsicher-Zählungen. Füllen Sie sie bei der Erstellung mit seed_statements oder einem excerpt auf, der AI-Seeds liefert; fügen Sie sie danach im Portal hinzu/bearbeiten Sie sie; ermöglichen Sie optional anonymen Lesern, mehr vorzuschlagen, wenn Embed-Beiträge für diese Diskussion aktiviert sind.

Gängige Workflows

Das übliche Muster ist »eine Diskussion pro veröffentlichter URL«, aber das ist eine Wahl, keine Anforderung. Sie können Diskussionen für Artikelseiten erstellen, eine CMS-ID ohne öffentlichen Artikel anhängen oder beide kombinieren, damit die Berichterstellung über Ihren Stack konsistent bleibt.

Unabhängig von den Bezeichnern gilt der Unterschied: Die Nachschlagefunktion/Erstellung gibt discussion_id zurück; Statements sind separat modelliert und werden durch das Embed, die Portal-Statement-Tools und Snapshot-Payloads sichtbar.

Programmatisches Embed (empfohlen)

  1. 1) Diskussion nach Artikel-URL nachschlagen
  2. 2) Falls keine vorhanden ist, erstellen Sie die Diskussions-Shell (Seed-Statements optional im gleichen Aufruf)
  3. 3) Speichern Sie discussion_id und verwenden Sie embed_url; überprüfen Sie Statements im Portal, falls Sie Anpassungen benötigen
# 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":"..."}'

Manuelles Embed (Schnelleinstieg)

Nutzen Sie den Embed-Generator, um Einmal-iframes zu erstellen. Er verlässt sich auf die gleichen APIs und gibt die gleichen discussion_id und embed_url zurück.

Einfache Startcheckliste

Redakteure und Produzenten können den Abschnitt unten überfliegen; teilen Sie die Engineering-Notizen mit der IT.

Für redaktionelle und Produktteams

  • Vokabelübersicht: eine Diskussion = ein Abstimmungsthema plus sein Embed-Wrapper; mehrere Aussagen darunter — die Zeilen, die Leser mit zustimmen / ablehnen / unsicher beantworten. Ausführlichere Erklärung →
  • Du entscheidest, wie jedes Abstimmungsthema heißt und was Leser zuerst sehen — die Artikel-Verknüpfung ist optional; Live-Blogs, Hubs und Spezialrojekte können stattdessen eine stabile ID verwenden.
  • Jede Webadresse, auf der der Snippet geladen wird, muss in deinem Partner Portal genehmigt werden — Test-Seiten und Live-Seiten sind getrennt.
  • Wenn deine Leser sowohl www. als auch nackte Domain-Namen eingeben, registriere zwei Genehmigungen.
  • Bitte das Engineering-Team, das Kurz-Tracking-Tag (?ref=…) hinzuzufügen, damit Berichte zuordenbar bleiben. Was die Tags bedeuten →
  • Optional: Frag nach automatischer Höhenberechnung für die Box, damit lange Artikel nicht abgeschnitten werden (ein kleines Script auf deiner Seite). So funktioniert's →
  • Bevorzuge automatische „etwas hat sich geändert"-Meldungen an deine Systeme gegenüber manueller Überprüfung. Webhooks-Übersicht →

Für Engineering und Hosting

  • Nutze Test-API-Keys nur mit Test-Domains; Live-Keys nur mit verifizierten Produktions-Domains.
  • Test-(test-)Embeds in der Produktion brauchen immer eine auflösbare Parent-Seite vom Browser — auch ohne PARTNER_EMBED_REQUIRE_PARENT_ORIGIN.
  • Optional für erweiterte Sicherheit: PARTNER_EMBED_REQUIRE_PARENT_ORIGIN=true (nur Produktion, Live-Partner-Embeds) blockiert iframe-HTML, wenn wir keine Parent-Seite von Origin oder Referer auflösen können — strenger, kann sich auf seltene Browser oder Datenschutzeinstellungen auswirken.
  • Inline-Leserbeiträge aus dem Embed beachten EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR und EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP in der Konfiguration (plus Pro-Nutzer-Limits).

Nach Artikel-URL suchen

Finde eine Diskussion anhand der URL deines Artikels. Die Abfrage gibt Diskussions-Metadaten zurück (discussion_id, embed_url usw.); die Aussagen, über die Leser abstimmen, kommen mit dem Embed und den Snapshot-Payloads dieser Diskussion.

GET /api/discussions/by-article-url

Abfrageparameter

url erforderlich Die Artikel-URL (URL-kodiert)
ref optional Partner-Referenz für Analytics

Beispielanfrage

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

Erfolgreiche Antwort (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"
}

Fehlerantworten

400 missing_url – URL-Parameter erforderlich
400 invalid_url – URL konnte nicht geparst werden
403 partner_disabled – Embed- und API-Zugriff für diese Partner-Ref widerrufen
404 no_discussion – Keine Diskussion für diese URL
429 rate_limited – Zu viele Anfragen; versuchen Sie es später erneut

Diskussions-Snapshot abrufen

Rufen Sie Beteiligungszähler und Metadaten ab. Enthält keine Analyseinhalte.

GET /api/discussions/{discussion_id}/snapshot

Pfad-Parameter

discussion_id erforderlich Die Diskussions-ID

Beispielanfrage

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

Erfolgreiche Antwort (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"
}

Wenn die Analyse nicht bereit ist

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

Fehlerantworten

403 partner_disabled – Embed- und API-Zugriff für diese Partner-Ref widerrufen
403 forbidden - Gültiger Test-API-Schlüssel erforderlich für Test-Diskussionen
404 discussion_not_found - Diskussion existiert nicht
429 rate_limited – Zu viele Anfragen; versuchen Sie es später erneut

Embed URL-Parameter

Passen Sie das Embed-Erscheinungsbild mit URL-Parametern an.

Embeds sind für native Society Speaks-Diskussionen verfügbar.

https://societyspeaks.io/discussions/{discussion_id}/embed
Parameter Beschreibung Beispiel
theme Voreingestelltes Theme: default, dark, editorial, minimal, bold, muted theme=editorial
primary Primärfarbe (Hex ohne #) primary=1e40af
bg Hintergrundfarbe (Hex ohne #) bg=f9fafb
font Schriftfamilie aus der Zulassungsliste font=Georgia
ref Partner-Referenz für Analytics ref=observer

Zulässige Schriftarten: system-ui, Georgia, Inter, Lato, Open Sans, Source Serif Pro

Embed: Session-Tracking & Datenschutz

Das Embed verwendet ein First-Party-Cookie, um anonyme Stimmen über Seitenladungen hinweg zu deduplizieren. Wenn das Embed in einem Cross-Domain-iframe geladen wird (der normale Fall für Partner-Websites), können Browser mit strikten Datenschutzeinstellungen dieses Cookie als Third-Party-Cookie blockieren.

Diskussionen, die über deine Partner-API erstellt werden, sind Partner-bezogen: die Embed-Seite prüft die Parent-Site-Herkunft (mit Origin oder Referer wenn nötig) gegen deine verifizierten Domains für die passende Test-/Live-Umgebung. Native öffentliche Diskussionen unterliegen dieser Framing-Gate nicht.

Keine Maßnahme erforderlich. Das Embed fällt automatisch auf einen localStorage-basierten Session-Identifier (embed_fingerprint) zurück, wenn Cookies nicht verfügbar sind. Die Stimmen-Deduplizierung funktioniert in beiden Modi.

So funktioniert es:

  • Das Embed generiert eine zufällige Teilnehmer-ID und speichert sie in localStorage unter dem Embed-Ursprung.
  • Diese ID wird mit jedem Stimmzettel als embed_fingerprint für serverseitige Deduplizierung gesendet.
  • Wenn der Benutzer Sitedaten löscht oder privates Browsing nutzt, startet eine neue Session (er kann erneut abstimmen).
  • Safari, Firefox und Brave werden vollständig über diesen Fallback unterstützt.

PostMessage Events

Das Embed kommuniziert mit dem übergeordneten Frame über postMessage.

societyspeaks:embed:loaded

Gesendet, wenn das Embed das Laden beendet.

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

societyspeaks:embed:resize

Gesendet, wenn sich die Embed-Inhaltshöhe ändert. Verwenden Sie dies zum Ändern der Iframe-Größe.

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

Beispiel: Automatische Iframe-Größenänderung

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

Ermöglichen Sie das automatische Einbetten von Society Speaks-Diskussionen auf Plattformen, die oEmbed unterstützen.

GET /api/oembed

Abfrageparameter

url erforderlich Society Speaks-Diskussions-URL
maxwidth optional Maximale Embed-Breite
maxheight optional Maximale Embed-Höhe
format optional Antwortformat (nur "json" unterstützt)

Beispielanfrage

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

Erfolgreiche Antwort (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
}

Automatische Erkennung: Diskussionsseiten enthalten ein <link rel="alternate" type="application/json+oembed">-Tag zur automatischen oEmbed-Erkennung durch kompatible Plattformen.

Diskussion erstellen (authentifiziert)

Erstelle eine Diskussion für eine bestimmte Story oder für jeden redaktionellen Kontext, den du mit einer stabilen ID benennen kannst. Gib article_url an, wenn du einen öffentlichen Link hast; nutze external_id, wenn nicht (oder nutze beides zur Nachverfolgung). Erfordert API-Key.

Dieser Endpoint erstellt die Diskussion immer zuerst. Bündel initial Aussagen in derselben Anfrage über seed_statements oder gib einen excerpt an, damit wir Seeds generieren können; sonst füge Aussagen später im Partner Portal hinzu. Lese, wie Diskussionen vs. Aussagen zu Feldern passen →

POST /api/partner/discussions

Header

X-API-Key erforderlich Ihr Partner-API-Schlüssel
Content-Type erforderlich application/json

Request-Body

article_url optional Kanonische Artikel-URL (normalisiert falls vorhanden)
external_id optional Stabile, vom Partner definierte ID (erforderlich, wenn article_url weggelassen wird)
title erforderlich Diskussionstitel (max. 200 Zeichen)
excerpt bedingt Artikelexzerpt für KI-generierte Aussagen
seed_statements bedingt Array von {content, position} Aussagen
source_name optional Ihr Publikationsname zur Quellenangabe
embed_statement_submissions_enabled optional Inline-Lesereingaben im Embed für diese Diskussion zulassen (Standard: false)

Hinweise: Geben Sie mindestens einen Identifier (article_url oder external_id) und entweder excerpt oder seed_statements an.

Empfehlung: Halten Sie embed_statement_submissions_enabled für die meisten Artikelseiten auf false und aktivieren Sie es nur für Diskussionen, bei denen Ihr Team Inline-Eingaben aus dem Embed möchte.

Beispielanfrage

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

Erfolgreiche Antwort (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
}

Fehlerantworten

401 invalid_api_key – API-Schlüssel fehlt oder ist ungültig
400 missing_identifier oder missing_content – (article_url oder external_id) und excerpt/seed_statements erforderlich
409 discussion_exists – Diskussion für diese URL existiert bereits

Diskussionsrichtlinie aktualisieren (Authentifiziert)

Verwenden Sie dies, um Diskussionen zu schließen/wieder zu öffnen, den Integritätsmodus umzuschalten und Inline-Embed-Eingaben zu steuern.

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

Empfohlener Standard für die meisten Artikelseiten: "embed_statement_submissions_enabled": false. Aktivieren Sie true für ausgewählte Diskussionen mit hohem Engagement (z. B. Live-Blogs oder Kampagnenzentren).

Webhook-Callbacks (Authentifiziert)

Konfigurieren Sie signierte Lifecycle-Callbacks, um Polling zu vermeiden. Verwenden Sie Server-zu-Server-Aufrufe mit einem vom Portal ausgegebenen Schlüssel.

GET /api/partner/webhooks – Webhook-Endpunkte auflisten

POST /api/partner/webhooks – Endpunkt erstellen und Signing Secret einmalig erhalten

PATCH /api/partner/webhooks/{endpoint_id} – Pause/Fortsetzen und Ereignisabonnements aktualisieren

POST /api/partner/webhooks/{endpoint_id}/rotate-secret – Signing Secret wechseln

DELETE /api/partner/webhooks/{endpoint_id} – Endpunkt löschen

Request-Header

Jede Webhook-Lieferung enthält diese Header:

Header Beschreibung
X-SocietySpeaks-EventEreignistyp, z. B. discussion.created
X-SocietySpeaks-Event-IdEindeutige Ereignis-UUID – verwenden Sie diese zur Deduplizierung
X-SocietySpeaks-TimestampUnix-Sekunden (UTC), wenn das Ereignis gesendet wurde
X-SocietySpeaks-SignatureHMAC-SHA256-Signatur – immer überprüfen

Webhook-Signaturen immer überprüfen

Ohne Überprüfung kann jede Partei, die Ihre Endpunkt-URL kennt, gefälschte Ereignisse senden. Lesen Sie den rohen Text vor dem JSON-Parsing und überprüfen Sie dann die HMAC-SHA256-Signatur mit Ihrem Signing Secret. Lehnen Sie Anfragen mit Zeitstempeln älter als 5 Minuten ab, um Replay-Attacken zu verhindern.

Signaturformat

Die signierte Payload ist {timestamp}.{raw_body} (Unix-Sekunden, ein Punkt, dann der rohe UTF-8-Request-Body). Der X-SocietySpeaks-Signature Header ist sha256=<hex_digest>.

Überprüfungsbeispiel

# 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

Manuelle Überprüfung (ohne SDK)

Wenn Sie unser SDK nicht verwenden, folgen Sie diesen Schritten genau:

  1. Lesen Sie den rohen Request-Body als Bytes – vor jeglichem JSON-Parsing.
  2. Parsen Sie X-SocietySpeaks-Timestamp als Ganzzahl. Falls er fehlt oder älter als 300 Sekunden ist, lehnen Sie mit HTTP 400 ab.
  3. Erstellen Sie die signierte Payload: {timestamp} + . + rohe Body-Bytes.
  4. Berechnen Sie HMAC-SHA256(key=signing_secret, msg=signed_payload) und kodieren Sie es hexadezimal.
  5. Fügen Sie sha256= am Anfang ein, um die erwartete Signaturzeichenkette zu bilden.
  6. Vergleichen Sie mit X-SocietySpeaks-Signature mit einer zeitkonstanten Vergleichsmethode – hmac.compare_digest in Python, crypto.timingSafeEqual in Node. Ein einfacher Stringvergleich offenbart Timing-Informationen.

Geheimnisverwaltung

Das Signing-Geheimnis wird einmalig bei der Erstellung eines Endpunkts zurückgegeben — speichern Sie es sicher in einer Umgebungsvariablen oder einem Secrets Manager, niemals im Quellcode. Es wird im Portal nie wieder angezeigt. Verwenden Sie POST /api/partner/webhooks/{endpoint_id}/rotate-secret, um ein neues Geheimnis auszustellen; akzeptieren Sie während der Übergabefrist kurzzeitig sowohl die alte als auch die neue Signatur, bevor Sie die alte außer Betrieb nehmen.

Analytics-Export (Authentifiziert)

Exportieren Sie Nutzungsereignisse in JSON oder CSV für BI-Pipelines.

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"

Verwenden Sie format=csv für direkten Dateiimport oder paginierten format=json für API-Consumer.

Kosten- und Missbrauchsschutz

Wer bezahlt was: Lookup, Snapshot, oEmbed und die Embed-Seite verwenden unser LLM nicht. Sie sind pro IP (und optional ref) begrenzt, um Missbrauch zu verhindern. Diskussion erstellen ist der einzige Endpunkt, der KI-generierte Seed-Statements auslösen kann (OpenAI/Anthropic); er verwendet unsere API-Schlüssel, nicht Ihre.

Partner verwenden von uns ausgegebene Schlüssel. Nur Anfragen mit einem gültigen X-API-Key (aus unserer Zulassungsliste) können Diskussionen erstellen. Wir verwenden von Partnern bereitgestellte LLM-Schlüssel nicht für den Create-Flow. Wenn ein Schlüssel missbraucht wird, widerrufen wir ihn.

Diskussion erstellen ist auf 30 Anfragen pro Stunde pro API-Schlüssel begrenzt. In Kombination mit der Schlüsselkontrolle begrenzt dies die LLM- und Datenbankkosten vom Create-Endpunkt.

Rate Limits

Endpunkt Limit
Nach Artikel-URL suchen 60 Anfragen / Minute / IP
Snapshot abrufen 120 Anfragen / Minute / IP
Abstimmungsübermittlung (Standard) 30 Anfragen / Minute / IP
Abstimmungsübermittlung (Integritätsmodus) 10 Anfragen / Minute / Diskussion
Statement-Erstellung (authentifiziert) 10 pro Stunde
Stellungnahme erstellen (anonym) 5 pro Stunde
Stellungnahme kennzeichnen (aus Einbettung) 10 Anfragen / Minute / IP
Inline-Aussage aus Embed (Partner-bezogene Diskussionen) Standard 25 / Leser / Stunde + IP-Obergrenze; siehe env
Diskussion erstellen 30 req / hr / key

Responses include a 429 Too Many Requests with a Retry-After header indicating seconds until the next request is allowed.

Integrity mode: Some discussions have stricter vote rate limits enabled for integrity protection. The embed handles this transparently — users see a “please slow down” message.

Inline-Aussagen einbetten: Flask-Limiter nutzt EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR und EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP (siehe config.py). Pro-Nutzer-Limits gelten auch, bevor Aussagen gespeichert werden.

Fehlerformat

All errors return JSON with a consistent format.

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

Fragen? Kontaktieren Sie uns oder besuchen Sie Partner Hub.