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 lookup, snapshot, and oEmbed from your browser (Swagger UI). Requests run against this site.
- Create Discussion requires an API key; add it in the playground using the Authorize button (X-API-Key).
- From the command line: use the
curlexamples in each section below, replacing the base URL and parameters as needed.
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
The X-API-Key header is intentionally excluded from browser CORS preflight.
Create Discussion 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.
If you need authenticated lookups from the browser, proxy through your own backend.
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 an article not ingested via RSS. 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 | required | The article URL (will be normalized) |
| 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 |
Note: Either excerpt or seed_statements must be provided.
Example Request
curl -X POST "https://societyspeaks.io/api/partner/discussions" \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-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?",
"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_content - Need excerpt or seed_statements
409
discussion_exists - Discussion already exists for URL
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.