استودیوی محتوا API

API نسخهٔ ۱

سایتت را به استودیوی محتوا وصل کن

تیم محتوای استودیو هر روز مقاله می‌نویسد، تصویر می‌سازد و صفحه‌های فعلی سایتت را بهتر می‌کند. این API راهی است که هر سایت یا پلتفرمی، با هر زبان و فریم‌ورکی، این کارها را بگیرد و خودش منتشر کند. اگر سایتت وردپرس است، به کد نیاز نداری: افزونهٔ ما را نصب کن و با یک کلیک وصل شو.

نشانی پایه
{{origin}}/api/v1
قالب
JSON روی HTTPS
احراز هویت
توکن Bearer برای هر پروژه
مشخصات
OpenAPI 3.1
فقط سرور به سرور. این API هدر CORS نمی‌فرستد و هیچ‌وقت نباید از مرورگر صدا زده شود. توکن را فقط روی سرور خودت نگه دار.

مدل کار در یک نگاه

  1. استودیو مقاله را آماده می‌کند و یک «تحویل» (delivery) در صف پروژه می‌گذارد.
  2. وب‌هوک امضاشده به سایتت خبر می‌دهد که کاری منتظر است. اگر وب‌هوک نداری، هر چند دقیقه صف را بخوان.
  3. سایتت تحویل را می‌گیرد، مقاله را به روش خودش منتشر می‌کند و نشانی زنده‌اش را به استودیو برمی‌گرداند (ack).
  4. سایتت فهرست صفحه‌ها و دیدگاه‌هایش را هم می‌فرستد تا تیم سایت موجود را بشناسد و بهترش کند.

شروع سریع

