メインコンテンツに移動

Partner API Reference

Society Speaks をプログラムによって統合します。編集局ワークフローと開発者速度向けに構築された JSON API で、埋め込み、スナップショット、および編集上の洞察を強化します。

ここが初めての方へ:パブリッシャーは投票トピックごとに討論を開始します — ほとんどの場合 1 つの記事 URL に合わせたものですが、代わりにパーマリンクのないハブやツール用に独自の external_id のみを使用できます。ステートメントはドラフト作成するか、提供されたコピーから作成できます。ホームページから推測されるものはありません(統合がそれを求める場合を除く)。 正式な定義と API フィールドマッピング:討論 vs ステートメント

クイックスタート

curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article"

その後、返された embed_url で埋め込みます。

開発者向け

予測可能なエンドポイント、明確なエラーコード、およびコピー & ペースト例。

編集リーダー向け

各質問、それがストーリーまたは ID にどう関連するか、および reader-submitted 行がエンベッドに表示されるかどうかを選択します。

ガバナンス向け

レート制限、帰属要件、および明確な信頼性の源。

API のテスト

  • パートナーポータル: ポータルを作成 Partner Portal: テスト API キーと DNS 検証トークンを取得するには
  • インタラクティブな遊び場: Open API Playground インタラクティブプレイグラウンド:
  • Write エンドポイントPOST/PATCH /api/partner/...)はサーバー間のみであり、ブラウザ Swagger からではなく、バックエンド または curl/Postman からテストする必要があります。
  • コマンドラインから: 以下の各セクションの curl の例を使用し、ベースURLとパラメータを必要に応じて置き換えてください。

ニュースルームワークフロークイックマップ: CMS マッピング用の external_id、システム同期用のウェブフック コールバック、アクセス制御用のパートナー ポータルロール。

認証 & 環境

ステージングでリハーサルしながらテストキーを使用します。課金がアクティブになったらライブキーに切り替えます。キーはパートナーポータルで作成されます。

テストキー: sspk_test_...
ライブキー: sspk_live_... (請求後に有効化)

iframe をホストする各ホスト名は、DNS 検証後にドメイン下に表示される必要があります — リハーサル(test)と本番(live)で別々に。典型的なニュースサイトは、読者がどちらを使用するかによって、www. とショートドメインの両方を登録します。

読者は常に通常の暗号化ウェブトラフィックで Society Speaks と対話します。ホスティングチームは、公開ウェブアドレス(当社側の BASE_URL)をブラウザが期待するものと一致させておく必要があります。

書き込み操作の場合はサーバー間のみ

ディスカッション作成とパートナー管理ルートはクライアント側JavaScriptではなく、サーバーから呼び出す必要があります(Origin ヘッダー付きのリクエストは 403 で拒否されます)。読み取り専用エンドポイント(検索、スナップショット、oEmbed)はAPIキーなしでブラウザから機能します。ウェブアプリから認証されたリクエストの場合は、バックエンド経由でプロキシして、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. 1つの依存関係をインストール

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

討論 vs ステートメント

これらの言葉を パートナーポータル、API レスポンス、サポート資料で一貫して使用しています — このセクションをざっと読めば、残りのドキュメントは予測可能なままです。

ディスカッション
1 つの reader-facing 投票体験の傘:見出しタイトル、サイトへのアンカー方法(article_url または external_id)、パートナー環境(test/live)、1 つのエンベッド/コンセンサス URL、その下の統合参加。API 経由で討論を作成することで、まずこのコンテナを確立します。
主張
その傘内の個々のプロンプト — それぞれが独自の同意 / 反対 / 不確定のカウントを収集します。作成中に seed_statements または AI シードを助ける excerpt で入力します。その後ポータルで追加/編集します。エンベッド送信がその討論に対してオンになっている場合、オプションで匿名読者にさらに提案させます。

一般的なワークフロー

通常のパターンは「公開 URL ごとに 1 つの討論」ですが、これは選択肢であり、要件ではありません。記事ページの討論を作成し、公開記事なしで CMS ID を添付したり、両方を混ぜてスタック全体でレポートを一貫させることができます。

識別子に関係なく、分割を覚えておきます:lookup/create は 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部門と共有してください。

編集・プロダクトチーム向け

  • 用語集:1つのディスカッション = 1つの投票トピックとそのエンベッドラッパー。その下に複数のステートメントがある — これは読者が同意・反対・不確定で答える行です。 詳しい説明 →
  • 各投票トピックの名前と読者が最初に見るものをあなたが決定します。記事との関連付けはオプションです。ライブブログ、ハブ、特別プロジェクトは記事URLの代わりに安定したIDを使用できます。
  • スニペットがロードされるすべてのウェブアドレスは、パートナーポータルで承認される必要があります。リハーサルサイトとライブサイトは別扱いです。
  • 読者がwww.と通常のドメイン名の両方を入力する場合は、2つの承認を登録してください。
  • エンジニアに短いトラッキングタグ(?ref=…)を追加するよう依頼して、レポートが属性化されたままになるようにしてください。 タグの意味 →
  • オプション:長い記事がクリップされないように、ボックスの自動高さを要求してください(あなた側で1つの小さなスクリプト)。 仕組み →
  • 手動チェックではなく、自動「変更あり」ピングをあなたのシステムに送ることを推奨します。 Webhooks概要 →

