跳过至主要内容

合作伙伴 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_statementsexcerpt 来提供 AI 种子;之后在门户网站中添加/编辑;当为该讨论启用匿名读者提交时,可选择允许他们提议更多内容。

常见工作流

通常的模式是「每个已发布的 URL 对应一个讨论」,但这是一种选择,不是必需的。您可以为文章页面创建讨论、附加 CMS ID 而不需要公开文章,或混合两者使报告在您的整个技术栈中保持一致。

无论使用什么标识符,请记住这种分割:查找/创建返回 discussion_id;语句是单独建模的,并通过嵌入、门户网站语句工具和快照负载呈现。

程序化嵌入(推荐)

  1. 1) 按文章 URL 查找讨论
  2. 2) 如果不存在,创建讨论外壳(可选在同一调用中播种语句)
  3. 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":"..."}'

手动嵌入(快速开始)

使用嵌入生成器创建一次性 iframe。它使用相同的 API 并返回相同的 discussion_idembed_url

简单的启动检查清单

编辑和制作人员可以浏览下面的部分;将工程说明与IT部门分享。

供编辑和产品团队使用

  • 词汇速查表:一个讨论=一个投票主题及其嵌入式包装器;下面有多个观点——读者用同意/不同意/不确定来回答的观点。 详细说明 →
  • 您可以决定每个投票主题的名称和读者首先看到的内容——文章关联是可选的;直播博客、资讯中心和特殊项目可以使用稳定ID而不是故事URL。
  • 每个可能加载代码片段的网址都必须在 Partner Portal 中获得批准——测试网站和正式网站需要分别审批。
  • 如果你的读者同时输入 www. 和不带 www 的域名,需要注册两个批准。
  • 请工程团队添加短追踪标签(?ref=…),以便报告能够保持可追踪性。 标签含义 →
  • 可选:要求为框自动调整高度,以便长文章不会被截断(你这边只需要一个小脚本)。 工作原理 →
  • 与其手动检查,不如设置自动「有变化」的提醒信号发送到你的系统。 Webhooks 概览 →

面向工程和托管

  • 仅将测试 API 密钥与演练域名匹配;仅将实时密钥与已验证的生产域名匹配。
  • 排练(test)嵌入在生产环境中始终需要浏览器可解析的父页面 — 即使没有 PARTNER_EMBED_REQUIRE_PARENT_ORIGIN
  • 可选的强化环境变量:PARTNER_EMBED_REQUIRE_PARENT_ORIGIN=true(仅生产环境和实时合作伙伴嵌入)在无法从 OriginReferer 解析父页面时阻止 iframe HTML — 更严格,可能影响不常见的浏览器或隐私设置。
  • 来自嵌入的内联读者投稿遵守配置中的 EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOREMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP(以及按人员限制)。

按文章 URL 查询

通过文章的 URL 查找讨论。查询返回讨论元数据(discussion_idembed_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"

成功响应 (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 预设主题:defaultdarkeditorialminimalboldmuted 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 创建的讨论是合作伙伴范围内的:嵌入页面检查父网站源(在需要时使用 OriginReferer)与匹配测试/生产环境的已验证域 — 本地公开讨论不受该框架限制。

无需操作。当 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_urlexternal_id),以及 excerptseed_statements

建议:在大多数文章页面上保持 embed_statement_submissions_enabledfalse,仅在团队希望从嵌入中获取内联声明的讨论中启用它。

示例请求

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"
  }'

成功响应 (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_identifiermissing_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-SignatureHMAC-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

手动验证(不使用 SDK)

如果你未使用我们的 SDK,请完全按照以下步骤操作:

  1. 将原始请求体作为字节读取 — 在进行任何 JSON 解析之前。
  2. X-SocietySpeaks-Timestamp 解析为整数。如果不存在或距现在超过 300 秒,拒绝并返回 HTTP 400。
  3. 构建签名的有效负载:{timestamp} + . + 原始体字节。
  4. 计算 HMAC-SHA256(key=signing_secret, msg=signed_payload) 并进行十六进制编码。
  5. 在前面附加 sha256= 以形成预期签名字符串。
  6. 使用 常时间 比较与 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 RequestsRetry-After 标头,指示距离下一个请求被允许还有多少秒。

完整性模式:某些讨论启用了更严格的投票速率限制以保护完整性。嵌入会透明地处理这一点 — 用户会看到「请放慢速度」的消息。

嵌入内联语句:Flask-Limiter 使用 EMBED_STATEMENT_SUBMIT_RATE_LIMIT_ACTOREMBED_STATEMENT_SUBMIT_RATE_LIMIT_IP(见 config.py)。按用户限额也会在语句存储前生效。

错误格式

所有错误都以一致的格式返回 JSON。

{
  "error": "error_code",
  "message": "Human-readable description of the error"
}

有疑问? 联系我们 或访问 合作伙伴中心.