در پنج قدم یک اتصال کامل می‌سازی. نمونه‌ها را با زبان دلخواهت ببین؛ انتخابت در همهٔ صفحه حفظ می‌شود.

  1. توکن بساز

    در استودیو به تنظیمات پروژه ← اتصال سایت و API برو، یک توکن با «دسترسی کامل» بساز و همان لحظه کپی‌اش کن. توکن فقط یک بار نمایش داده می‌شود و این شکل را دارد:

    cst_3f9a1c2e_9xQ2kq…(۴۳ نویسه)
  2. اتصال را آزمایش کن

    curl {{origin}}/api/v1/me \
      -H "Authorization: Bearer $STUDIO_TOKEN"
    const BASE = "{{origin}}/api/v1";
    const TOKEN = process.env.STUDIO_TOKEN;
    
    async function api(method, path, body) {
      const res = await fetch(BASE + path, {
        method,
        headers: {
          authorization: `Bearer ${TOKEN}`,
          ...(body ? { "content-type": "application/json" } : {}),
        },
        body: body ? JSON.stringify(body) : undefined,
      });
      const data = await res.json();
      if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
      return data;
    }
    
    console.log(await api("GET", "/me"));
    function studio_api(string $method, string $path, ?array $body = null): array {
        $ch = curl_init('{{origin}}/api/v1' . $path);
        $headers = ['Authorization: Bearer ' . getenv('STUDIO_TOKEN')];
        if ($body !== null) {
            $headers[] = 'Content-Type: application/json';
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
        }
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_HTTPHEADER => $headers,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 20,
        ]);
        $data = json_decode((string) curl_exec($ch), true);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        if ($status >= 400) throw new RuntimeException($data['error']['code'] ?? 'error');
        return $data;
    }
    
    print_r(studio_api('GET', '/me'));
    import os, requests
    
    BASE = "{{origin}}/api/v1"
    SESSION = requests.Session()
    SESSION.headers["Authorization"] = f"Bearer {os.environ['STUDIO_TOKEN']}"
    
    def api(method, path, body=None):
        res = SESSION.request(method, BASE + path, json=body, timeout=20)
        data = res.json()
        if not res.ok:
            raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
        return data
    
    print(api("GET", "/me"))
  3. سایتت را معرفی کن

    با این درخواست، این توکن «اتصال انتشار» پروژه می‌شود و در پنل مدیر «وصل است» نشان داده می‌شود.

    curl -X PUT {{origin}}/api/v1/site \
      -H "Authorization: Bearer $STUDIO_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"platform":"custom","siteUrl":"https://shop.example","publishMode":"publish"}'
    await api("PUT", "/site", { platform: "custom", siteUrl: "https://shop.example", publishMode: "publish" });
    studio_api('PUT', '/site', ['platform' => 'custom', 'siteUrl' => 'https://shop.example', 'publishMode' => 'publish']);
    api("PUT", "/site", {"platform": "custom", "siteUrl": "https://shop.example", "publishMode": "publish"})
  4. کارهای منتظر را بگیر و منتشر کن

    این حلقه را هر چند دقیقه (یا وقتی وب‌هوک رسید) اجرا کن. myCms جای کد انتشار خود توست.

    # کارهای منتظر
    curl {{origin}}/api/v1/deliveries -H "Authorization: Bearer $STUDIO_TOKEN"
    
    # گرفتن یکی (۱۵ دقیقه برای تو رزرو می‌شود)
    curl {{origin}}/api/v1/deliveries/dlv_8c1d… -H "Authorization: Bearer $STUDIO_TOKEN"
    
    # اعلام نتیجه
    curl -X POST {{origin}}/api/v1/deliveries/dlv_8c1d…/ack \
      -H "Authorization: Bearer $STUDIO_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"remoteId":"812","status":"publish","link":"https://shop.example/blog/shoe-wax/"}'
    const { data } = await api("GET", "/deliveries");
    for (const item of data) {
      const delivery = await api("GET", `/deliveries/${item.id}`);
      if (delivery.type !== "post.publish") continue;
      const post = delivery.payload;
      try {
        const saved = await myCms.upsertBySlug(post.slug, {
          title: post.title,
          html: post.contentHtml,
          excerpt: post.excerpt,
        });
        await api("POST", `/deliveries/${item.id}/ack`, {
          remoteId: String(saved.id),
          status: "publish",
          link: saved.url,
        });
      } catch (error) {
        await api("POST", `/deliveries/${item.id}/fail`, {
          code: "publish_failed",
          message: String(error.message).slice(0, 500),
          retryable: true,
        });
      }
    }
    $waiting = studio_api('GET', '/deliveries');
    foreach ($waiting['data'] as $item) {
        $delivery = studio_api('GET', '/deliveries/' . $item['id']);
        if ($delivery['type'] !== 'post.publish') continue;
        $post = $delivery['payload'];
        try {
            $saved = my_cms_upsert_by_slug($post['slug'], $post['title'], $post['contentHtml'], $post['excerpt']);
            studio_api('POST', '/deliveries/' . $item['id'] . '/ack', [
                'remoteId' => (string) $saved['id'],
                'status' => 'publish',
                'link' => $saved['url'],
            ]);
        } catch (Throwable $e) {
            studio_api('POST', '/deliveries/' . $item['id'] . '/fail', [
                'code' => 'publish_failed',
                'message' => mb_substr($e->getMessage(), 0, 500),
                'retryable' => true,
            ]);
        }
    }
    for item in api("GET", "/deliveries")["data"]:
        delivery = api("GET", f"/deliveries/{item['id']}")
        if delivery["type"] != "post.publish":
            continue
        post = delivery["payload"]
        try:
            saved = my_cms.upsert_by_slug(post["slug"], post["title"], post["contentHtml"], post["excerpt"])
            api("POST", f"/deliveries/{item['id']}/ack",
                {"remoteId": str(saved.id), "status": "publish", "link": saved.url})
        except Exception as error:
            api("POST", f"/deliveries/{item['id']}/fail",
                {"code": "publish_failed", "message": str(error)[:500], "retryable": True})
  5. وب‌هوک بگذار (پیشنهادی)

    تا مقاله‌ها همان لحظه منتشر شوند، نه در نوبت بعدی خواندن صف. راهنمای وب‌هوک را ببین.

احراز هویت و توکن

هر درخواست (به‌جز چند مسیر عمومی) باید توکن پروژه را در هدر Authorization بفرستد:

Authorization: Bearer cst_3f9a1c2e_9xQ2kq…
  • هر توکن فقط به یک پروژه دسترسی دارد. برای هر سایت توکن جدا بساز.
  • توکن فقط یک بار نمایش داده می‌شود. استودیو فقط هش SHA-256 آن را نگه می‌دارد و دیگر نمی‌تواند نشانش بدهد.
  • هرگز در نشانی (query string) نفرست. توکنی که در URL بیاید پذیرفته نمی‌شود، چون در لاگ‌ها و تاریخچه می‌ماند.
  • اعتبار: ۳۰ روز، ۹۰ روز، یک سال یا بدون انقضا. توکن اتصال یک‌کلیکی انقضا ندارد تا انتشار قطع نشود.
  • ابطال فوری: هر توکن را هر وقت بخواهی از تنظیمات پروژه باطل کن. «قطع اتصال سایت» توکن آن سایت را هم باطل می‌کند. سایتی که خودش قطع می‌شود، با DELETE /me توکن خودش را باطل می‌کند.
  • هر پروژه حداکثر ۱۰ توکن فعال دارد.
