주요 콘텐츠로 건너뛰기

파트너 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로 임베드합니다.

개발자용

예측 가능한 엔드포인트, 명확한 에러 코드, 복사/붙여넣기 예제입니다.

편집 리더용

각 질문, 어떻게 스토리나 ID에 매핑되는지, 그리고 독자가 제출한 텍스트가 임베드에 나타나는지 여부를 선택합니다.

거버넌스용

속도 제한, 저작자 표시 요구 사항, 명확한 정보 출처입니다.

API 테스트하기

  • 파트너 포털: 포털 만들기 파트너 포털:
  • 대화형 놀이터: 오픈 API 플레이그라운드 대화형 플레이그라운드:
  • 쓰기 엔드포인트(POST/PATCH /api/partner/...)는 서버 간 전용이며 브라우저 Swagger가 아닌 백엔드, curl, Postman에서 테스트해야 합니다.
  • 명령줄에서: 아래 각 섹션의 curl 예제를 사용하여 기본 URL과 매개변수를 필요에 따라 변경하세요.

뉴스룸 워크플로 빠른 맵: CMS 매핑을 위한 external_id, 시스템 동기화를 위한 webhook 콜백, 접근 제어를 위한 파트너 포털 역할.

인증 및 환경

스테이징에서 리허설하는 동안 테스트 키를 사용하세요. 청구가 활성화되면 라이브 키로 전환하세요. 키는 파트너 포털에서 생성됩니다.

테스트 키: 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. 필수 라이브러리 설치

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), 파트너 환경(test/live), 임베드/합의 URL 하나, 그 아래의 결합된 참여. API를 통해 토론을 만들면 이 컨테이너가 먼저 설정됩니다.
진술
그 총괄 내의 개별 프롬프트 — 각각 자체의 동의/비동의/미정 카운트를 수집합니다. 생성 중에 seed_statements 또는 AI 시드를 연료로 하는 excerpt로 채우세요. 이후 포털에서 추가/편집하세요. 임베드 제출이 해당 토론에 대해 켜져 있으면 선택적으로 익명 독자가 더 많은 것을 제안하도록 허용합니다.

일반적인 워크플로

일반적인 패턴은 「게시된 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와 공유하세요.

편집 및 제품 팀용

  • 하나의 토론 = 하나의 투표 주제와 그 임베드 래퍼입니다. 그 아래에는 여러 진술문이 있으며, 이는 독자들이 동의/반대/불확실함으로 답변하는 문장들입니다. 더 자세한 설명 →
  • 각 투표 주제를 어떻게 부를지, 독자들이 먼저 무엇을 볼지는 당신이 결정합니다. 기사 연동은 선택사항이며, 라이브 블로그, 허브, 특별 프로젝트는 스토리 URL 대신 안정적인 ID를 사용할 수 있습니다.
  • 스니펫이 로드될 수 있는 모든 웹 주소는 파트너 포털에서 승인받아야 합니다. 리허설 사이트와 라이브 사이트는 별도입니다.
  • 독자들이 www.과 도메인 이름 둘 다 입력하면, 두 개의 승인을 등록해야 합니다.
  • 엔지니어링팀에 짧은 추적 태그(?ref=…)를 추가하도록 요청하여 보고서가 속성 추적 가능하게 유지되도록 하세요. 태그의 의미 →
  • 선택사항: 긴 기사가 잘리지 않도록 박스의 자동 높이를 요청하세요(당신 쪽의 작은 스크립트 하나). 작동 원리 →
  • 수동 확인보다는 자동 「뭔가 변경됨」 핑을 당신의 시스템으로 보내는 것을 선호하세요. 웹훅 개요 →

엔지니어링 및 호스팅용

  • 테스트 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_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"

성공 응답 (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(파트너 사이트의 일반적인 경우)에 로드될 때, 엄격한 개인정보보호 설정의 브라우저는 이 쿠키를 제3자 쿠키로 차단할 수 있습니다.

파트너 API를 통해 생성된 토론은 파트너 범위입니다. 임베드 페이지는 상위 사이트 origin(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)

참고: 최소 하나의 식별자(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에 대한 토론이 이미 존재합니다

토론 정책 업데이트 (인증 필수)

이를 사용하여 토론을 종료/재개하고, 무결성 모드를 전환하며, 인라인 임베드 제출을 제어합니다.

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로 활성화하세요.

웹훅 콜백 (인증 필수)

폴링을 피하기 위해 서명된 라이프사이클 콜백을 구성하세요. 포털에서 발급한 키를 사용하여 서버 간 호출을 수행합니다.

GET /api/partner/webhooks - 웹훅 엔드포인트 목록

POST /api/partner/webhooks - 엔드포인트 생성 및 서명 비밀키 한 번 수신

PATCH /api/partner/webhooks/{endpoint_id} - 일시 중지/재개 및 이벤트 구독 업데이트

POST /api/partner/webhooks/{endpoint_id}/rotate-secret - 서명 비밀키 회전

DELETE /api/partner/webhooks/{endpoint_id} - 엔드포인트 삭제

요청 헤더

모든 웹훅 전달에는 다음 헤더가 포함됩니다:

헤더 설명
X-SocietySpeaks-Event이벤트 유형(예: discussion.created)
X-SocietySpeaks-Event-Id고유 이벤트 UUID — 중복 제거에 사용
X-SocietySpeaks-Timestamp이벤트가 전송된 Unix 초 (UTC)
X-SocietySpeaks-SignatureHMAC-SHA256 서명 — 항상 확인하세요

항상 웹훅 서명 확인

서명을 확인하지 않으면 엔드포인트 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. 일반 문자열 동등성 검사는 타이밍 정보를 유출합니다.

비밀키 관리

서명 비밀키는 엔드포인트를 생성할 때 한 번 반환됩니다 — 환경 변수 또는 비밀키 관리자에 안전하게 저장하고, 소스 코드에는 절대 저장하지 마세요. 포털에 다시 표시되지 않습니다. 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을 사용하세요.

비용 및 악용 방지

누가 무엇을 지불하는가: 조회, 스냅샷, oEmbed 및 임베드 페이지는 우리의 LLM을 사용하지 않습니다. IP(및 선택적 ref)당 속도 제한되어 악용을 방지합니다. 토론 생성

파트너는 우리가 발급한 키를 사용합니다. 유효한 X-API-Key(우리의 허용 목록에서)가 있는 요청만 토론을 생성할 수 있습니다. 우리는 파트너 제공 LLM 키를 생성 흐름에 사용하지 않습니다. 키가 악용되면 우리는 이를 취소합니다.

Create DiscussionAPI 키당 시간당 30개 요청으로 제한됩니다. 키 관리와 함께, 이는 생성 엔드포인트의 LLM 및 데이터베이스 비용을 제한합니다.

요청 제한

엔드포인트 제한
기사 URL로 조회 분당 60개 요청 / IP
스냅샷 가져오기 분당 120개 요청 / IP
투표 제출 (기본값) 분당 30개 요청 / IP
투표 제출 (무결성 모드) 토론당 10개 요청 / 분
의견 생성 (인증됨) 시간당 10개
의견 생성 (익명) 시간당 5개
의견 플래그 표시 (임베드에서) 분당 10개 요청 / IP
임베드의 인라인 진술문(파트너 범위 토론) 기본값 25 / 독자 / 시간 + IP 상한선; 환경 참조
토론 생성 키당 시간당 30개 요청

응답에는 429 Too Many Requests와 다음 요청이 허용될 때까지의 초 단위를 나타내는 Retry-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"
}

질문이 있으신가요? 문의하기 또는 파트너 허브.