Partner API Reference
Integrate Society Speaks programmatically. Power your embeds, snapshots, and editorial insights with JSON APIs built for newsroom workflows and developer velocity.
Quick start
curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article"
Then embed with the returned embed_url.
For developers
Predictable endpoints, clear error codes, and copy/paste examples.
For editorial leaders
Reliable participation signals and a transparent, auditable methodology.
For governance
Rate limits, attribution requirements, and a clear source of truth.
Testing the API
- Partner Portal: Create a portal to get your test API key and DNS verification token.
- Interactive playground: Open API Playground to try browser-safe endpoints (lookup, snapshot, oEmbed) from Swagger UI.
- Write endpoints (
POST/PATCH /api/partner/...) are server-to-server only and should be tested from your backend or curl/Postman, not from browser Swagger. - From the command line: use the
curlexamples in each section below, replacing the base URL and parameters as needed.
Newsroom workflow quick map: external_id for CMS mapping, webhook callbacks for system sync, and Partner Portal roles for access control.
Authentication & Environments
Use test keys for staging and live keys for production. Both run on the same base URL and are isolated by key type. Keys are issued in the Partner Portal.
sspk_test_...
sspk_live_... (activated after billing)
Domains must be verified via DNS TXT to allow embeds.
Server-to-server only for write operations
Create Discussion and partner management routes must be called from your server, not from client-side JavaScript
(requests with an Origin header are rejected with 403).
Read-only endpoints (Lookup, Snapshot, oEmbed) work from browsers without an API key.
For authenticated requests from a web app, proxy through your backend so API keys stay secret.
HTTPS required
All API requests must be made over HTTPS and enforced at your edge/proxy layer in production.
Never log your API key or include it in URLs — always send it in the X-API-Key header.
Client Libraries
v0.3.0
Drop-in helper clients for Python and Node.js. Each is a single self-contained file — no build step,
no transitive dependencies beyond requests (Python only).
Server-side use only — never expose your API key in browser code.
1. Add to your project
Download the file and place it alongside your application code.
Download societyspeaks_partner.py# Place societyspeaks_partner.py anywhere in your project, then: from societyspeaks_partner import SocietyspeaksPartnerClient, PartnerApiError
2. Install the one dependency
pip install requests
3. Initialise & make your first call
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
▶ View full source ▼ Hide source 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},
)
Base URL
https://societyspeaks.io/api
Common Workflows
Programmatic embed (recommended)
- 1) Lookup discussion by article URL
- 2) If none exists, create discussion
- 3) Store
discussion_idand useembed_url
# 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":"..."}'
Manual embed (quick start)
Use the Embed Generator to create one-off iframes. It relies on the same
APIs and returns the same discussion_id and embed_url.
Lookup by Article URL
Find a discussion by the URL of your article.
GET
/api/discussions/by-article-url
Query Parameters
| url | required | The article URL (URL-encoded) |
| ref | optional | Partner reference for analytics |
Example Request
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;
Success Response (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"
}
Error Responses
400
missing_url - URL parameter required
400
invalid_url - URL could not be parsed
403
partner_disabled - Embed and API access revoked for this partner ref
404
no_discussion - No discussion for this URL
429
rate_limited - Too many requests; retry later
Get Discussion Snapshot
Get participation counts and metadata. Does not include analysis content.
GET
/api/discussions/{discussion_id}/snapshot
Path Parameters
| discussion_id | required | The discussion ID |
Example Request
curl "https://societyspeaks.io/api/discussions/123/snapshot"
Success Response (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"
}
When Analysis Not Ready
{
"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"
}
Error Responses
403
partner_disabled - Embed and API access revoked for this partner ref
403
forbidden - Valid test API key required for test discussions
404
discussion_not_found - Discussion does not exist
429
rate_limited - Too many requests; retry later
Embed URL Parameters
Customize the embed appearance with URL parameters.
Embeds are available for Society Speaks native discussions.
https://societyspeaks.io/discussions/{discussion_id}/embed
| Parameter | Description | Example |
|---|---|---|
| theme | Preset theme: default, dark, editorial, minimal, bold, muted |
theme=editorial |
| primary | Primary color (hex without #) | primary=1e40af |
| bg | Background color (hex without #) | bg=f9fafb |
| font | Font family from allowlist | font=Georgia |
| ref | Partner reference for analytics | ref=observer |
Allowed fonts: system-ui, Georgia, Inter, Lato, Open Sans, Source Serif Pro
Embed: Session Tracking & Privacy
The embed uses a first-party cookie to deduplicate anonymous votes across page loads. When the embed is loaded in a cross-domain iframe (the normal case for partner sites), browsers with strict privacy settings may block this cookie as a third-party cookie.
No action required. The embed automatically falls back to a
localStorage-based session identifier (embed_fingerprint)
when cookies are unavailable. Vote deduplication works in both modes.
How it works:
- The embed generates a random participant ID and stores it in
localStorageunder the embed origin. - This ID is sent with each vote as
embed_fingerprintfor server-side deduplication. - If the user clears site data or uses private browsing, a new session starts (they can vote again).
- Safari, Firefox, and Brave are fully supported via this fallback.
PostMessage Events
The embed communicates with the parent frame via postMessage.
societyspeaks:embed:loaded
Sent when the embed finishes loading.
{
"type": "societyspeaks:embed:loaded",
"discussionId": 123,
"statementCount": 5
}
societyspeaks:embed:resize
Sent when the embed content height changes. Use to resize iframe.
{
"type": "societyspeaks:embed:resize",
"discussionId": 123,
"height": 450
}
Example: Auto-resize iframe
window.addEventListener('message', (event) => {
if (event.data.type === 'societyspeaks:embed:resize') {
const iframe = document.querySelector('iframe[src*="societyspeaks"]');
if (iframe) iframe.style.height = event.data.height + 'px';
}
});
oEmbed Provider
Enable automatic embedding of Society Speaks discussions in platforms that support oEmbed.
GET
/api/oembed
Query Parameters
| url | required | Society Speaks discussion URL |
| maxwidth | optional | Maximum embed width |
| maxheight | optional | Maximum embed height |
| format | optional | Response format (only "json" supported) |
Example Request
curl "https://societyspeaks.io/api/oembed?url=https://societyspeaks.io/discussions/123/my-discussion"
Success Response (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: Discussion pages include a <link rel="alternate" type="application/json+oembed"> tag for automatic oEmbed discovery by compatible platforms.
Create Discussion (Authenticated)
Create a discussion for a partner article or an internal CMS item. Requires API key.
POST
/api/partner/discussions
Headers
| X-API-Key | required | Your partner API key |
| Content-Type | required | application/json |
Request Body
| article_url | optional | Canonical article URL (normalized if provided) |
| external_id | optional | Stable partner-defined ID (required if article_url is omitted) |
| title | required | Discussion title (max 200 chars) |
| excerpt | conditional | Article excerpt for AI-generated statements |
| seed_statements | conditional | Array of {content, position} statements |
| source_name | optional | Your publication name for attribution |
| embed_statement_submissions_enabled | optional | Allow inline reader submissions in embed for this discussion (default false) |
Notes: Provide at least one identifier (article_url or external_id) and either excerpt or seed_statements.
Recommendation: keep embed_statement_submissions_enabled as false for most article pages, and enable it only for discussions where your team wants inline statement intake from the embed.
Example Request
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;
Success Response (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
}
Error Responses
401
invalid_api_key - Missing or invalid API key
400
missing_identifier or missing_content - Need (article_url or external_id) and excerpt/seed_statements
409
discussion_exists - Discussion already exists for URL
Update discussion policy (Authenticated)
Use this to close/reopen discussions, toggle integrity mode, and control inline embed submissions.
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
}'
Recommended default for most article pages: "embed_statement_submissions_enabled": false.
Enable true for selected high-engagement discussions (for example, live blogs or campaign hubs).
Webhook callbacks (Authenticated)
Configure signed lifecycle callbacks to avoid polling. Use server-to-server calls with a portal-issued key.
GET /api/partner/webhooks - list webhook endpoints
POST /api/partner/webhooks - create endpoint and receive signing secret once
PATCH /api/partner/webhooks/{endpoint_id} - pause/resume and update event subscriptions
POST /api/partner/webhooks/{endpoint_id}/rotate-secret - rotate signing secret
DELETE /api/partner/webhooks/{endpoint_id} - delete endpoint
Request headers
Every webhook delivery includes these headers:
| Header | Description |
|---|---|
| X-SocietySpeaks-Event | Event type, e.g. discussion.created |
| X-SocietySpeaks-Event-Id | Unique event UUID — use for deduplication |
| X-SocietySpeaks-Timestamp | Unix seconds (UTC) when the event was sent |
| X-SocietySpeaks-Signature | HMAC-SHA256 signature — always verify this |
Always verify webhook signatures
Without verification, any party that knows your endpoint URL can send forged events. Read the raw body before parsing JSON, then verify the HMAC-SHA256 signature using your signing secret. Reject requests with timestamps older than 5 minutes to prevent replay attacks.
Signature format
The signed payload is {timestamp}.{raw_body}
(Unix seconds, a literal dot, then the raw UTF-8 request body). The
X-SocietySpeaks-Signature header is sha256=<hex_digest>.
Verification example
# 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);
}
);
Manual verification (without the SDK)
If you are not using our SDK, follow these steps exactly:
- Read the raw request body as bytes — before any JSON parsing.
- Parse
X-SocietySpeaks-Timestampas an integer. If it is absent or more than 300 seconds in the past, reject with HTTP 400. - Build the signed payload:
{timestamp}+.+ raw body bytes. - Compute
HMAC-SHA256(key=signing_secret, msg=signed_payload)and hex-encode it. - Prepend
sha256=to form the expected signature string. - Compare with
X-SocietySpeaks-Signatureusing a constant-time comparison —hmac.compare_digestin Python,crypto.timingSafeEqualin Node. A plain string equality check leaks timing information.
Secret management
The signing secret is returned once when you create an endpoint — store it securely in an environment variable or secrets manager, never in source code.
It is never shown again in the portal. Use POST /api/partner/webhooks/{endpoint_id}/rotate-secret
to issue a new secret; briefly accept both the old and new signatures during the rollover period before decommissioning the old one.
Analytics export (Authenticated)
Export usage events in JSON or CSV for 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"
Use format=csv for direct file ingestion, or paginated format=json for API consumers.
Cost and Abuse Protection
Who pays for what: Lookup, snapshot, oEmbed, and the embed page do not use our LLM. They are rate-limited per IP (and optional ref) to prevent abuse. Create Discussion is the only endpoint that can trigger AI-generated seed statements (OpenAI/Anthropic); it uses our API keys, not yours.
Partners use keys we issue. Only requests with a valid X-API-Key (from our allowlist) can create discussions. We do not use partner-provided LLM keys for the create flow. If a key is abused, we revoke it.
Create Discussion is limited to 30 requests per hour per API key. Combined with key control, this caps LLM and database cost from the create endpoint.
Rate Limits
| Endpoint | Limit |
|---|---|
| Lookup by article URL | 60 req / min / IP |
| Get snapshot | 120 req / min / IP |
| Vote submission (default) | 30 req / min / IP |
| Vote submission (integrity mode) | 10 req / min / discussion |
| Statement creation (authenticated) | 10 per hour |
| Statement creation (anonymous) | 5 per hour |
| Flag statement (from embed) | 10 req / min / IP |
| Create discussion | 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.
Error Format
All errors return JSON with a consistent format.
{
"error": "error_code",
"message": "Human-readable description of the error"
}
Questions? Contact us or visit the Partner Hub.