پاسخ‌های احراز هویت
وضعیتکدیعنی
401missing_tokenهدر Authorization نیامده یا Bearer نیست.
401invalid_tokenتوکن وجود ندارد یا نادرست است.
401token_revokedتوکن باطل شده است.
401token_expiredاعتبار توکن تمام شده است.
403insufficient_scopeتوکن این دسترسی را ندارد. details.required دسترسی لازم را می‌گوید.
429too_many_failuresاز این IP تلاش ناموفق زیادی آمده؛ ۱۰ دقیقه صبر کن.

دسترسی‌ها (scopes)

هنگام ساخت توکن، فقط دسترسی‌هایی را بده که لازم است. «دسترسی کامل» همهٔ این‌هاست و برای انتشار روی سایت لازم است.

scopeاجازهمسیرها
project:readخواندن مشخصات پروژه و وضعیت اتصالGET /site
posts:readخواندن مقاله‌ها، تصویر شاخص و تصویرهای متن/posts، /figures
deliveries:readدیدن و گرفتن کارهای انتشارGET /deliveries
deliveries:writeاعلام انجام یا شکست کارPOST /deliveries/{id}/ack|fail
site:writeمعرفی سایت و فرستادن صفحه‌ها، دیدگاه‌ها و دسته‌هاPUT /site…
webhooks:manageتنظیم، آزمایش و چرخش راز وب‌هوک/webhook…

GET /me با هر توکنی کار می‌کند و بهترین راه آزمایش اتصال است.

خطاها

هر خطا یک شکل دارد. code ثابت و انگلیسی است و برای برنامه‌ات؛ message فارسی است و برای آدم‌ها. requestId را هنگام گزارش مشکل بفرست (در هدر X-Request-Id هم هست).

HTTP/1.1 422 Unprocessable Entity
{
  "error": {
    "code": "site_mismatch",
    "message": "link باید روی دامنهٔ سایت این پروژه باشد.",
    "requestId": "req_5c0e8b2f1a9d4e77",
    "details": { "field": "link" }
  }
}
وضعیتکدهای رایجچه کنی
400invalid_json، invalid_query، invalid_cursor، invalid_grantدرخواست را اصلاح کن؛ تکرارش فایده ندارد.
401 / 403بخش احراز هویتتوکن یا دسترسی‌اش را درست کن.
404not_found، delivery_not_foundنشانی یا شناسه را بررسی کن.
405method_not_allowedهدر Allow روش‌های مجاز را می‌گوید.
409delivery_closed، webhook_missingوضعیت را دوباره بخوان.
409connection_existsاین پروژه با توکن دیگری به سایت وصل است. فقط همان توکن اتصال را تغییر می‌دهد؛ مدیر اول از تنظیمات قطعش کند.
410delivery_goneمحتوای کار حذف شده و کار لغو شد؛ از آن بگذر.
413 / 415payload_too_large، unsupported_media_typeبدنه را کوچک‌تر کن و Content-Type: application/json بفرست.
422invalid_field، site_mismatch، invalid_webhook_url، too_many_itemsdetails.field فیلد مشکل‌دار را می‌گوید.
429rate_limitedبه اندازهٔ Retry-After ثانیه صبر کن.
5xxserver_errorبا فاصلهٔ فزاینده دوباره امتحان کن.

محدودیت‌ها

  • ۱۲۰ درخواست در دقیقه برای هر توکن. هر پاسخ هدرهای RateLimit-Limit، RateLimit-Remaining و RateLimit-Reset (ثانیه تا شروع دقیقهٔ بعد) را دارد. پاسخ 429 هدر Retry-After هم دارد.
  • بدنهٔ درخواست: حداکثر ۱ مگابایت؛ برای PUT /site/content و PUT /site/taxonomies حداکثر ۴ مگابایت.
  • دسته‌ها: هر PUT /site/content حداکثر ۱۰۰ آیتم و هر PUT /site/comments حداکثر ۲۰۰ دیدگاه. استودیو تا ۵۰۰۰ صفحه و ۲۰۰۰ دیدگاه آخر را نگه می‌دارد.
  • تلاش ناموفق: ۲۰ احراز هویت ناموفق از یک IP در ۱۰ دقیقه، آن IP را ۱۰ دقیقه مسدود می‌کند.

صفحه‌بندی و تکرار امن

صفحه‌بندی با cursor

فهرست‌ها { "data": [...], "nextCursor": "..." } برمی‌گردانند. تا وقتی nextCursor خالی نیست، آن را در ?cursor= بفرست. limit بین ۱ تا ۱۰۰ است (پیش‌فرض ۲۰).