エンジニア・ホスティング向け

  • テストAPIキーはリハーサルドメインのみと照合してください。ライブキーは検証済みのプロダクションドメインのみと照合してください。
  • プロダクション環境でのリハーサル(test)エンベッドは、PARTNER_EMBED_REQUIRE_PARENT_ORIGINがなくても、常にブラウザから解決可能な親ページが必要です。
  • オプションのセキュリティ強化環境:PARTNER_EMBED_REQUIRE_PARENT_ORIGIN=true(プロダクション、ライブパートナーエンベッドのみ)は、OriginまたはRefererから親ページを解決できない場合、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 プリセットテーマ: default, dark, editorial, minimal, bold, muted theme=editorial
primary プライマリカラー(#なし16進数) primary=1e40af
bg 背景色(#なし16進数) bg=f9fafb
font 許可リストからのフォントファミリー font=Georgia
ref 分析用パートナーリファレンス ref=observer

許可されたフォント: system-ui, Georgia, Inter, Lato, Open Sans, Source Serif Pro

埋め込み: セッションの追跡とプライバシー

埋め込みは、ページ読み込み時に匿名投票を重複排除するファーストパーティクッキーを使用します。埋め込みがクロスドメインiframe(パートナーサイトの通常の場合)で読み込まれる場合、厳密なプライバシー設定を持つブラウザはこのクッキーをサードパーティクッキーとしてブロックする可能性があります。

パートナーAPIを通じて作成されたディスカッションはパートナースコープです。エンベッドページは、検証済みドメイン(マッチング実行環境のtest/live)に対して親サイトのオリジン(Originまたは必要に応じてRefererを使用)をチェックします。ネイティブの公開ディスカッションはそのフレーミングゲートの対象ではありません。

対応は不要です。クッキーが利用できない場合、埋め込みは自動的に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
}

自動検出:ディスカッションページには、互換性のあるプラットフォームによる自動oEmbed検出用の<link rel="alternate" type="application/json+oembed">タグが含まれています。

ディスカッション作成(認証済み)

特定のストーリー用、またはスタッブルIDで名前を付けられる編集上のコンテキスト用にディスカッションを作成してください。公開リンクがある場合はarticle_urlを指定してください。ない場合はexternal_idを使用してください(またはトレーサビリティのために両方を使用してください)。APIキーが必要です。

このエンドポイントは常にディスカッションを最初に作成します。初期ステートメントを同じリクエストでseed_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)

注: 少なくとも1つの識別子(article_urlまたはexternal_id)を提供し、excerptまたはseed_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_identifier または missing_content - (article_url または external_id) と excerpt/seed_statements が必要です
409 discussion_exists - このURLの議論は既に存在します

議論ポリシーを更新(認証が必要)

議論をクローズ/再開したり、整合性モードを切り替えたり、インラインembed送信を管理するために使用します。

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 - エンドポイントを作成し、署名秘密鍵を1回受け取る

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) を計算し、16進エンコードします。
  5. 期待される署名文字列を作成するため sha256= を先頭に追加します。
  6. X-SocietySpeaks-Signature定時間比較を使用して比較してください — Python では hmac.compare_digest、Node では crypto.timingSafeEqual。通常の文字列等値チェックはタイミング情報を漏らします。

秘密鍵管理

署名秘密鍵はエンドポイント作成時に1回返されます — 環境変数またはシークレット管理ツールに安全に保存し、ソースコードには決して置かないでください。ポータルでは再表示されません。POST /api/partner/webhooks/{endpoint_id}/rotate-secret を使用して新しい秘密鍵を発行します。ロールオーバー期間中は古い署名と新しい署名の両方を一時的に受け入れ、その後で古いものを廃止してください。

分析エクスポート(認証が必要)

BIパイプライン用にJSONまたはCSV形式で使用イベントをエクスポートします。

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 を使用します。

コストと不正利用防止

誰が何を支払うか: Lookup、snapshot、oEmbed、およびembedページはLLMを使用しません。これらは不正利用を防ぐため、IPおよびオプションの ref ごとにレート制限されます。議論作成 は、AI生成されたシードステートメント(OpenAI/Anthropic)をトリガーできる唯一のエンドポイントです。これはあなたのAPIキーではなく、私たちのAPIキーを使用します。

パートナーは発行されたキーを使用します。 有効な X-API-Key(私たちのホワイトリストから)を持つリクエストのみが議論を作成できます。私たちは作成フロー用にパートナー提供のLLMキーを使用しません。キーが不正利用された場合、私たちはそれを取り消します。

Create DiscussionAPI キーあたり 1 時間に 30 リクエスト に制限されています。キー制御と組み合わせることで、create エンドポイントからの LLM およびデータベースコストを抑制します。

レート制限

エンドポイント 制限
記事 URL で検索 60 リクエスト / 分 / IP
スナップショット取得 120 リクエスト / 分 / IP
投票送信 (デフォルト) 30 リクエスト / 分 / IP
投票送信 (整合性モード) 10 リクエスト / 分 / ディスカッション
ステートメント作成 (認証済み) 1 時間に 10 件
ステートメント作成 (匿名) 1 時間に 5 件
ステートメントをフラグ (埋め込みから) 10 リクエスト / 分 / IP
エンベッドからのインラインステートメント(パートナースコープディスカッション) デフォルト25 / 読者 / 時間 + IP上限。詳しくは環境を参照
ディスカッション作成 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"
}

質問がありますか? お問い合わせ または Partner Hub.