合作伙伴 API 参考
以编程方式集成 Society Speaks。使用为新闻编辑室工作流和开发者效率构建的 JSON API 来驱动您的嵌入、快照和编辑洞见。
如果您是新用户:发布者为每个投票主题启动一个讨论——通常与一个文章 URL 对应,但您也可以为没有永久链接的中心或工具改用您自己的 external_id。陈述由您起草或从您提供的文案起草;除非您的集成要求,否则不会从您的主页推断任何内容。
正式定义及 API 字段映射:讨论与陈述。
快速入门
curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article"
然后使用返回的 embed_url 进行嵌入。
面向开发者
可预测的端点、清晰的错误代码和复制粘贴示例。
面向编辑领导
您可以选择每个问题、它如何映射到您的故事或身份,以及读者提交的内容是否出现在嵌入中。
面向治理
速率限制、属性要求和明确的真实来源。
测试 API
- 合作伙伴门户: 创建门户 获取您的测试 API 密钥和 DNS 验证令牌。
- 互动游乐场: 开放 API 游乐场 试用浏览器安全端点(查找、快照、oEmbed)来自 Swagger UI。
- 写入端点(
POST/PATCH /api/partner/...)仅支持服务器到服务器通信,应从您的后端或 curl/Postman 进行测试,不要从浏览器 Swagger 进行测试。 - 从命令行:使用下面每个部分中的
curl示例,根据需要替换基础 URL 和参数。
新闻编辑室工作流快速地图:external_id 用于 CMS 映射,webhook 回调用于系统同步,Partner Portal 角色用于访问控制。
身份验证与环境
在演练阶段使用测试密钥;一旦账单激活后切换到生产密钥。密钥在合作伙伴门户中创建。
sspk_test_...
sspk_live_... (激活后计费)
托管 iframe 的每个主机名必须在 DNS 验证后出现在域名下 — 演练环境(测试)和生产环境(生产)需要分别注册。典型的新闻网站会同时注册 www. 和短域名,以应对读者使用两种方式访问。
读者始终通过常规加密网络流量与 Society Speaks 互动;你的托管团队应确保我们的公共网络地址(我们这边的 BASE_URL)与浏览器预期一致。
仅服务器到服务器用于写入操作
创建讨论和合作伙伴管理路由必须从你的服务器调用,不能从客户端 JavaScript 调用(带有 Origin 标头的请求将被拒绝,返回 403)。只读端点(查询、快照、oEmbed)可从浏览器调用,无需 API 密钥。对于来自 web 应用的已验证请求,请通过你的后端代理,以保持 API 密钥保密。
需要 HTTPS
所有 API 请求必须通过 HTTPS 进行,并在生产环境中在你的边缘/代理层强制执行。永远不要记录你的 API 密钥或将其包含在 URL 中——始终在 X-API-Key 标头中发送。
客户端库
v0.3.0
用于 Python 和 Node.js 的开箱即用助手客户端。每个都是单个自包含文件——无需构建步骤,无需除 requests(仅 Python)外的传递依赖。仅用于服务器端——永远不要在浏览器代码中暴露你的 API 密钥。
1. 添加到你的项目
下载文件并将其放在应用代码旁边。
下载 societyspeaks_partner.py# Place societyspeaks_partner.py anywhere in your project, then: from societyspeaks_partner import SocietyspeaksPartnerClient, PartnerApiError
2. 安装唯一的依赖项
pip install requests
3. 初始化并进行首次调用
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
▶ 查看完整源代码 ▼ 隐藏源代码 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},
)
基础 URL
https://societyspeaks.io/api
讨论与陈述
我们在合作伙伴门户、API 响应和支持材料中保持这些术语的一致性 — 快速浏览一遍,其余文档就会保持可预测。
- 讨论
- 为一个读者面向的投票体验提供的整体框架:标题、其在你网站上的位置(
article_url和/或external_id)、合作伙伴环境(测试/生产)、单个嵌入/共识 URL,以及其下的综合参与情况。通过 API 创建讨论会首先建立这个容器。 - 陈述
- 该伞形框架内的单个提示——每个都会收集自己的赞同/不赞同/不确定计数。在创建期间使用
seed_statements或excerpt来提供 AI 种子;之后在门户网站中添加/编辑;当为该讨论启用匿名读者提交时,可选择允许他们提议更多内容。
常见工作流
通常的模式是「每个已发布的 URL 对应一个讨论」,但这是一种选择,不是必需的。您可以为文章页面创建讨论、附加 CMS ID 而不需要公开文章,或混合两者使报告在您的整个技术栈中保持一致。
无论使用什么标识符,请记住这种分割:查找/创建返回 discussion_id;语句是单独建模的,并通过嵌入、门户网站语句工具和快照负载呈现。
程序化嵌入(推荐)
- 1) 按文章 URL 查找讨论
- 2) 如果不存在,创建讨论外壳(可选在同一调用中播种语句)
- 3) 存储
discussion_id并使用embed_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":"..."}'
简单的启动检查清单
编辑和制作人员可以浏览下面的部分;将工程说明与IT部门分享。
供编辑和产品团队使用
- 词汇速查表:一个讨论=一个投票主题及其嵌入式包装器;下面有多个观点——读者用同意/不同意/不确定来回答的观点。 详细说明 →
- 您可以决定每个投票主题的名称和读者首先看到的内容——文章关联是可选的;直播博客、资讯中心和特殊项目可以使用稳定ID而不是故事URL。
- 每个可能加载代码片段的网址都必须在 Partner Portal 中获得批准——测试网站和正式网站需要分别审批。
- 如果你的读者同时输入
www.和不带 www 的域名,需要注册两个批准。 - 请工程团队添加短追踪标签(
?ref=…),以便报告能够保持可追踪性。 标签含义 → - 可选:要求为框自动调整高度,以便长文章不会被截断(你这边只需要一个小脚本)。 工作原理 →
- 与其手动检查,不如设置自动「有变化」的提醒信号发送到你的系统。 Webhooks 概览 →
面向工程和托管
- 仅将测试 API 密钥与演练域名匹配;仅将实时密钥与已验证的生产域名匹配。
- 排练(
test)嵌入在生产环境中始终需要浏览器可解析的父页面 — 即使没有PARTNER_EMBED_REQUIRE_PARENT_ORIGIN。 - 可选的强化环境变量:
PARTNER_EMBED_REQUIRE_PARENT_ORIGIN=true(仅生产环境和实时合作伙伴嵌入)在无法从Origin或Referer解析父页面时阻止 iframe HTML — 更严格,可能影响不常见的浏览器或隐私设置。 - 来自嵌入的内联读者投稿遵守配置中的
EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR和EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP(以及按人员限制)。
按文章 URL 查询
通过文章的 URL 查找讨论。查询返回讨论元数据(discussion_id、embed_url 等);读者投票的声明随该讨论的嵌入和快照负载一起到达。
GET
/api/discussions/by-article-url
查询参数
| url | 必需 | 文章 URL(URL 编码) |
| ref | 可选 | 用于分析的合作伙伴参考 |
示例请求
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;
成功响应 (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"
}
错误响应
400
missing_url - 需要 URL 参数
400
invalid_url - URL 无法解析
403
partner_disabled - 已为此合作伙伴参考撤销嵌入和 API 访问权限
404
no_discussion - 此 URL 没有讨论
429
rate_limited - 请求过多;稍后重试
获取讨论快照
获取参与计数和元数据。不包括分析内容。
GET
/api/discussions/{discussion_id}/snapshot
路径参数
| discussion_id | 必需 | 讨论 ID |
示例请求
curl "https://societyspeaks.io/api/discussions/123/snapshot"
成功响应 (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"
}
分析未就绪时
{
"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"
}
错误响应
403
partner_disabled - 已为此合作伙伴参考撤销嵌入和 API 访问权限
403
forbidden - 测试讨论需要有效的测试 API 密钥
404
discussion_not_found - 讨论不存在
429
rate_limited - 请求过多;稍后重试
嵌入 URL 参数
使用 URL 参数自定义嵌入外观。
嵌入适用于 Society Speaks 原生讨论。
https://societyspeaks.io/discussions/{discussion_id}/embed
| 参数 | 描述 | 例子 |
|---|---|---|
| theme | 预设主题:default、dark、editorial、minimal、bold、muted |
theme=editorial |
| primary | 主色(十六进制,不含 #) | primary=1e40af |
| bg | 背景颜色(十六进制,不含 #) | bg=f9fafb |
| font | 允许列表中的字体系列 | font=Georgia |
| ref | 用于分析的合作伙伴参考 | ref=observer |
允许的字体:system-ui、Georgia、Inter、Lato、Open Sans、Source Serif Pro
嵌入:会话追踪与隐私
嵌入使用第一方 Cookie 在页面加载期间消除重复的匿名投票。当嵌入在跨域 iframe(通常用于合作网站)中加载时,隐私设置严格的浏览器可能会将此 Cookie 视为第三方 Cookie 而将其阻止。
通过合作伙伴 API 创建的讨论是合作伙伴范围内的:嵌入页面检查父网站源(在需要时使用 Origin 或 Referer)与匹配测试/生产环境的已验证域 — 本地公开讨论不受该框架限制。
无需操作。当 Cookie 不可用时,嵌入会自动回退到基于 localStorage 的会话标识符(embed_fingerprint)。投票消除重复功能在两种模式下都可正常工作。
工作原理:
- 嵌入会生成一个随机参与者 ID 并将其存储在嵌入源的
localStorage中。 - 此 ID 随每次投票以
embed_fingerprint的形式发送,用于服务器端消除重复。 - 如果用户清除网站数据或使用隐私浏览,新会话将启动(他们可以再次投票)。
- Safari、Firefox 和 Brave 完全支持此回退方式。
PostMessage 事件
嵌入通过 postMessage 与父框架通信。
societyspeaks:embed:loaded
嵌入完成加载时发送。
{
"type": "societyspeaks:embed:loaded",
"discussionId": 123,
"statementCount": 5
}
societyspeaks:embed:resize
嵌入内容高度更改时发送。用于调整 iframe 大小。
{
"type": "societyspeaks:embed:resize",
"discussionId": 123,
"height": 450
}
示例:自动调整 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 提供商
启用在支持 oEmbed 的平台中自动嵌入 Society Speaks 讨论。
GET
/api/oembed
查询参数
| url | 必需 | Society Speaks 讨论 URL |
| maxwidth | 可选 | 最大嵌入宽度 |
| maxheight | 可选 | 最大嵌入高度 |
| format | 可选 | 响应格式(仅支持 "json") |
示例请求
curl "https://societyspeaks.io/api/oembed?url=https://societyspeaks.io/discussions/123/my-discussion"
成功响应 (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
}
自动发现:讨论页面包含 <link rel="alternate" type="application/json+oembed"> 标签,供兼容平台自动发现 oEmbed。
创建讨论(需身份验证)
为特定故事创建讨论,或为任何具有稳定 ID 的编辑内容创建讨论。当您有公开链接时,提供 article_url;当您没有时使用 external_id(或同时使用两者以便追溯)。需要 API 密钥。
此端点始终先创建讨论。通过 seed_statements 在同一请求中捆绑初始 statements,或提供 excerpt 以便我们生成种子陈述;否则之后从合作伙伴门户添加陈述。 了解讨论与陈述如何映射到字段 →
POST
/api/partner/discussions
请求头
| X-API-Key | 必需 | 您的合作伙伴 API 密钥 |
| Content-Type | 必需 | application/json |
请求正文
| article_url | 可选 | 规范文章 URL(如提供则标准化) |
| external_id | 可选 | 稳定的合作伙伴定义 ID(如省略 article_url 则必需) |
| title | 必需 | 讨论标题(最多 200 个字符) |
| excerpt | 条件 | 用于 AI 生成声明的文章摘录 |
| seed_statements | 条件 | 声明数组 {content, position} |
| source_name | 可选 | 您的出版物名称,用于署名 |
| embed_statement_submissions_enabled | 可选 | 允许在此讨论的嵌入中进行内联读者投稿(默认 false) |
注意:至少提供一个标识符(article_url 或 external_id),以及 excerpt 或 seed_statements。
建议:在大多数文章页面上保持 embed_statement_submissions_enabled 为 false,仅在团队希望从嵌入中获取内联声明的讨论中启用它。
示例请求
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;
成功响应 (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
}
错误响应
401
invalid_api_key - 缺少或无效的 API 密钥
400
missing_identifier 或 missing_content - 需要 (article_url 或 external_id) 和 excerpt/seed_statements
409
discussion_exists - 该 URL 的讨论已存在
更新讨论策略(需身份验证)
使用此功能关闭/重新开启讨论、切换完整性模式以及控制内嵌提交。
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
}'
大多数文章页面的推荐默认设置:"embed_statement_submissions_enabled": false。对于高参与度讨论(例如,实时博客或活动中心),启用 true。
Webhook 回调(需身份验证)
配置签名的生命周期回调以避免轮询。使用带有门户签发密钥的服务器到服务器调用。
GET /api/partner/webhooks - 列出 webhook 端点
POST /api/partner/webhooks - 创建端点并接收签名密钥(仅显示一次)
PATCH /api/partner/webhooks/{endpoint_id} - 暂停/恢复并更新事件订阅
POST /api/partner/webhooks/{endpoint_id}/rotate-secret - 轮换签名密钥
DELETE /api/partner/webhooks/{endpoint_id} - 删除端点
请求标头
每个 webhook 投递都包含以下标头:
| 标头 | 描述 |
|---|---|
| X-SocietySpeaks-Event | 事件类型,例如 discussion.created |
| X-SocietySpeaks-Event-Id | 唯一事件 UUID — 用于去重 |
| X-SocietySpeaks-Timestamp | 发送事件时的 Unix 秒数(UTC) |
| X-SocietySpeaks-Signature | HMAC-SHA256 签名 — 始终验证此项 |
始终验证 webhook 签名
如果不进行验证,任何知道你的端点 URL 的人都可以发送伪造事件。在解析 JSON 之前读取原始体,然后使用你的签名密钥验证 HMAC-SHA256 签名。拒绝时间戳超过 5 分钟的请求,以防止重放攻击。
签名格式
签名的有效负载是 {timestamp}.{raw_body}(Unix 秒数、一个字面点号,然后是原始 UTF-8 请求体)。X-SocietySpeaks-Signature 标头是 sha256=<hex_digest>。
验证示例
# 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);
}
);
手动验证(不使用 SDK)
如果你未使用我们的 SDK,请完全按照以下步骤操作:
- 将原始请求体作为字节读取 — 在进行任何 JSON 解析之前。
- 将
X-SocietySpeaks-Timestamp解析为整数。如果不存在或距现在超过 300 秒,拒绝并返回 HTTP 400。 - 构建签名的有效负载:
{timestamp}+.+ 原始体字节。 - 计算
HMAC-SHA256(key=signing_secret, msg=signed_payload)并进行十六进制编码。 - 在前面附加
sha256=以形成预期签名字符串。 - 使用 常时间 比较与
X-SocietySpeaks-Signature进行比较 — Python 中的hmac.compare_digest、Node 中的crypto.timingSafeEqual。普通字符串相等检查会泄露时间信息。
密钥管理
签名密钥在你创建端点时返回一次 — 将其安全地存储在环境变量或密钥管理器中,不要存储在源代码中。在门户中永不再次显示。使用 POST /api/partner/webhooks/{endpoint_id}/rotate-secret 签发新密钥;在停用旧密钥之前,在过渡期内短暂接受旧签名和新签名。
分析导出(需身份验证)
以 JSON 或 CSV 格式导出使用事件用于 BI 管道。
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"
使用 format=csv 进行直接文件导入,或对 API 消费者使用分页 format=json。
成本和滥用保护
谁为什么付费:查找、快照、oEmbed 和嵌入页面不使用我们的 LLM。它们按 IP(和可选的 ref)进行速率限制,以防止滥用。创建讨论是唯一可以触发 AI 生成的种子语句(OpenAI/Anthropic)的端点;它使用 我们的 API 密钥,而不是你的。
合作伙伴使用我们签发的密钥。只有带有有效 X-API-Key(来自我们的允许列表)的请求才能创建讨论。我们不会在创建流程中使用合作伙伴提供的 LLM 密钥。如果密钥被滥用,我们会撤销它。
创建讨论限制为每个 API 密钥每小时 30 个请求。结合密钥控制,这可以限制来自创建端点的 LLM 和数据库成本。
速率限制
| 端点 | 限制 |
|---|---|
| 按文章 URL 查询 | 60 请求/分钟/IP |
| 获取快照 | 120 请求/分钟/IP |
| 投票提交(默认) | 30 请求/分钟/IP |
| 投票提交(完整性模式) | 10 个请求 / 分钟 / 讨论 |
| 观点创建(认证用户) | 10 个 / 小时 |
| 观点创建(匿名) | 每小时5个 |
| 标记陈述(来自嵌入) | 10个请求/分钟/IP |
| 来自嵌入的内联陈述(合作伙伴范围讨论) | 默认 25 / 读者 / 小时 + IP 上限;见 env |
| 创建讨论 | 30个请求/小时/密钥 |
响应包含 429 Too Many Requests 和 Retry-After 标头,指示距离下一个请求被允许还有多少秒。
完整性模式:某些讨论启用了更严格的投票速率限制以保护完整性。嵌入会透明地处理这一点 — 用户会看到「请放慢速度」的消息。
嵌入内联语句:Flask-Limiter 使用 EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOR 和 EMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP(见 config.py)。按用户限额也会在语句存储前生效。
错误格式
所有错误都以一致的格式返回 JSON。
{
"error": "error_code",
"message": "Human-readable description of the error"
}