Idempotency-Key

روی هر POST و PUT می‌توانی هدر Idempotency-Key (۸ تا ۱۰۰ نویسه) بفرستی. اگر شبکه قطع شد و همان درخواست را با همان کلید تکرار کردی، استودیو تا ۲۴ ساعت همان پاسخ اول را برمی‌گرداند (با هدر Idempotent-Replayed: true) و کار را دوباره انجام نمی‌دهد. ack خودش هم تکرارپذیر است: ack دوباره برای یک تحویل، duplicate: true برمی‌گرداند.

چرخهٔ تحویل

هر کاری که سایت باید انجام دهد یک «تحویل» است. محتوای تحویل هنگام گرفتنش ساخته می‌شود، پس همیشه آخرین نسخهٔ متن را می‌گیری.

  1. pending
  2. deliveredlease 15 min
  3. acked
گرفتن یک تحویل آن را ۱۵ دقیقه برای تو رزرو می‌کند تا کارگر دیگری همان را برندارد. اگر در این مدت ack یا fail نکنی، به صف برمی‌گردد. fail با retryable: true آن را با فاصلهٔ فزاینده (۱، ۴، ۱۶، ۶۴ دقیقه…) تا ۵ بار به صف برمی‌گرداند؛ بعد از آن یا با retryable: false وضعیت failed می‌شود و مدیر در پنل می‌بیند.
نوعکار سایتack
post.publishمقالهٔ تازه را بساز (یا اگر remoteId دارد، به‌روز کن) با تصویر شاخص و تصویرهای متن.remoteId، status، link، coverSet
post.coverتصویر شاخص مقاله‌ای را که داری عوض کن.coverSet: true
content.updateصفحهٔ موجودی را که مدیر تأیید کرده بازنویسی کن.remoteId، link، hash
content.restoreنسخهٔ قبلی همان صفحه را برگردان.remoteId، hash

نوع ناشناخته‌ای دیدی؟ با fail و کد unsupported_type و retryable: false ردش کن تا صف گیر نکند.

انتشار مقاله

محتوای یک تحویل post.publish:

{
  "id": "dlv_8c1d0a4f2b7e9c3d5a61",
  "type": "post.publish",
  "status": "delivered",
  "leaseUntil": "2026-10-22T09:15:00.000Z",
  "payload": {
    "slug": "shoe-wax-guide",
    "title": "راهنمای واکس زدن کفش چرم",
    "excerpt": "واکس مناسب، برس نرم و ده دقیقه وقت؛ همهٔ آنچه برای براق کردن کفش لازم داری.",
    "keyword": "واکس کفش",
    "contentHtml": "<h2>ابزار لازم</h2><p>…</p><figure><img src=\"{{origin}}/api/v1/figures/3fa2c9e1b07d\" alt=\"…\"></figure>",
    "seo": { "title": "", "description": "واکس مناسب، برس نرم و…", "focusKeyword": "واکس کفش" },
    "cover": { "url": "{{origin}}/api/v1/posts/shoe-wax-guide/cover", "mime": "image/png", "alt": "راهنمای واکس زدن کفش چرم", "version": 1761124500000 },
    "figures": [{ "key": "3fa2c9e1b07d", "url": "{{origin}}/api/v1/figures/3fa2c9e1b07d", "mime": "image/png", "alt": "…" }],
    "remoteId": null
  }
}

قاعده‌ها

  • عنوان، H1 صفحه است. contentHtml با <h2> شروع می‌شود و H1 ندارد؛ عنوان را خودت H1 صفحه کن.
  • تصویرها را خودت میزبانی کن. نشانی cover.url و هر figures[].url فقط با همان توکن باز می‌شود. هر کدام را دانلود کن، در رسانه‌های سایت بگذار و نشانی‌اش را در contentHtml جایگزین کن (نشانی‌ها عیناً در HTML آمده‌اند).
  • سئو: seo.description توضیح متا و seo.focusKeyword کلمهٔ کلیدی است. seo.title برای مقالهٔ تازه خالی است تا قالب عنوان سایت دست نخورد.
  • تکراری نساز. اول با slug دنبال مقاله بگرد؛ اگر تحویلی دوباره آمد (مثلاً ack قبلی نرسید)، همان را به‌روز کن.
  • HTML را پاک‌سازی کن. محتوا را با ابزار امن پلتفرمت (مثل wp_kses_post در وردپرس) ذخیره کن.

پیش‌نویس و زمان‌بندی

