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
curlBeispiele 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.
sspk_test_...
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 ▼ Quelltext ausblenden 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_urlund/oderexternal_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_statementsoder einemexcerptauf, 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) Diskussion nach Artikel-URL nachschlagen
- 2) Falls keine vorhanden ist, erstellen Sie die Diskussions-Shell (Seed-Statements optional im gleichen Aufruf)
- 3) Speichern Sie
discussion_idund verwenden Sieembed_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 ohnePARTNER_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 vonOriginoderRefererauflösen können — strenger, kann sich auf seltene Browser oder Datenschutzeinstellungen auswirken. - Inline-Leserbeiträge aus dem Embed beachten
EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTORundEMBED_STATEMENT_SUBMIT_RATE_LIMIT_IPin 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"
import requests
resp = requests.get(
"https://societyspeaks.io/api/discussions/by-article-url",
params={"url": "https://example.com/article"}
)
data = resp.json()
embed_url = data["embed_url"]
const resp = await fetch(
`https://societyspeaks.io/api/discussions/by-article-url?` +
new URLSearchParams({ url: "https://example.com/article" })
);
const data = await resp.json();
const embedUrl = data.embed_url;
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
localStorageunter dem Embed-Ursprung. - Diese ID wird mit jedem Stimmzettel als
embed_fingerprintfü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"
}'
import requests
resp = requests.post(
"https://societyspeaks.io/api/partner/discussions",
headers={
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
},
json={
"article_url": "https://example.com/article/urban-cars",
"title": "Should cities ban cars from downtown areas?",
"excerpt": "A new study shows that car-free zones improve air quality...",
"source_name": "Example News",
},
)
data = resp.json()
discussion_id = data["discussion_id"]
embed_url = data["embed_url"]
const resp = await fetch("https://societyspeaks.io/api/partner/discussions", {
method: "POST",
headers: {
"X-API-Key": "your_api_key",
"Content-Type": "application/json",
},
body: JSON.stringify({
article_url: "https://example.com/article/urban-cars",
title: "Should cities ban cars from downtown areas?",
excerpt: "A new study shows that car-free zones improve air quality...",
source_name: "Example News",
}),
});
const data = await resp.json();
const { discussion_id, embed_url } = data;
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-Event | Ereignistyp, z. B. discussion.created |
| X-SocietySpeaks-Event-Id | Eindeutige Ereignis-UUID – verwenden Sie diese zur Deduplizierung |
| X-SocietySpeaks-Timestamp | Unix-Sekunden (UTC), wenn das Ereignis gesendet wurde |
| X-SocietySpeaks-Signature | HMAC-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
// Express example — adapt raw-body reading to your framework
const express = require("express");
const { SocietyspeaksPartnerClient } = require("./societyspeaks_partner");
const app = express();
const WEBHOOK_SECRET = process.env.SS_WEBHOOK_SECRET; // store in env, never hardcode
// Use express.raw() so the body stays as a Buffer before any parsing
app.post(
"/webhooks/societyspeaks",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body; // Buffer — do NOT call JSON.parse before verifying
let valid;
try {
valid = SocietyspeaksPartnerClient.verifyWebhookSignature(
rawBody,
req.headers["x-societyspeaks-signature"] ?? "",
req.headers["x-societyspeaks-timestamp"] ?? "",
WEBHOOK_SECRET,
300, // reject events older than 5 minutes
);
} catch (err) {
// Timestamp missing, malformed, or outside the tolerance window
return res.status(400).send(err.message);
}
if (!valid) return res.status(401).send("Invalid webhook signature.");
const event = JSON.parse(rawBody);
const eventType = req.headers["x-societyspeaks-event"];
const eventId = req.headers["x-societyspeaks-event-id"];
// Deduplicate using eventId (store processed IDs in your DB or cache)
if (alreadyProcessed(eventId)) return res.sendStatus(200);
if (eventType === "discussion.created") {
handleDiscussionCreated(event.data);
}
res.sendStatus(200);
}
);
Manuelle Überprüfung (ohne SDK)
Wenn Sie unser SDK nicht verwenden, folgen Sie diesen Schritten genau:
- Lesen Sie den rohen Request-Body als Bytes – vor jeglichem JSON-Parsing.
- Parsen Sie
X-SocietySpeaks-Timestampals Ganzzahl. Falls er fehlt oder älter als 300 Sekunden ist, lehnen Sie mit HTTP 400 ab. - Erstellen Sie die signierte Payload:
{timestamp}+.+ rohe Body-Bytes. - Berechnen Sie
HMAC-SHA256(key=signing_secret, msg=signed_payload)und kodieren Sie es hexadezimal. - Fügen Sie
sha256=am Anfang ein, um die erwartete Signaturzeichenkette zu bilden. - Vergleichen Sie mit
X-SocietySpeaks-Signaturemit einer zeitkonstanten Vergleichsmethode –hmac.compare_digestin Python,crypto.timingSafeEqualin 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.