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.
sspk_test_...
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 ▼ Bron verbergen 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_urlen/ofexternal_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_statementsof eenexcerptdat 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) Zoek discussie op artikel-URL
- 2) Maak zo nodig de discussieschaal aan (openingsstatementen optioneel in dezelfde aanroep)
- 3) Sla
discussion_idop en gebruikembed_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 zonderPARTNER_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 viaOriginofReferer— strenger en kan uncommon browsers of privacy-instellingen beïnvloeden. - Inlinestatements van lezers in de embed respecteren
EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTORenEMBED_STATEMENT_SUBMIT_RATE_LIMIT_IPin 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"
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;
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
localStorageonder de embedorigin. - Deze ID wordt met elke stem verzonden als
embed_fingerprintvoor 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"
}'
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;
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-Event | Gebeurtenistype, bijvoorbeeld discussion.created |
| X-SocietySpeaks-Event-Id | Unieke gebeurtenis-UUID — gebruiken voor deduplicatie |
| X-SocietySpeaks-Timestamp | Unix-seconden (UTC) wanneer de gebeurtenis werd verzonden |
| X-SocietySpeaks-Signature | HMAC-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
// 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);
}
);
Handmatige verificatie (zonder de SDK)
Als u onze SDK niet gebruikt, volgt u deze stappen exact:
- Lees de ruwe requestbody als bytes — vóór eventuele JSON-parsing.
- Parse
X-SocietySpeaks-Timestampals een geheel getal. Indien afwezig of meer dan 300 seconden in het verleden, afwijzen met HTTP 400. - Bouw de ondertekende payload:
{timestamp}+.+ ruwe body-bytes. - Bereken
HMAC-SHA256(key=signing_secret, msg=signed_payload)en hex-codeer dit. - Voeg
sha256=toe om de verwachte ondertekeningsstring te vormen. - Vergelijk met
X-SocietySpeaks-Signaturemet behulp van een constant-time-vergelijking —hmac.compare_digestin Python,crypto.timingSafeEqualin 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.