اگر سایتت مقاله‌ها را پیش‌نویس نگه می‌دارد یا زمان‌بندی می‌کند، ack را با status: "draft" یا "future" و بدون link بفرست. وقتی بعداً منتشر شد، همان مقاله را در PUT /site/content با status: "publish" و link بفرست؛ استودیو آن را منتشرشده حساب می‌کند و پیگیری سئو را شروع می‌کند.

تغییر صفحه‌های موجود

تیم برای صفحه‌های فعلی سایت (حتی آن‌هایی که خودش ننوشته) پیشنهاد بهبود می‌دهد. وقتی مدیر تأیید کرد، یک تحویل content.update می‌رسد:

{
  "remoteId": "41",
  "type": "page",
  "url": "https://shop.example/about/",
  "expectedHash": "9b0c…e4",
  "title": "دربارهٔ ما",
  "excerpt": "…",
  "contentHtml": "…",
  "seo": { "title": "دربارهٔ ما", "description": "…", "focusKeyword": "" },
  "figures": []
}
  1. هش فعلی صفحه را حساب کن (فرمول در همگام‌سازی).
  2. اگر با expectedHash برابر نیست، صفحه بعد از پیشنهاد عوض شده: چیزی را تغییر نده و با code: "snapshot_mismatch" fail کن. پیشنهاد «منقضی» می‌شود و تیم نسخهٔ تازه می‌سازد.
  3. اگر برابر است، عنوان، خلاصه، متن و سئو را بنویس. رسانه‌هایی که صفحه داشت و بازنویسی حذفشان کرده بود، از قبل به انتهای contentHtml برگردانده شده‌اند.
  4. ack را با hash صفحه بعد از ذخیره بفرست. با همین هش، مدیر می‌تواند تغییر را بعداً برگرداند (content.restore).

همگام‌سازی سایت

تیم فقط وقتی می‌تواند سایت موجودت را بهتر کند که آن را بشناسد. بعد از اتصال، همهٔ نوشته‌ها و برگه‌های منتشرشده را در دسته‌های ۱۰۰تایی بفرست، و از آن به بعد هر صفحه را هر وقت تغییر کرد. در همگام‌سازی کامل، اولین دسته را با "reset": true بفرست تا صفحه‌هایی که دیگر وجود ندارند از فهرست استودیو هم پاک شوند.

curl -X PUT {{origin}}/api/v1/site/content \
  -H "Authorization: Bearer $STUDIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"items":[{"remoteId":"41","type":"page","status":"publish",
      "link":"https://shop.example/about/","title":"دربارهٔ ما",
      "excerpt":"","contentHtml":"<p>…</p>","modified":"2026-10-01T10:00:00Z"},
     {"remoteId":"17","deleted":true}]}'
await api("PUT", "/site/content", {
  items: pages.map((page) => ({
    remoteId: String(page.id),
    type: page.type,            // "post" | "page" | …
    status: page.status,        // "publish" | "draft" | …
    link: page.url,
    title: page.title,
    excerpt: page.excerpt,
    contentHtml: page.rawHtml,  // HTML ذخیره‌شده، نه رندرشده
    modified: page.updatedAt,
  })),
});
$items = array_map(fn ($page) => [
    'remoteId' => (string) $page['id'],
    'type' => $page['type'],
    'status' => $page['status'],
    'link' => $page['url'],
    'title' => $page['title'],
    'excerpt' => $page['excerpt'],
    'contentHtml' => $page['raw_html'],
    'modified' => $page['updated_at'],
], $pages);
studio_api('PUT', '/site/content', ['items' => $items]);
api("PUT", "/site/content", {"items": [{
    "remoteId": str(page.id),
    "type": page.type,
    "status": page.status,
    "link": page.url,
    "title": page.title,
    "excerpt": page.excerpt,
    "contentHtml": page.raw_html,
    "modified": page.updated_at.isoformat(),
} for page in pages]})

هش محتوا

استودیو برای هر صفحه این هش را حساب می‌کند و تغییرها را فقط روی همان نسخه اعمال می‌کند. ورودی‌ها مقدارهای خام ذخیره‌شده هستند، با بایت NUL (\0) به هم چسبیده:

hash = sha256_hex(title + "\0" + excerpt + "\0" + contentHtml)
printf '%s\0%s\0%s' "$TITLE" "$EXCERPT" "$HTML" | sha256sum
import { createHash } from "node:crypto";
const hash = createHash("sha256").update([title, excerpt, html].join("\0")).digest("hex");
$hash = hash('sha256', $title . "\0" . $excerpt . "\0" . $html);
import hashlib
digest = hashlib.sha256("\0".join([title, excerpt, html]).encode("utf-8")).hexdigest()

