مرجع واجهة برمجة التطبيقات للشركاء
دمج Society Speaks برمجياً. قوّ عمليات الدمج والالتقاطات والرؤى الافتتاحية باستخدام واجهات برمجة تطبيقات JSON مصممة لأسير العمل في غرفة الأخبار وسرعة المطورين.
إذا كنت جديداً هنا: يبدأ الناشرون مناقشة لكل موضوع تصويت — في أغلب الأحيان متوافقة مع عنوان URL واحد، لكن يمكنك بدلاً من ذلك استخدام external_id الخاص بك فقط للمحاور أو الأدوات بدون رابط دائم. البيانات تنتمي إليك — يمكن صياغتها من قبلك أو من نسخ توفرها أنت؛ لا شيء مستنتج من صفحتك الرئيسية ما لم تطلب تكاملك ذلك.
التعاريف الرسمية وإضافة حقول API: مناقشة مقابل بيانات.
البدء السريع
curl "https://societyspeaks.io/api/discussions/by-article-url?url=https://example.com/article"
ثم ضمّن باستخدام embed_url المعاد.
للمطورين
نقاط نهاية يمكن التنبؤ بها وأكواد خطأ واضحة وأمثلة جاهزة للنسخ واللصق.
لقادة التحرير
تختار كل سؤال، وكيف يرتبط بقصصك أو معرّفاتك، وما إذا كانت الأسطر المقدمة من القارئ تظهر في التضمين.
للحوكمة
حدود معدل التطلب وومتطلبات الإسناد وتصدر موثوق للحقيقة.
اختبار واجهة برمجة التطبيقات
- بوابة الشركاء: أنشئ بوابة بوابة الشركاء:
- ملعب تفاعلي: ملعب API المفتوح ملعب تفاعلي:
- نقاط النهاية الكتابة (
POST/PATCH /api/partner/...) هي من خادم إلى خادم فقط ويجب اختبارها من الواجهة الخلفية أو curl/Postman، وليس من متصفح Swagger. - من سطر الأوامر: استخدم أمثلة
curlفي كل قسم أدناه، واستبدل عنوان URL الأساسي والمعاملات حسب الحاجة.
خريطة سير عمل غرفة الأخبار السريعة: external_id لتعيين نظام إدارة المحتوى، واستدعاءات webhook لمزامنة النظام، وأدوار Partner Portal للتحكم في الوصول.
المصادقة والبيئات
استخدم مفاتيح الاختبار أثناء التدريب على التدريج؛ انتقل لمفاتيح مباشرة بمجرد تفعيل الفواتير. يتم إنشاء المفاتيح في Partner Portal.
sspk_test_...
sspk_live_... (مُفعّل بعد الفواتير)
كل اسم مضيف تستضيف عليه iframe يجب أن يظهر ضمن Domains بعد التحقق من 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
مناقشة مقابل بيانات
نستخدم هذه الكلمات بشكل متسق في Partner Portal وردود API ومواد الدعم — اطلع على هذا مرة واحدة والبقية من الوثائق ستبقى متوقعة.
- نقاش
- المظلة لتجربة تصويت واحدة موجهة للقارئ: عنوان العنوان، وكيفية ربطه بموقعك (
article_urlو/أوexternal_id)، بيئة الشريك (اختبار/مباشر)، عناوين URL للتضمين/الإجماع، والمشاركة المدمجة أسفله. ينشئ إنشاء نقاش عبر API هذه الحاوية أولاً. - التصريحات
- الطلبات الفردية داخل تلك المظلة — كل منها يجمع عدد أصواتها الخاصة بالموافقة / عدم الموافقة / غير متأكد. املأها أثناء الإنشاء باستخدام
seed_statementsأوexcerptيوقد بذور الذكاء الاصطناعي؛ أضفها/عدّلها لاحقاً في البوابة؛ اسمح اختيارياً للقراء المجهولين بالمساهمة بالمزيد عندما تكون حقول تقديم التضمين مفعّلة لهذا النقاش.
سير العمل الشائع
النمط المعتاد هو «نقاش واحد لكل عنوان URL منشور»، لكن هذا اختيار وليس شرطاً. يمكنك إنشاء نقاشات لصفحات المقالات، وإرفاق معرّف CMS بدون مقالة عامة، أو الدمج بينهما حتى تبقى التقارير متسقة عبر مكدسك.
بغض النظر عن المعرّفات، تذكر الفصل: الإرجاع/الإنشاء يعيد 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":"..."}'
تضمين يدوي (بداية سريعة)
استخدم مولد التضمين لإنشاء iframes لمرة واحدة. يعتمد على نفس واجهات برمجة التطبيقات ويُرجع نفس discussion_id وembed_url.
قائمة التحقق من الإطلاق البسيطة
يمكن للمحررين والمنتجين تصفح القسم أدناه؛ شارك ملاحظات الهندسة مع قسم تكنولوجيا المعلومات.
لفريق التحرير والمنتج
- ملخص المفردات: نقاش واحد = موضوع تصويت واحد بالإضافة إلى غلاف التضمين الخاص به؛ عدة بيانات تجلس تحتها — الأسطر التي يجيب عليها القراء بموافقة / عدم موافقة / غير متأكد. شرح أطول →
- أنت تقرر ما يُطلق على كل موضوع تصويت وما يراه القراء أولاً — ربط المقالة اختياري؛ المدونات المباشرة والمحاور والمشاريع الخاصة يمكنها استخدام معرّف مستقر بدلاً من عنوان URL للقصة.
- يجب الموافقة على كل عنوان ويب قد تحمّل عليه الجزء في بوابة الشريك الخاصة بك — مواقع البروفة والمواقع المباشرة منفصلة.
- إذا كان القراء يكتبون كلاً من
www.واسم النطاق المجرد، سجّل موافقتين. - اطلب من الهندسة إضافة علامة التتبع القصيرة (
?ref=…) حتى تبقى التقارير قابلة للنسب. ما تعنيه العلامات → - اختياري: اطلب ارتفاعاً تلقائياً على الصندوق حتى لا تُقطع المقالات الطويلة (سكريبت صغير واحد من جانبك). كيف يعمل →
- فضّل «شيء ما تغيّر» الأصوات التلقائية لأنظمتك على الفحص اليدوي. نظرة عامة على Webhooks →
لفريق الهندسة والاستضافة
- ضع مفاتيح API للاختبار فقط مع نطاقات البروفة؛ مفاتيح مباشرة فقط مع نطاقات الإنتاج المُتحقق منها.
- تضمينات البروفة (
test) في الإنتاج تحتاج دائماً إلى صفحة الوالد قابلة للحل من المتصفح — حتى بدونPARTNER_EMBED_REQUIRE_PARENT_ORIGIN. - بيئة التصليب الاختيارية:
PARTNER_EMBED_REQUIRE_PARENT_ORIGIN=true(الإنتاج فقط، تضمينات الشريك المباشرة) تحظر HTML iframe عندما لا نستطيع حل صفحة الوالد منOriginأوReferer— أقسى، وقد تؤثر على المتصفحات غير الشائعة أو إعدادات الخصوصية. - تقديمات القارئ المضمّنة من التضمين تحترم
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 - تم إلغاء وصول التضمين وواجهة برمجة التطبيقات لهذا مرجع الشريك
404
no_discussion - لا يوجد نقاش لهذا عنوان URL
429
rate_limited - عدد كبير جداً من الطلبات؛ أعد المحاولة لاحقاً
الحصول على لقطة النقاش
احصل على عدد المشاركين والبيانات الوصفية. لا يتضمن محتوى التحليل.
GET
/api/discussions/{discussion_id}/snapshot
معاملات المسار
| discussion_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 - تم إلغاء وصول التضمين وواجهة برمجة التطبيقات لهذا مرجع الشريك
403
forbidden - مفتاح API اختبار صحيح مطلوب للنقاشات الاختبارية
404
discussion_not_found - النقاش غير موجود
429
rate_limited - عدد كبير جداً من الطلبات؛ أعد المحاولة لاحقاً
معاملات Embed URL
تخصيص مظهر التضمين باستخدام معاملات URL.
التضمينات متاحة لنقاشات Society Speaks الأصلية.
https://societyspeaks.io/discussions/{discussion_id}/embed
| المعامل | الوصف | مثال |
|---|---|---|
| theme | المظهر المسبق: default, dark, editorial, minimal, bold, muted |
theme=editorial |
| primary | اللون الأساسي (hex بدون #) | primary=1e40af |
| bg | لون الخلفية (hex بدون #) | bg=f9fafb |
| font | عائلة الخط من قائمة مسموحة | font=Georgia |
| ref | مرجع الشريك للتحليلات | ref=observer |
الخطوط المسموحة: system-ui, Georgia, Inter, Lato, Open Sans, Source Serif Pro
الدمج: تتبع الجلسة والخصوصية
يستخدم الدمج ملف تعريف ارتباط من الطرف الأول لإزالة تكرار الأصوات المجهولة عبر تحميلات الصفحات. عندما يتم تحميل الدمج في iframe عبر النطاق (الحالة العادية لمواقع الشركاء)، قد تحجب المتصفحات ذات إعدادات الخصوصية الصارمة ملف التعريف هذا باعتباره ملف تعريف ارتباط من طرف ثالث.
النقاشات المُنشأة من خلال API الشريك الخاص بك محدودة النطاق بالشريك: تتحقق صفحة التضمين من أصل الموقع الأب (باستخدام Origin أو Referer عند الحاجة) مقابل نطاقاتك المُتحقق منها للبيئة المطابقة للاختبار/المباشر. النقاشات العامة الأصلية لا تخضع لتلك بوابة الإطار.
لا يتطلب أي إجراء. يتراجع الدمج تلقائياً إلى معرّف جلسة قائم على localStorage (embed_fingerprint) عندما تكون ملفات تعريف الارتباط غير متاحة. يعمل إزالة تكرار الأصوات في كلا الوضعين.
كيف يعمل:
- ينشئ الدمج معرف مشارك عشوائي ويخزنه في
localStorageتحت أصل الدمج. - يتم إرسال هذا المعرف مع كل صوت باسم
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
تفعيل الدمج التلقائي لمناقشات Society Speaks في المنصات التي تدعم oEmbed.
GET
/api/oembed
معاملات الاستعلام
| url | مطلوب | عنوان URL لمناقشة Society Speaks |
| 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 من قبل المنصات المتوافقة.
إنشاء مناقشة (مصرح)
أنشئ نقاشاً لقصة محددة، أو لأي سياق تحريري يمكنك تسميته بمعرّف مستقر. وفّر article_url عندما يكون لديك رابط عام؛ استخدم external_id عندما لا يكون لديك (أو استخدم كليهما لتتبع الحقيقة). يتطلب مفتاح API.
ينشئ هذا الـ endpoint دائماً النقاش أولاً. جمّع البيانات الأولية في نفس الطلب عبر seed_statements أو وفّر excerpt حتى نتمكن من إنشاء بذور؛ وإلا أضف البيانات لاحقاً من بوابة الشريك. اقرأ كيف ينطبق النقاش مقابل البيانات على الحقول →
POST
/api/partner/discussions
الرؤوس
| X-API-Key | مطلوب | مفتاح API الشريك الخاص بك |
| Content-Type | مطلوب | application/json |
نص الطلب
| article_url | اختياري | عنوان URL المقالة الأساسي (معيّن إذا تم توفيره) |
| external_id | اختياري | معرف مستقر معرّف من قبل الشريك (مطلوب إذا تم حذف article_url) |
| title | مطلوب | عنوان المناقشة (حد أقصى 200 حرف) |
| excerpt | مشروط | مقتطف المقالة للبيانات المولدة بواسطة الذكاء الاصطناعي |
| 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 - النقاش موجود بالفعل لهذا الرابط
تحديث سياسة النقاش (موثق)
استخدم هذا لإغلاق/إعادة فتح النقاشات، وتبديل وضع السلامة، والتحكم في تقديمات 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 - إنشاء نقطة نهاية واستلام سر التوقيع مرة واحدة
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
بدون التحقق، يمكن لأي طرف يعرف عنوان نقطة النهاية الخاصة بك أن يرسل أحداثاً مزيفة. اقرأ الجسم الخام قبل تحليل 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باستخدام مقارنة ثابتة الوقت —hmac.compare_digestفي Python،crypto.timingSafeEqualفي Node. فحص التساوي البسيط يسرّب معلومات التوقيت.
إدارة السر
يتم إرجاع سر التوقيع مرة واحدة عند إنشاء نقطة نهاية — قم بتخزينه بأمان في متغير بيئة أو مدير أسرار، ليس أبداً في شيفرة المصدر. لا يتم عرضه مرة أخرى في البوابة. استخدم 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 لاستيعاب الملفات المباشر، أو format=json المترقق لمستهلكي API.
التكلفة وحماية الإساءة
من يدفع مقابل ماذا: البحث واللقطة والصورة المرئية وصفحة embed لا تستخدم LLM الخاص بنا. يتم تحديد معدل كل واحد منها لكل IP (وref اختياري) لمنع الإساءة. إنشاء النقاش هو الطريقة الوحيدة التي يمكن لها أن تؤدي إلى عبارات البذور التي تم إنشاؤها بواسطة AI (OpenAI/Anthropic)؛ يستخدم مفاتح API الخاصة بنا، وليس مفاتيحك.
الشركاء يستخدمون المفاتيح التي نصدرها. فقط الطلبات التي تحتوي على X-API-Key صحيح (من قائمة السماح بنا) يمكن أن تنشئ نقاشات. لا نستخدم مفاتيح LLM التي تم توفيرها من الشركاء لتدفق الإنشاء. إذا تم إساءة استخدام مفتاح، فإننا نلغيه.
إنشاء نقاش مقتصر على 30 طلب في الساعة لكل مفتاح API. بالاقتران مع التحكم في المفتاح، يحد هذا من تكاليف نموذج اللغة وقاعدة البيانات من نقطة الإنشاء.
حدود المعدل
| نقطة النهاية | الحد |
|---|---|
| البحث حسب رابط المقالة | 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"
}
أسئلة؟ اتصل بنا أو قم بزيارة مركز الشركاء.