دیدگاه‌ها و دسته‌ها

PUT /site/comments دیدگاه‌های تأییدشده را می‌گیرد تا تیم بداند خواننده‌ها چه می‌پرسند. فقط نام نمایشی و متن را بفرست؛ ایمیل و IP نفرست (اگر بیاید هم دور ریخته می‌شود). PUT /site/taxonomies فهرست کامل دسته‌ها و برچسب‌ها را جایگزین می‌کند.

PUT /site/comments
{ "items": [{ "remoteId": "c-901", "postRemoteId": "812", "postLink": "https://shop.example/blog/shoe-wax/",
  "author": "مینا", "content": "برای کفش جیر هم جواب می‌دهد؟", "date": "2026-10-21T18:02:00Z", "status": "approved" }] }

PUT /site/taxonomies
{ "categories": [{ "remoteId": "3", "name": "راهنما", "slug": "guides", "count": 24 }],
  "tags": [{ "remoteId": "11", "name": "چرم", "slug": "leather", "count": 9 }] }

وب‌هوک

وب‌هوک فقط خبر می‌دهد، محتوا ندارد. تو باید بعد از دریافتش تحویل را با توکنت بگیری؛ پس حتی اگر کسی وب‌هوک جعلی بفرستد، به محتوایی نمی‌رسد. باز هم امضا را همیشه بررسی کن.

تنظیم

PUT /webhook
{ "url": "https://shop.example/hooks/content-studio" }

→ { "url": "https://shop.example/hooks/content-studio", "secret": "whsec_Qm9…" }

نشانی باید https و روی همان دامنهٔ سایت پروژه باشد. راز فقط همین یک بار برمی‌گردد؛ اگر گمش کردی، POST /webhook/rotate راز تازه می‌دهد. POST /webhook/test یک رویداد ping می‌فرستد.

رویدادها

رویدادکیچه کنی
delivery.createdکاری در صف آمد.GET /deliveries/{data.deliveryId}
pingآزمایش از پنل یا API.فقط 200 برگردان.
connection.revokedمدیر اتصال را قطع یا توکن را باطل کرد.توکن و راز را پاک کن.
POST /hooks/content-studio
Content-Type: application/json
User-Agent: ContentStudio-Webhook/1.0.0
X-Studio-Event: delivery.created
X-Studio-Event-Id: evt_6b1f0c2d9e8a7f5c4b3a2918
X-Studio-Delivery: dlv_8c1d0a4f2b7e9c3d5a61
X-Studio-Signature: t=1761124500,v1=5f2b…c9

{"id":"evt_6b1f…","event":"delivery.created","projectId":"…","createdAt":"2026-10-22T09:15:00.000Z",
 "data":{"deliveryId":"dlv_8c1d0a4f2b7e9c3d5a61","type":"post.publish"}}

بررسی امضا

امضا HMAC-SHA256 رشتهٔ <t>.<بدنهٔ خام> با کل راز (همراه whsec_) است. بدنه را پیش از parse کردن بخوان، زمان را با فاصلهٔ حداکثر ۳۰۰ ثانیه بپذیر و مقایسه را زمان‌ثابت انجام بده.

# امضای مورد انتظار را دستی بساز
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyStudio(secret, header, rawBody) {
  const parts = Object.fromEntries(String(header).split(",").map((part) => part.trim().split("=")));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 || "", "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

// Express: app.post("/hooks/content-studio", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyStudio(SECRET, req.get("x-studio-signature"), req.body.toString("utf8"))) return res.sendStatus(401);
//   res.sendStatus(202);   // اول جواب بده، بعد کار کن
//   queue.push(JSON.parse(req.body).data.deliveryId);
// });
function studio_verify(string $secret, string $header, string $rawBody): bool {
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        $parts[$key] = $value;
    }
    $t = (int) ($parts['t'] ?? 0);
    if ($t === 0 || abs(time() - $t) > 300) return false;
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, (string) ($parts['v1'] ?? ''));
}

$raw = file_get_contents('php://input');
if (!studio_verify(getenv('STUDIO_WEBHOOK_SECRET'), $_SERVER['HTTP_X_STUDIO_SIGNATURE'] ?? '', $raw)) {
    http_response_code(401);
    exit;
}
http_response_code(202);
import hashlib, hmac, time

def verify_studio(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
    try:
        t = int(parts.get("t", "0"))
    except ValueError:
        return False
    if not t or abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

تحویل مطمئن

  • ظرف ۱۰ ثانیه با 2xx جواب بده و کار را بعد از جواب انجام بده.
  • اگر پاسخ 2xx نبود یا نرسید، استودیو بعد از ۱ دقیقه، ۵ دقیقه، ۳۰ دقیقه، ۲ ساعت و ۶ ساعت دوباره می‌فرستد. شناسهٔ X-Studio-Event-Id در تکرارها ثابت است؛ با آن تکراری‌ها را نادیده بگیر.
  • وب‌هوک جایگزین خواندن صف نیست: هر چند دقیقه GET /deliveries را هم بخوان تا هیچ کاری جا نماند.
  • وب‌هوک به ریدایرکت عمل نمی‌کند و به نشانی‌های خصوصی (localhost، شبکهٔ داخلی) فرستاده نمی‌شود.

اتصال یک‌کلیکی (برای سازندگان پلتفرم)

اگر برای پلتفرمی افزونه یا اپ می‌سازی، کاربرانت لازم نیست توکن کپی کنند. از جریان استاندارد OAuth 2 «authorization code» با PKCE استفاده کن؛ افزونهٔ وردپرس ما هم همین کار را می‌کند.

  1. verifier و challenge بساز

    VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n' | cut -c1-64)
    CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=\n')
    import { createHash, randomBytes } from "node:crypto";
    const verifier = randomBytes(32).toString("base64url");      // ۴۳ نویسه
    const challenge = createHash("sha256").update(verifier).digest("base64url");
    const state = randomBytes(16).toString("hex");               // در نشست کاربر نگه دار
    $b64url = fn (string $bin) => rtrim(strtr(base64_encode($bin), '+/', '-_'), '=');
    $verifier = $b64url(random_bytes(32));
    $challenge = $b64url(hash('sha256', $verifier, true));
    $state = bin2hex(random_bytes(16)); // در نشست کاربر نگه دار
    import base64, hashlib, secrets
    verifier = secrets.token_urlsafe(32)
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
    state = secrets.token_hex(16)  # در نشست کاربر نگه دار
  2. کاربر را به صفحهٔ تأیید بفرست

    {{origin}}/connect
      ?site=https://shop.example
      &redirect_uri=https://shop.example/admin/studio/callback
      &state=STATE
      &code_challenge=CHALLENGE
      &code_challenge_method=S256
      &platform=myplatform
      &site_name=فروشگاه کفش

    redirect_uri باید https و روی همان دامنهٔ site باشد. کاربر وارد استودیو می‌شود، پروژهٔ هم‌دامنه را انتخاب و تأیید می‌کند.

  3. کد را با توکن عوض کن

    استودیو کاربر را به redirect_uri?code=…&state=… برمی‌گرداند (یا error=access_denied اگر انصراف داد). state را با نسخهٔ نشست مقایسه کن، بعد از سرور:

    POST {{origin}}/api/v1/connect/token
    Content-Type: application/json
    
    { "code": "…", "code_verifier": "VERIFIER",
      "redirect_uri": "https://shop.example/admin/studio/callback",
      "webhook_url": "https://shop.example/hooks/content-studio" }
    
    → { "access_token": "cst_…", "token_type": "Bearer",
        "scope": "project:read posts:read deliveries:read deliveries:write site:write webhooks:manage",
        "project": { "id": "…", "name": "فروشگاه کفش", "siteUrl": "https://shop.example/" },
        "webhook": { "url": "https://shop.example/hooks/content-studio", "secret": "whsec_…" } }

    کد یک‌بارمصرف است و ۵ دقیقه اعتبار دارد. اتصال تازه، توکن اتصال قبلی همان پروژه را باطل می‌کند. توکن و راز وب‌هوک را رمزنگاری‌شده ذخیره کن.

مرجع endpointها

جزئیات کامل هر فیلد در مشخصات OpenAPI است و می‌توانی آن را در Postman، Insomnia یا ابزار ساخت SDK بارگذاری کنی.

روشمسیردسترسیکار
GET/api/v1عمومینام و نسخهٔ API
GET/api/v1/openapi.jsonعمومیمشخصات OpenAPI
GET/api/v1/plugins/wordpress/latestعمومیآخرین نسخهٔ افزونهٔ وردپرس
POST/api/v1/connect/tokenPKCEگرفتن توکن با کد اتصال
GET/api/v1/meهر توکنآزمایش توکن، پروژه و اتصال
DELETE/api/v1/meهر توکنباطل کردن همین توکن (قطع اتصال از سمت سایت)
GET/api/v1/postsposts:readفهرست مقاله‌ها (status، limit، cursor)
GET/api/v1/posts/{slug}posts:readیک مقاله با HTML کامل
GET/api/v1/posts/{slug}/coverposts:readتصویر شاخص (باینری)
GET/api/v1/figures/{key}posts:readتصویر داخل متن (باینری)
GET/api/v1/deliveriesdeliveries:readکارهای منتظر
GET/api/v1/deliveries/{id}deliveries:readگرفتن و رزرو یک کار
POST/api/v1/deliveries/{id}/ackdeliveries:writeاعلام انجام
POST/api/v1/deliveries/{id}/faildeliveries:writeاعلام شکست
GET/api/v1/siteproject:readوضعیت اتصال و همگام‌سازی
PUT/api/v1/sitesite:writeمعرفی سایت و اتصال انتشار
PUT/api/v1/site/contentsite:writeفرستادن صفحه‌ها
PUT/api/v1/site/commentssite:writeفرستادن دیدگاه‌ها
PUT/api/v1/site/taxonomiessite:writeدسته‌ها و برچسب‌ها
GET/api/v1/webhookwebhooks:manageوضعیت وب‌هوک
PUT/api/v1/webhookwebhooks:manageتنظیم نشانی
DELETE/api/v1/webhookwebhooks:manageحذف وب‌هوک
POST/api/v1/webhook/rotatewebhooks:manageراز تازه
POST/api/v1/webhook/testwebhooks:manageفرستادن ping

افزونهٔ وردپرس

برای سایت‌های وردپرسی همهٔ این کارها آماده است و به هیچ کدی نیاز نداری.

  1. افزونه را دانلود کن و در افزونه‌ها ← افزودن ← بارگذاری افزونه نصب و فعال کن.
  2. به تنظیمات ← استودیوی محتوا برو و اتصال با یک کلیک را بزن.
  3. در استودیو پروژه را تأیید کن؛ به وردپرس برمی‌گردی و تمام.
دانلود افزونه
  • انتشار خودکار مقاله با تصویر شاخص و تصویرهای متن در کتابخانهٔ رسانهٔ خود سایت
  • حالت انتشار: مستقیم، پیش‌نویس، یا زمان‌بندی با تأخیر یا ساعت ثابت
  • نویسنده و دستهٔ پیش‌فرض، و افزودن کلمهٔ کلیدی به‌عنوان برچسب
  • توضیح متا، کلمهٔ کلیدی و عنوان سئو در Yoast SEO و Rank Math
  • همگام‌سازی نوشته‌ها، برگه‌ها، دیدگاه‌ها و دسته‌ها
  • اعمال امن تغییرهای تأییدشده با بررسی هش و حفظ رسانه‌ها
  • وب‌هوک امضاشده و سرکشی پشتیبان هر ۵ دقیقه
  • صفحهٔ وضعیت، لاگ رویدادها و آزمون‌های «سلامت سایت»
  • رمزنگاری توکن روی سرور خودت و به‌روزرسانی خودکار

امنیت

  • توکن را مثل رمز عبور نگه دار: رمزنگاری‌شده در پایگاه داده یا متغیر محیطی، هرگز در کد سمت مرورگر، مخزن git یا لاگ.
  • کمترین دسترسی: برای ابزاری که فقط می‌خواند، توکن «فقط خواندن» بساز.
  • انقضا و چرخش: برای توکن‌های دستی انقضا بگذار و پیش از تمام شدنش توکن تازه بساز.
  • امضای وب‌هوک را همیشه بررسی کن و فاصلهٔ زمانی بیش از ۵ دقیقه را رد کن.
  • HTML را پاک‌سازی کن پیش از ذخیره، با ابزار امن پلتفرم خودت.
  • نشانی‌های تصویر را فقط با همین توکن و فقط از نشانی‌های همین استودیو دانلود کن.
  • نشت؟ همان لحظه توکن را از تنظیمات پروژه باطل کن؛ «رویدادهای اخیر API» در همان‌جا نشان می‌دهد چه کسی و کی از آن استفاده کرده.
  • مشکل امنیتی پیدا کردی؟ با requestId به پشتیبانی خبر بده؛ عمومی منتشرش نکن.

نسخه‌ها

نسخهٔ API در هدر X-Api-Version هر پاسخ می‌آید. تغییرهای ناسازگار فقط در /api/v2 می‌آیند؛ در v1 فقط فیلد و endpoint تازه اضافه می‌شود، پس فیلدهای ناشناخته را نادیده بگیر.

1.0.0 مهر ۱۴۰۵
نسخهٔ نخست: توکن‌های پروژه، تحویل‌ها، همگام‌سازی سایت، وب‌هوک امضاشده، اتصال یک‌کلیکی و افزونهٔ وردپرس.